test-article-class-summary-auto.mjs 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. /**
  2. * 老师端班级总结的自动刷新(spec §8.2)
  3. *
  4. * 覆盖:班级状态指纹的口径;定时 tick 的四道闸门(重入 / 分页隐藏 / 无人完成 /
  5. * 指纹未变);失败不记指纹于是下次 tick 重试;手动刷新同样更新指纹;计时器
  6. * 间隔与卸载清理。手法承 test-article-auto-format-flow.mjs —— 转译 composable,
  7. * 把 vue 与 service 换成桩。
  8. */
  9. import assert from 'node:assert/strict'
  10. import { readFile } from 'node:fs/promises'
  11. import ts from 'typescript'
  12. const root = new URL('../', import.meta.url)
  13. const read = p => readFile(new URL(p, root), 'utf8')
  14. const transpile = source => ts.transpileModule(source, {
  15. compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
  16. }).outputText
  17. let source = await read(
  18. 'src/views/Student/components/ArticleReadingClassPanel/useArticleClassSummary.ts',
  19. )
  20. source = source
  21. .replace(
  22. "import { ref, watch, onMounted, onUnmounted, type Ref } from 'vue'",
  23. [
  24. 'const ref = value => ({ value })',
  25. // watch 的 immediate 由测试自己触发(__watch()),好精确控制每一次 tick
  26. 'const watch = (_src, cb) => { globalThis.__watch = cb }',
  27. 'const onMounted = fn => { globalThis.__mounted = fn }',
  28. 'const onUnmounted = fn => { globalThis.__unmounted = fn }',
  29. ].join('; '),
  30. )
  31. .replace(
  32. /import \{\s*listArticleSessionsByConfig,\s*generateArticleClassSummary,\s*\} from '@\/views\/Editor\/EnglishSpeaking\/services\/articleReading'/,
  33. [
  34. 'const listArticleSessionsByConfig = (...a) => globalThis.__list(...a)',
  35. 'const generateArticleClassSummary = (...a) => globalThis.__summary(...a)',
  36. ].join('; '),
  37. )
  38. const { useArticleClassSummary, classStateKey } = await import(
  39. `data:text/javascript,${encodeURIComponent(transpile(source))}`
  40. )
  41. const flush = () => new Promise(resolve => setTimeout(resolve, 0))
  42. const row = (userId, status, overallScore = null) => ({
  43. userId, name: userId, status, overallScore, dims: null, sessionId: 's-' + userId, completedAt: null,
  44. })
  45. /** 组件挂载:驱动 watch 的 immediate 那一次,并跑 onMounted */
  46. function setup({ rows = [], summary } = {}) {
  47. globalThis.document = { hidden: false, addEventListener() {}, removeEventListener() {} }
  48. globalThis.__summaryCalls = 0
  49. globalThis.__list = async () => ({ summaries: current.rows })
  50. globalThis.__summary = async () => {
  51. globalThis.__summaryCalls += 1
  52. if (summary) return summary()
  53. return { summary: 'text-' + globalThis.__summaryCalls, generatedAt: 'now', fromCache: false, llmStatus: 'ok' }
  54. }
  55. const current = { rows }
  56. const api = useArticleClassSummary({
  57. configId: { value: 'cfg-1' },
  58. studentArray: { value: [{ userid: 'u1', name: 'u1' }, { userid: 'u2', name: 'u2' }] },
  59. })
  60. globalThis.__mounted()
  61. return { api, current, tick: async () => { globalThis.__watch(); await flush(); await flush() } }
  62. }
  63. function teardown() {
  64. globalThis.__unmounted()
  65. }
  66. // ---------------- 指纹口径 ----------------
  67. {
  68. const a = [row('u1', 'completed', 80), row('u2', 'reading')]
  69. const b = [row('u2', 'reading'), row('u1', 'completed', 80)]
  70. assert.equal(classStateKey('c', a), classStateKey('c', b), '与数组顺序无关')
  71. assert.notEqual(
  72. classStateKey('c', [row('u1', 'reading')]),
  73. classStateKey('c', [row('u1', 'completed')]),
  74. '状态变化要进指纹',
  75. )
  76. assert.notEqual(
  77. classStateKey('c', [row('u1', 'completed', 80)]),
  78. classStateKey('c', [row('u1', 'completed', 81)]),
  79. '分数变化要进指纹(分数晚于 status 落地)',
  80. )
  81. assert.notEqual(
  82. classStateKey('c', [row('u1', 'completed', null)]),
  83. classStateKey('c', [row('u1', 'completed', 0)]),
  84. 'null 与 0 不可混同',
  85. )
  86. assert.notEqual(
  87. classStateKey('cfg-1', a),
  88. classStateKey('cfg-2', a),
  89. 'configId 进指纹 —— 换配置自动失效',
  90. )
  91. }
  92. // ---------------- 闸门:全班都没开始 ----------------
  93. {
  94. const { current, tick } = setup({ rows: [row('u1', 'not_started'), row('u2', 'not_started')] })
  95. await tick()
  96. assert.equal(globalThis.__summaryCalls, 0, '一人都没开始 → 不生成(只能写「未开始 N 人」)')
  97. current.rows = [row('u1', 'reading'), row('u2', 'not_started')]
  98. await tick()
  99. assert.equal(globalThis.__summaryCalls, 1, '有人开始朗读就生成,不必等到有人完成')
  100. current.rows = [row('u1', 'completed', 70), row('u2', 'not_started')]
  101. await tick()
  102. assert.equal(globalThis.__summaryCalls, 2, '同一人从进行中变完成 → 再刷新一次')
  103. teardown()
  104. }
  105. // ---------------- 闸门:指纹未变 ----------------
  106. {
  107. const { api, current, tick } = setup({ rows: [row('u1', 'completed', 70)] })
  108. await tick()
  109. assert.equal(globalThis.__summaryCalls, 1)
  110. assert.equal(api.aiSummary.value, 'text-1')
  111. await tick()
  112. await tick()
  113. assert.equal(globalThis.__summaryCalls, 1, '班级状态没变,定时 tick 不烧 LLM')
  114. assert.equal(api.aiSummary.value, 'text-1', '措辞不会无故换掉')
  115. current.rows = [row('u1', 'completed', 70), row('u2', 'completed', 90)]
  116. await tick()
  117. assert.equal(globalThis.__summaryCalls, 2, '有新完成者就刷新')
  118. teardown()
  119. }
  120. // 分数晚于 status 落地:同一批人、只有分数补上,也算新东西
  121. {
  122. const { current, tick } = setup({ rows: [row('u1', 'completed', null)] })
  123. await tick()
  124. assert.equal(globalThis.__summaryCalls, 1)
  125. current.rows = [row('u1', 'completed', 76)]
  126. await tick()
  127. assert.equal(globalThis.__summaryCalls, 2, '分数回填后重算')
  128. teardown()
  129. }
  130. // ---------------- 失败不记指纹 → 下次 tick 自动重试 ----------------
  131. {
  132. let attempt = 0
  133. const { api, tick } = setup({
  134. rows: [row('u1', 'completed', 70)],
  135. summary: () => {
  136. attempt += 1
  137. if (attempt === 1) throw new Error('boom')
  138. return { summary: 'recovered', generatedAt: 'now', fromCache: false, llmStatus: 'ok' }
  139. },
  140. })
  141. const originalError = console.error
  142. console.error = () => {}
  143. try {
  144. await tick()
  145. assert.equal(api.aiError.value, 'SUMMARY_FAILED')
  146. assert.equal(api.aiSummary.value, '', '从未成功 → 正文仍为空,由弹框显示错误块')
  147. await tick()
  148. }
  149. finally {
  150. console.error = originalError
  151. }
  152. assert.equal(globalThis.__summaryCalls, 2, '失败不记指纹,下次 tick 重试')
  153. assert.equal(api.aiSummary.value, 'recovered')
  154. assert.equal(api.aiError.value, null)
  155. teardown()
  156. }
  157. // ---------------- 手动刷新也更新指纹 ----------------
  158. {
  159. const { api, tick } = setup({ rows: [row('u1', 'completed', 70)] })
  160. await api.fetchClassSummary()
  161. await api.refreshAISummary()
  162. assert.equal(globalThis.__summaryCalls, 1, '手动点按钮照常生成')
  163. await tick()
  164. assert.equal(globalThis.__summaryCalls, 1, '紧接着的 tick 是 no-op,不必重置计时器')
  165. teardown()
  166. }
  167. // ---------------- 分页隐藏时跳过,切回来立刻补 ----------------
  168. {
  169. const listeners = {}
  170. globalThis.document = {
  171. hidden: true,
  172. addEventListener: (type, fn) => { listeners[type] = fn },
  173. removeEventListener: (type) => { delete listeners[type] },
  174. }
  175. globalThis.__summaryCalls = 0
  176. globalThis.__list = async () => ({ summaries: [row('u1', 'completed', 70)] })
  177. globalThis.__summary = async () => {
  178. globalThis.__summaryCalls += 1
  179. return { summary: 'x', generatedAt: 'now', fromCache: false, llmStatus: 'ok' }
  180. }
  181. useArticleClassSummary({
  182. configId: { value: 'cfg-1' },
  183. studentArray: { value: [{ userid: 'u1', name: 'u1' }] },
  184. })
  185. globalThis.__mounted()
  186. globalThis.__watch()
  187. await flush(); await flush()
  188. assert.equal(globalThis.__summaryCalls, 0, '老师切走的分页不烧 LLM')
  189. globalThis.document.hidden = false
  190. listeners.visibilitychange()
  191. await flush(); await flush()
  192. assert.equal(globalThis.__summaryCalls, 1, '切回来立刻补一次,不必再等 60 秒')
  193. globalThis.__unmounted()
  194. assert.equal(listeners.visibilitychange, undefined, '卸载摘掉监听')
  195. }
  196. // ---------------- 计时器:间隔与清理 ----------------
  197. {
  198. const originalSet = globalThis.setInterval
  199. const originalClear = globalThis.clearInterval
  200. const created = []
  201. const cleared = []
  202. globalThis.setInterval = (_fn, ms) => { created.push(ms); return { id: created.length } }
  203. globalThis.clearInterval = handle => { cleared.push(handle) }
  204. try {
  205. const { api } = setup({ rows: [] })
  206. assert.deepEqual(created, [60_000], '定时间隔 60 秒')
  207. assert.ok(api.scheduleRefetch, 'WS 去抖刷新仍然对外暴露')
  208. teardown()
  209. assert.deepEqual(cleared, [{ id: 1 }], '卸载清掉定时器')
  210. }
  211. finally {
  212. globalThis.setInterval = originalSet
  213. globalThis.clearInterval = originalClear
  214. }
  215. }
  216. console.log('✓ test-article-class-summary-auto: all assertions passed')