mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-15 10:36:17 +08:00
171 lines
6.9 KiB
TypeScript
171 lines
6.9 KiB
TypeScript
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,
|
|
)
|
|
})
|
|
})
|