package main // 本文件锁定复审 P2-2 在**组合根**上的两道闸: // // 1. **关闭期取消 ⇒ 0 次拉起**(P2-2a):后端关闭时 ctx 被取消,探活随之返回 // `context.Canceled`,这必须是"跳过",绝不能在关闭过程中拉起 TTS; // 2. **探活地址非法 ⇒ 不启用、一次都不拉**(P2-2b):`probeUrl` 与 `voice-tts.json` // 的 `baseUrl` 兜底**两个来源**都要被挡(漏一个就等于绕过闸门,变成"每次启动 // 都拉一次进程")。 // // 用例除了看返回值,还会真等一会儿确认没有进程被拉起(拉起是异步的,脚本一跑 // 就会落 marker 文件);探活用黑洞监听(只接连接不回话),保证取消一定落在探活 // 进行中。 import ( "context" "fmt" "os" "path/filepath" "runtime" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch" ) // TestStartVoiceTTSLaunch_ShutdownCancelDoesNotLaunch 钉住"关闭期取消 ⇒ 0 次拉起"。 // // 变异点:去掉 ttslaunch 里 `errors.Is(probeErr, context.Canceled)` 那个跳过分支 ⇒ // 取消被当成"没起" ⇒ 脚本被拉起(marker 出现),本用例红。 func TestStartVoiceTTSLaunch_ShutdownCancelDoesNotLaunch(t *testing.T) { dir := t.TempDir() blackhole, addr := blackholeListener(t) defer blackhole.Close() a, buf := launchTestApp(t, dir) script, marker := markerBat(t, dir) writeLaunchPanel(t, dir, fmt.Sprintf( `{"enabled":true,"probeUrl":%q,"probeTimeoutMs":5000,"scriptPath":%q}`, "http://"+addr+"/", script)) ctx, cancel := context.WithCancel(context.Background()) launcher := a.startVoiceTTSLaunch(ctx) if launcher == nil { t.Fatal("启用后应拿到 launcher") } // 模拟后端关闭:探活还挂在黑洞上时取消。 cancel() launcher.Wait() assertNotLaunched(t, marker) if out := buf.String(); strings.Contains(out, "emotionTTS launch failed") { t.Fatalf("关闭期取消被记成了拉起失败: %s", out) } } // TestStartVoiceTTSLaunch_InvalidProbeURLStaysOff 钉住第一道闸:launch 文件里写了 // 非法 `probeUrl` ⇒ 返回 nil(不构造 launcher)、WARN 标出源、**一次都不拉**。 // // 变异点:去掉 `validateResolvedProbeURL` ⇒ 返回非 nil,且探活每轮必败 ⇒ 脚本被 // 拉起(marker 出现),两条断言都会红。 func TestStartVoiceTTSLaunch_InvalidProbeURLStaysOff(t *testing.T) { dir := t.TempDir() a, buf := launchTestApp(t, dir) script, marker := markerBat(t, dir) writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"probeUrl":"127.0.0.1:9880","scriptPath":%q}`, script)) launcher := a.startVoiceTTSLaunch(context.Background()) if launcher != nil { t.Fatal("非法 probeUrl 仍启用了自启(会每次启动都拉一次进程)") } assertNotLaunched(t, marker) out := buf.String() if !strings.Contains(out, "invalid probe url") { t.Fatalf("非法 probeUrl 没记 WARN: %s", out) } if !strings.Contains(out, "probe_url_source=voice-tts-launch.json") { t.Fatalf("WARN 没标出该改哪个文件: %s", out) } } // TestStartVoiceTTSLaunch_InvalidBaseURLFallbackStaysOff 钉住第二道闸:launch 文件 // 没写 probeUrl、而 `voice-tts.json` 的 baseUrl 非法 ⇒ 同样不启用、一次都不拉。 func TestStartVoiceTTSLaunch_InvalidBaseURLFallbackStaysOff(t *testing.T) { dir := t.TempDir() a, buf := launchTestApp(t, dir) script, marker := markerBat(t, dir) writeVoiceTTSPanel(t, a, `{"enabled":true,"baseUrl":"127.0.0.1:9880"}`) writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`, script)) launcher := a.startVoiceTTSLaunch(context.Background()) if launcher != nil { t.Fatal("baseUrl 兜底来的非法地址也没被挡住(每次启动都会拉一次进程)") } assertNotLaunched(t, marker) out := buf.String() if !strings.Contains(out, "invalid probe url") { t.Fatalf("非法 baseUrl 没记 WARN: %s", out) } if !strings.Contains(out, "probe_url_source=voice-tts.json") { t.Fatalf("WARN 没标出该改哪个文件: %s", out) } } // TestValidateResolvedProbeURL_EmptyMeansDefault 钉住"空串不在这里判死":两个文件 // 都没给地址时交给 Normalized 补 9880(默认值本身合法)。 func TestValidateResolvedProbeURL_EmptyMeansDefault(t *testing.T) { if err := validateResolvedProbeURL(probeResolution{url: "", probeSource: probeSourceDefault}); err != nil { t.Fatalf("空地址应交给默认值: %v", err) } if err := validateResolvedProbeURL(probeResolution{url: ttslaunch.DefaultProbeURL}); err != nil { t.Fatalf("默认地址必须合法: %v", err) } if err := validateResolvedProbeURL(probeResolution{url: "localhost:9880"}); err == nil { t.Fatal("漏 scheme 的地址被放过了") } } // markerBat 造一个"跑起来就落 marker"的 .bat,用于观察"到底有没有进程被拉起"。 // 非 Windows 返回空串(那台机器上这个脚本本来也拉不起来),调用方据此跳过观察。 func markerBat(t *testing.T, dir string) (script, marker string) { t.Helper() if runtime.GOOS != "windows" { return "", "" } script = filepath.Join(dir, "一键启动.bat") marker = filepath.Join(dir, "launched.txt") body := fmt.Sprintf("@echo off\r\necho launched> \"%s\"\r\n", marker) if err := os.WriteFile(script, []byte(body), 0o600); err != nil { t.Fatalf("write script: %v", err) } return script, marker } // assertNotLaunched 等一小会儿确认脚本**没有**被拉起(拉起是异步的,所以不能只看 // 返回值;空 marker = 非 Windows,跳过观察)。 func assertNotLaunched(t *testing.T, marker string) { t.Helper() if marker == "" { return } deadline := time.Now().Add(600 * time.Millisecond) for time.Now().Before(deadline) { if _, err := os.Stat(marker); err == nil { t.Fatalf("脚本被拉起了: %s", marker) } time.Sleep(50 * time.Millisecond) } }