| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126 |
- 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)
|