diff --git a/app/pages/classroom.vue b/app/pages/classroom.vue index fe47b36..fb62fdf 100644 --- a/app/pages/classroom.vue +++ b/app/pages/classroom.vue @@ -159,55 +159,80 @@

Build the radio patterns before Live ATC

Start with Foundations, master the mandatory elements, then practise a complete guided flight.
-
- mdi-flask-outline - Synthetic training data — not for operational use +
+ {{ curriculumStats.modules }} modules + {{ curriculumStats.lessons }} lessons + {{ curriculumStats.variations }} practice variations + {{ curriculumStats.masteredPct }}% mastered + + mdi-flask-outline + Synthetic training data +
-
-
+
-
- - mdi-format-list-numbered - Step {{ moduleNumber(m.id) }} · Recommended order - -
-
-
-
+
+ + -
{{ m.subtitle }}
-
-
-
-
- {{ doneCount(m.id) }}/{{ m.lessons.length }} mastered - {{ practisingCount(m.id) }} practising -
-
- -
+
-
+ +
    +
  • + +
  • +
+
@@ -1467,8 +1492,8 @@ import {useApi} from '~/composables/useApi' import {useAuthStore} from '~/stores/auth' import { CLASSROOM_ASSESSMENT_VERSION, - CLASSROOM_VARIANTS_FOR_MASTERY, createDefaultLearnConfig, + variantsForModule, } from '~~/shared/learn/config' import type {LearnConfig, LearnProgress, LearnState} from '~~/shared/learn/config' import { @@ -1476,6 +1501,7 @@ import { isCurrentMastery, updateMasteryProgress, } from '~~/shared/learn/assessment' +import {lessonVariantSignature} from '~~/shared/learn/variantSignature' import {learnModules, seedFullFlightScenario} from '~~/shared/data/learnModules' import { createBaseScenario, @@ -2818,7 +2844,6 @@ const readbackFieldRefs = new Map() const result = ref(null) const evaluating = ref(false) const modelAnswerRevealed = ref(false) -const variantCounted = ref(false) // First-run nudge: after the very first readback is checked, highlight the // footer's primary button so learners realise they continue from there. Shown @@ -3071,13 +3096,14 @@ const audioContentHidden = computed(() => canPlayPrompt.value && cfg.value.audio const audioSpeedDisplay = computed(() => (cfg.value.audioSpeed ?? 1).toFixed(2)) const currentVariantProgressLabel = computed(() => { if (!current.value || !activeLesson.value) return 'Practising' + const threshold = variantsForModule(current.value.id) const entry = progress.value[current.value.id]?.[activeLesson.value.id] const variants = entry?.assessmentVersion === CLASSROOM_ASSESSMENT_VERSION - ? Math.min(CLASSROOM_VARIANTS_FOR_MASTERY, entry.successfulVariants || 0) + ? Math.min(threshold, entry.successfulVariants || 0) : 0 - return variants >= CLASSROOM_VARIANTS_FOR_MASTERY + return isCurrentMastery(entry) ? 'Mastered' - : `${variants} of ${CLASSROOM_VARIANTS_FOR_MASTERY} clean variations` + : `${variants} of ${threshold} clean variations` }) const instructorVoiceOptions = [ @@ -3870,7 +3896,6 @@ function rollScenario(clear = false) { resetAnswers(true) resetAudioReveal() modelAnswerRevealed.value = false - variantCounted.value = false if (clear) { result.value = null } @@ -4026,10 +4051,10 @@ function evaluate() { passed: summary.passed, }, { modelAnswerRevealed: modelAnswerRevealed.value, - variantAlreadyCounted: variantCounted.value, + signature: lessonVariantSignature(activeLesson.value, scenario.value), + threshold: variantsForModule(modId), }) - if (update.counted) variantCounted.value = true progress.value[modId][lesId] = update.progress } finally { evaluating.value = false @@ -4133,6 +4158,12 @@ function isLessonMastered(modId: string, lesId: string): boolean { return isCurrentMastery(progress.value[modId]?.[lesId]) } +function lessonVariantCount(modId: string, lesId: string): number { + const entry = progress.value[modId]?.[lesId] + if (entry?.assessmentVersion !== CLASSROOM_ASSESSMENT_VERSION) return 0 + return Math.min(variantsForModule(modId), entry.successfulVariants || 0) +} + function isLessonPractising(modId: string, lesId: string): boolean { const entry = progress.value[modId]?.[lesId] if (!entry) return false @@ -4152,6 +4183,24 @@ function pct(modId: string) { return Math.round((doneCount(modId) / module.lessons.length) * 100) } +/** Scope figures for the hub header, derived from the curriculum itself. */ +const curriculumStats = computed(() => { + const list = modules.value + const lessons = list.reduce((sum, module) => sum + module.lessons.length, 0) + const variations = list.reduce( + (sum, module) => sum + module.lessons.length * variantsForModule(module.id), + 0, + ) + const mastered = list.reduce((sum, module) => sum + doneCount(module.id), 0) + + return { + modules: list.length, + lessons, + variations, + masteredPct: lessons ? Math.round((mastered / lessons) * 100) : 0, + } +}) + function moduleNumber(modId: string): number { const index = modules.value.findIndex(module => module.id === modId) return index >= 0 ? index + 1 : 0 @@ -5703,224 +5752,261 @@ onMounted(() => { font-weight: 700; } -.tiles { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 12px; - align-items: stretch +.curriculum-stats { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; } -.tile { +.curriculum-stat { + display: inline-flex; + align-items: baseline; + gap: 6px; + padding: 6px 11px; border: 1px solid var(--border); - background: color-mix(in srgb, var(--text) 6%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--text) 5%, transparent); + color: var(--t3); + font-size: 12px; + letter-spacing: .06em; + text-transform: uppercase; +} + +.curriculum-stat strong { + color: var(--text); + font-size: 14px; + font-weight: 800; + letter-spacing: 0; +} + +.curriculum-stat.is-accent { + border-color: color-mix(in srgb, var(--accent) 34%, transparent); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +.curriculum-stat.is-accent strong { + color: var(--accent); +} + +.curriculum-stat.is-warn { + gap: 5px; + border-color: color-mix(in srgb, #fbbf24 26%, transparent); + background: color-mix(in srgb, #fbbf24 8%, transparent); + color: color-mix(in srgb, #fcd34d 82%, var(--text)); + font-weight: 650; +} + +.module-sections { display: flex; flex-direction: column; + gap: 16px; + /* The footer is position: fixed — keep the last lesson chip clear of it. */ + padding-bottom: 72px; +} + +.module-section { + border: 1px solid var(--border); border-radius: 18px; + background: color-mix(in srgb, var(--text) 5%, transparent); + box-shadow: 0 12px 26px rgba(0, 0, 0, .22); overflow: hidden; - box-shadow: 0 12px 26px rgba(0, 0, 0, .25); - transition: transform .25s ease, box-shadow .25s ease; - position: relative; - min-height: 360px } -.tile.is-fresh { - border-color: color-mix(in srgb, var(--accent) 32%, transparent) +.module-section.is-fresh { + border-color: color-mix(in srgb, var(--accent) 32%, transparent); } -.tile.is-active { - border-color: color-mix(in srgb, #fbbf24 28%, transparent) +.module-section.is-active { + border-color: color-mix(in srgb, #fbbf24 28%, transparent); } -.tile.is-complete { - border-color: color-mix(in srgb, #22c55e 32%, transparent) +.module-section.is-complete { + border-color: color-mix(in srgb, #22c55e 32%, transparent); } -.tile.is-locked { - filter: saturate(.7) brightness(.9) +.module-section.is-locked { + filter: saturate(.7) brightness(.9); } -.tile:hover { - box-shadow: 0 18px 40px rgba(0, 0, 0, .3) +.module-section-head { + display: grid; + grid-template-columns: 96px minmax(0, 1fr); + gap: 14px; + padding: 14px; + align-items: center; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--text) 3%, transparent); } -.tile-media { - height: 140px; +.module-section-art { + height: 96px; + border-radius: 14px; background-size: cover; background-position: center; - position: relative } -.tile-badge { - position: absolute; - top: 12px; - left: 12px; - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 12px; - border-radius: 999px; - background: linear-gradient(120deg, color-mix(in srgb, var(--accent) 65%, transparent), color-mix(in srgb, var(--accent2) 65%, transparent)); - color: #f8fafc; - font-size: 12px; - letter-spacing: .08em; - text-transform: uppercase; - box-shadow: 0 6px 18px rgba(2, 6, 23, .4) -} - -.tile-body { - padding: 14px; +.module-section-meta { display: flex; flex-direction: column; - gap: 12px; - flex: 1 + gap: 6px; + min-width: 0; } -.tile-top { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 4px -} - -.tile-title { - font-weight: 600; - display: flex; - align-items: center; - gap: 6px -} - -.tile-status { +.module-section-step { display: inline-flex; align-items: center; - gap: 6px; - padding: 4px 10px; - border-radius: 999px; - font-size: 12px; - letter-spacing: .08em; - text-transform: uppercase; - border: 1px solid color-mix(in srgb, var(--text) 18%, transparent); - color: var(--t3) -} - -.tile-status.is-fresh { - color: var(--accent); - border-color: color-mix(in srgb, var(--accent) 35%, transparent); - background: color-mix(in srgb, var(--accent) 12%, transparent) -} - -.tile-status.is-active { - color: #fbbf24; - border-color: color-mix(in srgb, #fbbf24 40%, transparent); - background: color-mix(in srgb, #fbbf24 12%, transparent) -} - -.tile-status.is-complete { - color: #22c55e; - border-color: color-mix(in srgb, #22c55e 40%, transparent); - background: color-mix(in srgb, #22c55e 14%, transparent) -} - -.tile-status.is-locked { + gap: 5px; color: var(--t3); - border-color: color-mix(in srgb, var(--text) 14%, transparent); - background: color-mix(in srgb, var(--text) 4%, transparent) + font-size: 11px; + font-weight: 700; + letter-spacing: .1em; + text-transform: uppercase; } -.tile-overlay { - position: absolute; - inset: 0; - background: rgba(6, 12, 34, .82); - color: #f8fafc; +.module-section-title { display: flex; align-items: center; - justify-content: center; - text-align: center; - padding: 28px; - backdrop-filter: blur(4px); - pointer-events: none + gap: 8px; + margin: 0; + font-size: 18px; + font-weight: 800; } -.tile-overlay-inner { +/* A full-width bar at 0% just reads as a divider — keep it short and paired + with the counts it describes. */ +.module-section-meta .line { + max-width: 260px; + height: 6px; + margin-top: 2px; +} + +.module-section-counts { display: flex; - flex-direction: column; - align-items: center; - gap: 10px; - max-width: 240px -} - -.tile-overlay-title { - font-weight: 600; + flex-wrap: wrap; + gap: 4px 14px; + color: var(--t3); + font-size: 11px; letter-spacing: .08em; - text-transform: uppercase + text-transform: uppercase; } -.tile-overlay-sub { - font-size: 14px; - color: rgba(248, 250, 252, .75) +.module-section-cta { + grid-column: 1 / -1; + justify-content: center; } -.tile-overlay-link { - pointer-events: auto; - background: none; - border: none; - padding: 0; - margin: 0 2px; - font: inherit; - color: inherit; +.lesson-chips { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 8px; + margin: 0; + padding: 14px; + list-style: none; +} + +.lesson-chip { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + padding: 9px 11px; + border: 1px solid var(--border); + border-radius: 12px; + background: color-mix(in srgb, var(--text) 4%, transparent); + color: var(--text); + text-align: left; cursor: pointer; - text-decoration: none; + transition: border-color .18s ease, background .18s ease, transform .18s ease; } -.tile-overlay-link:hover, -.tile-overlay-link:focus-visible { - text-decoration: underline; - color: #f8fafc; +.lesson-chip:hover, +.lesson-chip:focus-visible { + border-color: color-mix(in srgb, var(--accent) 44%, transparent); + background: color-mix(in srgb, var(--accent) 9%, transparent); + transform: translateY(-1px); outline: none; } -.line { - height: 8px; - border: 1px solid var(--border); - background: color-mix(in srgb, var(--text) 8%, transparent); - border-radius: 999px; - overflow: hidden -} - -.line-fill { - height: 100%; - background: linear-gradient(90deg, var(--accent), var(--accent2)) -} - -.tile-progress-meta { - display: flex; - justify-content: space-between; +.lesson-chip-icon { + flex: none; color: var(--t3); - font-size: 12px; - letter-spacing: .08em; - text-transform: uppercase } -.tile-actions { +.lesson-chip.is-great .lesson-chip-icon { + color: #22c55e; +} + +.lesson-chip.is-progress .lesson-chip-icon { + color: #fbbf24; +} + +.lesson-chip-body { display: flex; flex-direction: column; - gap: 10px; - align-items: stretch; - margin-top: auto + gap: 1px; + min-width: 0; + flex: 1 1 auto; } -.tile-actions .btn { - width: 100%; - justify-content: center; +.lesson-chip-title { + font-size: 13px; + font-weight: 650; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lesson-chip-sub { + color: var(--t3); + font-size: 10px; + letter-spacing: .07em; + text-transform: uppercase; +} + +.lesson-chip-dots { + display: inline-flex; + align-items: center; + flex: none; + gap: 4px; +} + +.variant-dot { + width: 6px; + height: 6px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--text) 24%, transparent); + background: transparent; +} + +.variant-dot.is-on { + border-color: var(--accent); + background: var(--accent); +} + +.lesson-chip.is-great .variant-dot.is-on { + border-color: #22c55e; + background: #22c55e; } @media (min-width: 720px) { - .tile-actions { - flex-direction: row; - flex-wrap: wrap; + .module-section-head { + grid-template-columns: 120px minmax(0, 1fr) auto; } - .tile-actions .btn { - flex: 1 1 50%; + .module-section-cta { + grid-column: auto; + align-self: center; + } +} + +@media (max-width: 520px) { + .module-section-head { + grid-template-columns: 1fr; + } + + .module-section-art { + height: 120px; } } @@ -7408,7 +7494,7 @@ onMounted(() => { animation: none !important; } - .tile, + .lesson-chip, .lesson, .challenge-card, .hero-orb, diff --git a/docs/plans/2026-07-30-classroom-production-value.md b/docs/plans/2026-07-30-classroom-production-value.md new file mode 100644 index 0000000..77b5a9e --- /dev/null +++ b/docs/plans/2026-07-30-classroom-production-value.md @@ -0,0 +1,94 @@ +# Classroom Production Value Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Triple the perceived and actual scope of the Classroom without authoring new lessons, and fill the empty overview page with the 63 lessons that already exist. + +**Architecture:** Mastery moves from a flat two-variant counter to per-module thresholds backed by persisted variant signatures (a hash over each roll's expected answers). The overview replaces four anonymous tiles with four stacked module sections that list every lesson. + +**Tech Stack:** Nuxt 4, Vue 3, TypeScript, `node:test` via tsx. + +Design: [2026-07-30-classroom-production-value-design.md](2026-07-30-classroom-production-value-design.md) + +--- + +### Task 1: Variant signature utility + +**Files:** +- Create: `shared/learn/variantSignature.ts` +- Test: `shared/learn/variantSignature.test.ts` + +FNV-1a over `lesson.fields.map(f => f.expected(scenario)).join('')`, base36. +Two rolls demanding the same answers collide by design; that is the point. + +Tests: stable for the same scenario, differs for different expected answers, +non-empty for a lesson with no fields. + +### Task 2: Per-module thresholds and the new progress field + +**Files:** +- Modify: `shared/learn/config.ts` + +Add `variantSignatures?: string[]` to `LessonProgress`. Replace +`CLASSROOM_VARIANTS_FOR_MASTERY` with `CLASSROOM_VARIANTS_BY_MODULE` +(`normalize` 3, `arc` 4, `decision-tree` 4, `full-flight` 5), +`CLASSROOM_VARIANTS_DEFAULT` 3, and `variantsForModule(modId)`. + +Keep the old constant exported as a deprecated alias equal to +`CLASSROOM_VARIANTS_DEFAULT` only if a call site still needs it; otherwise +remove it and fix the two call sites in `classroom.vue`. + +### Task 3: Signature-aware mastery + +**Files:** +- Modify: `shared/learn/assessment.ts` +- Test: `tests/shared/classroomCurriculum.test.ts` + +`updateMasteryProgress(previous, assessment, { modelAnswerRevealed, signature, threshold })`. + +- Counts when passed, not revealed, and `signature` is not already stored. +- Appends the signature, sets `successfulVariants = max(previous, signatures.length)`. +- **`done` is sticky:** `done: previous?.done || successfulVariants >= threshold`. + This is what preserves existing customers' check marks when the threshold rises. + +`isCurrentMastery(progress)` keeps its current shape — it reads `done`, so +grandfathered entries stay mastered. + +Tests: duplicate signature does not count; distinct signatures accumulate; +revealed answer never counts; a legacy `{done:true, successfulVariants:2}` stays +mastered under a threshold of 4; threshold lookup falls back to the default. + +### Task 4: Wire the signature through the lesson runner + +**Files:** +- Modify: `app/pages/classroom.vue` (evaluation at ~4021, `rollScenario` at ~3862, + `currentVariantProgressLabel` at ~3072) + +Compute the signature from `activeLesson.value` and `scenario.value` at +evaluation time. Delete the `variantCounted` ref — persisted signatures replace +it and fix the reload-dedup hole. Label reads `x of N clean variations` against +the module threshold. + +### Task 5: Lesson chips on the overview + +**Files:** +- Modify: `app/pages/classroom.vue` (hub markup at 158-212, styles) + +Four stacked module sections: header (art band, title, subtitle, progress bar, +counts, Continue button) plus a grid of lesson chips. Each chip shows the lesson +title, `lessonScoreIcon`, variant dots against the module threshold, and best +score. Clicking a chip calls `openLessonFromSearch(modId, lesId)`, which already +does exactly the right thing. + +### Task 6: Scope figures in the hub header + +**Files:** +- Modify: `app/pages/classroom.vue` + +Computed `curriculumStats`: module count, lesson count, and total variations +(sum of `lessons.length * variantsForModule(id)`). Rendered under the subtitle +with overall mastery progress. + +### Task 7: Verify and ship + +`yarn test`, `yarn typecheck`, commit, merge into `release`, push. diff --git a/package.json b/package.json index 60c6cd2..bac32c3 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "postinstall": "nuxt prepare && (git config core.hooksPath .githooks 2>/dev/null || true)", "typecheck": "vue-tsc --build", "sharp:rebuild": "SHARP_IGNORE_GLOBAL_LIBVIPS=1 yarn rebuild sharp", - "test": "tsx --tsconfig tsconfig.tests.json --test \"tests/**/*.test.ts\" \"server/**/*.test.ts\" \"shared/**/*.test.ts\"" + "test": "tsx --tsconfig tsconfig.tests.json --test tests/*/*.test.ts server/*/*.test.ts shared/*/*.test.ts" }, "dependencies": { "@nuxt/image": "^2.0.0", diff --git a/shared/learn/assessment.ts b/shared/learn/assessment.ts index cf0f97c..871ca6e 100644 --- a/shared/learn/assessment.ts +++ b/shared/learn/assessment.ts @@ -1,6 +1,5 @@ import { CLASSROOM_ASSESSMENT_VERSION, - CLASSROOM_VARIANTS_FOR_MASTERY, type LessonProgress, } from './config' @@ -43,13 +42,21 @@ export type ProgressUpdate = { export function updateMasteryProgress( previous: LessonProgress | undefined, assessment: AssessmentSummary, - options: { modelAnswerRevealed: boolean; variantAlreadyCounted: boolean }, + options: { modelAnswerRevealed: boolean; signature: string; threshold: number }, ): ProgressUpdate { const currentVersion = previous?.assessmentVersion === CLASSROOM_ASSESSMENT_VERSION const previousVariants = currentVersion ? (previous?.successfulVariants || 0) : 0 - const counted = assessment.passed && !options.modelAnswerRevealed && !options.variantAlreadyCounted + const previousSignatures = currentVersion ? (previous?.variantSignatures || []) : [] + + const counted = assessment.passed + && !options.modelAnswerRevealed + && !previousSignatures.includes(options.signature) + + const variantSignatures = counted + ? [...previousSignatures, options.signature] + : previousSignatures const successfulVariants = Math.min( - CLASSROOM_VARIANTS_FOR_MASTERY, + options.threshold, previousVariants + (counted ? 1 : 0), ) @@ -57,17 +64,24 @@ export function updateMasteryProgress( counted, progress: { best: Math.max(previous?.best || 0, assessment.score), - done: successfulVariants >= CLASSROOM_VARIANTS_FOR_MASTERY, + // Sticky: raising the threshold must never revoke a check mark someone + // already earned under the old bar. + done: (currentVersion && previous?.done === true) || successfulVariants >= options.threshold, assessmentVersion: CLASSROOM_ASSESSMENT_VERSION, successfulVariants, + variantSignatures, }, } } +/** + * `done` already encodes the module threshold that applied when it was set, so + * it is the single source of truth. Entries from before the versioned + * assessment still need a review pass. + */ export function isCurrentMastery(progress: LessonProgress | undefined): boolean { return Boolean( progress?.assessmentVersion === CLASSROOM_ASSESSMENT_VERSION - && progress.done - && (progress.successfulVariants || 0) >= CLASSROOM_VARIANTS_FOR_MASTERY, + && progress.done, ) } diff --git a/shared/learn/config.ts b/shared/learn/config.ts index 2390fa2..6ef0335 100644 --- a/shared/learn/config.ts +++ b/shared/learn/config.ts @@ -3,6 +3,11 @@ export interface LessonProgress { done: boolean assessmentVersion?: number successfulVariants?: number + /** + * Signatures of the rolled scenarios already passed for this lesson, so the + * same variation cannot be counted twice — not even across page reloads. + */ + variantSignatures?: string[] } export type LearnProgress = Record> @@ -25,7 +30,24 @@ export interface LearnState { } export const CLASSROOM_ASSESSMENT_VERSION = 2 -export const CLASSROOM_VARIANTS_FOR_MASTERY = 2 + +/** + * Clean variations required before a lesson counts as mastered. Short drills + * need less repetition than a full scenario, so the bar rises with module + * complexity. Every lesson rolls a fresh scenario, so the material is there. + */ +export const CLASSROOM_VARIANTS_BY_MODULE: Record = { + 'normalize': 3, + 'arc': 4, + 'decision-tree': 4, + 'full-flight': 5, +} + +export const CLASSROOM_VARIANTS_DEFAULT = 3 + +export function variantsForModule(moduleId: string): number { + return CLASSROOM_VARIANTS_BY_MODULE[moduleId] ?? CLASSROOM_VARIANTS_DEFAULT +} export const LEARN_CONFIG_DEFAULTS: LearnConfig = { tts: false, diff --git a/shared/learn/variantSignature.test.ts b/shared/learn/variantSignature.test.ts new file mode 100644 index 0000000..0bba013 --- /dev/null +++ b/shared/learn/variantSignature.test.ts @@ -0,0 +1,55 @@ +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 { + return { + fields: keys.map(key => ({ + key, + label: key, + expected: (scenario: Scenario) => String((scenario as unknown as Record)[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 + assert.ok(lessonVariantSignature(lesson, scenarioA).length > 0) + }) +}) diff --git a/shared/learn/variantSignature.ts b/shared/learn/variantSignature.ts new file mode 100644 index 0000000..809fc11 Binary files /dev/null and b/shared/learn/variantSignature.ts differ diff --git a/tests/shared/classroomCurriculum.test.ts b/tests/shared/classroomCurriculum.test.ts index 989534f..a8dd0fc 100644 --- a/tests/shared/classroomCurriculum.test.ts +++ b/tests/shared/classroomCurriculum.test.ts @@ -7,7 +7,12 @@ import { isCurrentMastery, updateMasteryProgress, } from '~~/shared/learn/assessment' -import { CLASSROOM_ASSESSMENT_VERSION } from '~~/shared/learn/config' +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', () => { @@ -126,45 +131,96 @@ describe('Classroom assessment', () => { assert.equal(summary?.passed, false) }) - it('requires two unrevealed variations for current mastery', () => { + 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, - variantAlreadyCounted: 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, - variantAlreadyCounted: true, + signature: 'aaa', + threshold: 3, }) + assert.equal(duplicate.counted, false) assert.equal(duplicate.progress.successfulVariants, 1) const revealed = updateMasteryProgress(first.progress, passed, { modelAnswerRevealed: true, - variantAlreadyCounted: false, + signature: 'bbb', + threshold: 3, }) assert.equal(revealed.progress.successfulVariants, 1) const second = updateMasteryProgress(first.progress, passed, { modelAnswerRevealed: false, - variantAlreadyCounted: false, + signature: 'bbb', + threshold: 3, }) - assert.equal(second.progress.done, true) - assert.equal(isCurrentMastery(second.progress), true) + 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('treats legacy completion as review needed rather than current mastery', () => { + 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) - assert.equal( - isCurrentMastery({ - best: 100, - done: true, - assessmentVersion: CLASSROOM_ASSESSMENT_VERSION, - successfulVariants: 2, - }), - true, - ) + }) + + 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`, + ) + } }) })