test-article-reading-engine.mjs 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900
  1. import assert from 'node:assert/strict'
  2. import { readFile } from 'node:fs/promises'
  3. import ts from 'typescript'
  4. const sourceUrl = new URL('../src/views/Editor/EnglishSpeaking/composables/useArticleReadingEngine.ts', import.meta.url)
  5. let source = await readFile(sourceUrl, 'utf8')
  6. // Vue 运行时替换为最小 shim;watch 只需支持 getter + 立即回调语义
  7. source = source.replace(
  8. "import { ref, computed, watch, onUnmounted, toValue, type MaybeRefOrGetter } from 'vue'",
  9. `
  10. const __watchers = []
  11. const ref = value => {
  12. const r = { _v: value, get value() { return this._v }, set value(next) { this._v = next; __runWatchers() } }
  13. return r
  14. }
  15. const computed = getter => ({ get value() { return getter() } })
  16. const watch = (getter, cb) => { __watchers.push({ getter, cb, last: getter() }) }
  17. const onUnmounted = () => {}
  18. const toValue = v => (typeof v === 'function' ? v() : (v && typeof v === 'object' && 'value' in v ? v.value : v))
  19. globalThis.__runWatchers = () => {
  20. for (const w of __watchers) {
  21. const next = w.getter()
  22. if (next !== w.last) { const prev = w.last; w.last = next; w.cb(next, prev) }
  23. }
  24. }
  25. `,
  26. )
  27. // 运行时依赖全部替换为注入桩;测试永远传完整 overrides,这些默认值不会被执行
  28. source = source
  29. .replace("import { useAudioRecorder } from './useAudioRecorder'", 'const useAudioRecorder = () => globalThis.__deps.recorder')
  30. .replace("import { useAudioPlayer } from './useAudioPlayer'", 'const useAudioPlayer = () => globalThis.__deps.player')
  31. .replace(
  32. /import \{[\s\S]*?\} from '\.\.\/services\/articleReading'/,
  33. `
  34. const createArticleSession = (...a) => globalThis.__deps.createSession(...a)
  35. const getArticleReport = (...a) => globalThis.__deps.getReport(...a)
  36. const completeArticleSession = (...a) => globalThis.__deps.completeSession(...a)
  37. const getArticleDemoAudio = (...a) => globalThis.__deps.getDemoAudio(...a)
  38. const abandonArticleSession = (...a) => globalThis.__deps.abandonSession(...a)
  39. `,
  40. )
  41. .replace(
  42. /import \{[\s\S]*?\} from '\.\.\/services\/articleErrors'/,
  43. `
  44. const friendlyArticleError = cause => {
  45. const raw = cause instanceof Error ? cause.message : String(cause ?? '')
  46. return raw === 'Session not found' ? '__localized_session_not_found__' : '__localized_generic__'
  47. }
  48. const isArticleSessionGone = cause => cause?.status === 409
  49. `,
  50. )
  51. .replace(
  52. "import { openArticleParagraphStream, type ArticleParagraphStream } from '../services/articleReadingStream'",
  53. 'const openArticleParagraphStream = (...a) => globalThis.__deps.createStream(...a)',
  54. )
  55. .replace(
  56. "import { isFinalReportReady, pollDelayMs } from '../preview/articleReadingModel'",
  57. `
  58. const isFinalReportReady = r => !r.paragraphs.some(p => p.status === 'generating')
  59. && (r.overallStatus === 'completed' || r.overallStatus === 'failed' || r.overallStatus === null)
  60. const pollDelayMs = attempt => Math.min(8000, 1000 * 2 ** Math.max(0, attempt))
  61. `,
  62. )
  63. const compiled = ts.transpileModule(source, {
  64. compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
  65. }).outputText
  66. const mod = await import(`data:text/javascript,${encodeURIComponent(compiled)}`)
  67. // ---------------- fakes ----------------
  68. function makeRecorder(sampleRate = 48000) {
  69. const rec = {
  70. recordingDuration: { _v: 0, get value() { return this._v }, set value(v) { this._v = v; globalThis.__runWatchers() } },
  71. sampleRate: { value: sampleRate },
  72. onChunk: { value: null },
  73. started: 0,
  74. cleaned: 0,
  75. async startRecording() { this.started += 1 },
  76. async stopRecording() { return { wav: true, size: 1 } },
  77. // 真实实现在这一步停 mediaStream track、关 AudioContext、断开 worklet、清时长 interval。
  78. // 不拆的后果不是泄漏一点内存:孤儿 worklet 会继续把音频灌进下一段录音。
  79. cleanup() { this.cleaned += 1 },
  80. }
  81. return rec
  82. }
  83. function makePlayer() {
  84. return {
  85. playingId: { value: null },
  86. loadingId: { value: null },
  87. errorId: { value: null },
  88. plays: [],
  89. // lazy-url 的 URL 解析发生在 play() 内部(与真 player 一致):示范音频那条
  90. // 失败路径要经过这里,引擎的 error.value 才立得起来
  91. async play(id, source) {
  92. this.plays.push({ id, source })
  93. this.loadingId.value = id
  94. if (source.kind === 'lazy-url') {
  95. try { await source.resolve(new AbortController().signal) }
  96. catch {
  97. this.loadingId.value = null
  98. this.errorId.value = id
  99. return
  100. }
  101. }
  102. this.loadingId.value = null
  103. this.playingId.value = id
  104. },
  105. stop() { this.playingId.value = null; this.loadingId.value = null },
  106. }
  107. }
  108. function makeStream() {
  109. const stream = {
  110. chunks: [],
  111. stopped: false,
  112. aborted: null,
  113. resolveAck: null,
  114. pushChunk(c) { this.chunks.push(c) },
  115. stop() { this.stopped = true; return new Promise(res => { this.resolveAck = res }) },
  116. abort(reason) { this.aborted = reason },
  117. }
  118. return stream
  119. }
  120. /** 手控时钟:测试自己决定何时推进 timeout/interval。 */
  121. function makeClock() {
  122. let nextId = 1
  123. let nowMs = Date.UTC(2026, 0, 1, 0, 0, 0)
  124. const timeouts = new Map()
  125. const intervals = new Map()
  126. return {
  127. timeouts,
  128. intervals,
  129. now() { return nowMs },
  130. /** 推进墙上时钟(秒)。不自动触发 interval —— 用 tickInterval 显式驱动。 */
  131. advance(seconds) { nowMs += seconds * 1000 },
  132. setTimeout(fn, delay) { const id = nextId++; timeouts.set(id, { fn, delay }); return id },
  133. clearTimeout(id) { timeouts.delete(id) },
  134. setInterval(fn, delay) { const id = nextId++; intervals.set(id, { fn, delay }); return id },
  135. clearInterval(id) { intervals.delete(id) },
  136. async runTimeouts() {
  137. const pending = [...timeouts.entries()]
  138. timeouts.clear()
  139. for (const [, t] of pending) { t.fn(); await flush() }
  140. },
  141. async tickInterval(times = 1) {
  142. for (let i = 0; i < times; i++) {
  143. for (const [, t] of [...intervals.entries()]) t.fn()
  144. await flush()
  145. }
  146. },
  147. }
  148. }
  149. function makeDoc(hidden = false) {
  150. return {
  151. hidden,
  152. listeners: {},
  153. addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn) },
  154. removeEventListener(type, fn) {
  155. this.listeners[type] = (this.listeners[type] || []).filter(f => f !== fn)
  156. },
  157. fire(type) { for (const fn of [...(this.listeners[type] || [])]) fn() },
  158. }
  159. }
  160. const flush = () => new Promise(resolve => setImmediate(resolve))
  161. function paragraphs(statuses) {
  162. return statuses.map((status, idx) => ({
  163. idx, text: `p${idx}`, status, audioUrl: null, overallScore: null, dims: null, words: [],
  164. }))
  165. }
  166. function reportOf(statuses, overallStatus = null, extra = {}) {
  167. return {
  168. sessionId: 'sess-1', title: 'T', status: 'reading', totalDurationSeconds: null,
  169. // §4.4:绝对截止时刻。默认 null = 不限时,既有用例的行为因此不变。
  170. expiresAt: null,
  171. paragraphs: paragraphs(statuses), overall: null, overallStatus, ...extra,
  172. }
  173. }
  174. function setup({
  175. statuses = ['pending', 'pending'], reports = [],
  176. showReport = true, durationMinutes = 1, expiresAt = null, userId = 'u1', abandonSession, completeSession,
  177. getReport, createSession, getDemoAudio, onSessionAbandoned, doc, clock,
  178. } = {}) {
  179. const recorder = makeRecorder()
  180. const player = makePlayer()
  181. const streams = []
  182. const calls = { complete: 0, getReport: 0, demo: 0, abandon: 0 }
  183. let reportQueue = [...reports]
  184. const deps = {
  185. recorder,
  186. player,
  187. document: doc ?? makeDoc(),
  188. setTimeout: (...a) => clock.setTimeout(...a),
  189. clearTimeout: id => clock.clearTimeout(id),
  190. setInterval: (...a) => clock.setInterval(...a),
  191. clearInterval: id => clock.clearInterval(id),
  192. now: () => clock.now(),
  193. async createSession(...a) {
  194. if (createSession) return createSession(...a)
  195. return reportOf(statuses, null, { expiresAt })
  196. },
  197. async getReport() {
  198. calls.getReport += 1
  199. if (getReport) return getReport()
  200. return reportQueue.length > 1 ? reportQueue.shift() : (reportQueue[0] ?? reportOf(statuses, null, { expiresAt }))
  201. },
  202. completeSession: completeSession ?? (async () => { calls.complete += 1 }),
  203. async getDemoAudio(...a) {
  204. calls.demo += 1
  205. if (getDemoAudio) return getDemoAudio(...a)
  206. return { url: 'https://s3/demo.mp3' }
  207. },
  208. abandonSession: abandonSession ?? (async id => { calls.abandon += 1; return { sessionId: id, status: 'abandoned' } }),
  209. createStream(opts) { const s = makeStream(); s.opts = opts; streams.push(s); return s },
  210. }
  211. globalThis.__deps = deps
  212. const completed = []
  213. const engine = mod.useArticleReadingEngine(
  214. {
  215. configId: 'c1', userId, durationMinutes, showReport,
  216. onSessionCompleted: id => completed.push(id),
  217. onSessionAbandoned,
  218. },
  219. deps,
  220. )
  221. return { engine, recorder, player, streams, calls, completed, deps, setReports: r => { reportQueue = [...r] } }
  222. }
  223. // ---- 1. stream 建立前的 PCM 不丢;handshake 用 recorder 真实 sampleRate ----
  224. {
  225. const clock = makeClock()
  226. const { engine, recorder, streams } = setup({ clock })
  227. await engine.startSession()
  228. await clock.runTimeouts()
  229. const startPromise = engine.startRecording()
  230. const early = new Uint8Array([7, 7]).buffer
  231. recorder.onChunk.value?.(early)
  232. await startPromise
  233. assert.equal(streams.length, 1)
  234. assert.equal(streams[0].opts.sampleRate, 48000, 'handshake must use the recorder sample rate')
  235. assert.equal(streams[0].chunks[0], early, 'PCM produced before the stream existed must still be first')
  236. }
  237. // ---- 2. finishRecording:先取 WAV 再 stop;ack 后立刻前进,不等分数 ----
  238. {
  239. const clock = makeClock()
  240. const { engine, streams } = setup({ clock })
  241. await engine.startSession()
  242. await clock.runTimeouts()
  243. await engine.startRecording()
  244. const finishing = engine.finishRecording()
  245. await flush()
  246. assert.equal(streams[0].stopped, true, 'stop must be sent after the WAV blob is taken')
  247. assert.equal(engine.recordingState.value, 'processing')
  248. streams[0].resolveAck({ type: 'ack', status: 'generating', attempt: 1, truncated: false })
  249. await finishing
  250. assert.equal(engine.currentParagraphIdx.value, 1, 'must advance on ack without waiting for scores')
  251. assert.ok(engine.localAudioByParagraph.value.get(0), 'own recording must be replayable immediately')
  252. }
  253. // ---- 3. 单段 180s 只停当前段;练习计时归零才 complete ----
  254. {
  255. const clock = makeClock()
  256. const { engine, recorder, streams, calls } = setup({ clock })
  257. await engine.startSession()
  258. await clock.runTimeouts()
  259. await engine.startRecording()
  260. recorder.recordingDuration.value = 180
  261. await flush()
  262. streams[0].resolveAck?.({ type: 'ack', status: 'generating', attempt: 1, truncated: false })
  263. await flush()
  264. assert.equal(calls.complete, 0, '180s paragraph cap must not complete the whole practice')
  265. }
  266. // ---- 3a. 计时器按时间戳算:后台标签页节流也不会少算(spec §9.2.3)----
  267. {
  268. const clock = makeClock()
  269. const expiresAt = new Date(clock.now() + 600_000).toISOString() // 10 分钟后
  270. const { engine } = setup({ clock, expiresAt, durationMinutes: 10 })
  271. await engine.startSession()
  272. await clock.runTimeouts()
  273. assert.equal(engine.elapsedPracticeSeconds.value, 0, 'count-up timer starts at zero')
  274. // 墙上时钟走了 300 秒,但 interval 只被浏览器放行了一次
  275. clock.advance(300)
  276. await clock.tickInterval(1)
  277. assert.equal(engine.elapsedPracticeSeconds.value, 300,
  278. 'tick accumulation would say 1; only a timestamp reading says 300')
  279. }
  280. // ---- 3b. 到点:冻结在上限,且绝不发 complete ----
  281. {
  282. const clock = makeClock()
  283. const expiresAt = new Date(clock.now() + 600_000).toISOString()
  284. const { engine, calls } = setup({ clock, expiresAt, durationMinutes: 10 })
  285. await engine.startSession()
  286. await clock.runTimeouts()
  287. clock.advance(700) // 越过截止时刻
  288. await clock.tickInterval(1)
  289. assert.equal(engine.elapsedPracticeSeconds.value, 600, 'must freeze at the cap, not overshoot')
  290. assert.equal(calls.complete, 0, 'the deadline belongs to the backend; the FE never completes')
  291. }
  292. // ---- 3c. 到点只问一次后端,不重复 ----
  293. {
  294. const clock = makeClock()
  295. const expiresAt = new Date(clock.now() + 600_000).toISOString()
  296. const { engine, calls } = setup({
  297. clock, expiresAt, durationMinutes: 10,
  298. getReport: async () => reportOf(['completed', 'completed'], 'completed', { expiresAt, status: 'completed' }),
  299. })
  300. await engine.startSession()
  301. await clock.runTimeouts()
  302. const before = calls.getReport
  303. clock.advance(700)
  304. await clock.tickInterval(1)
  305. await clock.runTimeouts()
  306. assert.equal(calls.getReport, before + 1, 'exactly one query at the local deadline')
  307. await clock.tickInterval(3)
  308. await clock.runTimeouts()
  309. assert.equal(calls.getReport, before + 1, 'and only one — the interval is already stopped')
  310. }
  311. // ---- 3d. 没有截止时刻(不限时):不起 interval,不显示计时 ----
  312. {
  313. const clock = makeClock()
  314. const { engine } = setup({ clock, expiresAt: null })
  315. await engine.startSession()
  316. await clock.runTimeouts()
  317. assert.equal(engine.elapsedPracticeSeconds.value, null, 'no deadline means no timer to show')
  318. assert.equal(clock.intervals.size, 0, 'no interval may be started without a deadline')
  319. }
  320. // ---- 3e. 后端单方面结束,showReport 开:转结果页流程(spec §9.2.3 第 2 条)----
  321. {
  322. const clock = makeClock()
  323. const expiresAt = new Date(clock.now() + 600_000).toISOString()
  324. const { engine, completed } = setup({
  325. clock, expiresAt, durationMinutes: 10, showReport: true,
  326. getReport: async () => reportOf(['completed', 'completed'], 'generating', { expiresAt, status: 'completed' }),
  327. })
  328. await engine.startSession()
  329. await clock.runTimeouts()
  330. clock.advance(700)
  331. await clock.tickInterval(1)
  332. await clock.runTimeouts()
  333. assert.equal(engine.stage.value, 'report-loading', 'a server-side end must open the report gate')
  334. assert.deepEqual(completed, ['sess-1'], 'the teacher must still get the broadcast')
  335. }
  336. // ---- 3f. 后端单方面结束,showReport 关:留在练习页 + 合上只读锁(§9.2.1)----
  337. {
  338. const clock = makeClock()
  339. const expiresAt = new Date(clock.now() + 600_000).toISOString()
  340. const { engine } = setup({
  341. clock, expiresAt, durationMinutes: 10, showReport: false,
  342. getReport: async () => reportOf(['completed', 'completed'], 'completed', { expiresAt, status: 'completed' }),
  343. })
  344. await engine.startSession()
  345. await clock.runTimeouts()
  346. clock.advance(700)
  347. await clock.tickInterval(1)
  348. await clock.runTimeouts()
  349. assert.equal(engine.stage.value, 'reading', 'hidden reports keep the student on the practice page')
  350. assert.equal(engine.sessionEnded.value, true, 'the read-only lock must engage')
  351. assert.equal(engine.error.value, null, 'a server-side end is not an error')
  352. }
  353. // ---- 3g. 本地已 complete 过:后端的结束信号不得再广播一次 ----
  354. {
  355. const clock = makeClock()
  356. const expiresAt = new Date(clock.now() + 600_000).toISOString()
  357. const { engine, completed } = setup({
  358. clock, expiresAt, durationMinutes: 10, showReport: true,
  359. getReport: async () => reportOf(['completed', 'completed'], 'completed', { expiresAt, status: 'completed' }),
  360. })
  361. await engine.startSession()
  362. await clock.runTimeouts()
  363. await engine.completeAndShowReport()
  364. await clock.runTimeouts()
  365. clock.advance(700)
  366. await clock.tickInterval(1)
  367. await clock.runTimeouts()
  368. assert.deepEqual(completed, ['sess-1'], 'exactly one completion broadcast')
  369. }
  370. // ---- 3h. resume 重启显示计时器,算出的是真实已用时长 ----
  371. {
  372. const clock = makeClock()
  373. // 截止时刻在 4 分钟后,配置 10 分钟 → 已经过了 6 分钟
  374. const expiresAt = new Date(clock.now() + 240_000).toISOString()
  375. const { engine } = setup({ clock, expiresAt, durationMinutes: 10 })
  376. engine.resumeFromReport(reportOf(['pending', 'pending'], null, { expiresAt }))
  377. assert.equal(engine.elapsedPracticeSeconds.value, 360,
  378. 'resume must show real elapsed time, not reset to zero')
  379. }
  380. // ---- 4. 后台标签页不发 GET;回到前台继续;退避不因隐藏而推进 ----
  381. {
  382. const clock = makeClock()
  383. const doc = makeDoc(true)
  384. const { engine, calls } = setup({ clock, doc, statuses: ['generating'], reports: [reportOf(['generating'])] })
  385. await engine.startSession()
  386. await clock.runTimeouts()
  387. assert.equal(calls.getReport, 0, 'hidden tab must not issue GET')
  388. doc.hidden = false
  389. doc.fire('visibilitychange')
  390. await clock.runTimeouts()
  391. assert.ok(calls.getReport >= 1, 'returning to foreground must resume polling')
  392. }
  393. {
  394. // 退避序列 1s/2s/4s/8s 封顶
  395. assert.equal(Math.min(8000, 1000 * 2 ** 0), 1000)
  396. assert.equal(Math.min(8000, 1000 * 2 ** 5), 8000)
  397. }
  398. // ---- 5. overallStatus=null 且无 generating 段可进 report;ready 前保持 report-loading ----
  399. {
  400. const clock = makeClock()
  401. const { engine } = setup({
  402. clock,
  403. statuses: ['completed'],
  404. reports: [reportOf(['generating'], null), reportOf(['completed'], null)],
  405. })
  406. await engine.startSession()
  407. await clock.runTimeouts()
  408. await engine.completeAndShowReport()
  409. await clock.runTimeouts()
  410. assert.equal(engine.stage.value, 'report-loading', 'must not reveal a partial report while generating')
  411. await clock.runTimeouts()
  412. assert.equal(engine.stage.value, 'report')
  413. }
  414. // ---- 5b. 段落全 completed 但 overallStatus='generating'(真实 complete 窗口):
  415. // 必须持续轮询直到 overall 落定,不得卡在 report-loading ----
  416. {
  417. const clock = makeClock()
  418. const { engine, calls } = setup({
  419. clock,
  420. statuses: ['completed'],
  421. reports: [
  422. reportOf(['completed'], 'generating'),
  423. reportOf(['completed'], 'generating'),
  424. reportOf(['completed'], 'completed'),
  425. ],
  426. })
  427. await engine.startSession()
  428. await clock.runTimeouts()
  429. await engine.completeAndShowReport()
  430. await clock.runTimeouts()
  431. assert.equal(engine.stage.value, 'report-loading', 'overallStatus=generating must keep the loading gate closed')
  432. const midGets = calls.getReport
  433. await clock.runTimeouts()
  434. assert.ok(calls.getReport > midGets, 'must keep polling while overallStatus is still generating')
  435. await clock.runTimeouts()
  436. assert.equal(engine.stage.value, 'report', 'report must appear once overallStatus settles to completed')
  437. }
  438. // ---- 6. 全段评完静默 complete 一次,stage 仍是 reading;随后看报告不再 POST ----
  439. {
  440. const clock = makeClock()
  441. const { engine, calls, completed } = setup({
  442. clock,
  443. statuses: ['completed'],
  444. reports: [reportOf(['completed'], 'completed')],
  445. })
  446. await engine.startSession()
  447. await clock.runTimeouts()
  448. await engine.completeAndShowReport()
  449. await clock.runTimeouts()
  450. const afterFirst = calls.complete
  451. assert.equal(afterFirst, 1, 'completion must be issued exactly once')
  452. assert.equal(completed.length, 1, 'onSessionCompleted must fire once')
  453. await engine.completeAndShowReport()
  454. await clock.runTimeouts()
  455. assert.equal(calls.complete, 1, 'viewing the report again must not re-POST complete')
  456. }
  457. // ---- 7. POST 之前抓到的 report 不得满足最终 gate ----
  458. {
  459. const clock = makeClock()
  460. const { engine, calls } = setup({
  461. clock,
  462. statuses: ['completed'],
  463. reports: [reportOf(['completed'], null)],
  464. })
  465. await engine.startSession()
  466. await clock.runTimeouts()
  467. // 没有 generating 段就不启动轮询,所以此刻静默预热还没有机会跑
  468. assert.equal(calls.complete, 0)
  469. assert.equal(calls.getReport, 0, 'a report fetched before the POST cannot satisfy the final gate')
  470. await engine.completeAndShowReport()
  471. await clock.runTimeouts()
  472. assert.equal(engine.stage.value, 'report')
  473. assert.ok(calls.getReport >= 1, 'at least one GET must be issued after the completion POST')
  474. assert.equal(calls.complete, 1, 'completion is issued exactly once')
  475. }
  476. // ---- 8. 取消录音:abort 流、回到 idle、不产生评分任务 ----
  477. {
  478. const clock = makeClock()
  479. const { engine, streams } = setup({ clock })
  480. await engine.startSession()
  481. await clock.runTimeouts()
  482. await engine.startRecording()
  483. await engine.cancelRecording()
  484. assert.equal(streams[0].stopped, false, 'cancel must not send stop, so no scoring task is created')
  485. assert.match(streams[0].aborted, /cancel/i)
  486. assert.equal(engine.recordingState.value, 'idle')
  487. assert.equal(engine.currentParagraphIdx.value, 0, 'cancel must not advance to the next paragraph')
  488. assert.equal(engine.localAudioByParagraph.value.size, 0, 'a cancelled attempt leaves no replayable audio')
  489. }
  490. // ---- showReport=false:completed 的 session 不进结果页,留在练习页 ----
  491. {
  492. const { engine } = setup({ showReport: false })
  493. engine.resumeFromReport({ ...reportOf(['completed'], 'completed'), status: 'completed' })
  494. assert.equal(engine.stage.value, 'reading', 'showReport 关闭时 completed 也留在 reading')
  495. assert.equal(engine.sessionEnded.value, true, 'sessionEnded 仍为 true —— 只读锁靠它')
  496. }
  497. // ---- showReport=true:completed 的 session 照旧进结果页 ----
  498. {
  499. const { engine } = setup({ showReport: true })
  500. engine.resumeFromReport({ ...reportOf(['completed'], 'completed'), status: 'completed' })
  501. assert.equal(engine.stage.value, 'report', 'showReport 打开时行为不变')
  502. }
  503. // ---- restartSession:先 abandon 再回起始页 ----
  504. {
  505. const abandoned = []
  506. const { engine } = setup({
  507. abandonSession: async id => { abandoned.push(id); return { sessionId: id, status: 'abandoned' } },
  508. })
  509. engine.resumeFromReport(reportOf(['pending', 'pending']))
  510. assert.equal(engine.stage.value, 'reading')
  511. const ok = await engine.restartSession()
  512. assert.equal(ok, true)
  513. assert.deepEqual(abandoned, ['sess-1'], '把旧 session 送去 abandon')
  514. assert.equal(engine.stage.value, 'ready', '回起始页')
  515. assert.equal(engine.report.value, null, 'report 已清空')
  516. }
  517. // ---- restartSession 失败:不重置,返回 false,旧 session 仍然有效 ----
  518. {
  519. const { engine } = setup({
  520. abandonSession: async () => { throw new Error('network down') },
  521. })
  522. engine.resumeFromReport(reportOf(['pending', 'pending']))
  523. const ok = await engine.restartSession()
  524. assert.equal(ok, false)
  525. assert.equal(engine.stage.value, 'reading', '失败不能把学生踢回起始页')
  526. assert.equal(engine.report.value?.sessionId, 'sess-1', '旧 session 仍在')
  527. assert.equal(engine.error.value, null, '失败只回 false,文案由调用方决定')
  528. }
  529. // ---- 录音中「重新开始」:录音器必须真的拆掉 ----
  530. // 只摘 onChunk 不够:mediaStream track、AudioContext、worklet、时长 interval 都还活着,
  531. // 孤儿 worklet 会继续把音频灌进下一段录音(上传的 WAV 与 WS 流都成了两套采集图交织)。
  532. {
  533. const clock = makeClock()
  534. const { engine, recorder, streams } = setup({ clock })
  535. await engine.startSession()
  536. await clock.runTimeouts()
  537. await engine.startRecording()
  538. assert.equal(recorder.cleaned, 0, '录音进行中,还没到拆的时候')
  539. const ok = await engine.restartSession()
  540. assert.equal(ok, true)
  541. assert.equal(recorder.cleaned, 1, '重新开始必须拆掉录音器,不能只摘 onChunk')
  542. assert.equal(recorder.onChunk.value, null, 'onChunk 也要摘')
  543. assert.ok(streams[0].aborted, 'WS 流一并中止')
  544. assert.equal(engine.stage.value, 'ready')
  545. }
  546. // ---- 「重新开始」成功后广播作废,老师端才会把「已完成」刷回「未开始」----
  547. {
  548. const abandonedBroadcasts = []
  549. const { engine } = setup({
  550. onSessionAbandoned: id => abandonedBroadcasts.push(id),
  551. })
  552. engine.resumeFromReport(reportOf(['pending', 'pending']))
  553. await engine.restartSession()
  554. assert.deepEqual(abandonedBroadcasts, ['sess-1'], '成功作废要通知调用方去广播')
  555. }
  556. // ---- 作废失败不广播 ----
  557. {
  558. const abandonedBroadcasts = []
  559. const { engine } = setup({
  560. abandonSession: async () => { throw new Error('network down') },
  561. onSessionAbandoned: id => abandonedBroadcasts.push(id),
  562. })
  563. engine.resumeFromReport(reportOf(['pending', 'pending']))
  564. await engine.restartSession()
  565. assert.deepEqual(abandonedBroadcasts, [], '没作废成功就不能报「这一轮不算了」')
  566. }
  567. console.log('article reading engine: all cases passed')
  568. // ---- 错误本地化:每个出口都是本地化文案,绝不是响应体(spec §9.2.5 A1)----
  569. const LEAK = 'Session not found'
  570. // 1. startSession
  571. {
  572. const clock = makeClock()
  573. const { engine } = setup({ clock, createSession: async () => { throw new Error(LEAK) } })
  574. await engine.startSession()
  575. assert.equal(engine.error.value, '__localized_session_not_found__',
  576. 'a failed start must not show the backend detail')
  577. }
  578. // 5. pollOnce
  579. {
  580. const clock = makeClock()
  581. const { engine } = setup({
  582. clock,
  583. statuses: ['generating'],
  584. getReport: async () => { throw new Error(LEAK) },
  585. })
  586. await engine.startSession()
  587. await clock.runTimeouts()
  588. assert.equal(engine.error.value, '__localized_session_not_found__')
  589. }
  590. // 6. playDemoAudio
  591. {
  592. const clock = makeClock()
  593. // 必须经 setup 注入:引擎把 overrides 展开进一个**新对象**,
  594. // 构造之后再改外面那个 deps 是改不到它的。
  595. const { engine } = setup({ clock, getDemoAudio: async () => { throw new Error(LEAK) } })
  596. await engine.startSession()
  597. await clock.runTimeouts()
  598. await engine.playDemoAudio(0)
  599. assert.equal(engine.error.value, '__localized_session_not_found__')
  600. }
  601. // 4. completeAndShowReport
  602. {
  603. const clock = makeClock()
  604. const { engine } = setup({ clock, completeSession: async () => { throw new Error(LEAK) } })
  605. await engine.startSession()
  606. await clock.runTimeouts()
  607. await engine.completeAndShowReport()
  608. assert.equal(engine.error.value, '__localized_session_not_found__')
  609. }
  610. // 2. startRecording(麦克风;不是后端 detail → 走兜底)
  611. {
  612. const clock = makeClock()
  613. const { engine, recorder } = setup({ clock })
  614. await engine.startSession()
  615. await clock.runTimeouts()
  616. recorder.startRecording = async () => { throw new Error('NotAllowedError') }
  617. await engine.startRecording()
  618. assert.equal(engine.error.value, '__localized_generic__',
  619. 'an unmapped cause must fall back, never surface raw')
  620. }
  621. // 409 的分支逻辑保留(spec §9.2.5 第 4 条),同时文案也本地化
  622. {
  623. const clock = makeClock()
  624. const gone = Object.assign(new Error('Session is abandoned'), { status: 409 })
  625. const { engine } = setup({ clock, completeSession: async () => { throw gone } })
  626. await engine.startSession()
  627. await clock.runTimeouts()
  628. await engine.completeAndShowReport()
  629. assert.equal(engine.sessionInvalid.value, true, '409 must still flag sessionInvalid')
  630. assert.ok(!String(engine.error.value).includes('{'), 'never any JSON in user-facing copy')
  631. }
  632. console.log('article reading engine: error localization cases passed')
  633. // ---- 轮询失败上限(spec §9.2.5 待办 Unsure-1)----
  634. // 后端持续不应答 → 敲满上限就停手,不再每 8 秒一次敲到学生关页面
  635. {
  636. const clock = makeClock()
  637. const { engine, calls } = setup({
  638. clock,
  639. statuses: ['generating'],
  640. getReport: async () => { throw new Error('boom') },
  641. })
  642. await engine.startSession()
  643. for (let i = 0; i < 30; i++) await clock.runTimeouts()
  644. assert.equal(calls.getReport, 8, 'must give up after MAX_POLL_FAILURES consecutive failures')
  645. assert.equal(clock.timeouts.size, 0, 'no timer may be left armed after giving up')
  646. assert.ok(engine.error.value, 'the banner must stay so the student knows why it stopped')
  647. }
  648. // 中途成功一次 → 失败计数归零,健康的长轮询不会被误掐
  649. {
  650. const clock = makeClock()
  651. let n = 0
  652. const clockRef = clock
  653. const { engine, calls } = setup({
  654. clock: clockRef,
  655. statuses: ['generating'],
  656. getReport: async () => {
  657. n += 1
  658. // 前 5 次失败,第 6 次成功(仍 generating),之后继续失败
  659. if (n === 6) return reportOf(['generating'])
  660. throw new Error('boom')
  661. },
  662. })
  663. await engine.startSession()
  664. for (let i = 0; i < 40; i++) await clock.runTimeouts()
  665. // 5 次失败 + 1 次成功(归零)+ 8 次失败 = 14
  666. assert.equal(calls.getReport, 14, 'a successful response must reset the failure budget')
  667. }
  668. // 停手之后,新一轮(schedulePoll(0))要拿到新的失败预算
  669. {
  670. const clock = makeClock()
  671. const { engine, calls } = setup({
  672. clock,
  673. statuses: ['generating'],
  674. getReport: async () => { throw new Error('boom') },
  675. })
  676. await engine.startSession()
  677. for (let i = 0; i < 30; i++) await clock.runTimeouts()
  678. assert.equal(calls.getReport, 8)
  679. // 学生做了点什么(录完一段 / 点重试)→ 重新起一轮
  680. engine.resumeFromReport(reportOf(['generating']))
  681. for (let i = 0; i < 30; i++) await clock.runTimeouts()
  682. assert.equal(calls.getReport, 16, 'a fresh chain must get a fresh budget, not stop on failure #1')
  683. }
  684. console.log('article reading engine: poll failure cap cases passed')
  685. // ---- 轮询横幅不滞留(spec §9.2.5 待办 Unsure-3)----
  686. // 失败一次 → 横幅出现;下一次成功 → 横幅收走,不留在学生眼前
  687. {
  688. const clock = makeClock()
  689. let n = 0
  690. const { engine } = setup({
  691. clock,
  692. statuses: ['generating'],
  693. getReport: async () => {
  694. n += 1
  695. if (n === 1) throw new Error('boom')
  696. return reportOf(['generating'])
  697. },
  698. })
  699. await engine.startSession()
  700. await clock.runTimeouts()
  701. assert.ok(engine.error.value, 'a transient poll failure must raise the banner')
  702. await clock.runTimeouts()
  703. assert.equal(engine.error.value, null, 'a later successful poll must take its own banner down')
  704. }
  705. // 但**只**收自己竖的那面:录音被拒的解释必须活过随后那次成功轮询。
  706. // finishRecording 的 catch 设完 error 就 schedulePoll(0)(§4.4 到期收口),
  707. // 无差别清 error 会让学生在唯读锁合上的同一瞬间失去「为什么录不了」的唯一说明。
  708. {
  709. const clock = makeClock()
  710. const { engine } = setup({ clock, getReport: async () => reportOf(['pending', 'pending']) })
  711. await engine.startSession()
  712. await clock.runTimeouts()
  713. engine.error.value = '__recording_rejected__'
  714. await clock.runTimeouts() // 成功轮询跑一轮
  715. assert.equal(engine.error.value, '__recording_rejected__',
  716. 'a non-poll error must survive a successful poll')
  717. }
  718. console.log('article reading engine: stale banner cases passed')
  719. // ---- 编辑态预览:userId 原样透传给 createSession,null 不得被改写成空串(spec §4.5)----
  720. {
  721. const clock = makeClock()
  722. const seen = []
  723. const { engine } = setup({
  724. clock,
  725. userId: null,
  726. createSession: async (configId, uid) => {
  727. seen.push([configId, uid])
  728. return reportOf(['pending', 'pending'])
  729. },
  730. })
  731. await engine.startSession()
  732. assert.deepEqual(seen, [['c1', null]],
  733. 'editor preview must reach the backend as null, not "" — an empty string is a student named ""')
  734. }
  735. {
  736. const clock = makeClock()
  737. const seen = []
  738. const { engine } = setup({
  739. clock,
  740. userId: 'u9',
  741. createSession: async (configId, uid) => {
  742. seen.push([configId, uid])
  743. return reportOf(['pending', 'pending'])
  744. },
  745. })
  746. await engine.startSession()
  747. assert.deepEqual(seen, [['c1', 'u9']], 'a real student id must still be forwarded verbatim')
  748. }
  749. // ---- 双击「开始朗读」只发一个 POST /session ----
  750. // 后端 get-or-create 无锁、无唯一约束:两个并发请求会双双查不到既有 session 然后各插一行。
  751. // 编辑态预览(userId=null)必然如此 —— `_latest_session` 对空 user 早退返回 None(spec §4.5)。
  752. // 多出来那一行会算进 §4.3 B 的「N 名学生正在练习中」直到过期,还会多写一笔作业记录(§9.2.4)。
  753. {
  754. const clock = makeClock()
  755. let calls = 0
  756. let release
  757. const gate = new Promise(resolve => { release = resolve })
  758. const { engine } = setup({
  759. clock,
  760. userId: null,
  761. createSession: async () => {
  762. calls += 1
  763. await gate
  764. return reportOf(['pending', 'pending'])
  765. },
  766. })
  767. const first = engine.startSession()
  768. await flush()
  769. assert.equal(engine.starting.value, true, 'POST 在途时 starting 必须为真,视图据此置灰')
  770. const second = engine.startSession() // 第二次点击:必须被闩早退吃掉
  771. release()
  772. await Promise.all([first, second])
  773. assert.equal(calls, 1, 'double click must not create a second session')
  774. assert.equal(engine.starting.value, false, '结束后闩必须放开,失败重试才点得动')
  775. }
  776. // ---- 开始失败后闩要放开:学生还能再点一次(承 §9.2.5 A2 的 readyError 出口)----
  777. {
  778. const clock = makeClock()
  779. let calls = 0
  780. const { engine } = setup({
  781. clock,
  782. createSession: async () => {
  783. calls += 1
  784. if (calls === 1) throw new Error('Config not found')
  785. return reportOf(['pending', 'pending'])
  786. },
  787. })
  788. await engine.startSession()
  789. assert.equal(engine.stage.value, 'ready', '失败停在起始页')
  790. assert.ok(engine.error.value, '失败必须有文案')
  791. assert.equal(engine.starting.value, false, 'finally 放闩,不是只在成功路径上放')
  792. await engine.startSession()
  793. assert.equal(calls, 2, '第二次点击必须真的发出去')
  794. assert.equal(engine.stage.value, 'reading')
  795. }