import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { reviewModules, verifyReview } from './review-content.mjs'; import { emptyReviewState, restoreReviewState, reviewProgress, reviewSignature, } from '../app/review-state.ts'; const read = (path) => JSON.parse(readFileSync(path, 'utf8')); const lessons = read('public/content/review.json'); const sources = new Map( reviewModules.map((slug) => [ slug, read(slug === 'api' ? 'app/lessons.json' : `public/content/${slug}.json`), ]), ); assert.equal(lessons.length, 9); const markdown = readFileSync('public/downloads/review-lessons.md', 'utf8'); let items = 0; let criteria = 0; for (const [index, lesson] of lessons.entries()) { assert.deepEqual(verifyReview(lesson, index, sources), []); const start = markdown.indexOf(`## ${lesson.id} ${lesson.title}\n`); const end = index < lessons.length - 1 ? markdown.indexOf( `## ${lessons[index + 1].id} ${lessons[index + 1].title}\n`, start + 1, ) : markdown.length; assert(start >= 0 && end > start, `missing review/${lesson.id} download`); const text = markdown.slice(start, end); const headings = [ '### 如何自测', '### 核心理解问题', '### 情境推演', '### 掌握标准与证据', '### 本轮结果与回学计划', '### 参考要点与易错点', ]; const positions = headings.map((heading) => text.indexOf(heading)); assert( positions.every( (position, i) => position >= 0 && (!i || position > positions[i - 1]), ), 'questions must precede reference answers', ); for (const item of [...lesson.questions, ...lesson.challenges]) { assert( text.slice(0, positions[5]).includes(item.prompt), `${item.id}: missing question`, ); for (const point of item.checkpoints) assert( text.slice(positions[5]).includes(point), `${item.id}: missing answer point`, ); assert(text.slice(positions[5]).includes(item.misconception)); for (const link of item.revisit) assert(text.includes(`${link.lessonId} · ${link.label}`)); items++; } for (const item of lesson.mastery) { assert(text.includes(item.text) && text.includes(item.evidence)); criteria++; } const empty = emptyReviewState(); assert.equal( reviewProgress(lesson, empty).mastered, false, 'opening a review does not mean mastery', ); const answered = { ...empty, answers: Object.fromEntries( [...lesson.questions, ...lesson.challenges].map((item) => [ item.id, 'ready', ]), ), }; assert.equal( reviewProgress(lesson, answered).mastered, false, 'self-rated answers alone cannot pass the evidence gate', ); const complete = { ...answered, evidence: Object.fromEntries(lesson.mastery.map((item) => [item.id, true])), notes: { q1: '我的判断与证据' }, }; assert.equal(reviewProgress(lesson, complete).mastered, true); for (const item of [...lesson.questions, ...lesson.challenges]) { assert.equal( reviewProgress(lesson, { ...complete, answers: { ...complete.answers, [item.id]: 'revisit' }, }).mastered, false, 'one unresolved item must keep the module incomplete', ); } assert.equal( reviewProgress(lesson, { ...complete, evidence: { ...complete.evidence, m1: false }, }).mastered, false, ); const raw = JSON.stringify({ sourceModule: lesson.sourceModule, signature: reviewSignature(lesson), state: complete, }); assert.deepEqual( restoreReviewState(raw, lesson), complete, 'answers and notes must survive reload', ); const other = lessons[(index + 1) % lessons.length]; assert.deepEqual( restoreReviewState(raw, other), emptyReviewState(), 'ratings must not leak between modules', ); assert.deepEqual( restoreReviewState(raw, { ...lesson, summary: lesson.summary + ' changed', }), complete, 'editorial summaries must not invalidate self-ratings', ); const changed = restoreReviewState(raw, { ...lesson, questions: lesson.questions.map((item, i) => i === 0 ? { ...item, prompt: item.prompt + ' changed' } : item, ), }); assert.deepEqual( changed, { ...emptyReviewState(), notes: complete.notes, contentChanged: true }, 'changed questions preserve notes but require fresh self-ratings', ); assert.deepEqual(restoreReviewState('{broken', lesson), emptyReviewState()); const invalid = JSON.stringify({ sourceModule: lesson.sourceModule, signature: reviewSignature(lesson), state: { answers: { q1: 'maybe' }, notes: { q1: 123 }, evidence: { m1: 'true' }, }, }); assert.deepEqual( restoreReviewState(invalid, lesson), emptyReviewState(), 'invalid saved fields cannot count as evidence', ); } console.log( `Review: ${lessons.length} units, ${items} questions/challenges, ${criteria} mastery criteria; return links, download order, evidence gate, persistence, isolation and corrupt storage passed.`, );