mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-15 10:36:17 +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>
56 lines
1.8 KiB
TypeScript
56 lines
1.8 KiB
TypeScript
import { describe, it } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
|
|
import { lessonVariantSignature } from './variantSignature'
|
|
import type { Lesson, Scenario } from './types'
|
|
|
|
function lessonWithFields(keys: string[]): Pick<Lesson, 'fields'> {
|
|
return {
|
|
fields: keys.map(key => ({
|
|
key,
|
|
label: key,
|
|
expected: (scenario: Scenario) => String((scenario as unknown as Record<string, unknown>)[key] ?? ''),
|
|
})),
|
|
}
|
|
}
|
|
|
|
const scenarioA = { runway: '25L', squawk: '4711' } as unknown as Scenario
|
|
const scenarioB = { runway: '07R', squawk: '4711' } as unknown as Scenario
|
|
|
|
describe('lessonVariantSignature', () => {
|
|
it('is stable for the same scenario', () => {
|
|
const lesson = lessonWithFields(['runway', 'squawk'])
|
|
assert.equal(
|
|
lessonVariantSignature(lesson, scenarioA),
|
|
lessonVariantSignature(lesson, scenarioA),
|
|
)
|
|
})
|
|
|
|
it('differs when an expected answer differs', () => {
|
|
const lesson = lessonWithFields(['runway', 'squawk'])
|
|
assert.notEqual(
|
|
lessonVariantSignature(lesson, scenarioA),
|
|
lessonVariantSignature(lesson, scenarioB),
|
|
)
|
|
})
|
|
|
|
it('ignores scenario values the lesson never asks for', () => {
|
|
const lesson = lessonWithFields(['squawk'])
|
|
assert.equal(
|
|
lessonVariantSignature(lesson, scenarioA),
|
|
lessonVariantSignature(lesson, scenarioB),
|
|
)
|
|
})
|
|
|
|
it('returns a non-empty token for a lesson without fields', () => {
|
|
assert.ok(lessonVariantSignature(lessonWithFields([]), scenarioA).length > 0)
|
|
})
|
|
|
|
it('survives a field whose expected() throws', () => {
|
|
const lesson = {
|
|
fields: [{ key: 'boom', label: 'boom', expected: () => { throw new Error('nope') } }],
|
|
} as unknown as Pick<Lesson, 'fields'>
|
|
assert.ok(lessonVariantSignature(lesson, scenarioA).length > 0)
|
|
})
|
|
})
|