- 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引
- 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装)
- 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息
⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
465 lines
18 KiB
Go
465 lines
18 KiB
Go
package channelbridge
|
||
|
||
// 本文件锁定**入站语音 → 文本归一**(切片 IM-ASR;design/06 §1.1、design/08 §4.1):
|
||
//
|
||
// - 转写结果写回 `Content` ⇒ 落库与模型可见消息看到的都是**同一份文本**;
|
||
// - **平台自带转写优先**:Content 已有真实文本(微信 voice_item.text)⇒ 一次 ASR 都
|
||
// 不调;QQ 的 `[语音]` 占位标记不算文字 ⇒ 照样转写;
|
||
// - **静默降级被识破**:`silent` 档的「成功」被当失败处理;
|
||
// - **临时熔断**:可分类失败(鉴权/欠费/限流/未配置)之后不再调上游,但消息面仍有
|
||
// 一行诚实提示;TTL 到期或一次成功即恢复;
|
||
// - 拿不到字节 / 格式不支持 / 超时 / 空识别结果都**诚实提示**,绝不静默、绝不留空;
|
||
// - 纯语音消息不再被丢空判据丢掉。
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||
)
|
||
|
||
// providerFailureForTest 构造一个「按内容发生」的上游失败(格式不支持一类):它只带
|
||
// `ErrProviderFailed`、不带任何可熔断的分类哨兵 —— 用它断言熔断的**分类**是准的。
|
||
func providerFailureForTest() error {
|
||
return fmt.Errorf("voice: %w: unsupported format", voice.ErrProviderFailed)
|
||
}
|
||
|
||
// fakeVoiceSTT 是可编程的假 STT:记录调用次数与每次的音频(格式+字节),可返回任意
|
||
// STTOutcome,也可阻塞到 ctx 结束(超时用例)。
|
||
type fakeVoiceSTT struct {
|
||
mu sync.Mutex
|
||
calls int
|
||
seen []voice.Audio
|
||
reply voice.STTOutcome
|
||
block bool
|
||
}
|
||
|
||
func (f *fakeVoiceSTT) STTOutcome(ctx context.Context, audio voice.Audio) voice.STTOutcome {
|
||
f.mu.Lock()
|
||
f.calls++
|
||
f.seen = append(f.seen, audio)
|
||
reply, block := f.reply, f.block
|
||
f.mu.Unlock()
|
||
if block {
|
||
<-ctx.Done()
|
||
return voice.STTOutcome{Provider: voice.SilentProviderName, Cause: ctx.Err()}
|
||
}
|
||
return reply
|
||
}
|
||
|
||
func (f *fakeVoiceSTT) callCount() int {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
return f.calls
|
||
}
|
||
|
||
func (f *fakeVoiceSTT) lastAudio(t *testing.T) voice.Audio {
|
||
t.Helper()
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
if len(f.seen) == 0 {
|
||
t.Fatal("STT 从未被调用")
|
||
}
|
||
return f.seen[len(f.seen)-1]
|
||
}
|
||
|
||
func (f *fakeVoiceSTT) set(out voice.STTOutcome) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
f.reply = out
|
||
}
|
||
|
||
// fakeClock 是可推进的墙上时钟(熔断的 TTL 断言必须确定性)。
|
||
type fakeClock struct {
|
||
mu sync.Mutex
|
||
now time.Time
|
||
}
|
||
|
||
func (c *fakeClock) Now() time.Time {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
return c.now
|
||
}
|
||
|
||
func (c *fakeClock) advance(d time.Duration) {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
c.now = c.now.Add(d)
|
||
}
|
||
|
||
// voiceEnv 是入站语音用例的装配:真循环(装配期)+ 假模型 + 记录落库的历史 + 假 STT +
|
||
// 可推进时钟。
|
||
type voiceEnv struct {
|
||
bridge *Bridge
|
||
model *stubModelAdapter
|
||
history *captureHistory
|
||
stt *fakeVoiceSTT
|
||
clock *fakeClock
|
||
logs *safeBuffer
|
||
}
|
||
|
||
func newVoiceEnv(t *testing.T, stt VoiceSTT, timeout time.Duration) *voiceEnv {
|
||
t.Helper()
|
||
logs := &safeBuffer{}
|
||
log := slog.New(slog.NewTextHandler(logs, nil))
|
||
modelAdapter := &stubModelAdapter{}
|
||
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(modelAdapter, nil))
|
||
if err != nil {
|
||
t.Fatalf("构造 agent: %v", err)
|
||
}
|
||
runner := loop.New(loop.Options{Agent: ag, MaxRounds: 4, Log: log})
|
||
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
|
||
hist := &captureHistory{}
|
||
clock := &fakeClock{now: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)}
|
||
b, err := New(Options{
|
||
Log: log, Loop: runner, Sessions: sess, Owner: testOwnerProfile("telegram", "100"),
|
||
History: hist, VoiceSTT: stt, VoiceSTTTimeout: timeout, Now: clock.Now,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
platform := &fakeAdapter{name: "telegram"}
|
||
b.Add(platform)
|
||
return &voiceEnv{bridge: b, model: modelAdapter, history: hist, clock: clock, logs: logs}
|
||
}
|
||
|
||
// voiceMessage 构造一条主人私聊的**纯语音**消息(无正文 + 已内联字节)。
|
||
func voiceMessage(name, mime string, data []byte) adapter.UnifiedMessage {
|
||
msg := directMessage()
|
||
msg.Content = ""
|
||
msg.ContentType = adapter.ContentVoice
|
||
msg.Media = []adapter.Media{{
|
||
Kind: adapter.MediaVoice, Name: name, MIMEType: mime, Size: int64(len(data)), Data: data,
|
||
}}
|
||
return msg
|
||
}
|
||
|
||
// lastUserContent 返回最近一次落库的用户消息正文(没有则空串)。
|
||
func lastUserContent(env *voiceEnv) string {
|
||
content := ""
|
||
for _, m := range env.history.records() {
|
||
if m.Role == history.RoleUser {
|
||
content = m.Content
|
||
}
|
||
}
|
||
return content
|
||
}
|
||
|
||
// TestInboundVoiceNormalizedIntoSingleVisibleText 覆盖验收:转写结果写回 Content ⇒
|
||
// 模型可见视图与落库的用户消息都是**同一份文本**(不是空行、也不是占位标记)。
|
||
func TestInboundVoiceNormalizedIntoSingleVisibleText(t *testing.T) {
|
||
t.Parallel()
|
||
stt := &fakeVoiceSTT{reply: voice.STTOutcome{Text: " 你好呀,昔涟 ", Provider: "cloud"}}
|
||
env := newVoiceEnv(t, stt, 0)
|
||
|
||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||
|
||
if stt.callCount() != 1 {
|
||
t.Fatalf("STT 调用次数 = %d, want 1", stt.callCount())
|
||
}
|
||
audio := stt.lastAudio(t)
|
||
if audio.Format != "ogg" || string(audio.Data) != "bytes" {
|
||
t.Fatalf("STT 收到的音频 = %+v, want ogg/bytes", audio)
|
||
}
|
||
if got := lastUserContent(env); got != "你好呀,昔涟" {
|
||
t.Fatalf("落库的用户正文 = %q, want 转写文本(去首尾空白)", got)
|
||
}
|
||
if visible := env.model.visibleText(); !strings.Contains(visible, "你好呀,昔涟") {
|
||
t.Fatalf("模型可见视图里没有转写文本:%q", visible)
|
||
}
|
||
if log := env.logs.String(); !strings.Contains(log, "inbound voice transcribed") ||
|
||
!strings.Contains(log, "source=services/channelbridge/voice") {
|
||
t.Fatalf("转写日志缺少子功能来源:%q", log)
|
||
}
|
||
}
|
||
|
||
// TestInboundVoicePlatformTextWinsOverASR 覆盖验收「平台文本优先(不调 ASR)」:微信
|
||
// 自带转写已经进了 Content ⇒ 一次上游调用都不能发生(不重复付费)。
|
||
func TestInboundVoicePlatformTextWinsOverASR(t *testing.T) {
|
||
t.Parallel()
|
||
stt := &fakeVoiceSTT{reply: voice.STTOutcome{Text: "不该被用到"}}
|
||
env := newVoiceEnv(t, stt, 0)
|
||
msg := voiceMessage("v.silk", "audio/silk", []byte("bytes"))
|
||
msg.Content = "你好" // 平台自带转写(weixin_ilink 的 voice_item.text)
|
||
|
||
env.bridge.process(context.Background(), msg)
|
||
|
||
if stt.callCount() != 0 {
|
||
t.Fatalf("STT 调用次数 = %d, want 0(平台已给文字,绝不重复付费)", stt.callCount())
|
||
}
|
||
if got := lastUserContent(env); got != "你好" {
|
||
t.Fatalf("落库的用户正文 = %q, want 平台自带转写", got)
|
||
}
|
||
}
|
||
|
||
// TestInboundVoicePlaceholderTextStillTranscribes 钉住反面:QQ 的 `[语音]` 是**占位
|
||
// 标记**、不是人说的话 ⇒ 照样转写,并把占位标记换成真实文本(否则纯语音整条丢掉)。
|
||
func TestInboundVoicePlaceholderTextStillTranscribes(t *testing.T) {
|
||
t.Parallel()
|
||
stt := &fakeVoiceSTT{reply: voice.STTOutcome{Text: "语音里的内容", Provider: "cloud"}}
|
||
env := newVoiceEnv(t, stt, 0)
|
||
msg := voiceMessage("v.amr", "audio/amr", []byte("bytes"))
|
||
msg.Content = "[语音]"
|
||
|
||
env.bridge.process(context.Background(), msg)
|
||
|
||
if stt.callCount() != 1 {
|
||
t.Fatalf("STT 调用次数 = %d, want 1(占位标记不算文字)", stt.callCount())
|
||
}
|
||
if got := lastUserContent(env); got != "语音里的内容" {
|
||
t.Fatalf("落库的用户正文 = %q, want 转写文本", got)
|
||
}
|
||
}
|
||
|
||
// TestInboundVoiceSilentDegradationIsFailure 覆盖验收「静默降级被识破」:链路末端
|
||
// `silent` 档返回空文本 + 无错误 —— 它必须被当**失败**处理(落一行诚实提示),
|
||
// 绝不能当成一次成功的空转写(那样消息就静默了)。
|
||
func TestInboundVoiceSilentDegradationIsFailure(t *testing.T) {
|
||
t.Parallel()
|
||
t.Run("静默档位带原因", func(t *testing.T) {
|
||
t.Parallel()
|
||
stt := &fakeVoiceSTT{reply: voice.STTOutcome{
|
||
Text: "", Provider: voice.SilentProviderName, Cause: voice.ErrNotConfigured,
|
||
}}
|
||
env := newVoiceEnv(t, stt, 0)
|
||
|
||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||
|
||
got := lastUserContent(env)
|
||
if !strings.Contains(got, "没有转写") || !strings.Contains(got, "未配置") {
|
||
t.Fatalf("落库正文 = %q, want 一行讲清原因的诚实提示", got)
|
||
}
|
||
if log := env.logs.String(); !strings.Contains(log, "category=not_configured") {
|
||
t.Fatalf("日志缺少可分类的原因:%q", log)
|
||
}
|
||
})
|
||
// 「只信文本 / 只信 err == nil」的实现会在这里静默:空文本 + 无错误看着像一次
|
||
// 成功的空转写,但档位是静默档 ⇒ 必须仍按失败处理(这就是本切片的硬要求)。
|
||
t.Run("静默档位无原因", func(t *testing.T) {
|
||
t.Parallel()
|
||
stt := &fakeVoiceSTT{reply: voice.STTOutcome{Text: "", Provider: voice.SilentProviderName}}
|
||
env := newVoiceEnv(t, stt, 0)
|
||
|
||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||
|
||
got := lastUserContent(env)
|
||
if !strings.Contains(got, "没有转写") {
|
||
t.Fatalf("落库正文 = %q, want 静默降级的诚实提示(绝不留空消息)", got)
|
||
}
|
||
if log := env.logs.String(); !strings.Contains(log, "category=silent_degraded") {
|
||
t.Fatalf("日志缺少静默降级类别:%q", log)
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestInboundVoiceEmptyResultIsHonestButNotFailure 区分「服务正常但没识别出文字」与
|
||
// 「上游不可用」:前者不熔断(provider 真转写,文本为空)。
|
||
func TestInboundVoiceEmptyResultIsHonestButNotFailure(t *testing.T) {
|
||
t.Parallel()
|
||
stt := &fakeVoiceSTT{reply: voice.STTOutcome{Text: " ", Provider: "cloud"}}
|
||
env := newVoiceEnv(t, stt, 0)
|
||
|
||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||
|
||
if got := lastUserContent(env); !strings.Contains(got, "没有识别出文字") {
|
||
t.Fatalf("落库正文 = %q, want 空识别结果的诚实提示", got)
|
||
}
|
||
// 第二次仍要正常调上游(没有被误开的熔断挡住)。
|
||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||
if stt.callCount() != 2 {
|
||
t.Fatalf("STT 调用次数 = %d, want 2(空结果不是故障,不得熔断)", stt.callCount())
|
||
}
|
||
}
|
||
|
||
// TestInboundVoiceBreakerStopsUpstreamAfterClassifiedFailure 覆盖验收「熔断(连续失败后
|
||
// 不再调用上游 + 消息面仍有诚实提示)」:第一次鉴权失败 ⇒ 熔断;第二条语音**一次上游
|
||
// 调用都不发生**,但消息面照样落一行提示(绝不静默)。
|
||
func TestInboundVoiceBreakerStopsUpstreamAfterClassifiedFailure(t *testing.T) {
|
||
t.Parallel()
|
||
stt := &fakeVoiceSTT{reply: voice.STTOutcome{
|
||
Text: "", Provider: voice.SilentProviderName, Cause: voice.ErrAuthFailed,
|
||
}}
|
||
env := newVoiceEnv(t, stt, 0)
|
||
|
||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||
first := lastUserContent(env)
|
||
if !strings.Contains(first, "鉴权失败") {
|
||
t.Fatalf("第一条的落库正文 = %q, want 鉴权失败的原因方向", first)
|
||
}
|
||
|
||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||
second := lastUserContent(env)
|
||
|
||
if stt.callCount() != 1 {
|
||
t.Fatalf("STT 调用次数 = %d, want 1(熔断期间绝不调上游、不白等超时)", stt.callCount())
|
||
}
|
||
if !strings.Contains(second, "熔断") {
|
||
t.Fatalf("第二条的落库正文 = %q, want 熔断退避的诚实提示", second)
|
||
}
|
||
if strings.TrimSpace(second) == "" || strings.TrimSpace(first) == "" {
|
||
t.Fatal("熔断路径留下了空消息 —— 违反「绝不静默」")
|
||
}
|
||
}
|
||
|
||
// TestInboundVoiceBreakerRecoversOnTTLAndSuccess 钉住熔断是**临时**的:TTL 到期恢复调用,
|
||
// 一次成功把退避与计数清零(下一次立刻能再调)。
|
||
func TestInboundVoiceBreakerRecoversOnTTLAndSuccess(t *testing.T) {
|
||
t.Parallel()
|
||
stt := &fakeVoiceSTT{reply: voice.STTOutcome{
|
||
Text: "", Provider: voice.SilentProviderName, Cause: voice.ErrRateLimited,
|
||
}}
|
||
env := newVoiceEnv(t, stt, 0)
|
||
|
||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||
if stt.callCount() != 1 {
|
||
t.Fatalf("首次调用次数 = %d, want 1", stt.callCount())
|
||
}
|
||
// TTL 未到:短路。
|
||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||
if stt.callCount() != 1 {
|
||
t.Fatalf("熔断期间调用次数 = %d, want 1", stt.callCount())
|
||
}
|
||
// TTL 到期:恢复调用,且这次成功。
|
||
env.clock.advance(6 * time.Minute)
|
||
stt.set(voice.STTOutcome{Text: "恢复了", Provider: "cloud"})
|
||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||
if stt.callCount() != 2 {
|
||
t.Fatalf("TTL 到期后调用次数 = %d, want 2(熔断必须自动恢复)", stt.callCount())
|
||
}
|
||
if got := lastUserContent(env); got != "恢复了" {
|
||
t.Fatalf("落库正文 = %q, want 恢复后的转写文本", got)
|
||
}
|
||
// 成功即清零:紧接着的第三条必须还能调上游(不带退避)。
|
||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||
if stt.callCount() != 3 {
|
||
t.Fatalf("成功之后调用次数 = %d, want 3(成功必须清零熔断)", stt.callCount())
|
||
}
|
||
}
|
||
|
||
// TestInboundVoiceFormatRejectionDoesNotTripBreaker 钉住熔断的**分类**:按内容发生的
|
||
// 失败(上游说这份音频不行)不该关掉整个转写面 —— 后续语音必须还能被尝试。
|
||
func TestInboundVoiceFormatRejectionDoesNotTripBreaker(t *testing.T) {
|
||
t.Parallel()
|
||
stt := &fakeVoiceSTT{reply: voice.STTOutcome{
|
||
Text: "", Provider: voice.SilentProviderName,
|
||
Cause: providerFailureForTest(),
|
||
}}
|
||
env := newVoiceEnv(t, stt, 0)
|
||
|
||
env.bridge.process(context.Background(), voiceMessage("v.amr", "audio/amr", []byte("bytes")))
|
||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||
|
||
if stt.callCount() != 2 {
|
||
t.Fatalf("STT 调用次数 = %d, want 2(按内容的失败不得熔断整个转写面)", stt.callCount())
|
||
}
|
||
if got := lastUserContent(env); strings.TrimSpace(got) == "" {
|
||
t.Fatal("失败路径留下了空消息")
|
||
}
|
||
}
|
||
|
||
// TestInboundVoiceTimeoutIsHonest 覆盖验收「超时诚实」:上游卡住 ⇒ 独立超时把它掐掉,
|
||
// 消息面落一行「超时」,绝不留空。
|
||
func TestInboundVoiceTimeoutIsHonest(t *testing.T) {
|
||
t.Parallel()
|
||
stt := &fakeVoiceSTT{block: true}
|
||
env := newVoiceEnv(t, stt, 30*time.Millisecond)
|
||
|
||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||
|
||
got := lastUserContent(env)
|
||
if !strings.Contains(got, "超时") {
|
||
t.Fatalf("落库正文 = %q, want 超时的诚实提示", got)
|
||
}
|
||
if log := env.logs.String(); !strings.Contains(log, "category=timeout") {
|
||
t.Fatalf("日志缺少超时类别:%q", log)
|
||
}
|
||
}
|
||
|
||
// TestInboundVoiceWithoutBytesOrFormatIsHonest 覆盖两条「拿不到音频」的路径:没有字节
|
||
// (渠道只给平台内部引用 / 代取失败)与格式不在**可转写白名单**内 —— 都落诚实提示,
|
||
// 且**不调上游**。
|
||
//
|
||
// ⚠️ 文案留痕(2026-10-06,切片 CODE-VOICE):本用例原先钉的是「格式不支持(需要先转码,
|
||
// 当前未做)」——那句在转码能力(design/25)落地后**就是一句假话**(design/25 §5.4 明令
|
||
// 同步改)。因此这里改钉新口径:格式**不在可转写白名单内**。这不是「改测试迁就代码」,
|
||
// 而是被断言保护的行为**已按设计变更**(与 QQ `[语音]` 占位标记那条同一性质)。
|
||
func TestInboundVoiceWithoutBytesOrFormatIsHonest(t *testing.T) {
|
||
t.Parallel()
|
||
t.Run("没有字节", func(t *testing.T) {
|
||
t.Parallel()
|
||
stt := &fakeVoiceSTT{}
|
||
env := newVoiceEnv(t, stt, 0)
|
||
msg := voiceMessage("v.ogg", "audio/ogg", nil)
|
||
|
||
env.bridge.process(context.Background(), msg)
|
||
|
||
if stt.callCount() != 0 {
|
||
t.Fatalf("STT 调用次数 = %d, want 0(没有字节可转写)", stt.callCount())
|
||
}
|
||
if got := lastUserContent(env); !strings.Contains(got, "没有拿到音频字节") {
|
||
t.Fatalf("落库正文 = %q, want 字节缺失的诚实提示", got)
|
||
}
|
||
})
|
||
t.Run("格式不在可转写白名单内", func(t *testing.T) {
|
||
t.Parallel()
|
||
stt := &fakeVoiceSTT{}
|
||
env := newVoiceEnv(t, stt, 0)
|
||
msg := voiceMessage("v.silk", "audio/silk", []byte("bytes"))
|
||
|
||
env.bridge.process(context.Background(), msg)
|
||
|
||
if stt.callCount() != 0 {
|
||
t.Fatalf("STT 调用次数 = %d, want 0(格式不在白名单,不调上游)", stt.callCount())
|
||
}
|
||
got := lastUserContent(env)
|
||
if !strings.Contains(got, "不在服务端可转写的白名单内") {
|
||
t.Fatalf("落库正文 = %q, want 白名单外的诚实提示", got)
|
||
}
|
||
if strings.Contains(got, "当前未做") {
|
||
t.Fatalf("落库正文 = %q:转码能力已落地(design/25),这句「当前未做」是假话", got)
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestInboundPureVoiceIsNotDropped 覆盖验收「inbound 放宽后不再丢纯语音」:无正文、
|
||
// 无字节的纯语音消息(telegram 的 file_id 取不到字节那种)也必须跑完流水线并留痕。
|
||
func TestInboundPureVoiceIsNotDropped(t *testing.T) {
|
||
t.Parallel()
|
||
stt := &fakeVoiceSTT{}
|
||
env := newVoiceEnv(t, stt, 0)
|
||
msg := voiceMessage("", "audio/ogg", nil)
|
||
|
||
env.bridge.process(context.Background(), msg)
|
||
|
||
if env.model.calls() == 0 {
|
||
t.Fatal("纯语音消息没有跑到模型(被丢空判据丢掉了)")
|
||
}
|
||
if got := lastUserContent(env); strings.TrimSpace(got) == "" {
|
||
t.Fatal("纯语音消息落库成空行 —— 违反「绝不静默」")
|
||
}
|
||
}
|
||
|
||
// TestInboundVoiceWithoutSTTWiringIsHonest 覆盖「未接线」:组合根没注入 STT 时,带语音的
|
||
// 消息仍要落一行「未配置」的诚实提示(绝不静默丢),且不 panic。
|
||
func TestInboundVoiceWithoutSTTWiringIsHonest(t *testing.T) {
|
||
t.Parallel()
|
||
env := newVoiceEnv(t, nil, 0)
|
||
|
||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||
|
||
got := lastUserContent(env)
|
||
if !strings.Contains(got, "没有转写") || !strings.Contains(got, "未配置") {
|
||
t.Fatalf("落库正文 = %q, want 未配置的诚实提示", got)
|
||
}
|
||
}
|