import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import ts from 'typescript' const sourceUrl = new URL('../src/views/Editor/EnglishSpeaking/services/articleReadingStream.ts', import.meta.url) let source = await readFile(sourceUrl, 'utf8') source = source.replace( "import { buildArticleWsUrl } from './speakingApiConfig'", "const buildArticleWsUrl = path => `wss://example.test/api/speaking/article${path}`", ) const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, }).outputText class FakeWebSocket { static CONNECTING = 0 static OPEN = 1 static CLOSING = 2 static CLOSED = 3 static instances = [] readyState = FakeWebSocket.CONNECTING binaryType = '' sent = [] onopen = null onmessage = null onerror = null onclose = null constructor(url) { this.url = url FakeWebSocket.instances.push(this) } send(payload) { this.sent.push(payload) } close() { this.readyState = FakeWebSocket.CLOSED this.onclose?.({}) } } globalThis.WebSocket = FakeWebSocket const mod = await import(`data:text/javascript,${encodeURIComponent(compiled)}`) // ---- 1. 正常时序:start → PCM → stop → ack ---- // chunk 先于 open 抵达,必须排队而不是丢弃(录音器早于 socket 就绪是常态) const stream = mod.openArticleParagraphStream({ sessionId: 's1', paragraphIdx: 2, sampleRate: 48000, }) const firstChunk = new Uint8Array([1, 2, 3, 4]).buffer stream.pushChunk(firstChunk) const ackPromise = stream.stop() const ws = FakeWebSocket.instances[0] assert.equal(ws.url, 'wss://example.test/api/speaking/article/session/s1/paragraph/2/stream') ws.readyState = FakeWebSocket.OPEN ws.onopen() assert.deepEqual(JSON.parse(ws.sent[0]), { type: 'start', sampleRate: 48000, bits: 16, channels: 1, }) assert.equal(ws.sent[1], firstChunk) assert.deepEqual(JSON.parse(ws.sent[2]), { type: 'stop' }) ws.onmessage({ data: JSON.stringify({ type: 'ack', status: 'generating', attempt: 1, truncated: false }) }) assert.deepEqual(await ackPromise, { type: 'ack', status: 'generating', attempt: 1, truncated: false, }) // ---- 2. error frame 以其 message reject ---- const errStream = mod.openArticleParagraphStream({ sessionId: 's2', paragraphIdx: 0, sampleRate: 16000 }) const errAck = errStream.stop() const errWs = FakeWebSocket.instances[1] errWs.readyState = FakeWebSocket.OPEN errWs.onopen() errWs.onmessage({ data: JSON.stringify({ type: 'error', message: 'assessment failed' }) }) await assert.rejects(errAck, /assessment failed/) // ---- 3. abort() 关闭连接并 reject 未决的 stop ---- const abortStream = mod.openArticleParagraphStream({ sessionId: 's3', paragraphIdx: 1, sampleRate: 16000 }) const abortWs = FakeWebSocket.instances[2] abortWs.readyState = FakeWebSocket.OPEN abortWs.onopen() const abortAck = abortStream.stop() abortStream.abort('user left the paragraph') assert.equal(abortWs.readyState, FakeWebSocket.CLOSED) await assert.rejects(abortAck, /user left the paragraph/) // ---- 4. 连接在 ack 之前关闭必须 reject,不能静默挂起 ---- const deadStream = mod.openArticleParagraphStream({ sessionId: 's4', paragraphIdx: 0, sampleRate: 16000 }) const deadWs = FakeWebSocket.instances[3] deadWs.readyState = FakeWebSocket.OPEN deadWs.onopen() const deadAck = deadStream.stop() deadWs.close() await assert.rejects(deadAck, /closed before acknowledgement/) // ---- 5. stop 之后再推 PCM 是编程错误 ---- const lateStream = mod.openArticleParagraphStream({ sessionId: 's5', paragraphIdx: 0, sampleRate: 16000 }) const lateWs = FakeWebSocket.instances[4] lateWs.readyState = FakeWebSocket.OPEN lateWs.onopen() const lateAck = lateStream.stop() assert.throws(() => lateStream.pushChunk(new Uint8Array([9]).buffer), /Cannot push PCM after stop/) lateWs.onmessage({ data: JSON.stringify({ type: 'ack', status: 'generating', attempt: 1, truncated: false }) }) await lateAck