Files
AskaEth 355f81d04a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 第 2 批 5 个文件注释改中文(只改注释,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」。本批 = WS 上行门与握手回放那批文件:
ws_hub.go / ws_history.go / ws_route.go / ws_role_gate_test.go / voice_signal_test.go
含中文的整行注释 6 → 148;无行尾注释。

校验(判据 = "只改注释、零代码字节改动"):
- git diff -U0 | 排除 +++/--- / 注释行 / 空行 / 行尾注释 → 为空
  (含"把纯空行变动也视为违规"的更严变体,同样为空)
- 独立实现:两侧剥掉注释后逐行比对代码文本 → 5/5 全等
- gofmt -l 干净(改动前同样干净,不存在"顺手格式化")
- go test ./... 全绿;go build/vet 绿;linux/amd64 + darwin/arm64 交叉编译绿
安全约束强度逐句保留:ws_route.go 的「默认拒(非黑名单)/ 删掉它等于放开一切」、
ws_history.go 的「门在 replayHistory 第一条语句 / 扩角色须先登记 design/11 §7.2 /
回放不含 thinking」、ws_role_gate_test.go 的测试名与断言字面量一字符未改。
2026-10-03 22:10:33 +08:00

184 lines
5.9 KiB
Go

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