diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSpeechServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSpeechServiceTest.java index 3d7432d0..88b84971 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSpeechServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSpeechServiceTest.java @@ -60,4 +60,44 @@ class AihrSpeechServiceTest { assertThrows(IllegalArgumentException.class, () -> AihrSpeechService.buildRequestBody( mapper, runtime, "测试", null, invalid)); } + + @Test + void buildsDashScopeQwenTtsRequestAndNormalizesWorkspaceEndpoint() { + ObjectMapper mapper = new ObjectMapper(); + SpeechModel runtime = new SpeechModel( + "dashscope", + "qwen-audio-3.0-tts-flash", + "", + "" + ); + + var body = AihrSpeechService.buildDashScopeRequestBody( + mapper, runtime, "请先确认业主的诉求。", + new VoiceProfile("mentor", null, 0.88, "warm", "mandarin")); + + assertEquals("qwen-audio-3.0-tts-flash", body.path("model").asText()); + assertEquals("请先确认业主的诉求。", body.path("input").path("text").asText()); + assertEquals("longanhuan_v3.6", body.path("input").path("voice").asText()); + assertEquals("mp3", body.path("input").path("format").asText()); + assertEquals(24000, body.path("input").path("sample_rate").asInt()); + assertEquals(0.88, body.path("input").path("rate").asDouble()); + assertEquals( + "https://ws-example.cn-beijing.maas.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer", + AihrSpeechService.dashScopeSynthesisUri("https://ws-example.cn-beijing.maas.aliyuncs.com/compatible-mode/v1").toString() + ); + } + + @Test + void extractsDashScopeAudioUrlOnlyWhenPresent() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + String response = """ + {"output":{"audio":{"url":"http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/audio.mp3?sig=ok"}}} + """; + + assertEquals( + "http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/audio.mp3?sig=ok", + AihrSpeechService.dashScopeAudioUrl(mapper, response).orElseThrow() + ); + assertTrue(AihrSpeechService.dashScopeAudioUrl(mapper, "{\"output\":{}}").isEmpty()); + } } diff --git a/mobile-uni/tests/tts-voice-preference.test.mjs b/mobile-uni/tests/tts-voice-preference.test.mjs new file mode 100644 index 00000000..03b39b7e --- /dev/null +++ b/mobile-uni/tests/tts-voice-preference.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import vm from 'node:vm'; +import ts from 'typescript'; + +const source = (path) => readFile(new URL(path, import.meta.url), 'utf8'); + +const preferenceRuntime = async (storage = new Map()) => { + const service = await source('../src/services/tts-voice-preference.ts'); + const compiled = ts.transpileModule(service, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } + }); + const runtimeModule = { exports: {} }; + vm.runInNewContext(compiled.outputText, { + exports: runtimeModule.exports, + module: runtimeModule, + uni: { + getStorageSync: (key) => storage.get(key), + setStorageSync: (key, value) => storage.set(key, value) + } + }); + return runtimeModule.exports; +}; + +test('播报音色按租户和账号隔离保存,非法值回退跟随场景', async () => { + const storage = new Map(); + const runtime = await preferenceRuntime(storage); + + assert.equal(runtime.getTtsVoicePreference('000000', '13800000000'), ''); + assert.equal(runtime.setTtsVoicePreference('000000', '13800000000', 'longanhuan_v3.6'), 'longanhuan_v3.6'); + assert.equal(runtime.getTtsVoicePreference('000000', '13800000000'), 'longanhuan_v3.6'); + assert.equal(runtime.getTtsVoicePreference('000000', '13900000000'), ''); + assert.equal(runtime.setTtsVoicePreference('000000', '13800000000', 'untrusted-voice'), ''); +}); + +test('全局选择只覆盖 voice,保留场景角色、情绪、语速和方言', async () => { + const runtime = await preferenceRuntime(); + const profile = { role: 'customer', emotion: 'intense', speed: 1.12, dialect: 'sichuanese' }; + + assert.deepEqual( + runtime.applyTtsVoicePreference(profile, 'longanhuan_v3.6'), + { ...profile, voice: 'longanhuan_v3.6' } + ); + assert.deepEqual(runtime.applyTtsVoicePreference(profile, ''), profile); +}); + +test('语音播放链路在合成前统一注入全局音色,且缓存键包含处理后的 profile', async () => { + const sourceText = await source('../src/services/speech.ts'); + + assert.match(sourceText, /applyTtsVoicePreference/); + assert.match(sourceText, /getTtsVoicePreference/); + assert.match(sourceText, /cacheKey\(value, effectiveProfile, practiceContext\)/); + assert.match(sourceText, /synthesizeSpeech\(value, effectiveProfile, practiceContext\)/); +});