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/useDialogueEngine.ts', import.meta.url) let source = await readFile(sourceUrl, 'utf8') source = source .replace( "import { ref, reactive, computed, onUnmounted } from 'vue'", ` const ref = value => ({ value }) const reactive = value => value const computed = getter => ({ get value() { return getter() } }) const onUnmounted = () => {} `, ) .replace( "import { createDialogueApi, DialogueApiError } from '../services/llmService'", ` const createDialogueApi = () => globalThis.__dialogueApi class DialogueApiError extends Error { constructor(message, status) { super(message) this.status = status } } `, ) .replace( "import { buildSpeakingWsUrl } from '../services/speakingApiConfig'", "const buildSpeakingWsUrl = () => 'wss://example.test/api/speaking/dialogue/speak-stream'", ) 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 speakCalls = [] globalThis.__dialogueApi = { async *speak(sessionId, audioBlob, signal, turnId) { speakCalls.push({ sessionId, audioBlob, signal, turnId }) yield { type: 'transcript', text: 'hello' } yield { type: 'token', text: 'Hi.' } yield { type: 'done', isComplete: false } }, generateGreeting() { throw new Error('not used') }, getReport() { throw new Error('not used') }, completeSession() { throw new Error('not used') }, } const mod = await import(`data:text/javascript,${encodeURIComponent(compiled)}`) const engine = mod.useDialogueEngine() engine.attachSession({ sessionId: 'session-1', totalRounds: 3 }) const ctl = engine.beginStudentStream({ sampleRate: 16000, bits: 16, channels: 1 }) assert.ok(ctl) const ws = FakeWebSocket.instances[0] ws.readyState = FakeWebSocket.CLOSED ws.onerror?.({}) ws.onclose?.({}) const audioBlob = new Blob([new Uint8Array([1, 2, 3, 4])], { type: 'audio/webm' }) ctl.commit(audioBlob) await new Promise(resolve => setTimeout(resolve, 0)) // WS 失败时不再自动降级 HTTP——api.speak 不应被自动调用。 assert.equal(speakCalls.length, 0, 'auto fallback to /speak should not happen') // 应当只 push 一条 student 消息(已 splice 掉 ai 占位),处于 error 状态等待用户点重试。 assert.equal(engine.messages.value.length, 1) const studentMsg = engine.messages.value[0] assert.equal(studentMsg.role, 'student') assert.equal(studentMsg.status, 'error') assert.equal(studentMsg.recovery, 'retry') assert.equal(studentMsg.audioBlob, audioBlob, 'audioBlob preserved for retry') assert.equal(studentMsg.turnId, ctl.turnId) // 用户点重试 → engine.retryMessage 走 sendStudentMessage → /speak(HTTP)。 await engine.retryMessage(studentMsg.id) assert.equal(speakCalls.length, 1, 'retry should invoke /speak once') assert.equal(speakCalls[0].sessionId, 'session-1') assert.equal(speakCalls[0].turnId, ctl.turnId, 'retry reuses original turnId for idempotency') assert.equal(speakCalls[0].audioBlob, audioBlob)