import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; const source = (path) => readFile(new URL(path, import.meta.url), 'utf8'); test('实时陪练通过受认证代理接入,前端不携带供应商密钥、不自组会话配置', async () => { const [service, page, backendService, backendSessionService, backendPersonaRegistry] = await Promise.all([ source('../src/services/realtime-practice.ts'), source('../src/pages/user/practice/index.vue'), source('../../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePracticeService.java'), source('../../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimeSessionService.java'), source('../../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePersonaRegistry.java') ]); // 传输与事件协议 assert.match(service, /\/api\/train\/practice\/realtime\/sdp/); assert.match(service, /conversation\.item\.input_audio_transcription\.completed/); assert.match(service, /response\.audio_transcript\.done/); assert.match(service, /session\.created/); assert.match(service, /session\.updated/); assert.match(service, /activeAttempt/); assert.match(service, /resumeAudio/); // 方案B 架构:session.update 由服务端组装,浏览器只原样转发;instructions/音色不下放前端 assert.match(service, /\/api\/train\/practice\/realtime\/session/); assert.match(service, /sendSessionUpdate/); assert.doesNotMatch(service, /instructions/); assert.doesNotMatch(service, /REALTIME_PRACTICE_VOICES|createRealtimePracticeSessionUpdate|normalizeRealtimePracticeVoice/); assert.doesNotMatch(service, /semantic_vad/); assert.doesNotMatch(service, /DASHSCOPE_API_KEY|Authorization:\s*Bearer/); // function calling 工具桥:事件分支、串行链、软着陆兜底、回传协议 assert.match(service, /\/api\/train\/practice\/realtime\/tools\/invoke/); assert.match(service, /response\.function_call_arguments\.done/); assert.match(service, /function_call_output/); assert.match(service, /response\.create/); assert.match(service, /toolCallChain/); assert.match(service, /工具暂时不可用,请直接回应员工/); assert.match(service, /calling-tool/); // 服务端是会话配置的唯一组装点 assert.match(backendSessionService, /session\.update/); assert.match(backendSessionService, /semantic_vad/); assert.match(backendSessionService, /TOOL_SEARCH_KNOWLEDGE/); assert.match(backendPersonaRegistry, /search_knowledge/); assert.match(backendPersonaRegistry, /owner-calm/); assert.match(backendPersonaRegistry, /digital-mentor/); assert.match(backendService, /实时陪练限流暂时不可用/); // 页面:人设卡片选择 + 本地持久化;音色 picker 已删除 assert.match(page, /实时陪练 Beta/); assert.match(page, /实时陪练人设/); assert.match(page, /aihr_realtime_practice_persona/); assert.match(page, /selectRealtimePersona/); assert.match(page, /fetchRealtimePersonas/); assert.match(page, /createRealtimeSession/); assert.match(page, /正在查知识库/); assert.match(page, /class="panel scenario-card realtime-entry-card employee-card"/); assert.match(page, /const canStartRealtime = computed/); assert.match(page, /if \(!canStartRealtime\.value \|\| !requireLogin\(\)\) return;/); assert.doesNotMatch(page, /realtimeVoice|selectRealtimeVoice|aihr_realtime_practice_voice/); }); test('工具调用串行化与竞态防护:链式执行、attempt 双检、stop 重置', async () => { const service = await source('../src/services/realtime-practice.ts'); // 串行链必须基于上一次调用收尾,且带末端 catch 防毒化 assert.match(service, /toolCallChain = toolCallChain\.then/); assert.match(service, /\.catch\(\(\) => \{/); // stop() 递增 attempt 使迟到的工具回调失效,并重置链 assert.match(service, /activeAttempt \+= 1/); assert.match(service, /toolCallChain = Promise\.resolve\(\)/); // 回传前双检:attempt 仍当前 + 数据通道仍打开 assert.match(service, /!isCurrent\(attempt\) \|\| channel\.readyState !== 'open'/); });