package main // 本文件锁定切片 M47 在**组合根**上的口径(都是"改坏就静默开错/静默拖慢启动"的那种): // // 1. 面板文件没配过 / 写了 `enabled:false` ⇒ **不启用**(返回 nil),连探活都不发; // 2. 启用后 `startVoiceTTSLaunch` **立即返回**,即使探活会挂住整整一个超时 —— // 证明 TTS 的延迟不计入后端启动时长(它是唯一"启动耗时"可测的量); // 3. 拉不起来(脚本不存在)⇒ 只有 WARN,**没有任何 error 回流**到启动路径。 // // 它用 `DefaultVoiceTTSLaunchPath` 的真实落点 // ($DATA_DIR/config/voice-tts-launch.json)跑,因此也钉住"零新 .env 键"。 import ( "bytes" "context" "fmt" "log/slog" "net" "os" "path/filepath" "strings" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch" ) // captureBuf 是并发安全的日志缓冲(WARN 由后台 goroutine 写)。 type captureBuf struct { mu sync.Mutex buf bytes.Buffer } func (b *captureBuf) Write(p []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() return b.buf.Write(p) } func (b *captureBuf) String() string { b.mu.Lock() defer b.mu.Unlock() return b.buf.String() } // launchTestApp 造一个只够本题用的 app(数据目录 + 收日志的 logger)。 func launchTestApp(t *testing.T, dataDir string) (*app, *captureBuf) { t.Helper() buf := &captureBuf{} log := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})) return &app{cfg: &config.Config{DataDir: dataDir}, log: log}, buf } // writeLaunchPanel 把面板文件写到真实落点($DATA_DIR/config/voice-tts-launch.json)。 func writeLaunchPanel(t *testing.T, dataDir, body string) { t.Helper() path := runtimeconfig.DefaultVoiceTTSLaunchPath(dataDir) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("write panel: %v", err) } } // TestStartVoiceTTSLaunch_DefaultOff 钉住"默认关":没配过 ⇒ 不构造、不探活、不拉起。 func TestStartVoiceTTSLaunch_DefaultOff(t *testing.T) { a, buf := launchTestApp(t, t.TempDir()) start := time.Now() if l := a.startVoiceTTSLaunch(context.Background()); l != nil { t.Fatal("没配过却启用了自启") } if elapsed := time.Since(start); elapsed > 100*time.Millisecond { t.Fatalf("默认关也花了 %v", elapsed) } if !strings.Contains(buf.String(), "not enabled") { t.Fatalf("没留一条「没启用」的痕迹: %s", buf.String()) } } // TestStartVoiceTTSLaunch_ExplicitFalse 钉住"显式 false 同样一次都不拉"。 func TestStartVoiceTTSLaunch_ExplicitFalse(t *testing.T) { dir := t.TempDir() writeLaunchPanel(t, dir, `{"enabled":false}`) a, _ := launchTestApp(t, dir) if l := a.startVoiceTTSLaunch(context.Background()); l != nil { t.Fatal("enabled:false 却启用了自启") } } // TestStartVoiceTTSLaunch_ReturnsBeforeProbe 是本切片的"启动耗时"证明: // 探活会挂住一个 800ms 的超时(对端只接连接不回话),而挂载动作必须在毫秒级返回。 // // 同时它顺带钉住第 3 条:脚本不存在 ⇒ 后台只记 WARN,`startVoiceTTSLaunch` 自己 // 没有任何 error 可返回(签名里就没有),后端启动路径不受影响。 func TestStartVoiceTTSLaunch_ReturnsBeforeProbe(t *testing.T) { dir := t.TempDir() blackhole, addr := blackholeListener(t) defer blackhole.Close() writeLaunchPanel(t, dir, fmt.Sprintf( `{"enabled":true,"probeUrl":%q,"probeTimeoutMs":800,"scriptPath":%q}`, "http://"+addr+"/", filepath.Join(dir, "不存在的一键启动.bat"))) a, buf := launchTestApp(t, dir) start := time.Now() launcher := a.startVoiceTTSLaunch(context.Background()) elapsed := time.Since(start) if launcher == nil { t.Fatal("启用后应拿到 launcher") } if elapsed > 150*time.Millisecond { t.Fatalf("挂载花了 %v —— 探活被同步等在了启动路径上", elapsed) } // 后台那次探活会等到 800ms 超时,然后去拉一个不存在的脚本。 launcher.Wait() out := buf.String() if !strings.Contains(out, "level=WARN") || !strings.Contains(out, "backend startup unaffected") { t.Fatalf("拉起失败没有只记 WARN: %s", out) } } // TestVoiceTTSLaunchConfig_MapsTuning 钉住"面板字段 → launcher 配置"的映射: // 缺省值不由组合根裁决(空字段原样传下去,由 ttslaunch.Config.Normalized 补)。 func TestVoiceTTSLaunchConfig_MapsTuning(t *testing.T) { enabled := true cfg := voiceTTSLaunchConfig(runtimeconfig.VoiceTTSLaunchTuning{ Enabled: &enabled, ScriptPath: `D:\Project\Service\emotionTTS_v5.1.0\一键启动.bat`, ProbeURL: "http://127.0.0.1:9880", ProbeTimeoutMs: 250, CooldownSec: 90, }) if !cfg.Enabled { t.Fatal("enabled 没映射过来") } if cfg.ProbeTimeout != 250*time.Millisecond || cfg.Cooldown != 90*time.Second { t.Fatalf("超时/冷却没映射: %+v", cfg) } if cfg.ScriptPath != ttslaunch.DefaultScriptPath || cfg.ProbeURL != ttslaunch.DefaultProbeURL { t.Fatalf("路径/地址没映射: %+v", cfg) } // 空配置 ⇒ 不启用,且一个缺省值都不在这里填(Normalized 才是唯一裁决处)。 if empty := voiceTTSLaunchConfig(runtimeconfig.VoiceTTSLaunchTuning{}); empty.Enabled { t.Fatal("空配置不该启用") } else if empty.ScriptPath != "" || empty.ProbeURL != "" || empty.ProbeTimeout != 0 { t.Fatalf("组合根不该替 launcher 填缺省值: %+v", empty) } } // blackholeListener 起一个"只接连接、绝不回话"的回环监听:探活会一直等到自己的 // 超时,正好用来证明挂载动作没有同步等它。 func blackholeListener(t *testing.T) (net.Listener, string) { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } var conns []net.Conn var mu sync.Mutex go func() { for { c, err := ln.Accept() if err != nil { return } mu.Lock() conns = append(conns, c) mu.Unlock() } }() t.Cleanup(func() { mu.Lock() defer mu.Unlock() for _, c := range conns { _ = c.Close() } }) return ln, ln.Addr().String() }