import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import ts from 'typescript' const sourceUrl = new URL('../src/views/Editor/EnglishSpeaking/composables/useArticleReadingEngine.ts', import.meta.url) let source = await readFile(sourceUrl, 'utf8') // Vue 运行时替换为最小 shim;watch 只需支持 getter + 立即回调语义 source = source.replace( "import { ref, computed, watch, onUnmounted, toValue, type MaybeRefOrGetter } from 'vue'", ` const __watchers = [] const ref = value => { const r = { _v: value, get value() { return this._v }, set value(next) { this._v = next; __runWatchers() } } return r } const computed = getter => ({ get value() { return getter() } }) const watch = (getter, cb) => { __watchers.push({ getter, cb, last: getter() }) } const onUnmounted = () => {} const toValue = v => (typeof v === 'function' ? v() : (v && typeof v === 'object' && 'value' in v ? v.value : v)) globalThis.__runWatchers = () => { for (const w of __watchers) { const next = w.getter() if (next !== w.last) { const prev = w.last; w.last = next; w.cb(next, prev) } } } `, ) // 运行时依赖全部替换为注入桩;测试永远传完整 overrides,这些默认值不会被执行 source = source .replace("import { useAudioRecorder } from './useAudioRecorder'", 'const useAudioRecorder = () => globalThis.__deps.recorder') .replace("import { useAudioPlayer } from './useAudioPlayer'", 'const useAudioPlayer = () => globalThis.__deps.player') .replace( /import \{[\s\S]*?\} from '\.\.\/services\/articleReading'/, ` const createArticleSession = (...a) => globalThis.__deps.createSession(...a) const getArticleReport = (...a) => globalThis.__deps.getReport(...a) const completeArticleSession = (...a) => globalThis.__deps.completeSession(...a) const getArticleDemoAudio = (...a) => globalThis.__deps.getDemoAudio(...a) const abandonArticleSession = (...a) => globalThis.__deps.abandonSession(...a) `, ) .replace( /import \{[\s\S]*?\} from '\.\.\/services\/articleErrors'/, ` const friendlyArticleError = cause => { const raw = cause instanceof Error ? cause.message : String(cause ?? '') return raw === 'Session not found' ? '__localized_session_not_found__' : '__localized_generic__' } const isArticleSessionGone = cause => cause?.status === 409 `, ) .replace( "import { openArticleParagraphStream, type ArticleParagraphStream } from '../services/articleReadingStream'", 'const openArticleParagraphStream = (...a) => globalThis.__deps.createStream(...a)', ) .replace( "import { isFinalReportReady, pollDelayMs } from '../preview/articleReadingModel'", ` const isFinalReportReady = r => !r.paragraphs.some(p => p.status === 'generating') && (r.overallStatus === 'completed' || r.overallStatus === 'failed' || r.overallStatus === null) const pollDelayMs = attempt => Math.min(8000, 1000 * 2 ** Math.max(0, attempt)) `, ) const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, }).outputText const mod = await import(`data:text/javascript,${encodeURIComponent(compiled)}`) // ---------------- fakes ---------------- function makeRecorder(sampleRate = 48000) { const rec = { recordingDuration: { _v: 0, get value() { return this._v }, set value(v) { this._v = v; globalThis.__runWatchers() } }, sampleRate: { value: sampleRate }, onChunk: { value: null }, started: 0, cleaned: 0, async startRecording() { this.started += 1 }, async stopRecording() { return { wav: true, size: 1 } }, // 真实实现在这一步停 mediaStream track、关 AudioContext、断开 worklet、清时长 interval。 // 不拆的后果不是泄漏一点内存:孤儿 worklet 会继续把音频灌进下一段录音。 cleanup() { this.cleaned += 1 }, } return rec } function makePlayer() { return { playingId: { value: null }, loadingId: { value: null }, errorId: { value: null }, plays: [], // lazy-url 的 URL 解析发生在 play() 内部(与真 player 一致):示范音频那条 // 失败路径要经过这里,引擎的 error.value 才立得起来 async play(id, source) { this.plays.push({ id, source }) this.loadingId.value = id if (source.kind === 'lazy-url') { try { await source.resolve(new AbortController().signal) } catch { this.loadingId.value = null this.errorId.value = id return } } this.loadingId.value = null this.playingId.value = id }, stop() { this.playingId.value = null; this.loadingId.value = null }, } } function makeStream() { const stream = { chunks: [], stopped: false, aborted: null, resolveAck: null, pushChunk(c) { this.chunks.push(c) }, stop() { this.stopped = true; return new Promise(res => { this.resolveAck = res }) }, abort(reason) { this.aborted = reason }, } return stream } /** 手控时钟:测试自己决定何时推进 timeout/interval。 */ function makeClock() { let nextId = 1 let nowMs = Date.UTC(2026, 0, 1, 0, 0, 0) const timeouts = new Map() const intervals = new Map() return { timeouts, intervals, now() { return nowMs }, /** 推进墙上时钟(秒)。不自动触发 interval —— 用 tickInterval 显式驱动。 */ advance(seconds) { nowMs += seconds * 1000 }, setTimeout(fn, delay) { const id = nextId++; timeouts.set(id, { fn, delay }); return id }, clearTimeout(id) { timeouts.delete(id) }, setInterval(fn, delay) { const id = nextId++; intervals.set(id, { fn, delay }); return id }, clearInterval(id) { intervals.delete(id) }, async runTimeouts() { const pending = [...timeouts.entries()] timeouts.clear() for (const [, t] of pending) { t.fn(); await flush() } }, async tickInterval(times = 1) { for (let i = 0; i < times; i++) { for (const [, t] of [...intervals.entries()]) t.fn() await flush() } }, } } function makeDoc(hidden = false) { return { hidden, listeners: {}, addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn) }, removeEventListener(type, fn) { this.listeners[type] = (this.listeners[type] || []).filter(f => f !== fn) }, fire(type) { for (const fn of [...(this.listeners[type] || [])]) fn() }, } } const flush = () => new Promise(resolve => setImmediate(resolve)) function paragraphs(statuses) { return statuses.map((status, idx) => ({ idx, text: `p${idx}`, status, audioUrl: null, overallScore: null, dims: null, words: [], })) } function reportOf(statuses, overallStatus = null, extra = {}) { return { sessionId: 'sess-1', title: 'T', status: 'reading', totalDurationSeconds: null, // §4.4:绝对截止时刻。默认 null = 不限时,既有用例的行为因此不变。 expiresAt: null, paragraphs: paragraphs(statuses), overall: null, overallStatus, ...extra, } } function setup({ statuses = ['pending', 'pending'], reports = [], showReport = true, durationMinutes = 1, expiresAt = null, userId = 'u1', abandonSession, completeSession, getReport, createSession, getDemoAudio, onSessionAbandoned, doc, clock, } = {}) { const recorder = makeRecorder() const player = makePlayer() const streams = [] const calls = { complete: 0, getReport: 0, demo: 0, abandon: 0 } let reportQueue = [...reports] const deps = { recorder, player, document: doc ?? makeDoc(), setTimeout: (...a) => clock.setTimeout(...a), clearTimeout: id => clock.clearTimeout(id), setInterval: (...a) => clock.setInterval(...a), clearInterval: id => clock.clearInterval(id), now: () => clock.now(), async createSession(...a) { if (createSession) return createSession(...a) return reportOf(statuses, null, { expiresAt }) }, async getReport() { calls.getReport += 1 if (getReport) return getReport() return reportQueue.length > 1 ? reportQueue.shift() : (reportQueue[0] ?? reportOf(statuses, null, { expiresAt })) }, completeSession: completeSession ?? (async () => { calls.complete += 1 }), async getDemoAudio(...a) { calls.demo += 1 if (getDemoAudio) return getDemoAudio(...a) return { url: 'https://s3/demo.mp3' } }, abandonSession: abandonSession ?? (async id => { calls.abandon += 1; return { sessionId: id, status: 'abandoned' } }), createStream(opts) { const s = makeStream(); s.opts = opts; streams.push(s); return s }, } globalThis.__deps = deps const completed = [] const engine = mod.useArticleReadingEngine( { configId: 'c1', userId, durationMinutes, showReport, onSessionCompleted: id => completed.push(id), onSessionAbandoned, }, deps, ) return { engine, recorder, player, streams, calls, completed, deps, setReports: r => { reportQueue = [...r] } } } // ---- 1. stream 建立前的 PCM 不丢;handshake 用 recorder 真实 sampleRate ---- { const clock = makeClock() const { engine, recorder, streams } = setup({ clock }) await engine.startSession() await clock.runTimeouts() const startPromise = engine.startRecording() const early = new Uint8Array([7, 7]).buffer recorder.onChunk.value?.(early) await startPromise assert.equal(streams.length, 1) assert.equal(streams[0].opts.sampleRate, 48000, 'handshake must use the recorder sample rate') assert.equal(streams[0].chunks[0], early, 'PCM produced before the stream existed must still be first') } // ---- 2. finishRecording:先取 WAV 再 stop;ack 后立刻前进,不等分数 ---- { const clock = makeClock() const { engine, streams } = setup({ clock }) await engine.startSession() await clock.runTimeouts() await engine.startRecording() const finishing = engine.finishRecording() await flush() assert.equal(streams[0].stopped, true, 'stop must be sent after the WAV blob is taken') assert.equal(engine.recordingState.value, 'processing') streams[0].resolveAck({ type: 'ack', status: 'generating', attempt: 1, truncated: false }) await finishing assert.equal(engine.currentParagraphIdx.value, 1, 'must advance on ack without waiting for scores') assert.ok(engine.localAudioByParagraph.value.get(0), 'own recording must be replayable immediately') } // ---- 3. 单段 180s 只停当前段;练习计时归零才 complete ---- { const clock = makeClock() const { engine, recorder, streams, calls } = setup({ clock }) await engine.startSession() await clock.runTimeouts() await engine.startRecording() recorder.recordingDuration.value = 180 await flush() streams[0].resolveAck?.({ type: 'ack', status: 'generating', attempt: 1, truncated: false }) await flush() assert.equal(calls.complete, 0, '180s paragraph cap must not complete the whole practice') } // ---- 3a. 计时器按时间戳算:后台标签页节流也不会少算(spec §9.2.3)---- { const clock = makeClock() const expiresAt = new Date(clock.now() + 600_000).toISOString() // 10 分钟后 const { engine } = setup({ clock, expiresAt, durationMinutes: 10 }) await engine.startSession() await clock.runTimeouts() assert.equal(engine.elapsedPracticeSeconds.value, 0, 'count-up timer starts at zero') // 墙上时钟走了 300 秒,但 interval 只被浏览器放行了一次 clock.advance(300) await clock.tickInterval(1) assert.equal(engine.elapsedPracticeSeconds.value, 300, 'tick accumulation would say 1; only a timestamp reading says 300') } // ---- 3b. 到点:冻结在上限,且绝不发 complete ---- { const clock = makeClock() const expiresAt = new Date(clock.now() + 600_000).toISOString() const { engine, calls } = setup({ clock, expiresAt, durationMinutes: 10 }) await engine.startSession() await clock.runTimeouts() clock.advance(700) // 越过截止时刻 await clock.tickInterval(1) assert.equal(engine.elapsedPracticeSeconds.value, 600, 'must freeze at the cap, not overshoot') assert.equal(calls.complete, 0, 'the deadline belongs to the backend; the FE never completes') } // ---- 3c. 到点只问一次后端,不重复 ---- { const clock = makeClock() const expiresAt = new Date(clock.now() + 600_000).toISOString() const { engine, calls } = setup({ clock, expiresAt, durationMinutes: 10, getReport: async () => reportOf(['completed', 'completed'], 'completed', { expiresAt, status: 'completed' }), }) await engine.startSession() await clock.runTimeouts() const before = calls.getReport clock.advance(700) await clock.tickInterval(1) await clock.runTimeouts() assert.equal(calls.getReport, before + 1, 'exactly one query at the local deadline') await clock.tickInterval(3) await clock.runTimeouts() assert.equal(calls.getReport, before + 1, 'and only one — the interval is already stopped') } // ---- 3d. 没有截止时刻(不限时):不起 interval,不显示计时 ---- { const clock = makeClock() const { engine } = setup({ clock, expiresAt: null }) await engine.startSession() await clock.runTimeouts() assert.equal(engine.elapsedPracticeSeconds.value, null, 'no deadline means no timer to show') assert.equal(clock.intervals.size, 0, 'no interval may be started without a deadline') } // ---- 3e. 后端单方面结束,showReport 开:转结果页流程(spec §9.2.3 第 2 条)---- { const clock = makeClock() const expiresAt = new Date(clock.now() + 600_000).toISOString() const { engine, completed } = setup({ clock, expiresAt, durationMinutes: 10, showReport: true, getReport: async () => reportOf(['completed', 'completed'], 'generating', { expiresAt, status: 'completed' }), }) await engine.startSession() await clock.runTimeouts() clock.advance(700) await clock.tickInterval(1) await clock.runTimeouts() assert.equal(engine.stage.value, 'report-loading', 'a server-side end must open the report gate') assert.deepEqual(completed, ['sess-1'], 'the teacher must still get the broadcast') } // ---- 3f. 后端单方面结束,showReport 关:留在练习页 + 合上只读锁(§9.2.1)---- { const clock = makeClock() const expiresAt = new Date(clock.now() + 600_000).toISOString() const { engine } = setup({ clock, expiresAt, durationMinutes: 10, showReport: false, getReport: async () => reportOf(['completed', 'completed'], 'completed', { expiresAt, status: 'completed' }), }) await engine.startSession() await clock.runTimeouts() clock.advance(700) await clock.tickInterval(1) await clock.runTimeouts() assert.equal(engine.stage.value, 'reading', 'hidden reports keep the student on the practice page') assert.equal(engine.sessionEnded.value, true, 'the read-only lock must engage') assert.equal(engine.error.value, null, 'a server-side end is not an error') } // ---- 3g. 本地已 complete 过:后端的结束信号不得再广播一次 ---- { const clock = makeClock() const expiresAt = new Date(clock.now() + 600_000).toISOString() const { engine, completed } = setup({ clock, expiresAt, durationMinutes: 10, showReport: true, getReport: async () => reportOf(['completed', 'completed'], 'completed', { expiresAt, status: 'completed' }), }) await engine.startSession() await clock.runTimeouts() await engine.completeAndShowReport() await clock.runTimeouts() clock.advance(700) await clock.tickInterval(1) await clock.runTimeouts() assert.deepEqual(completed, ['sess-1'], 'exactly one completion broadcast') } // ---- 3h. resume 重启显示计时器,算出的是真实已用时长 ---- { const clock = makeClock() // 截止时刻在 4 分钟后,配置 10 分钟 → 已经过了 6 分钟 const expiresAt = new Date(clock.now() + 240_000).toISOString() const { engine } = setup({ clock, expiresAt, durationMinutes: 10 }) engine.resumeFromReport(reportOf(['pending', 'pending'], null, { expiresAt })) assert.equal(engine.elapsedPracticeSeconds.value, 360, 'resume must show real elapsed time, not reset to zero') } // ---- 4. 后台标签页不发 GET;回到前台继续;退避不因隐藏而推进 ---- { const clock = makeClock() const doc = makeDoc(true) const { engine, calls } = setup({ clock, doc, statuses: ['generating'], reports: [reportOf(['generating'])] }) await engine.startSession() await clock.runTimeouts() assert.equal(calls.getReport, 0, 'hidden tab must not issue GET') doc.hidden = false doc.fire('visibilitychange') await clock.runTimeouts() assert.ok(calls.getReport >= 1, 'returning to foreground must resume polling') } { // 退避序列 1s/2s/4s/8s 封顶 assert.equal(Math.min(8000, 1000 * 2 ** 0), 1000) assert.equal(Math.min(8000, 1000 * 2 ** 5), 8000) } // ---- 5. overallStatus=null 且无 generating 段可进 report;ready 前保持 report-loading ---- { const clock = makeClock() const { engine } = setup({ clock, statuses: ['completed'], reports: [reportOf(['generating'], null), reportOf(['completed'], null)], }) await engine.startSession() await clock.runTimeouts() await engine.completeAndShowReport() await clock.runTimeouts() assert.equal(engine.stage.value, 'report-loading', 'must not reveal a partial report while generating') await clock.runTimeouts() assert.equal(engine.stage.value, 'report') } // ---- 5b. 段落全 completed 但 overallStatus='generating'(真实 complete 窗口): // 必须持续轮询直到 overall 落定,不得卡在 report-loading ---- { const clock = makeClock() const { engine, calls } = setup({ clock, statuses: ['completed'], reports: [ reportOf(['completed'], 'generating'), reportOf(['completed'], 'generating'), reportOf(['completed'], 'completed'), ], }) await engine.startSession() await clock.runTimeouts() await engine.completeAndShowReport() await clock.runTimeouts() assert.equal(engine.stage.value, 'report-loading', 'overallStatus=generating must keep the loading gate closed') const midGets = calls.getReport await clock.runTimeouts() assert.ok(calls.getReport > midGets, 'must keep polling while overallStatus is still generating') await clock.runTimeouts() assert.equal(engine.stage.value, 'report', 'report must appear once overallStatus settles to completed') } // ---- 6. 全段评完静默 complete 一次,stage 仍是 reading;随后看报告不再 POST ---- { const clock = makeClock() const { engine, calls, completed } = setup({ clock, statuses: ['completed'], reports: [reportOf(['completed'], 'completed')], }) await engine.startSession() await clock.runTimeouts() await engine.completeAndShowReport() await clock.runTimeouts() const afterFirst = calls.complete assert.equal(afterFirst, 1, 'completion must be issued exactly once') assert.equal(completed.length, 1, 'onSessionCompleted must fire once') await engine.completeAndShowReport() await clock.runTimeouts() assert.equal(calls.complete, 1, 'viewing the report again must not re-POST complete') } // ---- 7. POST 之前抓到的 report 不得满足最终 gate ---- { const clock = makeClock() const { engine, calls } = setup({ clock, statuses: ['completed'], reports: [reportOf(['completed'], null)], }) await engine.startSession() await clock.runTimeouts() // 没有 generating 段就不启动轮询,所以此刻静默预热还没有机会跑 assert.equal(calls.complete, 0) assert.equal(calls.getReport, 0, 'a report fetched before the POST cannot satisfy the final gate') await engine.completeAndShowReport() await clock.runTimeouts() assert.equal(engine.stage.value, 'report') assert.ok(calls.getReport >= 1, 'at least one GET must be issued after the completion POST') assert.equal(calls.complete, 1, 'completion is issued exactly once') } // ---- 8. 取消录音:abort 流、回到 idle、不产生评分任务 ---- { const clock = makeClock() const { engine, streams } = setup({ clock }) await engine.startSession() await clock.runTimeouts() await engine.startRecording() await engine.cancelRecording() assert.equal(streams[0].stopped, false, 'cancel must not send stop, so no scoring task is created') assert.match(streams[0].aborted, /cancel/i) assert.equal(engine.recordingState.value, 'idle') assert.equal(engine.currentParagraphIdx.value, 0, 'cancel must not advance to the next paragraph') assert.equal(engine.localAudioByParagraph.value.size, 0, 'a cancelled attempt leaves no replayable audio') } // ---- showReport=false:completed 的 session 不进结果页,留在练习页 ---- { const { engine } = setup({ showReport: false }) engine.resumeFromReport({ ...reportOf(['completed'], 'completed'), status: 'completed' }) assert.equal(engine.stage.value, 'reading', 'showReport 关闭时 completed 也留在 reading') assert.equal(engine.sessionEnded.value, true, 'sessionEnded 仍为 true —— 只读锁靠它') } // ---- showReport=true:completed 的 session 照旧进结果页 ---- { const { engine } = setup({ showReport: true }) engine.resumeFromReport({ ...reportOf(['completed'], 'completed'), status: 'completed' }) assert.equal(engine.stage.value, 'report', 'showReport 打开时行为不变') } // ---- restartSession:先 abandon 再回起始页 ---- { const abandoned = [] const { engine } = setup({ abandonSession: async id => { abandoned.push(id); return { sessionId: id, status: 'abandoned' } }, }) engine.resumeFromReport(reportOf(['pending', 'pending'])) assert.equal(engine.stage.value, 'reading') const ok = await engine.restartSession() assert.equal(ok, true) assert.deepEqual(abandoned, ['sess-1'], '把旧 session 送去 abandon') assert.equal(engine.stage.value, 'ready', '回起始页') assert.equal(engine.report.value, null, 'report 已清空') } // ---- restartSession 失败:不重置,返回 false,旧 session 仍然有效 ---- { const { engine } = setup({ abandonSession: async () => { throw new Error('network down') }, }) engine.resumeFromReport(reportOf(['pending', 'pending'])) const ok = await engine.restartSession() assert.equal(ok, false) assert.equal(engine.stage.value, 'reading', '失败不能把学生踢回起始页') assert.equal(engine.report.value?.sessionId, 'sess-1', '旧 session 仍在') assert.equal(engine.error.value, null, '失败只回 false,文案由调用方决定') } // ---- 录音中「重新开始」:录音器必须真的拆掉 ---- // 只摘 onChunk 不够:mediaStream track、AudioContext、worklet、时长 interval 都还活着, // 孤儿 worklet 会继续把音频灌进下一段录音(上传的 WAV 与 WS 流都成了两套采集图交织)。 { const clock = makeClock() const { engine, recorder, streams } = setup({ clock }) await engine.startSession() await clock.runTimeouts() await engine.startRecording() assert.equal(recorder.cleaned, 0, '录音进行中,还没到拆的时候') const ok = await engine.restartSession() assert.equal(ok, true) assert.equal(recorder.cleaned, 1, '重新开始必须拆掉录音器,不能只摘 onChunk') assert.equal(recorder.onChunk.value, null, 'onChunk 也要摘') assert.ok(streams[0].aborted, 'WS 流一并中止') assert.equal(engine.stage.value, 'ready') } // ---- 「重新开始」成功后广播作废,老师端才会把「已完成」刷回「未开始」---- { const abandonedBroadcasts = [] const { engine } = setup({ onSessionAbandoned: id => abandonedBroadcasts.push(id), }) engine.resumeFromReport(reportOf(['pending', 'pending'])) await engine.restartSession() assert.deepEqual(abandonedBroadcasts, ['sess-1'], '成功作废要通知调用方去广播') } // ---- 作废失败不广播 ---- { const abandonedBroadcasts = [] const { engine } = setup({ abandonSession: async () => { throw new Error('network down') }, onSessionAbandoned: id => abandonedBroadcasts.push(id), }) engine.resumeFromReport(reportOf(['pending', 'pending'])) await engine.restartSession() assert.deepEqual(abandonedBroadcasts, [], '没作废成功就不能报「这一轮不算了」') } console.log('article reading engine: all cases passed') // ---- 错误本地化:每个出口都是本地化文案,绝不是响应体(spec §9.2.5 A1)---- const LEAK = 'Session not found' // 1. startSession { const clock = makeClock() const { engine } = setup({ clock, createSession: async () => { throw new Error(LEAK) } }) await engine.startSession() assert.equal(engine.error.value, '__localized_session_not_found__', 'a failed start must not show the backend detail') } // 5. pollOnce { const clock = makeClock() const { engine } = setup({ clock, statuses: ['generating'], getReport: async () => { throw new Error(LEAK) }, }) await engine.startSession() await clock.runTimeouts() assert.equal(engine.error.value, '__localized_session_not_found__') } // 6. playDemoAudio { const clock = makeClock() // 必须经 setup 注入:引擎把 overrides 展开进一个**新对象**, // 构造之后再改外面那个 deps 是改不到它的。 const { engine } = setup({ clock, getDemoAudio: async () => { throw new Error(LEAK) } }) await engine.startSession() await clock.runTimeouts() await engine.playDemoAudio(0) assert.equal(engine.error.value, '__localized_session_not_found__') } // 4. completeAndShowReport { const clock = makeClock() const { engine } = setup({ clock, completeSession: async () => { throw new Error(LEAK) } }) await engine.startSession() await clock.runTimeouts() await engine.completeAndShowReport() assert.equal(engine.error.value, '__localized_session_not_found__') } // 2. startRecording(麦克风;不是后端 detail → 走兜底) { const clock = makeClock() const { engine, recorder } = setup({ clock }) await engine.startSession() await clock.runTimeouts() recorder.startRecording = async () => { throw new Error('NotAllowedError') } await engine.startRecording() assert.equal(engine.error.value, '__localized_generic__', 'an unmapped cause must fall back, never surface raw') } // 409 的分支逻辑保留(spec §9.2.5 第 4 条),同时文案也本地化 { const clock = makeClock() const gone = Object.assign(new Error('Session is abandoned'), { status: 409 }) const { engine } = setup({ clock, completeSession: async () => { throw gone } }) await engine.startSession() await clock.runTimeouts() await engine.completeAndShowReport() assert.equal(engine.sessionInvalid.value, true, '409 must still flag sessionInvalid') assert.ok(!String(engine.error.value).includes('{'), 'never any JSON in user-facing copy') } console.log('article reading engine: error localization cases passed') // ---- 轮询失败上限(spec §9.2.5 待办 Unsure-1)---- // 后端持续不应答 → 敲满上限就停手,不再每 8 秒一次敲到学生关页面 { const clock = makeClock() const { engine, calls } = setup({ clock, statuses: ['generating'], getReport: async () => { throw new Error('boom') }, }) await engine.startSession() for (let i = 0; i < 30; i++) await clock.runTimeouts() assert.equal(calls.getReport, 8, 'must give up after MAX_POLL_FAILURES consecutive failures') assert.equal(clock.timeouts.size, 0, 'no timer may be left armed after giving up') assert.ok(engine.error.value, 'the banner must stay so the student knows why it stopped') } // 中途成功一次 → 失败计数归零,健康的长轮询不会被误掐 { const clock = makeClock() let n = 0 const clockRef = clock const { engine, calls } = setup({ clock: clockRef, statuses: ['generating'], getReport: async () => { n += 1 // 前 5 次失败,第 6 次成功(仍 generating),之后继续失败 if (n === 6) return reportOf(['generating']) throw new Error('boom') }, }) await engine.startSession() for (let i = 0; i < 40; i++) await clock.runTimeouts() // 5 次失败 + 1 次成功(归零)+ 8 次失败 = 14 assert.equal(calls.getReport, 14, 'a successful response must reset the failure budget') } // 停手之后,新一轮(schedulePoll(0))要拿到新的失败预算 { const clock = makeClock() const { engine, calls } = setup({ clock, statuses: ['generating'], getReport: async () => { throw new Error('boom') }, }) await engine.startSession() for (let i = 0; i < 30; i++) await clock.runTimeouts() assert.equal(calls.getReport, 8) // 学生做了点什么(录完一段 / 点重试)→ 重新起一轮 engine.resumeFromReport(reportOf(['generating'])) for (let i = 0; i < 30; i++) await clock.runTimeouts() assert.equal(calls.getReport, 16, 'a fresh chain must get a fresh budget, not stop on failure #1') } console.log('article reading engine: poll failure cap cases passed') // ---- 轮询横幅不滞留(spec §9.2.5 待办 Unsure-3)---- // 失败一次 → 横幅出现;下一次成功 → 横幅收走,不留在学生眼前 { const clock = makeClock() let n = 0 const { engine } = setup({ clock, statuses: ['generating'], getReport: async () => { n += 1 if (n === 1) throw new Error('boom') return reportOf(['generating']) }, }) await engine.startSession() await clock.runTimeouts() assert.ok(engine.error.value, 'a transient poll failure must raise the banner') await clock.runTimeouts() assert.equal(engine.error.value, null, 'a later successful poll must take its own banner down') } // 但**只**收自己竖的那面:录音被拒的解释必须活过随后那次成功轮询。 // finishRecording 的 catch 设完 error 就 schedulePoll(0)(§4.4 到期收口), // 无差别清 error 会让学生在唯读锁合上的同一瞬间失去「为什么录不了」的唯一说明。 { const clock = makeClock() const { engine } = setup({ clock, getReport: async () => reportOf(['pending', 'pending']) }) await engine.startSession() await clock.runTimeouts() engine.error.value = '__recording_rejected__' await clock.runTimeouts() // 成功轮询跑一轮 assert.equal(engine.error.value, '__recording_rejected__', 'a non-poll error must survive a successful poll') } console.log('article reading engine: stale banner cases passed') // ---- 编辑态预览:userId 原样透传给 createSession,null 不得被改写成空串(spec §4.5)---- { const clock = makeClock() const seen = [] const { engine } = setup({ clock, userId: null, createSession: async (configId, uid) => { seen.push([configId, uid]) return reportOf(['pending', 'pending']) }, }) await engine.startSession() assert.deepEqual(seen, [['c1', null]], 'editor preview must reach the backend as null, not "" — an empty string is a student named ""') } { const clock = makeClock() const seen = [] const { engine } = setup({ clock, userId: 'u9', createSession: async (configId, uid) => { seen.push([configId, uid]) return reportOf(['pending', 'pending']) }, }) await engine.startSession() assert.deepEqual(seen, [['c1', 'u9']], 'a real student id must still be forwarded verbatim') } // ---- 双击「开始朗读」只发一个 POST /session ---- // 后端 get-or-create 无锁、无唯一约束:两个并发请求会双双查不到既有 session 然后各插一行。 // 编辑态预览(userId=null)必然如此 —— `_latest_session` 对空 user 早退返回 None(spec §4.5)。 // 多出来那一行会算进 §4.3 B 的「N 名学生正在练习中」直到过期,还会多写一笔作业记录(§9.2.4)。 { const clock = makeClock() let calls = 0 let release const gate = new Promise(resolve => { release = resolve }) const { engine } = setup({ clock, userId: null, createSession: async () => { calls += 1 await gate return reportOf(['pending', 'pending']) }, }) const first = engine.startSession() await flush() assert.equal(engine.starting.value, true, 'POST 在途时 starting 必须为真,视图据此置灰') const second = engine.startSession() // 第二次点击:必须被闩早退吃掉 release() await Promise.all([first, second]) assert.equal(calls, 1, 'double click must not create a second session') assert.equal(engine.starting.value, false, '结束后闩必须放开,失败重试才点得动') } // ---- 开始失败后闩要放开:学生还能再点一次(承 §9.2.5 A2 的 readyError 出口)---- { const clock = makeClock() let calls = 0 const { engine } = setup({ clock, createSession: async () => { calls += 1 if (calls === 1) throw new Error('Config not found') return reportOf(['pending', 'pending']) }, }) await engine.startSession() assert.equal(engine.stage.value, 'ready', '失败停在起始页') assert.ok(engine.error.value, '失败必须有文案') assert.equal(engine.starting.value, false, 'finally 放闩,不是只在成功路径上放') await engine.startSession() assert.equal(calls, 2, '第二次点击必须真的发出去') assert.equal(engine.stage.value, 'reading') }