test-dialogue-stream-fallback.mjs 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  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/useDialogueEngine.ts', import.meta.url)
  5. let source = await readFile(sourceUrl, 'utf8')
  6. source = source
  7. .replace(
  8. "import { ref, reactive, computed, onUnmounted } from 'vue'",
  9. `
  10. const ref = value => ({ value })
  11. const reactive = value => value
  12. const computed = getter => ({ get value() { return getter() } })
  13. const onUnmounted = () => {}
  14. `,
  15. )
  16. .replace(
  17. "import { createDialogueApi, DialogueApiError } from '../services/llmService'",
  18. `
  19. const createDialogueApi = () => globalThis.__dialogueApi
  20. class DialogueApiError extends Error {
  21. constructor(message, status) {
  22. super(message)
  23. this.status = status
  24. }
  25. }
  26. `,
  27. )
  28. .replace(
  29. "import { buildSpeakingWsUrl } from '../services/speakingApiConfig'",
  30. "const buildSpeakingWsUrl = () => 'wss://example.test/api/speaking/dialogue/speak-stream'",
  31. )
  32. const compiled = ts.transpileModule(source, {
  33. compilerOptions: {
  34. module: ts.ModuleKind.ESNext,
  35. target: ts.ScriptTarget.ES2022,
  36. },
  37. }).outputText
  38. class FakeWebSocket {
  39. static CONNECTING = 0
  40. static OPEN = 1
  41. static CLOSING = 2
  42. static CLOSED = 3
  43. static instances = []
  44. readyState = FakeWebSocket.CONNECTING
  45. binaryType = ''
  46. sent = []
  47. onopen = null
  48. onmessage = null
  49. onerror = null
  50. onclose = null
  51. constructor(url) {
  52. this.url = url
  53. FakeWebSocket.instances.push(this)
  54. }
  55. send(payload) {
  56. this.sent.push(payload)
  57. }
  58. close() {
  59. this.readyState = FakeWebSocket.CLOSED
  60. this.onclose?.({})
  61. }
  62. }
  63. globalThis.WebSocket = FakeWebSocket
  64. const speakCalls = []
  65. globalThis.__dialogueApi = {
  66. async *speak(sessionId, audioBlob, signal, turnId) {
  67. speakCalls.push({ sessionId, audioBlob, signal, turnId })
  68. yield { type: 'transcript', text: 'hello' }
  69. yield { type: 'token', text: 'Hi.' }
  70. yield { type: 'done', isComplete: false }
  71. },
  72. generateGreeting() {
  73. throw new Error('not used')
  74. },
  75. getReport() {
  76. throw new Error('not used')
  77. },
  78. completeSession() {
  79. throw new Error('not used')
  80. },
  81. }
  82. const mod = await import(`data:text/javascript,${encodeURIComponent(compiled)}`)
  83. const engine = mod.useDialogueEngine()
  84. engine.attachSession({ sessionId: 'session-1', totalRounds: 3 })
  85. const ctl = engine.beginStudentStream({ sampleRate: 16000, bits: 16, channels: 1 })
  86. assert.ok(ctl)
  87. const ws = FakeWebSocket.instances[0]
  88. ws.readyState = FakeWebSocket.CLOSED
  89. ws.onerror?.({})
  90. ws.onclose?.({})
  91. const audioBlob = new Blob([new Uint8Array([1, 2, 3, 4])], { type: 'audio/webm' })
  92. ctl.commit(audioBlob)
  93. await new Promise(resolve => setTimeout(resolve, 0))
  94. // WS 失败时不再自动降级 HTTP——api.speak 不应被自动调用。
  95. assert.equal(speakCalls.length, 0, 'auto fallback to /speak should not happen')
  96. // 应当只 push 一条 student 消息(已 splice 掉 ai 占位),处于 error 状态等待用户点重试。
  97. assert.equal(engine.messages.value.length, 1)
  98. const studentMsg = engine.messages.value[0]
  99. assert.equal(studentMsg.role, 'student')
  100. assert.equal(studentMsg.status, 'error')
  101. assert.equal(studentMsg.recovery, 'retry')
  102. assert.equal(studentMsg.audioBlob, audioBlob, 'audioBlob preserved for retry')
  103. assert.equal(studentMsg.turnId, ctl.turnId)
  104. // 用户点重试 → engine.retryMessage 走 sendStudentMessage → /speak(HTTP)。
  105. await engine.retryMessage(studentMsg.id)
  106. assert.equal(speakCalls.length, 1, 'retry should invoke /speak once')
  107. assert.equal(speakCalls[0].sessionId, 'session-1')
  108. assert.equal(speakCalls[0].turnId, ctl.turnId, 'retry reuses original turnId for idempotency')
  109. assert.equal(speakCalls[0].audioBlob, audioBlob)