Files
OpenSquawk/tests/shared/classroomCurriculum.test.ts
itsrubberduck 2c5c2d5239 feat(classroom): show the full curriculum and raise the mastery bar
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>
2026-07-30 20:08:55 +02:00

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`,
)
}
})
})