check-review.mjs 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. import assert from 'node:assert/strict';
  2. import { readFileSync } from 'node:fs';
  3. import { reviewModules, verifyReview } from './review-content.mjs';
  4. import {
  5. emptyReviewState,
  6. restoreReviewState,
  7. reviewProgress,
  8. reviewSignature,
  9. } from '../app/review-state.ts';
  10. const read = (path) => JSON.parse(readFileSync(path, 'utf8'));
  11. const lessons = read('public/content/review.json');
  12. const sources = new Map(
  13. reviewModules.map((slug) => [
  14. slug,
  15. read(slug === 'api' ? 'app/lessons.json' : `public/content/${slug}.json`),
  16. ]),
  17. );
  18. assert.equal(lessons.length, 9);
  19. const markdown = readFileSync('public/downloads/review-lessons.md', 'utf8');
  20. let items = 0;
  21. let criteria = 0;
  22. for (const [index, lesson] of lessons.entries()) {
  23. assert.deepEqual(verifyReview(lesson, index, sources), []);
  24. const start = markdown.indexOf(`## ${lesson.id} ${lesson.title}\n`);
  25. const end =
  26. index < lessons.length - 1
  27. ? markdown.indexOf(
  28. `## ${lessons[index + 1].id} ${lessons[index + 1].title}\n`,
  29. start + 1,
  30. )
  31. : markdown.length;
  32. assert(start >= 0 && end > start, `missing review/${lesson.id} download`);
  33. const text = markdown.slice(start, end);
  34. const headings = [
  35. '### 如何自测',
  36. '### 核心理解问题',
  37. '### 情境推演',
  38. '### 掌握标准与证据',
  39. '### 本轮结果与回学计划',
  40. '### 参考要点与易错点',
  41. ];
  42. const positions = headings.map((heading) => text.indexOf(heading));
  43. assert(
  44. positions.every(
  45. (position, i) => position >= 0 && (!i || position > positions[i - 1]),
  46. ),
  47. 'questions must precede reference answers',
  48. );
  49. for (const item of [...lesson.questions, ...lesson.challenges]) {
  50. assert(
  51. text.slice(0, positions[5]).includes(item.prompt),
  52. `${item.id}: missing question`,
  53. );
  54. for (const point of item.checkpoints)
  55. assert(
  56. text.slice(positions[5]).includes(point),
  57. `${item.id}: missing answer point`,
  58. );
  59. assert(text.slice(positions[5]).includes(item.misconception));
  60. for (const link of item.revisit)
  61. assert(text.includes(`${link.lessonId} · ${link.label}`));
  62. items++;
  63. }
  64. for (const item of lesson.mastery) {
  65. assert(text.includes(item.text) && text.includes(item.evidence));
  66. criteria++;
  67. }
  68. const empty = emptyReviewState();
  69. assert.equal(
  70. reviewProgress(lesson, empty).mastered,
  71. false,
  72. 'opening a review does not mean mastery',
  73. );
  74. const answered = {
  75. ...empty,
  76. answers: Object.fromEntries(
  77. [...lesson.questions, ...lesson.challenges].map((item) => [
  78. item.id,
  79. 'ready',
  80. ]),
  81. ),
  82. };
  83. assert.equal(
  84. reviewProgress(lesson, answered).mastered,
  85. false,
  86. 'self-rated answers alone cannot pass the evidence gate',
  87. );
  88. const complete = {
  89. ...answered,
  90. evidence: Object.fromEntries(lesson.mastery.map((item) => [item.id, true])),
  91. notes: { q1: '我的判断与证据' },
  92. };
  93. assert.equal(reviewProgress(lesson, complete).mastered, true);
  94. for (const item of [...lesson.questions, ...lesson.challenges]) {
  95. assert.equal(
  96. reviewProgress(lesson, {
  97. ...complete,
  98. answers: { ...complete.answers, [item.id]: 'revisit' },
  99. }).mastered,
  100. false,
  101. 'one unresolved item must keep the module incomplete',
  102. );
  103. }
  104. assert.equal(
  105. reviewProgress(lesson, {
  106. ...complete,
  107. evidence: { ...complete.evidence, m1: false },
  108. }).mastered,
  109. false,
  110. );
  111. const raw = JSON.stringify({
  112. sourceModule: lesson.sourceModule,
  113. signature: reviewSignature(lesson),
  114. state: complete,
  115. });
  116. assert.deepEqual(
  117. restoreReviewState(raw, lesson),
  118. complete,
  119. 'answers and notes must survive reload',
  120. );
  121. const other = lessons[(index + 1) % lessons.length];
  122. assert.deepEqual(
  123. restoreReviewState(raw, other),
  124. emptyReviewState(),
  125. 'ratings must not leak between modules',
  126. );
  127. assert.deepEqual(
  128. restoreReviewState(raw, {
  129. ...lesson,
  130. summary: lesson.summary + ' changed',
  131. }),
  132. complete,
  133. 'editorial summaries must not invalidate self-ratings',
  134. );
  135. const changed = restoreReviewState(raw, {
  136. ...lesson,
  137. questions: lesson.questions.map((item, i) =>
  138. i === 0 ? { ...item, prompt: item.prompt + ' changed' } : item,
  139. ),
  140. });
  141. assert.deepEqual(
  142. changed,
  143. { ...emptyReviewState(), notes: complete.notes, contentChanged: true },
  144. 'changed questions preserve notes but require fresh self-ratings',
  145. );
  146. assert.deepEqual(restoreReviewState('{broken', lesson), emptyReviewState());
  147. const invalid = JSON.stringify({
  148. sourceModule: lesson.sourceModule,
  149. signature: reviewSignature(lesson),
  150. state: {
  151. answers: { q1: 'maybe' },
  152. notes: { q1: 123 },
  153. evidence: { m1: 'true' },
  154. },
  155. });
  156. assert.deepEqual(
  157. restoreReviewState(invalid, lesson),
  158. emptyReviewState(),
  159. 'invalid saved fields cannot count as evidence',
  160. );
  161. }
  162. console.log(
  163. `Review: ${lessons.length} units, ${items} questions/challenges, ${criteria} mastery criteria; return links, download order, evidence gate, persistence, isolation and corrupt storage passed.`,
  164. );