|
|
@@ -0,0 +1,900 @@
|
|
|
+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')
|
|
|
+}
|