| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238 |
- /**
- * 老师端班级总结的自动刷新(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')
|