merge: classroom overhaul

This commit is contained in:
itsrubberduck
2026-07-27 11:55:08 +02:00
12 changed files with 1891 additions and 1916 deletions

View File

@@ -384,6 +384,18 @@ const openManager = () => {
color: var(--accent);
}
@media (max-width: 767px) {
.cookie-manage-button {
position: static;
display: block;
width: fit-content;
margin: 0.75rem;
margin-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0));
padding: 0.4rem 0.75rem;
font-size: 0.75rem;
}
}
.cookie-fade-enter-active,
.cookie-fade-leave-active {
transition: opacity 0.25s ease;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -32,11 +32,24 @@ function sanitizeProgress(input: LearnProgress | undefined): LearnProgress | und
const bestRaw = (value as any).best
const doneRaw = (value as any).done
const assessmentVersionRaw = (value as any).assessmentVersion
const successfulVariantsRaw = (value as any).successfulVariants
const best = typeof bestRaw === 'number' && Number.isFinite(bestRaw) ? Math.max(0, Math.min(100, Math.round(bestRaw))) : 0
const done = Boolean(doneRaw)
const assessmentVersion = typeof assessmentVersionRaw === 'number' && Number.isFinite(assessmentVersionRaw)
? Math.max(0, Math.round(assessmentVersionRaw))
: undefined
const successfulVariants = typeof successfulVariantsRaw === 'number' && Number.isFinite(successfulVariantsRaw)
? Math.max(0, Math.min(100, Math.round(successfulVariantsRaw)))
: undefined
lessonProgress[lessonId] = { best, done }
lessonProgress[lessonId] = {
best,
done,
...(assessmentVersion !== undefined ? { assessmentVersion } : {}),
...(successfulVariants !== undefined ? { successfulVariants } : {}),
}
}
sanitized[moduleId] = lessonProgress

View File

