package voice // 本文件锁定**一次链上 STT 的诚实报告**(切片 IM-ASR):`STTOutcome` 必须把静默档位 // 掩盖的真实原因带出来,`Transcribed()` 必须把「真转写(文本可能为空)」与「静默降级/ // 未配置」分开,`CircuitWorthy` 只对「再试也是白试」的类别为真。 // // 这些断言直接对应桥接器的熔断与诚实提示:判错了就会出现「每条语音都撞一次付费墙」 // 或「一条被拒的语音关掉整个转写面」。 import ( "errors" "fmt" "testing" ) func TestSTTOutcomeReportsRealTranscription(t *testing.T) { t.Parallel() chain := NewChain(&stubProvider{name: "cloud", stt: &stubSTT{text: "你好"}}) out := chain.STTOutcome(t.Context(), Audio{Format: "ogg", Data: []byte("a")}) if !out.Transcribed() { t.Fatalf("Transcribed() = false, want true(provider=%q cause=%v)", out.Provider, out.Cause) } if out.Text != "你好" || out.Provider != "cloud" || out.Cause != nil { t.Fatalf("outcome = %+v, want 你好/cloud/nil", out) } } // TestSTTOutcomeEmptyTextFromRealProviderIsNotFailure 钉住一个容易搞反的区分:云端 // 转写成功但**没识别出文字**(静音/噪声)不是故障 —— 它绝不熔断、也不该说「服务挂了」。 func TestSTTOutcomeEmptyTextFromRealProviderIsNotFailure(t *testing.T) { t.Parallel() chain := NewChain(&stubProvider{name: "cloud", stt: &stubSTT{text: ""}}) out := chain.STTOutcome(t.Context(), Audio{Format: "ogg", Data: []byte("a")}) if !out.Transcribed() { t.Fatalf("Transcribed() = false, want true(空文本但服务正常): %+v", out) } if out.Provider != "cloud" || out.Cause != nil { t.Fatalf("outcome = %+v, want cloud/nil", out) } if CircuitWorthy(out.Cause) { t.Fatal("空文本被判成「再试也是白试」,这会误开熔断") } } // TestSTTOutcomeRevealsSilentDegradation 是本切片的核心断言:静默档位返回「空文本 + // nil 错」,因此只看文本会把「没有可用的识别后端」当成功 —— `Cause` 必须把它揭穿。 func TestSTTOutcomeRevealsSilentDegradation(t *testing.T) { t.Parallel() chain := NewChain( &stubProvider{name: "cloud", stt: &stubSTT{err: ErrAuthFailed}}, SilentProvider(), ) out := chain.STTOutcome(t.Context(), Audio{Format: "ogg", Data: []byte("a")}) if out.Transcribed() { t.Fatalf("静默降级被判成真转写:%+v", out) } if out.Provider != SilentProviderName { t.Fatalf("provider = %q, want %q", out.Provider, SilentProviderName) } if !errors.Is(out.Cause, ErrAuthFailed) { t.Fatalf("cause = %v, want ErrAuthFailed(被静默档位掩盖的真实原因必须带出来)", out.Cause) } if !CircuitWorthy(out.Cause) { t.Fatal("鉴权失败必须可熔断(否则每条语音都撞一次付费墙)") } } // TestSTTOutcomeWithoutAnyStepReportsNotConfigured 覆盖「链上压根没有云端档位」: // 静默档位成功但没有前车之鉴 ⇒ 归为「未配置」,同样可熔断。 func TestSTTOutcomeWithoutAnyStepReportsNotConfigured(t *testing.T) { t.Parallel() out := NewChain(SilentProvider()).STTOutcome(t.Context(), Audio{Format: "ogg"}) if out.Transcribed() || !errors.Is(out.Cause, ErrNotConfigured) { t.Fatalf("outcome = %+v, want 未配置(不可当真转写)", out) } if !CircuitWorthy(out.Cause) { t.Fatal("未配置必须可熔断(否则每条语音都白跑一趟)") } } func TestSTTOutcomeEmptyChain(t *testing.T) { t.Parallel() out := NewChain().STTOutcome(t.Context(), Audio{Format: "ogg"}) if out.Transcribed() || !errors.Is(out.Cause, ErrProviderFailed) { t.Fatalf("outcome = %+v, want ErrProviderFailed", out) } } // TestSTTOutcomeKeepsProviderOrder 钉住它仍走既有的降级顺序(不因为新增报告面而改变 // 「先云端、后本地」的语义)。 func TestSTTOutcomeKeepsProviderOrder(t *testing.T) { t.Parallel() chain := NewChain( &stubProvider{name: "cloud", stt: &stubSTT{err: ErrRateLimited}}, &stubProvider{name: "local", stt: &stubSTT{text: "本地转写"}}, SilentProvider(), ) out := chain.STTOutcome(t.Context(), Audio{Format: "ogg"}) if !out.Transcribed() || out.Text != "本地转写" || out.Provider != "local" { t.Fatalf("outcome = %+v, want 落到 local", out) } } // TestCircuitWorthyClassification 钉住分类口径:只有「账户/配额/权限层面」的失败为真; // 按内容发生的失败(格式不支持、解码失败)为假 —— 后者若熔断,一条被拒的语音会让 // 后续语音全部降级。 func TestCircuitWorthyClassification(t *testing.T) { t.Parallel() cases := []struct { name string err error want bool }{ {"nil", nil, false}, {"未配置", ErrNotConfigured, true}, {"鉴权失败", ErrAuthFailed, true}, {"欠费/额度", ErrQuotaExceeded, true}, {"限流", ErrRateLimited, true}, {"包装后的鉴权失败", fmt.Errorf("voice: 上游: %w", ErrAuthFailed), true}, {"普通上游失败", ErrProviderFailed, false}, {"格式不支持", fmt.Errorf("voice: %w: unsupported format", ErrProviderFailed), false}, {"未知错误", errors.New("boom"), false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := CircuitWorthy(tc.err); got != tc.want { t.Fatalf("CircuitWorthy(%v) = %v, want %v", tc.err, got, tc.want) } }) } } // TestSTTOutcomeErrorStringsAreClassified 钉住鉴权/限流/欠费的错误串里同时带着 // 分类哨兵与上游细节(既能让调用方分类,也让日志能说清是哪一次失败)。 func TestSTTOutcomeErrorStringsAreClassified(t *testing.T) { t.Parallel() for name, err := range map[string]error{ "auth": ErrAuthFailed, "quota": ErrQuotaExceeded, "rate": ErrRateLimited, } { t.Run(name, func(t *testing.T) { t.Parallel() if !errors.Is(err, ErrProviderFailed) { t.Fatalf("分类哨兵 %v 必须以 ErrProviderFailed 为底(既有 errors.Is 判定不得变化)", err) } }) } }