test-article-reading-stream.mjs 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  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/services/articleReadingStream.ts', import.meta.url)
  5. let source = await readFile(sourceUrl, 'utf8')
  6. source = source.replace(
  7. "import { buildArticleWsUrl } from './speakingApiConfig'",
  8. "const buildArticleWsUrl = path => `wss://example.test/api/speaking/article${path}`",
  9. )
  10. const compiled = ts.transpileModule(source, {
  11. compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
  12. }).outputText
  13. class FakeWebSocket {
  14. static CONNECTING = 0
  15. static OPEN = 1
  16. static CLOSING = 2
  17. static CLOSED = 3
  18. static instances = []
  19. readyState = FakeWebSocket.CONNECTING
  20. binaryType = ''
  21. sent = []
  22. onopen = null
  23. onmessage = null
  24. onerror = null
  25. onclose = null
  26. constructor(url) {
  27. this.url = url
  28. FakeWebSocket.instances.push(this)
  29. }
  30. send(payload) {
  31. this.sent.push(payload)
  32. }
  33. close() {
  34. this.readyState = FakeWebSocket.CLOSED
  35. this.onclose?.({})
  36. }
  37. }
  38. globalThis.WebSocket = FakeWebSocket
  39. const mod = await import(`data:text/javascript,${encodeURIComponent(compiled)}`)
  40. // ---- 1. 正常时序:start → PCM → stop → ack ----
  41. // chunk 先于 open 抵达,必须排队而不是丢弃(录音器早于 socket 就绪是常态)
  42. const stream = mod.openArticleParagraphStream({
  43. sessionId: 's1',
  44. paragraphIdx: 2,
  45. sampleRate: 48000,
  46. })
  47. const firstChunk = new Uint8Array([1, 2, 3, 4]).buffer
  48. stream.pushChunk(firstChunk)
  49. const ackPromise = stream.stop()
  50. const ws = FakeWebSocket.instances[0]
  51. assert.equal(ws.url, 'wss://example.test/api/speaking/article/session/s1/paragraph/2/stream')
  52. ws.readyState = FakeWebSocket.OPEN
  53. ws.onopen()
  54. assert.deepEqual(JSON.parse(ws.sent[0]), {
  55. type: 'start', sampleRate: 48000, bits: 16, channels: 1,
  56. })
  57. assert.equal(ws.sent[1], firstChunk)
  58. assert.deepEqual(JSON.parse(ws.sent[2]), { type: 'stop' })
  59. ws.onmessage({ data: JSON.stringify({ type: 'ack', status: 'generating', attempt: 1, truncated: false }) })
  60. assert.deepEqual(await ackPromise, {
  61. type: 'ack', status: 'generating', attempt: 1, truncated: false,
  62. })
  63. // ---- 2. error frame 以其 message reject ----
  64. const errStream = mod.openArticleParagraphStream({ sessionId: 's2', paragraphIdx: 0, sampleRate: 16000 })
  65. const errAck = errStream.stop()
  66. const errWs = FakeWebSocket.instances[1]
  67. errWs.readyState = FakeWebSocket.OPEN
  68. errWs.onopen()
  69. errWs.onmessage({ data: JSON.stringify({ type: 'error', message: 'assessment failed' }) })
  70. await assert.rejects(errAck, /assessment failed/)
  71. // ---- 3. abort() 关闭连接并 reject 未决的 stop ----
  72. const abortStream = mod.openArticleParagraphStream({ sessionId: 's3', paragraphIdx: 1, sampleRate: 16000 })
  73. const abortWs = FakeWebSocket.instances[2]
  74. abortWs.readyState = FakeWebSocket.OPEN
  75. abortWs.onopen()
  76. const abortAck = abortStream.stop()
  77. abortStream.abort('user left the paragraph')
  78. assert.equal(abortWs.readyState, FakeWebSocket.CLOSED)
  79. await assert.rejects(abortAck, /user left the paragraph/)
  80. // ---- 4. 连接在 ack 之前关闭必须 reject,不能静默挂起 ----
  81. const deadStream = mod.openArticleParagraphStream({ sessionId: 's4', paragraphIdx: 0, sampleRate: 16000 })
  82. const deadWs = FakeWebSocket.instances[3]
  83. deadWs.readyState = FakeWebSocket.OPEN
  84. deadWs.onopen()
  85. const deadAck = deadStream.stop()
  86. deadWs.close()
  87. await assert.rejects(deadAck, /closed before acknowledgement/)
  88. // ---- 5. stop 之后再推 PCM 是编程错误 ----
  89. const lateStream = mod.openArticleParagraphStream({ sessionId: 's5', paragraphIdx: 0, sampleRate: 16000 })
  90. const lateWs = FakeWebSocket.instances[4]
  91. lateWs.readyState = FakeWebSocket.OPEN
  92. lateWs.onopen()
  93. const lateAck = lateStream.stop()
  94. assert.throws(() => lateStream.pushChunk(new Uint8Array([9]).buffer), /Cannot push PCM after stop/)
  95. lateWs.onmessage({ data: JSON.stringify({ type: 'ack', status: 'generating', attempt: 1, truncated: false }) })
  96. await lateAck