package gateway import ( "context" "encoding/base64" "encoding/json" "net/http" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" ) // signalVoice 是同时实现 voiceProviderReporter 的 VoiceService,使测试既能 // 控制 STT/TTS 结果,也能控制所上报的 provider 档位。 type signalVoice struct { text string audio voice.Audio rung string sttErr error ttsErr error } func (s *signalVoice) STT(_ context.Context, _ voice.Audio) (string, error) { return s.text, s.sttErr } func (s *signalVoice) TTS(_ context.Context, _ string) (voice.Audio, error) { return s.audio, s.ttsErr } func (s *signalVoice) CloneVoice(_ context.Context, _, _ string) (string, error) { return "", nil } func (s *signalVoice) STTWithProvider(_ context.Context, _ voice.Audio) (string, string, error) { return s.text, s.rung, s.sttErr } func (s *signalVoice) TTSWithProvider(_ context.Context, _ string) (voice.Audio, string, error) { return s.audio, s.rung, s.ttsErr } func TestHandleTranscribe_SilentSignal(t *testing.T) { env := newEnv(t, nil, false) env.srv.SetVoice(&signalVoice{rung: "silent"}) token := env.token(t, auth.RoleOwner) resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/transcribe", map[string]any{ "audio": base64.StdEncoding.EncodeToString([]byte("raw")), }, token) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, body = %s", resp.StatusCode, body) } var data struct { Text string `json:"text"` Provider string `json:"provider"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &data); err != nil { t.Fatalf("decode data: %v (body=%s)", err, body) } if data.Text != "" || data.Provider != "silent" { t.Errorf("data = %+v, want empty text + provider silent", data) } } func TestHandleTranscribe_SilentFallbackWithoutReporter(t *testing.T) { env := newEnv(t, nil, false) // audioStubVoice 没有实现 voiceProviderReporter,因此处理函数仍须把 // 空转写标为 silent。 env.srv.SetVoice(&audioStubVoice{}) token := env.token(t, auth.RoleOwner) resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/transcribe", map[string]any{ "audio": base64.StdEncoding.EncodeToString([]byte("raw")), }, token) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, body = %s", resp.StatusCode, body) } var data struct { Provider string `json:"provider"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &data); err != nil { t.Fatalf("decode data: %v (body=%s)", err, body) } if data.Provider != "silent" { t.Errorf("provider = %q, want silent (body=%s)", data.Provider, body) } } func TestHandleTranscribe_SuccessOmitsProvider(t *testing.T) { env := newEnv(t, nil, false) env.srv.SetVoice(&signalVoice{text: "你好", rung: "cloud"}) token := env.token(t, auth.RoleOwner) resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/transcribe", map[string]any{ "audio": base64.StdEncoding.EncodeToString([]byte("raw")), }, token) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, body = %s", resp.StatusCode, body) } var raw map[string]json.RawMessage if err := json.Unmarshal(decodeBody(t, body)["data"], &raw); err != nil { t.Fatalf("decode data: %v (body=%s)", err, body) } if _, present := raw["provider"]; present { t.Errorf("provider must be omitted on a non-empty transcription: %s", body) } } func TestHandleSpeech_EmptyAudioSignal(t *testing.T) { env := newEnv(t, nil, false) env.srv.SetVoice(&signalVoice{rung: "silent"}) token := env.token(t, auth.RoleOwner) resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{ "text": "你好", }, token) if resp.StatusCode != http.StatusInternalServerError { t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body) } if got := resp.Header.Get(voiceProviderHeader); got != "silent" { t.Errorf("%s = %q, want silent", voiceProviderHeader, got) } var env2 struct { Error struct { Code string `json:"code"` } `json:"error"` } if err := json.Unmarshal(body, &env2); err != nil { t.Fatalf("decode error: %v (body=%s)", err, body) } if env2.Error.Code != "tts_empty" { t.Errorf("error code = %q, want tts_empty (body=%s)", env2.Error.Code, body) } } func TestHubVoiceOutput_SilentSignal(t *testing.T) { t.Parallel() hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好", Steps: 1, StopReason: "stop"}}, nil) hb.voice = &signalVoice{text: "你好", rung: "silent"} // 自 design/11 §7.2 B 起 voice_input 是面向主人的指令,因此驱动它的连接 // 必须携带 owner/admin claims。 c := newOwnerConn(hb, 32) hb.register(c) c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"audio":"aGVsbG8=","format":"wav"}`)}) ev := readFakeFrame(t, c) if ev.Type != wsVoiceOutput { t.Fatalf("frame type = %q, want voice_output (frame=%+v)", ev.Type, ev) } r, ok := ev.Data.(map[string]any) if !ok { t.Fatalf("data = %T, want map[string]any", ev.Data) } if r["provider"] != "silent" { t.Errorf("provider = %v, want silent (frame=%+v)", r["provider"], ev.Data) } } func TestHubVoiceOutput_SuccessOmitsProvider(t *testing.T) { t.Parallel() hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好", Steps: 1, StopReason: "stop"}}, nil) hb.voice = &signalVoice{ text: "你好", audio: voice.Audio{Format: "wav", Data: []byte("audio-bytes")}, rung: "edge-tts", } c := newOwnerConn(hb, 32) hb.register(c) c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"audio":"aGVsbG8=","format":"wav"}`)}) ev := readFakeFrame(t, c) if ev.Type != wsVoiceOutput { t.Fatalf("frame type = %q, want voice_output (frame=%+v)", ev.Type, ev) } r, ok := ev.Data.(map[string]any) if !ok { t.Fatalf("data = %T, want map[string]any", ev.Data) } if _, present := r["provider"]; present { t.Errorf("provider must be omitted for non-empty audio: %+v", ev.Data) } }