mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-16 03:37:13 +08:00
The overview rendered four anonymous module tiles and half a page of empty space, which undersold 63 existing lessons. It now stacks four module sections that list every lesson as a chip with status and variant dots, and the header states the real scope: 4 modules, 63 lessons, 262 variations. Mastery moves from a flat two-variation counter to per-module thresholds (3/4/4/5) backed by persisted variant signatures — a hash over each roll's expected answers. That triples the practice runs to full mastery without authoring a single new lesson, and fixes a dedup hole where the same variation counted twice after a page reload. Existing progress is preserved: `done` is sticky, so raising the bar never revokes a check mark someone already earned. Also fixes `yarn test`, which passed quoted globs Node 20 does not expand — it exited 0 without running a single test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
227 lines
8.7 KiB
TypeScript
227 lines
8.7 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,
|
|
CLASSROOM_VARIANTS_BY_MODULE,
|
|
CLASSROOM_VARIANTS_DEFAULT,
|
|
variantsForModule,
|
|
} 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 the module threshold in distinct unrevealed variations', () => {
|
|
const passed = { score: 100, hits: 2, required: 2, similarity: 1, passed: true }
|
|
const first = updateMasteryProgress(undefined, passed, {
|
|
modelAnswerRevealed: false,
|
|
signature: 'aaa',
|
|
threshold: 3,
|
|
})
|
|
assert.equal(first.counted, true)
|
|
assert.equal(first.progress.done, false)
|
|
assert.equal(first.progress.successfulVariants, 1)
|
|
assert.deepEqual(first.progress.variantSignatures, ['aaa'])
|
|
|
|
const duplicate = updateMasteryProgress(first.progress, passed, {
|
|
modelAnswerRevealed: false,
|
|
signature: 'aaa',
|
|
threshold: 3,
|
|
})
|
|
assert.equal(duplicate.counted, false)
|
|
assert.equal(duplicate.progress.successfulVariants, 1)
|
|
|
|
const revealed = updateMasteryProgress(first.progress, passed, {
|
|
modelAnswerRevealed: true,
|
|
signature: 'bbb',
|
|
threshold: 3,
|
|
})
|
|
assert.equal(revealed.progress.successfulVariants, 1)
|
|
|
|
const second = updateMasteryProgress(first.progress, passed, {
|
|
modelAnswerRevealed: false,
|
|
signature: 'bbb',
|
|
threshold: 3,
|
|
})
|
|
assert.equal(second.progress.done, false)
|
|
|
|
const third = updateMasteryProgress(second.progress, passed, {
|
|
modelAnswerRevealed: false,
|
|
signature: 'ccc',
|
|
threshold: 3,
|
|
})
|
|
assert.equal(third.progress.done, true)
|
|
assert.equal(isCurrentMastery(third.progress), true)
|
|
})
|
|
|
|
it('does not count a failed attempt', () => {
|
|
const failed = { score: 40, hits: 1, required: 2, similarity: 0.4, passed: false }
|
|
const update = updateMasteryProgress(undefined, failed, {
|
|
modelAnswerRevealed: false,
|
|
signature: 'aaa',
|
|
threshold: 3,
|
|
})
|
|
assert.equal(update.counted, false)
|
|
assert.equal(update.progress.successfulVariants, 0)
|
|
assert.equal(update.progress.best, 40)
|
|
})
|
|
|
|
it('keeps mastery earned under the old two-variation bar', () => {
|
|
const legacy = {
|
|
best: 100,
|
|
done: true,
|
|
assessmentVersion: CLASSROOM_ASSESSMENT_VERSION,
|
|
successfulVariants: 2,
|
|
}
|
|
assert.equal(isCurrentMastery(legacy), true)
|
|
|
|
const passed = { score: 100, hits: 2, required: 2, similarity: 1, passed: true }
|
|
const next = updateMasteryProgress(legacy, passed, {
|
|
modelAnswerRevealed: false,
|
|
signature: 'aaa',
|
|
threshold: 4,
|
|
})
|
|
assert.equal(next.progress.done, true, 'raising the bar must not revoke a check mark')
|
|
assert.equal(next.progress.successfulVariants, 3)
|
|
})
|
|
|
|
it('treats pre-version-2 completion as review needed rather than current mastery', () => {
|
|
assert.equal(isCurrentMastery({ best: 100, done: true }), false)
|
|
})
|
|
|
|
it('resolves per-module thresholds and falls back for unknown modules', () => {
|
|
assert.equal(variantsForModule('normalize'), 3)
|
|
assert.equal(variantsForModule('full-flight'), 5)
|
|
assert.equal(variantsForModule('nope'), CLASSROOM_VARIANTS_DEFAULT)
|
|
})
|
|
|
|
it('covers every shipped module with an explicit threshold', () => {
|
|
for (const module of learnModules) {
|
|
assert.ok(
|
|
CLASSROOM_VARIANTS_BY_MODULE[module.id],
|
|
`${module.id} has no explicit variant threshold`,
|
|
)
|
|
}
|
|
})
|
|
})
|