package ttslaunch import ( "bytes" "context" "errors" "log/slog" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" ) // errDown 是"探活判定没起"的假错误(只要非 nil,口径就是"没起")。 var errDown = errors.New("probe: connection refused") // syncBuf 是并发安全的日志缓冲(日志由后台 goroutine 写,用例稍后读)。 type syncBuf struct { mu sync.Mutex buf bytes.Buffer } func (b *syncBuf) Write(p []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() return b.buf.Write(p) } func (b *syncBuf) String() string { b.mu.Lock() defer b.mu.Unlock() return b.buf.String() } // captureLogger 返回一个把 DEBUG 也收下的日志器 + 它的缓冲。 func captureLogger() (*slog.Logger, *syncBuf) { buf := &syncBuf{} return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf } // 用例里的"已在跑/没起"都是注入的判据,因此不碰网络也不起进程。 // countingSpawn 返回一个"只数次数"的 spawn 实现。 func countingSpawn(n *int32) spawnFunc { return func(context.Context) error { atomic.AddInt32(n, 1) return nil } } // TestCheck_AlreadyRunning_NeverLaunches 钉住「已在跑 ⇒ 什么都不做」。 // // 变异点:只要探活成功仍去 spawn(例如把探活结果当"警告"继续走),本用例立刻红。 func TestCheck_AlreadyRunning_NeverLaunches(t *testing.T) { var spawned int32 l := New(Config{Enabled: true}, WithProbe(func(context.Context) error { return nil }), WithSpawn(countingSpawn(&spawned))) if got := l.Check(context.Background()); got != OutcomeAlreadyRunning { t.Fatalf("Outcome = %v, want already_running", got) } if got := atomic.LoadInt32(&spawned); got != 0 { t.Fatalf("已在跑仍拉起了 %d 次", got) } } // TestCheck_NotRunning_LaunchesOnce 钉住「没在跑 ⇒ 拉一次」。 func TestCheck_NotRunning_LaunchesOnce(t *testing.T) { var spawned int32 l := New(Config{Enabled: true}, WithProbe(func(context.Context) error { return errDown }), WithSpawn(countingSpawn(&spawned))) if got := l.Check(context.Background()); got != OutcomeLaunched { t.Fatalf("Outcome = %v, want launched", got) } if got := atomic.LoadInt32(&spawned); got != 1 { t.Fatalf("拉起 %d 次, want 1", got) } } // TestTrigger_ConcurrentTriggers_LaunchOnce 钉住「同进程内并发触发只拉一个」。 // // 探活被卡住(release 之前不返回)以拉宽竞争窗口:10 个并发 Trigger 里应当 // 只有**一个**接到单,最终只 spawn 一次。 // // 变异点:去掉 inflight 守卫 ⇒ accepted 变成 10;去掉冷却/checkMu ⇒ spawned 可能 >1。 func TestTrigger_ConcurrentTriggers_LaunchOnce(t *testing.T) { var spawned, accepted int32 release := make(chan struct{}) l := New(Config{Enabled: true}, WithProbe(func(context.Context) error { <-release // 探活挂住,让 10 个 Trigger 都碰到 inflight 窗口 return errDown }), WithSpawn(countingSpawn(&spawned))) var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go func() { defer wg.Done() if l.Trigger(context.Background()) { atomic.AddInt32(&accepted, 1) } }() } // 给并发触发足够时间撞上 inflight;这一步只影响用例的"严格度",不影响结论。 time.Sleep(100 * time.Millisecond) close(release) wg.Wait() l.Wait() if got := atomic.LoadInt32(&accepted); got != 1 { t.Fatalf("接单 %d 次, want 1(并发触发只该有一个派后台任务)", got) } if got := atomic.LoadInt32(&spawned); got != 1 { t.Fatalf("拉起 %d 次, want 1", got) } } // TestCheck_CooldownSuppressesRepeat 钉住「一次尝试之后的冷却」:后端重启风暴里 // 不会每次都去拉一个正在启动中的 TTS。 func TestCheck_CooldownSuppressesRepeat(t *testing.T) { var spawned int32 now := time.Date(2026, 10, 24, 12, 0, 0, 0, time.UTC) clock := func() time.Time { return now } l := New(Config{Enabled: true, Cooldown: time.Minute}, WithProbe(func(context.Context) error { return errDown }), WithSpawn(countingSpawn(&spawned)), WithClock(clock)) if got := l.Check(context.Background()); got != OutcomeLaunched { t.Fatalf("第一次: Outcome = %v, want launched", got) } now = now.Add(30 * time.Second) if got := l.Check(context.Background()); got != OutcomeCooling { t.Fatalf("冷却期内: Outcome = %v, want cooling", got) } if got := atomic.LoadInt32(&spawned); got != 1 { t.Fatalf("冷却期内又拉起: %d 次", got) } now = now.Add(time.Minute) if got := l.Check(context.Background()); got != OutcomeLaunched { t.Fatalf("冷却过后: Outcome = %v, want launched", got) } if got := atomic.LoadInt32(&spawned); got != 2 { t.Fatalf("冷却过后拉起 %d 次, want 2", got) } } // TestTrigger_MissingScript_WarnsAndNeverPropagates 钉住「拉不起来时后端启动不受 // 影响」:脚本不存在 ⇒ 一条带 source-ish 字段的 WARN,**没有** error 通道、没有 // panic,且这次失败同样进入冷却(重启风暴不会变成重试风暴)。 func TestTrigger_MissingScript_WarnsAndNeverPropagates(t *testing.T) { log, buf := captureLogger() missing := filepath.Join(t.TempDir(), "不存在的一键启动.bat") l := New(Config{Enabled: true, ScriptPath: missing}, WithLogger(log), // 探活也注入:本用例要验的是"拉的失败",不能因为本机真跑着 TTS 就走成 // already_running。 WithProbe(func(context.Context) error { return errDown })) if !l.Trigger(context.Background()) { t.Fatal("启用时 Trigger 应接单") } l.Wait() out := buf.String() if !strings.Contains(out, "level=WARN") { t.Fatalf("拉不起来没记 WARN: %s", out) } if !strings.Contains(out, "backend startup unaffected") { t.Fatalf("WARN 没说明后端启动不受影响: %s", out) } if got := l.Check(context.Background()); got != OutcomeCooling { t.Fatalf("失败之后仍不冷却: Outcome = %v", got) } } // TestTrigger_ReturnsImmediately 钉住「探活/拉起不占调用方时长」:探活要 1 秒, // Trigger 必须在毫秒级返回(同步实现会红)。 func TestTrigger_ReturnsImmediately(t *testing.T) { l := New(Config{Enabled: true}, WithProbe(func(context.Context) error { time.Sleep(1 * time.Second) return errDown }), WithSpawn(func(context.Context) error { return nil })) start := time.Now() if !l.Trigger(context.Background()) { t.Fatal("启用时 Trigger 应接单") } if elapsed := time.Since(start); elapsed > 100*time.Millisecond { t.Fatalf("Trigger 阻塞了 %v(应在毫秒级返回)", elapsed) } l.Wait() } // TestTrigger_Disabled_DoesNothing 钉住「默认关」:一次探活都不发、一次都不拉。 func TestTrigger_Disabled_DoesNothing(t *testing.T) { var spawned int32 l := New(Config{}, // 零值 = 默认关 WithProbe(func(context.Context) error { t.Error("未启用时不该探活") return nil }), WithSpawn(countingSpawn(&spawned))) if l.Trigger(context.Background()) { t.Fatal("未启用时 Trigger 不该接单") } l.Wait() if got := atomic.LoadInt32(&spawned); got != 0 { t.Fatalf("未启用却拉起了 %d 次", got) } if got := l.Check(context.Background()); got != OutcomeDisabled { t.Fatalf("Check = %v, want disabled", got) } } // TestConfig_Normalized 钉住缺省值的唯一裁决处:零值 ⇒ 主人的脚本 + 回环地址 + // 毫秒级探活 + 一段冷却。 func TestConfig_Normalized(t *testing.T) { got := Config{}.Normalized() if got.Enabled { t.Fatal("零值必须默认关") } if got.ScriptPath != DefaultScriptPath { t.Fatalf("ScriptPath = %q", got.ScriptPath) } if got.ProbeURL != DefaultProbeURL { t.Fatalf("ProbeURL = %q", got.ProbeURL) } if got.ProbeTimeout != DefaultProbeTimeout || got.Cooldown != DefaultCooldown { t.Fatalf("超时/冷却没补默认值: %+v", got) } if !strings.Contains(DefaultScriptPath, "一键启动.bat") { t.Fatalf("默认脚本路径不对: %q", DefaultScriptPath) } } // TestOutcome_String 让日志读得懂(写错就是"日志说 cooling 实际在拉")。 func TestOutcome_String(t *testing.T) { want := map[Outcome]string{ OutcomeDisabled: "disabled", OutcomeAlreadyRunning: "already_running", OutcomeLaunched: "launched", OutcomeCooling: "cooling", OutcomeFailed: "failed", OutcomeCanceled: "canceled", } for o, w := range want { if o.String() != w { t.Fatalf("%d.String() = %q, want %q", int(o), o.String(), w) } } }