@@ -1,5 +1,5 @@
import { createBaseScenario, createScenarioSeries, digitsToWords, formatTemp, lettersToNato } from '~~/shared/learn/scenario'
import type { Lesson, ModuleDef, Scenario } from '~~/shared/learn/types'
import type { Lesson, LessonField, ModuleDef, Scenario } from '~~/shared/learn/types'
function gradientArt(colors: string[]): string {
const stops = colors
@@ -568,7 +568,7 @@ const readbackLessons: Lesson[] = [
keywords: ['Delivery', 'Readback'],
hints: [
'Remember the order: destination SID runway altitude squawk.',
'Speak altitude and squawk digits clearly.'
'Use the prescribed ICAO pronunciations for altitude and squawk digits.'
],
fields: [
{
@@ -3308,34 +3308,726 @@ const fullFlightLessons: Lesson[] = [
}
]
const copyLessonIds = new Set(['icao-alphabet', 'icao-marathon', 'atis', 'metar'])
const situationLessonIds = new Set([
'radio-check',
'clearance-contact',
'pushback-ready',
'taxi-request',
'lineup-request',
'departure-checkin',
'unable-direct',
'alt-accept',
'alt-reject',
'approach-established',
'taxi-in-request',
'mayday',
'pan-pan',
'go-around',
'tcas-ra',
'tcas-resume',
'full-clearance-contact',
'full-departure-checkin',
'full-pushback-request',
'full-taxi-request',
'full-approach-checkin',
'full-established-report',
])
const operationalFieldPattern =
/call\s?sign|runway|heading|direction|altitude|level|speed|qnh|squawk|frequency|channel|route|taxi|hold|sid|star|approach/i
function curateField(field: LessonField, promptKind: 'atc' | 'situation' | 'copy'): LessonField {
const safetyCritical = promptKind !== 'copy' && operationalFieldPattern.test(`${field.key} ${field.label}`)
return {
...field,
required: field.required ?? true,
safetyCritical: field.safetyCritical ?? safetyCritical,
matching: field.matching ?? (safetyCritical ? 'controlled' : 'exact'),
}
}
function approachWithoutRunway(scenario: Scenario): string {
return scenario.approach
.replace(new RegExp(`\\s+${scenario.arrivalRunway}$`, 'i'), '')
.trim()
}
function callsignAlternatives(scenario: Scenario): string[] {
return [
scenario.radioCall,
`${scenario.airlineCall} ${scenario.flightNumber}`,
`${scenario.airlineCall} ${scenario.flightNumberWords}`,
scenario.callsign,
]
}
const situationPrompts: Partial<Record<string, (scenario: Scenario) => string>> = {
'clearance-contact': scenario =>
`You are at stand ${scenario.stand} with information ${scenario.atisCodeWord}. Request IFR clearance to ${scenario.destination.city} from ${scenario.airport.city} Delivery.`,
'pushback-ready': scenario =>
`You are ready at stand ${scenario.stand}. Request pushback and start from ${scenario.airport.city} Ground.`,
'taxi-request': scenario =>
`Pushback is complete at stand ${scenario.stand}. Request taxi from ${scenario.airport.city} Ground.`,
'lineup-request': scenario =>
`You are holding short of runway ${scenario.runway} and ready for departure. Advise ${scenario.airport.city} Tower.`,
'unable-direct': scenario =>
`ATC offers direct ${scenario.transition}, but you cannot accept it. State UNABLE and continue the assigned ${scenario.sid}.`,
'approach-established': scenario =>
`You are established on the localizer for runway ${scenario.arrivalRunway}. Make the requested report.`,
'taxi-in-request': scenario =>
`You have vacated runway ${scenario.arrivalRunway}. Request taxi to stand ${scenario.arrivalStand} from ${scenario.destination.city} Ground.`,
'standby': () =>
'ATC asks a non-urgent question, but you need a short moment before answering. Ask ATC to stand by.',
'full-clearance-contact': scenario =>
`You are at stand ${scenario.stand} with information ${scenario.atisCodeWord}. Request IFR clearance to ${scenario.destination.city} from ${scenario.airport.city} Delivery.`,
}
function clearanceWithoutWind(lesson: Lesson, landing: boolean): Lesson {
const prefix = lesson.id.startsWith('full-') ? 'full-' : ''
const runwayKey = landing ? `${prefix}landing-runway` : `${prefix}tko-runway`
const callsignKey = landing ? `${prefix}landing-callsign` : `${prefix}tko-callsign`
const fields = lesson.fields.filter(field => !/wind/i.test(`${field.key} ${field.label}`))
return {
...lesson,
hints: [
`Read back the runway, ${landing ? 'landing' : 'take-off'} clearance, and callsign.`,
'Wind is information and is not a mandatory readback item.',
],
fields,
readback: [
{ type: 'text', text: 'Runway ' },
{ type: 'field', key: runwayKey, width: 'sm' },
{ type: 'text', text: landing ? ', cleared to land, ' : ', cleared for take-off, ' },
{ type: 'field', key: callsignKey, width: 'lg' },
],
}
}
function curateLesson(source: Lesson): Lesson {
let lesson = { ...source, fields: [...source.fields], readback: [...source.readback] }
if (lesson.id === 'radio-check') {
lesson = {
...lesson,
title: 'Request a Radio Check',
desc: 'Make a complete radio-check transmission and understand readability 15',
hints: [
'Call the station, identify yourself, say “RADIO CHECK”, then state the channel.',
'The reply uses one exact readability value: 1 unreadable, 2 readable now and then, 3 readable with difficulty, 4 readable, 5 perfectly readable.',
],
fields: [
{
key: 'rc-station',
label: 'Station',
expected: scenario => `${scenario.airport.city} Ground`,
alternatives: scenario => [`${scenario.airport.icao} Ground`, `${scenario.airport.name} Ground`],
width: 'lg',
},
{
key: 'rc-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: callsignAlternatives,
width: 'lg',
},
{
key: 'rc-frequency',
label: 'Channel',
expected: scenario => scenario.groundFreq,
alternatives: scenario => [scenario.groundFreq.replace('.', ''), scenario.frequencyWords.GND],
width: 'md',
},
],
readback: [
{ type: 'field', key: 'rc-station', width: 'lg' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'rc-callsign', width: 'lg' },
{ type: 'text', text: ', radio check ' },
{ type: 'field', key: 'rc-frequency', width: 'md' },
],
phrase: scenario => `You have selected ${scenario.groundFreq}. Request a radio check from ${scenario.airport.city} Ground.`,
info: scenario => [
`Channel: ${scenario.groundFreq} (${scenario.frequencyWords.GND})`,
'Readability scale: 1 unreadable · 2 readable now and then · 3 readable with difficulty · 4 readable · 5 perfectly readable',
],
}
}
if (['clearance-contact', 'full-clearance-contact'].includes(lesson.id)) {
const prefix = lesson.id.startsWith('full-') ? 'full-' : ''
const callsignKey = `${prefix}clearance-contact-callsign`
lesson = {
...lesson,
hints: [
'Address the unit, use your full callsign, then give ATIS, destination, stand, and request.',
'Transmit the ATIS identifier as its ICAO spelling word, for example “information Papa”.',
],
fields: [
{
key: callsignKey,
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: callsignAlternatives,
width: 'lg',
},
...lesson.fields,
],
readback: [
{ type: 'text', text: scenario => `${scenario.airport.city} Delivery, ` },
{ type: 'field', key: callsignKey, width: 'lg' },
{ type: 'text', text: ', information ' },
...lesson.readback.slice(1),
],
}
}
if (['takeoff', 'full-takeoff'].includes(lesson.id)) lesson = clearanceWithoutWind(lesson, false)
if (['landing-clearance', 'full-landing'].includes(lesson.id)) lesson = clearanceWithoutWind(lesson, true)
if (['lineup', 'full-lineup'].includes(lesson.id)) {
lesson.hints = [
'Read back the runway, “line up and wait”, and your callsign.',
'Keep the clearance elements in the order in which they were issued.',
]
}
if (['tower-contact', 'full-tower-contact'].includes(lesson.id)) {
lesson = {
...lesson,
desc: 'Confirm the transfer to tower',
hints: ['Read back the tower channel and your callsign.'],
readback: lesson.readback.filter(segment =>
segment.type !== 'text' || !segment.text.toString().includes('when number one')),
phrase: scenario => `${scenario.radioCall}, contact Tower ${scenario.towerFreq}.`,
}
}
if (lesson.id === 'clearance-amendment') {
lesson = {
...lesson,
title: 'Reclearance',
hints: ['Start with “RECLEARED”, then repeat every changed clearance element and your callsign.'],
phrase: scenario => `${scenario.radioCall}, recleared via ${scenario.sid} ${scenario.transition}, runway ${scenario.runway}.`,
readback: lesson.readback.map(segment =>
segment.type === 'text' && typeof segment.text === 'string' && /amended/i.test(segment.text)
? { ...segment, text: segment.text.replace(/amended clearance:?/i, 'RECLEARED') }
: segment),
}
}
if (['pushback', 'full-pushback'].includes(lesson.id)) {
const callsignField = lesson.fields.find(field => /callsign/i.test(field.key))
if (callsignField) {
lesson = {
...lesson,
title: 'Pushback Clearance',
desc: 'Acknowledge a standard pushback approval',
hints: ['Read back “pushback approved” and your callsign. Start-up is a separate clearance when required.'],
fields: [callsignField],
readback: [
{ type: 'text', text: 'Pushback approved, ' },
{ type: 'field', key: callsignField.key, width: 'lg' },
],
phrase: scenario => `${scenario.radioCall}, pushback approved.`,
}
}
}
if (lesson.id === 'pushback-delay') {
lesson = {
...lesson,
title: 'Pushback Delay',
desc: 'Acknowledge an expected pushback delay',
hints: ['A delay is not a pushback approval. Do not start moving until approval is issued.'],
phrase: scenario => `${scenario.radioCall}, expect ${scenario.pushDelayWords} delay due traffic.`,
}
}
if (['approach-vector'].includes(lesson.id)) {
lesson = {
...lesson,
fields: [
{
key: 'vector-direction',
label: 'Turn direction',
expected: () => 'left',
alternatives: () => ['left turn'],
width: 'sm',
},
...lesson.fields,
],
readback: [
{ type: 'text', text: 'Turn ' },
{ type: 'field', key: 'vector-direction', width: 'sm' },
{ type: 'text', text: ', heading ' },
...lesson.readback.slice(1),
],
hints: ['Turn direction, heading, level, speed, then callsign. Every assigned value is mandatory.'],
}
}
if (['descent-clearance', 'full-descent'].includes(lesson.id)) {
const prefix = lesson.id.startsWith('full-') ? 'full-' : ''
const levelKey = `${prefix}descent-level`
lesson = {
...lesson,
desc: 'Read back the assigned descent level, arrival clearance, QNH, and callsign',
fields: [
{
key: levelKey,
label: 'Assigned level',
expected: scenario => scenario.altitudes.initialWords,
alternatives: scenario => [scenario.altitudes.initial.toString(), `${scenario.altitudes.initial} feet`],
width: 'md',
},
...lesson.fields,
],
readback: [
{ type: 'text', text: 'Descend to ' },
{ type: 'field', key: levelKey, width: 'md' },
{ type: 'text', text: ', via ' },
...lesson.readback.slice(1),
],
phrase: scenario =>
`${scenario.radioCall}, descend to ${scenario.altitudes.initial} feet via ${scenario.arrivalStar} ${scenario.arrivalTransition}, QNH ${scenario.arrivalQnh}.`,
}
}
if (['approach-clearance', 'full-approach'].includes(lesson.id)) {
lesson = {
...lesson,
fields: lesson.fields.map(field =>
/app-type/.test(field.key) ? { ...field, expected: approachWithoutRunway } : field),
phrase: scenario =>
`${scenario.radioCall}, cleared ${approachWithoutRunway(scenario)} approach runway ${scenario.arrivalRunway}, report established.`,
}
}
if (lesson.id === 'alt-accept') {
lesson = {
...lesson,
title: 'Say Again',
desc: 'Request a repeat when a transmission is not understood',
hints: ['Use “SAY AGAIN” instead of guessing a safety-critical instruction.'],
fields: [{
key: 'say-again-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: callsignAlternatives,
width: 'lg',
}],
readback: [
{ type: 'field', key: 'say-again-callsign', width: 'lg' },
{ type: 'text', text: ', say again.' },
],
phrase: () => 'A controller transmission was blocked by interference. Ask for it again.',
info: () => ['Never invent or assume a clearance you did not understand.'],
}
}
if (lesson.id === 'alt-reject') {
lesson = {
...lesson,
title: 'Unable: Reason and Request',
desc: 'Decline an instruction and offer a safe alternative',
hints: ['State “UNABLE”, give a concise reason, then request an alternative.'],
fields: [
{
key: 'unable-reason',
label: 'Reason',
expected: scenario => scenario.emergencyProblem,
width: 'lg',
},
{
key: 'unable-request',
label: 'Alternative request',
expected: scenario => scenario.emergencyIntent,
width: 'lg',
},
{
key: 'unable-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: callsignAlternatives,
width: 'lg',
},
],
readback: [
{ type: 'text', text: 'Unable due ' },
{ type: 'field', key: 'unable-reason', width: 'lg' },
{ type: 'text', text: ', request ' },
{ type: 'field', key: 'unable-request', width: 'lg' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'unable-callsign', width: 'lg' },
],
phrase: scenario =>
`You cannot comply because of ${scenario.emergencyProblem}. Request to ${scenario.emergencyIntent}.`,
info: () => ['UNABLE is not a rejection without context: give the operational reason and a workable request.'],
}
}
if (['departure-checkin', 'full-departure-checkin'].includes(lesson.id)) {
const prefix = lesson.id.startsWith('full-') ? 'full-' : ''
const callsign = lesson.fields.find(field => /callsign/i.test(field.key))
const passingKey = `${prefix}depcheck-passing`
const clearedKey = `${prefix}depcheck-cleared`
if (callsign) {
lesson = {
...lesson,
desc: 'Make the required initial call after changing to departure',
hints: [
'State the unit, full callsign, passing level, and cleared level.',
'Add an assigned speed only when ATC has issued one.',
],
fields: [
callsign,
{
key: passingKey,
label: 'Passing level',
expected: scenario => scenario.altitudes.initialWords,
alternatives: scenario => [scenario.altitudes.initial.toString(), `${scenario.altitudes.initial} feet`],
width: 'md',
},
{
key: clearedKey,
label: 'Cleared level',
expected: scenario => scenario.altitudes.climbWords,
alternatives: scenario => [scenario.altitudes.climb.toString(), `${scenario.altitudes.climb} feet`],
width: 'md',
},
],
readback: [
{ type: 'text', text: scenario => `${scenario.airport.city} Departure, ` },
{ type: 'field', key: callsign.key, width: 'lg' },
{ type: 'text', text: ', passing ' },
{ type: 'field', key: passingKey, width: 'md' },
{ type: 'text', text: ', cleared ' },
{ type: 'field', key: clearedKey, width: 'md' },
],
phrase: scenario =>
`You have changed to ${scenario.airport.city} Departure. You are passing ${scenario.altitudes.initial} feet, cleared to ${scenario.altitudes.climb} feet.`,
}
}
}
if (['mayday', 'pan-pan'].includes(lesson.id)) {
const distress = lesson.id === 'mayday'
const prefix = distress ? 'mayday' : 'pan'
lesson = {
...lesson,
title: distress ? 'MAYDAY Declaration' : 'PAN PAN Declaration',
desc: distress
? 'Build a complete distress transmission'
: 'Build a complete urgency transmission',
hints: [
`${distress ? 'MAYDAY' : 'PAN PAN'} is preferably transmitted three times.`,
'Include station, callsign, nature, intention, position, level, and heading.',
],
fields: [
{
key: `${prefix}-callsign`,
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: callsignAlternatives,
width: 'lg',
},
{
key: `${prefix}-nature`,
label: 'Nature',
expected: scenario => scenario.emergencyProblem,
width: 'lg',
},
{
key: `${prefix}-intention`,
label: 'Intention',
expected: scenario => scenario.emergencyIntent,
width: 'lg',
},
{
key: `${prefix}-position`,
label: 'Position',
expected: scenario => scenario.airport.city,
alternatives: scenario => [scenario.airport.icao, scenario.airport.name],
width: 'md',
},
{
key: `${prefix}-level`,
label: 'Level',
expected: scenario => scenario.altitudes.initialWords,
alternatives: scenario => [scenario.altitudes.initial.toString(), `${scenario.altitudes.initial} feet`],
width: 'md',
},
{
key: `${prefix}-heading`,
label: 'Heading',
expected: scenario => scenario.emergencyHeading,
alternatives: scenario => [scenario.emergencyHeadingWords],
width: 'md',
},
],
readback: [
{
type: 'text',
text: scenario =>
`${scenario.airport.city} Control, ${distress ? 'MAYDAY MAYDAY MAYDAY' : 'PAN PAN PAN PAN PAN PAN'}, `,
},
{ type: 'field', key: `${prefix}-callsign`, width: 'lg' },
{ type: 'text', text: ', ' },
{ type: 'field', key: `${prefix}-nature`, width: 'lg' },
{ type: 'text', text: ', intend to ' },
{ type: 'field', key: `${prefix}-intention`, width: 'lg' },
{ type: 'text', text: ', position ' },
{ type: 'field', key: `${prefix}-position`, width: 'md' },
{ type: 'text', text: ', level ' },
{ type: 'field', key: `${prefix}-level`, width: 'md' },
{ type: 'text', text: ', heading ' },
{ type: 'field', key: `${prefix}-heading`, width: 'md' },
],
phrase: scenario =>
`${distress ? 'A grave and imminent danger exists' : 'An urgent situation exists'}: ${scenario.emergencyProblem}. You intend to ${scenario.emergencyIntent}. Build the complete transmission.`,
info: () => [
distress
? 'Use MAYDAY for grave and imminent danger requiring immediate assistance.'
: 'Use PAN PAN for an urgent situation that does not require immediate assistance.',
],
standard: 'SERA.14095',
}
}
if (lesson.id === 'continue-approach') {
lesson = {
...lesson,
whyItMatters: '“Continue approach” allows the approach to continue but is not a clearance to land.',
hints: ['Acknowledge the instruction. Do not treat it as a landing clearance.'],
}
}
if (lesson.id === 'standby') {
lesson = {
...lesson,
whyItMatters: '“STANDBY” only asks for time. It is not an approval, denial, clearance, or substitute for a mandatory safety readback.',
hints: ['Use it only for a brief delay in a non-urgent exchange.'],
}
}
const situationPrompt = situationPrompts[lesson.id]
if (situationPrompt) {
lesson = { ...lesson, phrase: situationPrompt }
}
if (lesson.id === 'go-around') {
const callsignField = lesson.fields.find(field => /callsign/i.test(field.key))
if (callsignField) {
lesson = {
...lesson,
fields: [callsignField],
readback: [
{ type: 'field', key: callsignField.key, width: 'lg' },
{ type: 'text', text: ', going around.' },
],
phrase: () => 'You discontinue the approach. Make the immediate pilot report; ATC instructions follow separately.',
hints: ['Transmit your callsign and “GOING AROUND”. Do not combine an assumed missed-approach clearance.'],
}
}
}
if (lesson.id === 'tcas-ra') {
const callsignKey = 'tcas-ra-callsign'
lesson = {
...lesson,
fields: [{
key: callsignKey,
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: callsignAlternatives,
width: 'lg',
}],
readback: [
{ type: 'field', key: callsignKey, width: 'lg' },
{ type: 'text', text: ', TCAS RA.' },
],
phrase: () => 'A resolution advisory is active. Report it immediately.',
hints: ['Use the exact concise report “TCAS RA”. Follow the RA even if it conflicts with an ATC instruction.'],
}
}
if (lesson.id === 'tcas-resume') {
const callsignKey = 'tcas-resume-callsign'
lesson = {
...lesson,
fields: [{
key: callsignKey,
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: callsignAlternatives,
width: 'lg',
}],
readback: [
{ type: 'field', key: callsignKey, width: 'lg' },
{ type: 'text', text: ', clear of conflict, returning to assigned clearance.' },
],
phrase: () => 'The resolution advisory has ended and you are returning to the assigned clearance.',
hints: ['Report “CLEAR OF CONFLICT, RETURNING TO ASSIGNED CLEARANCE”.'],
}
}
const promptKind = copyLessonIds.has(lesson.id)
? 'copy'
: situationLessonIds.has(lesson.id)
? 'situation'
: 'atc'
const responseLabel = promptKind === 'copy'
? (lesson.id === 'atis' || lesson.id === 'metar' ? 'Decoded values' : 'Your copy')
: promptKind === 'situation'
? 'Pilot call'
: 'Pilot readback'
return {
...lesson,
prompt: { kind: promptKind, text: lesson.phrase },
responseLabel,
standard: lesson.standard ?? (
promptKind === 'copy'
? 'ICAO Annex 10 / SERA.14070'
: 'SERA.8015 and Appendix 1 to AMC1 SERA.14001'
),
whyItMatters: lesson.whyItMatters ?? (
promptKind === 'copy'
? 'Accurate copying prevents identifiers and operational values from being confused.'
: 'The highlighted elements carry the clearance or operational intent and must not be guessed.'
),
fields: lesson.fields.map(field => curateField(field, promptKind)),
}
}
function fullFlightCallLesson(
id: string,
title: string,
desc: string,
build: (scenario: Scenario) => { situation: string; text: string },
): Lesson {
const callsignKey = `${id}-callsign`
return {
id,
title,
desc,
keywords: ['Guided flight', 'Pilot call'],
hints: ['Address the unit first, use your full callsign, then state the operational request or report.'],
fields: [{
key: callsignKey,
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: callsignAlternatives,
width: 'lg',
required: true,
safetyCritical: true,
matching: 'controlled',
}],
readback: [
{ type: 'text', text: scenario => build(scenario).text.split(scenario.radioCall)[0] ?? '' },
{ type: 'field', key: callsignKey, width: 'lg' },
{ type: 'text', text: scenario => build(scenario).text.split(scenario.radioCall).slice(1).join(scenario.radioCall) },
],
phrase: scenario => build(scenario).situation,
prompt: { kind: 'situation', text: scenario => build(scenario).situation },
responseLabel: 'Pilot call',
standard: 'Appendix 1 to AMC1 SERA.14001',
whyItMatters: 'A concise initial call gives ATC the aircraft identity and the information needed for the next instruction.',
info: () => ['Synthetic training sequence — not for operational use.'],
generate: makeFullFlightGenerator(),
}
}
const fullFlightAdditions: Record<string, Lesson> = {
'full-pushback-request': fullFlightCallLesson(
'full-pushback-request',
'Pushback Request',
'Request pushback and start from the assigned stand',
scenario => ({
situation: `You are ready at stand ${scenario.stand}. Request pushback and start from ${scenario.airport.city} Ground.`,
text: `${scenario.airport.city} Ground, ${scenario.radioCall}, stand ${scenario.stand}, request pushback and start.`,
}),
),
'full-taxi-request': fullFlightCallLesson(
'full-taxi-request',
'Taxi Request',
'Call ground when ready to taxi',
scenario => ({
situation: `Pushback is complete at stand ${scenario.stand}. Request taxi from ${scenario.airport.city} Ground.`,
text: `${scenario.airport.city} Ground, ${scenario.radioCall}, stand ${scenario.stand}, request taxi.`,
}),
),
'full-approach-checkin': fullFlightCallLesson(
'full-approach-checkin',
'Approach Initial Call',
'Check in after the approach channel change',
scenario => ({
situation: `You have changed to ${scenario.destination.city} Approach. You are passing ${scenario.altitudes.climb} feet, cleared to ${scenario.altitudes.initial} feet.`,
text: `${scenario.destination.city} Approach, ${scenario.radioCall}, passing ${scenario.altitudes.climbWords}, cleared ${scenario.altitudes.initialWords}.`,
}),
),
'full-established-report': fullFlightCallLesson(
'full-established-report',
'Established Report',
'Report established when instructed',
scenario => ({
situation: `You are established on the ${approachWithoutRunway(scenario)} approach runway ${scenario.arrivalRunway}. Report established.`,
text: `${scenario.destination.city} Approach, ${scenario.radioCall}, established runway ${scenario.arrivalRunway}.`,
}),
),
'full-vacate-transfer': fullFlightCallLesson(
'full-vacate-transfer',
'Runway Vacated and Ground Transfer',
'Report clear of the runway after changing to ground',
scenario => ({
situation: `You have vacated runway ${scenario.arrivalRunway} via ${scenario.arrivalTaxiRoute} and changed to Ground.`,
text: `${scenario.destination.city} Ground, ${scenario.radioCall}, runway vacated via ${scenario.arrivalTaxiRoute}.`,
}),
),
}
const curatedFullFlightLessons = fullFlightLessons
.filter(lesson => !['full-tower-contact', 'full-departure-handoff'].includes(lesson.id))
.flatMap(lesson => {
const additions: Lesson[] = []
if (lesson.id === 'full-pushback') additions.push(fullFlightAdditions['full-pushback-request']!)
if (lesson.id === 'full-taxi') additions.push(fullFlightAdditions['full-taxi-request']!)
if (lesson.id === 'full-descent') additions.push(fullFlightAdditions['full-approach-checkin']!)
if (lesson.id === 'full-landing') additions.push(fullFlightAdditions['full-established-report']!)
if (lesson.id === 'full-taxi-in') additions.push(fullFlightAdditions['full-vacate-transfer']!)
return [...additions, lesson]
})
.map(curateLesson)
export const learnModules: ModuleDef[] = [
{
id: 'normalize',
title: 'Fundamentals · Basics',
subtitle: 'Alphabet, Call Signs, ATIS & METAR',
title: 'Foundations',
subtitle: 'Numbers, callsigns, ATIS/METAR, radio checks, and channels',
art: '/img/learn/modules/img14.jpeg',
lessons: fundamentalsLessons
lessons: fundamentalsLessons.map(curateLesson)
},
{
id: 'arc',
title: 'Readbacks · Essential Calls',
subtitle: 'Clearances, taxi, approach & landing',
title: 'Mandatory Readbacks',
subtitle: 'Route, runway, QNH, squawk, level, heading, speed, and channels',
art: '/img/learn/modules/img11.jpeg',
lessons: readbackLessons
lessons: readbackLessons.map(curateLesson)
},
{
id: 'decision-tree',
title: 'ATC · Advanced Calls',
subtitle: 'Requests, contingencies & interrupts',
title: 'Pilot Calls & Abnormal Situations',
subtitle: 'Initial calls, unable, corrections, go-around, emergencies, and TCAS',
art: '/img/learn/modules/img10.jpeg',
lessons: decisionTreeLessons
lessons: decisionTreeLessons.map(curateLesson)
},
{
id: 'full-flight',
title: 'Full Flight · Gate to Gate',
subtitle: 'One linked scenario from clearance to taxi-in',
title: 'Guided Flight Sequence',
subtitle: 'One consistent 17-step training flight from clearance to taxi-in',
art: '/img/learn/modules/img6.jpeg',
lessons: fullFlightLessons,
lessons: curatedFullFlightLessons,
meta: {
flightPlan: true,
briefingArt: '/img/learn/missions/full-flight/briefing-hero.png'

View File

@@ -0,0 +1,73 @@
import {
CLASSROOM_ASSESSMENT_VERSION,
CLASSROOM_VARIANTS_FOR_MASTERY,
type LessonProgress,
} from './config'
export type AssessableField = {
key: string
required: boolean
pass: boolean
similarity: number
}
export type AssessmentSummary = {
score: number
hits: number
required: number
similarity: number
passed: boolean
}
export function assessRequiredFields(fields: AssessableField[]): AssessmentSummary | null {
const requiredFields = fields.filter(field => field.required)
if (!requiredFields.length) return null
const hits = requiredFields.filter(field => field.pass).length
const similarity = requiredFields.reduce((sum, field) => sum + field.similarity, 0) / requiredFields.length
return {
score: Math.round(similarity * 100),
hits,
required: requiredFields.length,
similarity,
passed: hits === requiredFields.length,
}
}
export type ProgressUpdate = {
progress: LessonProgress
counted: boolean
}
export function updateMasteryProgress(
previous: LessonProgress | undefined,
assessment: AssessmentSummary,
options: { modelAnswerRevealed: boolean; variantAlreadyCounted: boolean },
): ProgressUpdate {
const currentVersion = previous?.assessmentVersion === CLASSROOM_ASSESSMENT_VERSION
const previousVariants = currentVersion ? (previous?.successfulVariants || 0) : 0
const counted = assessment.passed && !options.modelAnswerRevealed && !options.variantAlreadyCounted
const successfulVariants = Math.min(
CLASSROOM_VARIANTS_FOR_MASTERY,
previousVariants + (counted ? 1 : 0),
)
return {
counted,
progress: {
best: Math.max(previous?.best || 0, assessment.score),
done: successfulVariants >= CLASSROOM_VARIANTS_FOR_MASTERY,
assessmentVersion: CLASSROOM_ASSESSMENT_VERSION,
successfulVariants,
},
}
}
export function isCurrentMastery(progress: LessonProgress | undefined): boolean {
return Boolean(
progress?.assessmentVersion === CLASSROOM_ASSESSMENT_VERSION
&& progress.done
&& (progress.successfulVariants || 0) >= CLASSROOM_VARIANTS_FOR_MASTERY,
)
}

View File

@@ -1,6 +1,8 @@
export interface LessonProgress {
best: number
done: boolean
assessmentVersion?: number
successfulVariants?: number
}
export type LearnProgress = Record<string, Record<string, LessonProgress>>
@@ -14,12 +16,17 @@ export interface LearnConfig {
}
export interface LearnState {
/** Legacy compatibility only. Classroom no longer awards or displays XP. */
xp: number
progress: LearnProgress
config: LearnConfig
/** Legacy compatibility only. All Classroom modules are now available. */
unlockedModules: string[]
}
export const CLASSROOM_ASSESSMENT_VERSION = 2
export const CLASSROOM_VARIANTS_FOR_MASTERY = 2
export const LEARN_CONFIG_DEFAULTS: LearnConfig = {
tts: false,
radioLevel: 5,

View File

@@ -2,7 +2,7 @@ import type { AirlineData, AirportData, Frequency, FrequencyType, Scenario } fro
import { generateSquawk } from '../utils/transponder'
const natoMap: Record<string, string> = {
A: 'Alpha',
A: 'Alfa',
B: 'Bravo',
C: 'Charlie',
D: 'Delta',
@@ -32,14 +32,14 @@ const natoMap: Record<string, string> = {
const atcNumberWords: Record<string, string> = {
'0': 'zero',
'1': 'one',
'2': 'two',
'1': 'wun',
'2': 'too',
'3': 'tree',
'4': 'four',
'4': 'fower',
'5': 'fife',
'6': 'six',
'7': 'seven',
'8': 'eight',
'8': 'ait',
'9': 'niner'
}
@@ -243,12 +243,17 @@ export function runwayToWords(runway: string): string {
return suffixWord ? `${base} ${suffixWord}` : base
}
function frequencyToSpeech(freq: string): string {
export function frequencyToSpeech(freq: string): string {
const [intPart, decimalPart] = freq.split('.')
const intWords = digitsToWords(intPart)
if (!decimalPart) return intWords
const trimmed = decimalPart.replace(/0+$/, '')
const decWords = trimmed ? digitsToWords(trimmed) : 'zero'
const channelDigits = decimalPart.padEnd(3, '0').slice(0, 3)
const spokenDigits = channelDigits.endsWith('00')
? channelDigits.slice(0, 1)
: channelDigits.endsWith('0')
? channelDigits.slice(0, 2)
: channelDigits
const decWords = digitsToWords(spokenDigits)
return `${intWords} decimal ${decWords}`
}
@@ -258,14 +263,19 @@ export function formatTemp(temp: number): string {
}
function temperatureToWords(temp: number): string {
const prefix = temp < 0 ? 'minus' : 'plus'
return `${prefix} ${digitsToWords(Math.abs(temp).toString())}`
const prefix = temp < 0 ? 'minus ' : ''
return `${prefix}${digitsToWords(Math.abs(temp).toString())}`
}
function qnhToWords(qnh: number): string {
export function qnhToWords(qnh: number): string {
if (qnh === 1000) return 'wun thousand'
return digitsToWords(qnh.toString())
}
export function squawkToWords(squawk: string): string {
return squawk === '1000' ? 'wun thousand' : digitsToWords(squawk)
}
function windToWords(direction: number, speed: number): string {
const dir = direction.toString().padStart(3, '0')
const spd = speed.toString().padStart(2, '0')
@@ -306,7 +316,14 @@ export function altitudeToWords(value: number): string {
const remainder = value % 1000
let words = thousands ? `${digitsToWords(thousands.toString())} thousand` : ''
if (remainder) {
words = `${words} ${digitsToWords(remainder.toString())}`.trim()
const hundreds = Math.floor(remainder / 100)
const tens = remainder % 100
if (hundreds) {
words = `${words} ${digitsToWords(hundreds.toString())} hundred`.trim()
}
if (tens) {
words = `${words} ${digitsToWords(tens.toString().padStart(2, '0'))}`.trim()
}
}
return words.trim()
}
@@ -340,7 +357,7 @@ export function createBaseScenario(): Scenario {
const windDirectionStr = windDirection.toString().padStart(3, '0')
const windSpeedStr = windSpeed.toString().padStart(2, '0')
const visibility = choice(['9999', '9000', '8000', '6000'])
const cloud = choice(['SKC', 'FEW020', 'SCT025', 'BKN030'])
const cloud = choice(['NSC', 'FEW020', 'SCT025', 'BKN030', 'OVC040'])
const temperature = randInt(-3, 28)
const dewpoint = Math.max(temperature - randInt(2, 6), -10)
const atisCode = choice(atisLetters)
@@ -454,7 +471,7 @@ export function createBaseScenario(): Scenario {
climbWords: altitudeToWords(climbAltitude)
},
squawk,
squawkWords: digitsToWords(squawk),
squawkWords: squawkToWords(squawk),
qnh,
qnhWords: qnhToWords(qnh),
atisCode,

View File

@@ -148,6 +148,9 @@ export type LessonField = {
expected: (scenario: Scenario) => string
alternatives?: (scenario: Scenario) => string[]
threshold?: number
required?: boolean
safetyCritical?: boolean
matching?: 'exact' | 'controlled' | 'fuzzy'
placeholder?: string
width?: BlankWidth
inputmode?: 'text' | 'numeric'
@@ -162,6 +165,13 @@ export type ReferenceEntry = {
value: string
}
export type LessonPromptKind = 'atc' | 'situation' | 'copy'
export type LessonPrompt = {
kind: LessonPromptKind
text: (scenario: Scenario) => string
}
export type Lesson = {
id: string
title: string
@@ -171,6 +181,11 @@ export type Lesson = {
fields: LessonField[]
readback: ReadbackSegment[]
defaultFrequency?: string
prompt?: LessonPrompt
responseLabel?: 'Pilot readback' | 'Pilot call' | 'Your copy' | 'Decoded values'
standard?: string
whyItMatters?: string
/** @deprecated Use prompt.text. Kept while legacy curriculum entries are curated. */
phrase: (scenario: Scenario) => string
info: (scenario: Scenario) => string[]
reference?: (scenario: Scenario) => ReferenceEntry[]

View File

@@ -16,7 +16,7 @@ export const PUBLIC_SEO_ROUTES: SeoRoute[] = [
{
path: '/',
title: 'Train ATC before you fly VATSIM | OpenSquawk',
description: 'Practice realistic ATC phraseology with guided scenarios, instant feedback and structured radio training for VATSIM and flight simulation.',
description: 'Learn ICAO/SERA radio patterns in audio-first Classroom drills with typed pilot responses, then practise them in the Live ATC alpha.',
image: '/img/learn/missions/full-flight/briefing-hero.png',
changefreq: 'weekly',
priority: 1,
@@ -25,7 +25,7 @@ export const PUBLIC_SEO_ROUTES: SeoRoute[] = [
{
path: '/de',
title: 'ATC-Funk für VATSIM trainieren | OpenSquawk',
description: 'Trainiere realistische ATC-Phraseologie mit geführten Szenarien, direktem Feedback und strukturierten Funkübungen für VATSIM und Flugsimulation.',
description: 'Lerne ICAO/SERA-Funkmuster in audio-geführten Classroom-Übungen mit getippten Pilotenmeldungen und übe sie anschließend in Live ATC.',
image: '/img/learn/missions/full-flight/briefing-hero.png',
changefreq: 'weekly',
priority: 0.9,
@@ -142,11 +142,11 @@ export const FAQ_SCHEMA = {
'@type': 'FAQPage',
mainEntity: [
['Is Classroom free right now?', 'Yes. Classroom is currently free during the invitation alpha.'],
['What is the difference between Classroom and Live ATC?', 'Classroom is structured listening, readback and feedback training. Live ATC is the real-time simulator radio loop and is still in closed alpha.'],
['What is the difference between Classroom and Live ATC?', 'Classroom uses audio-first prompts, typed pilot responses and feedback on required elements, phraseology and structure. Live ATC is the real-time simulator radio loop with PTT or text input. Both are in invitation alpha.'],
['Do I need to be on VATSIM to use it?', 'No. OpenSquawk is built for VATSIM preparation, so you can train before flying live on network frequencies.'],
['Can I self-host and inspect the stack?', 'Yes. The core is open source and self-hosting remains free.'],
['How do I get access?', 'Join the waitlist for Classroom and Live ATC. Access is released in rolling batches.'],
['Is this for real-world aviation?', 'No. OpenSquawk is simulator training software and is not intended for real-world ATC communication.'],
['Is this for real-world aviation?', 'No. OpenSquawk uses synthetic training data and is not a source for real-world navigation, frequencies or ATC communication.'],
].map(([name, text]) => ({
'@type': 'Question',
name,
@@ -159,11 +159,11 @@ export const DE_FAQ_SCHEMA = {
'@type': 'FAQPage',
mainEntity: [
['Ist Classroom aktuell kostenlos?', 'Ja. Classroom ist während der Einladungs-Alpha aktuell kostenlos.'],
['Was ist der Unterschied zwischen Classroom und Live ATC?', 'Classroom bietet strukturierte Hör-, Readback- und Feedbackübungen. Live ATC ist der Echtzeit-Funk für den Flugsimulator und befindet sich in der geschlossenen Alpha.'],
['Was ist der Unterschied zwischen Classroom und Live ATC?', 'Classroom bietet audio-geführte Übungen mit getippten Pilotenmeldungen und Feedback zu Pflichtangaben, Phraseologie und Struktur. Live ATC ist der Echtzeit-Funk mit PTT oder Texteingabe. Beide befinden sich in der Einladungs-Alpha.'],
['Muss ich bei VATSIM angemeldet sein?', 'Nein. OpenSquawk bereitet dich auf VATSIM vor, damit du vor dem ersten Onlineflug sicher üben kannst.'],
['Kann ich OpenSquawk selbst hosten?', 'Ja. Der Kern ist Open Source und kann kostenlos selbst gehostet werden.'],
['Wie erhalte ich Zugang?', 'Trage dich in die Warteliste für Classroom und Live ATC ein. Einladungen werden schrittweise freigeschaltet.'],
['Ist OpenSquawk für die reale Luftfahrt gedacht?', 'Nein. OpenSquawk ist Trainingssoftware für Flugsimulatoren und nicht für echten Flugfunk vorgesehen.'],
['Ist OpenSquawk für die reale Luftfahrt gedacht?', 'Nein. OpenSquawk verwendet synthetische Trainingsdaten und ist keine Quelle für reale Navigation, Frequenzen oder echten Flugfunk.'],
].map(([name, text]) => ({
'@type': 'Question',
name,
@@ -179,7 +179,7 @@ export const SOFTWARE_APPLICATION_SCHEMA = {
applicationCategory: 'EducationalApplication',
applicationSubCategory: 'Flight simulator ATC training',
operatingSystem: 'Web',
description: 'Structured ATC radio and phraseology training for flight simulator pilots preparing for VATSIM.',
description: 'Audio-first Classroom drills with typed pilot responses and an invitation-alpha Live ATC simulator radio loop.',
image: absoluteSiteUrl(DEFAULT_OG_IMAGE),
offers: {
'@type': 'Offer',
@@ -188,9 +188,10 @@ export const SOFTWARE_APPLICATION_SCHEMA = {
availability: 'https://schema.org/LimitedAvailability',
},
featureList: [
'Guided ATC communication scenarios',
'Readback practice and instant feedback',
'Flight simulator integration',
'Audio-first ICAO/SERA training prompts',
'Typed pilot calls and readbacks with field-level feedback',
'Rule-based Live ATC alpha sessions with PTT or text input',
'Optional alpha simulator Bridge',
'Self-hosted open-source option',
],
}

View File

@@ -0,0 +1,170 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { learnModules } from '~~/shared/data/learnModules'
import {
assessRequiredFields,
isCurrentMastery,
updateMasteryProgress,
} from '~~/shared/learn/assessment'
import { CLASSROOM_ASSESSMENT_VERSION } from '~~/shared/learn/config'
describe('Classroom curriculum contract', () => {
it('exposes the four goal-led modules and a 17-step guided flight', () => {
assert.deepEqual(
learnModules.map(module => module.title),
[
'Foundations',
'Mandatory Readbacks',
'Pilot Calls & Abnormal Situations',
'Guided Flight Sequence',
],
)
assert.equal(learnModules.find(module => module.id === 'full-flight')?.lessons.length, 17)
})
it('gives every lesson an explicit prompt, response label and valid required fields', () => {
const ids = new Set<string>()
for (const module of learnModules) {
for (const lesson of module.lessons) {
assert.equal(ids.has(lesson.id), false, `duplicate lesson id: ${lesson.id}`)
ids.add(lesson.id)
assert.ok(lesson.prompt, `${lesson.id} has no prompt`)
assert.ok(lesson.responseLabel, `${lesson.id} has no response label`)
assert.ok(lesson.standard, `${lesson.id} has no standard reference`)
assert.ok(lesson.whyItMatters, `${lesson.id} has no learning rationale`)
const scenario = lesson.generate()
const prompt = lesson.prompt?.text(scenario).trim() ?? ''
assert.ok(prompt, `${lesson.id} has an empty prompt`)
if (lesson.prompt?.kind === 'situation') {
assert.equal(
prompt.includes(scenario.radioCall),
false,
`${lesson.id} exposes the finished pilot response as its situation prompt`,
)
assert.equal(
lesson.fields.some(field => /callsign/i.test(`${field.key} ${field.label}`)),
true,
`${lesson.id} does not assess the pilot callsign`,
)
}
const fieldKeys = new Set(lesson.fields.map(field => field.key))
for (const field of lesson.fields) {
assert.equal(field.required, true, `${lesson.id}/${field.key} must declare required`)
assert.ok(field.matching, `${lesson.id}/${field.key} has no matching policy`)
assert.ok(field.expected(scenario).trim(), `${lesson.id}/${field.key} has no expected value`)
}
for (const segment of lesson.readback) {
if (segment.type === 'field') {
assert.equal(fieldKeys.has(segment.key), true, `${lesson.id} references missing field ${segment.key}`)
}
}
}
}
})
it('uses correct prompt roles for pilot calls, ATC instructions and copy exercises', () => {
const byId = new Map(learnModules.flatMap(module => module.lessons).map(lesson => [lesson.id, lesson]))
for (const id of ['clearance-contact', 'departure-checkin', 'mayday', 'pan-pan', 'go-around', 'radio-check']) {
assert.equal(byId.get(id)?.prompt?.kind, 'situation', `${id} must not be played as ATC`)
assert.equal(byId.get(id)?.responseLabel, 'Pilot call')
}
for (const id of ['atis', 'metar', 'icao-alphabet']) {
assert.equal(byId.get(id)?.prompt?.kind, 'copy')
}
assert.equal(byId.get('takeoff')?.prompt?.kind, 'atc')
})
it('models critical readbacks and emergency calls without misleading fields', () => {
const byId = new Map(learnModules.flatMap(module => module.lessons).map(lesson => [lesson.id, lesson]))
for (const id of ['takeoff', 'landing-clearance', 'full-takeoff', 'full-landing']) {
const lesson = byId.get(id)
assert.ok(lesson)
assert.equal(lesson?.fields.some(field => /wind/i.test(`${field.key} ${field.label}`)), false)
}
assert.equal(byId.get('approach-vector')?.fields.some(field => field.key === 'vector-direction'), true)
assert.equal(byId.get('descent-clearance')?.fields.some(field => field.key === 'descent-level'), true)
assert.equal(byId.get('radio-check')?.fields.some(field => /readability/i.test(field.key)), false)
for (const id of ['mayday', 'pan-pan']) {
const labels = byId.get(id)?.fields.map(field => field.label) ?? []
for (const expected of ['Callsign', 'Nature', 'Intention', 'Position', 'Level', 'Heading']) {
assert.equal(labels.includes(expected), true, `${id} misses ${expected}`)
}
}
})
it('keeps one scenario identity throughout the guided flight', () => {
const lessons = learnModules.find(module => module.id === 'full-flight')?.lessons ?? []
const scenarios = lessons.map(lesson => lesson.generate())
assert.ok(scenarios.length)
for (const scenario of scenarios) {
assert.equal(scenario.callsign, scenarios[0]?.callsign)
assert.equal(scenario.airport.icao, scenarios[0]?.airport.icao)
assert.equal(scenario.destination.icao, scenarios[0]?.destination.icao)
assert.equal(scenario.runway, scenarios[0]?.runway)
}
})
})
describe('Classroom assessment', () => {
it('never passes when one required safety element is missing at 80 percent', () => {
const summary = assessRequiredFields([
{ key: 'runway', required: true, pass: true, similarity: 1 },
{ key: 'heading', required: true, pass: true, similarity: 1 },
{ key: 'level', required: true, pass: true, similarity: 1 },
{ key: 'speed', required: true, pass: true, similarity: 1 },
{ key: 'callsign', required: true, pass: false, similarity: 0 },
])
assert.equal(summary?.score, 80)
assert.equal(summary?.passed, false)
})
it('requires two unrevealed variations for current mastery', () => {
const passed = { score: 100, hits: 2, required: 2, similarity: 1, passed: true }
const first = updateMasteryProgress(undefined, passed, {
modelAnswerRevealed: false,
variantAlreadyCounted: false,
})
assert.equal(first.progress.done, false)
assert.equal(first.progress.successfulVariants, 1)
const duplicate = updateMasteryProgress(first.progress, passed, {
modelAnswerRevealed: false,
variantAlreadyCounted: true,
})
assert.equal(duplicate.progress.successfulVariants, 1)
const revealed = updateMasteryProgress(first.progress, passed, {
modelAnswerRevealed: true,
variantAlreadyCounted: false,
})
assert.equal(revealed.progress.successfulVariants, 1)
const second = updateMasteryProgress(first.progress, passed, {
modelAnswerRevealed: false,
variantAlreadyCounted: false,
})
assert.equal(second.progress.done, true)
assert.equal(isCurrentMastery(second.progress), true)
})
it('treats legacy completion as review needed rather than current mastery', () => {
assert.equal(isCurrentMastery({ best: 100, done: true }), false)
assert.equal(
isCurrentMastery({
best: 100,
done: true,
assessmentVersion: CLASSROOM_ASSESSMENT_VERSION,
successfulVariants: 2,
}),
true,
)
})
})

View File

@@ -5,16 +5,23 @@ import {
altitudeToWords,
createBaseScenario,
createScenarioSeries,
frequencyToSpeech,
minutesToWords,
qnhToWords,
runwayToWords,
squawkToWords,
} from '~~/shared/learn/scenario'
describe('scenario helpers', () => {
it('formats runway, minutes and altitude values', () => {
assert.equal(runwayToWords('25R'), 'two fife right')
assert.equal(minutesToWords(0), 'one minute')
assert.equal(minutesToWords(4.4), 'four minutes')
assert.equal(altitudeToWords(4500), 'four thousand fife zero zero')
assert.equal(runwayToWords('25R'), 'too fife right')
assert.equal(minutesToWords(0), 'wun minute')
assert.equal(minutesToWords(4.4), 'fower minutes')
assert.equal(altitudeToWords(4500), 'fower thousand fife hundred')
assert.equal(qnhToWords(1000), 'wun thousand')
assert.equal(squawkToWords('1000'), 'wun thousand')
assert.equal(frequencyToSpeech('118.700'), 'wun wun ait decimal seven')
assert.equal(frequencyToSpeech('121.805'), 'wun too wun decimal ait zero fife')
})
it('creates a base scenario with critical data fields', () => {
@@ -25,6 +32,7 @@ describe('scenario helpers', () => {
assert.equal(scenario.frequencies.length > 0, true)
assert.equal(typeof scenario.handoff.frequencyWords, 'string')
assert.equal(scenario.handoff.frequencyWords.length > 0, true)
assert.equal(/\bSKC\b/.test(scenario.metar), false)
})
})