| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900 |
- 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')
- }
|