/** * 老师端班级总结的自动刷新(spec §8.2) * * 覆盖:班级状态指纹的口径;定时 tick 的四道闸门(重入 / 分页隐藏 / 无人完成 / * 指纹未变);失败不记指纹于是下次 tick 重试;手动刷新同样更新指纹;计时器 * 间隔与卸载清理。手法承 test-article-auto-format-flow.mjs —— 转译 composable, * 把 vue 与 service 换成桩。 */ import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import ts from 'typescript' const root = new URL('../', import.meta.url) const read = p => readFile(new URL(p, root), 'utf8') const transpile = source => ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, }).outputText let source = await read( 'src/views/Student/components/ArticleReadingClassPanel/useArticleClassSummary.ts', ) source = source .replace( "import { ref, watch, onMounted, onUnmounted, type Ref } from 'vue'", [ 'const ref = value => ({ value })', // watch 的 immediate 由测试自己触发(__watch()),好精确控制每一次 tick 'const watch = (_src, cb) => { globalThis.__watch = cb }', 'const onMounted = fn => { globalThis.__mounted = fn }', 'const onUnmounted = fn => { globalThis.__unmounted = fn }', ].join('; '), ) .replace( /import \{\s*listArticleSessionsByConfig,\s*generateArticleClassSummary,\s*\} from '@\/views\/Editor\/EnglishSpeaking\/services\/articleReading'/, [ 'const listArticleSessionsByConfig = (...a) => globalThis.__list(...a)', 'const generateArticleClassSummary = (...a) => globalThis.__summary(...a)', ].join('; '), ) const { useArticleClassSummary, classStateKey } = await import( `data:text/javascript,${encodeURIComponent(transpile(source))}` ) const flush = () => new Promise(resolve => setTimeout(resolve, 0)) const row = (userId, status, overallScore = null) => ({ userId, name: userId, status, overallScore, dims: null, sessionId: 's-' + userId, completedAt: null, }) /** 组件挂载:驱动 watch 的 immediate 那一次,并跑 onMounted */ function setup({ rows = [], summary } = {}) { globalThis.document = { hidden: false, addEventListener() {}, removeEventListener() {} } globalThis.__summaryCalls = 0 globalThis.__list = async () => ({ summaries: current.rows }) globalThis.__summary = async () => { globalThis.__summaryCalls += 1 if (summary) return summary() return { summary: 'text-' + globalThis.__summaryCalls, generatedAt: 'now', fromCache: false, llmStatus: 'ok' } } const current = { rows } const api = useArticleClassSummary({ configId: { value: 'cfg-1' }, studentArray: { value: [{ userid: 'u1', name: 'u1' }, { userid: 'u2', name: 'u2' }] }, }) globalThis.__mounted() return { api, current, tick: async () => { globalThis.__watch(); await flush(); await flush() } } } function teardown() { globalThis.__unmounted() } // ---------------- 指纹口径 ---------------- { const a = [row('u1', 'completed', 80), row('u2', 'reading')] const b = [row('u2', 'reading'), row('u1', 'completed', 80)] assert.equal(classStateKey('c', a), classStateKey('c', b), '与数组顺序无关') assert.notEqual( classStateKey('c', [row('u1', 'reading')]), classStateKey('c', [row('u1', 'completed')]), '状态变化要进指纹', ) assert.notEqual( classStateKey('c', [row('u1', 'completed', 80)]), classStateKey('c', [row('u1', 'completed', 81)]), '分数变化要进指纹(分数晚于 status 落地)', ) assert.notEqual( classStateKey('c', [row('u1', 'completed', null)]), classStateKey('c', [row('u1', 'completed', 0)]), 'null 与 0 不可混同', ) assert.notEqual( classStateKey('cfg-1', a), classStateKey('cfg-2', a), 'configId 进指纹 —— 换配置自动失效', ) } // ---------------- 闸门:全班都没开始 ---------------- { const { current, tick } = setup({ rows: [row('u1', 'not_started'), row('u2', 'not_started')] }) await tick() assert.equal(globalThis.__summaryCalls, 0, '一人都没开始 → 不生成(只能写「未开始 N 人」)') current.rows = [row('u1', 'reading'), row('u2', 'not_started')] await tick() assert.equal(globalThis.__summaryCalls, 1, '有人开始朗读就生成,不必等到有人完成') current.rows = [row('u1', 'completed', 70), row('u2', 'not_started')] await tick() assert.equal(globalThis.__summaryCalls, 2, '同一人从进行中变完成 → 再刷新一次') teardown() } // ---------------- 闸门:指纹未变 ---------------- { const { api, current, tick } = setup({ rows: [row('u1', 'completed', 70)] }) await tick() assert.equal(globalThis.__summaryCalls, 1) assert.equal(api.aiSummary.value, 'text-1') await tick() await tick() assert.equal(globalThis.__summaryCalls, 1, '班级状态没变,定时 tick 不烧 LLM') assert.equal(api.aiSummary.value, 'text-1', '措辞不会无故换掉') current.rows = [row('u1', 'completed', 70), row('u2', 'completed', 90)] await tick() assert.equal(globalThis.__summaryCalls, 2, '有新完成者就刷新') teardown() } // 分数晚于 status 落地:同一批人、只有分数补上,也算新东西 { const { current, tick } = setup({ rows: [row('u1', 'completed', null)] }) await tick() assert.equal(globalThis.__summaryCalls, 1) current.rows = [row('u1', 'completed', 76)] await tick() assert.equal(globalThis.__summaryCalls, 2, '分数回填后重算') teardown() } // ---------------- 失败不记指纹 → 下次 tick 自动重试 ---------------- { let attempt = 0 const { api, tick } = setup({ rows: [row('u1', 'completed', 70)], summary: () => { attempt += 1 if (attempt === 1) throw new Error('boom') return { summary: 'recovered', generatedAt: 'now', fromCache: false, llmStatus: 'ok' } }, }) const originalError = console.error console.error = () => {} try { await tick() assert.equal(api.aiError.value, 'SUMMARY_FAILED') assert.equal(api.aiSummary.value, '', '从未成功 → 正文仍为空,由弹框显示错误块') await tick() } finally { console.error = originalError } assert.equal(globalThis.__summaryCalls, 2, '失败不记指纹,下次 tick 重试') assert.equal(api.aiSummary.value, 'recovered') assert.equal(api.aiError.value, null) teardown() } // ---------------- 手动刷新也更新指纹 ---------------- { const { api, tick } = setup({ rows: [row('u1', 'completed', 70)] }) await api.fetchClassSummary() await api.refreshAISummary() assert.equal(globalThis.__summaryCalls, 1, '手动点按钮照常生成') await tick() assert.equal(globalThis.__summaryCalls, 1, '紧接着的 tick 是 no-op,不必重置计时器') teardown() } // ---------------- 分页隐藏时跳过,切回来立刻补 ---------------- { const listeners = {} globalThis.document = { hidden: true, addEventListener: (type, fn) => { listeners[type] = fn }, removeEventListener: (type) => { delete listeners[type] }, } globalThis.__summaryCalls = 0 globalThis.__list = async () => ({ summaries: [row('u1', 'completed', 70)] }) globalThis.__summary = async () => { globalThis.__summaryCalls += 1 return { summary: 'x', generatedAt: 'now', fromCache: false, llmStatus: 'ok' } } useArticleClassSummary({ configId: { value: 'cfg-1' }, studentArray: { value: [{ userid: 'u1', name: 'u1' }] }, }) globalThis.__mounted() globalThis.__watch() await flush(); await flush() assert.equal(globalThis.__summaryCalls, 0, '老师切走的分页不烧 LLM') globalThis.document.hidden = false listeners.visibilitychange() await flush(); await flush() assert.equal(globalThis.__summaryCalls, 1, '切回来立刻补一次,不必再等 60 秒') globalThis.__unmounted() assert.equal(listeners.visibilitychange, undefined, '卸载摘掉监听') } // ---------------- 计时器:间隔与清理 ---------------- { const originalSet = globalThis.setInterval const originalClear = globalThis.clearInterval const created = [] const cleared = [] globalThis.setInterval = (_fn, ms) => { created.push(ms); return { id: created.length } } globalThis.clearInterval = handle => { cleared.push(handle) } try { const { api } = setup({ rows: [] }) assert.deepEqual(created, [60_000], '定时间隔 60 秒') assert.ok(api.scheduleRefetch, 'WS 去抖刷新仍然对外暴露') teardown() assert.deepEqual(cleared, [{ id: 1 }], '卸载清掉定时器') } finally { globalThis.setInterval = originalSet globalThis.clearInterval = originalClear } } console.log('✓ test-article-class-summary-auto: all assertions passed')