-
-
+
+
+
-
{{ 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 11b58f1..f7d8fa3 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`,
+ )
+ }
})
})