塔 2026-10-26 裁决:主人若把合成端口从 9880 改掉,不该还要记得两处一起改。 回退只做一向,且只发生在组合根(包内不互相读配置): 1. voice-tts-launch.json 显式写了 probeUrl ⇒ 以它为准,**永不**被别的键压过; 2. 没写 ⇒ 用 voice-tts.json 的 baseUrl(同一个服务); 3. 两者都空(或 voice-tts.json 坏掉)⇒ 默认 http://127.0.0.1:9880。 启动 Info 行同时打印 probe_url 与 probe_url_source(voice-tts-launch.json / voice-tts.json / default),一眼看出用的是哪个源。反向(拿这里地址去改合成面) 一律不做。VoiceTTSTuning 坏掉时只回落默认地址并 WARN,**不**因此关掉自启。 新增三条用例(均已变异验证打红): - ProbeURLInheritedFromVoiceTTS:真让探活打到自定义端口(hits>0),摘掉回退即红 - ProbeURLFallsBackToDefault:两者都空 ⇒ 9880 + source=default - ExplicitProbeURLWins:显式压过 baseUrl,且 baseUrl 一次都不被探
289 lines
11 KiB
Go
289 lines
11 KiB
Go
package main
|
||
|
||
// 本文件锁定切片 M47 在**组合根**上的口径(都是"改坏就静默开错/静默拖慢启动"的那种):
|
||
//
|
||
// 1. 面板文件没配过 / 写了 `enabled:false` ⇒ **不启用**(返回 nil),连探活都不发;
|
||
// 2. 启用后 `startVoiceTTSLaunch` **立即返回**,即使探活会挂住整整一个超时 ——
|
||
// 证明 TTS 的延迟不计入后端启动时长(它是唯一"启动耗时"可测的量);
|
||
// 3. 拉不起来(脚本不存在)⇒ 只有 WARN,**没有任何 error 回流**到启动路径。
|
||
// 4. 探活地址**单向**回退(塔 2026-10-26 裁决):launch 文件显式写了 `probeUrl`
|
||
// 就以它为准;没写则用 `voice-tts.json` 的 `baseUrl`;两者都空才是默认 9880。
|
||
// 启动 Info 行必须同时给出地址与来源。
|
||
//
|
||
// 它用 `DefaultVoiceTTSLaunchPath` 的真实落点
|
||
// ($DATA_DIR/config/voice-tts-launch.json)跑,因此也钉住"零新 .env 键"。
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"net"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"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()
|
||
}
|
||
|
||
// TestStartVoiceTTSLaunch_ProbeURLInheritedFromVoiceTTS 钉住**单向回退**的第一向:
|
||
// launch 文件没写 `probeUrl` 时,用 `voice-tts.json` 的 `baseUrl`(主人换端口只该
|
||
// 改一处)。
|
||
//
|
||
// 它不只比字符串 —— 真让探活打到那个端口(hits > 0):把回退摘掉,探活就会跑去
|
||
// 默认 9880,本用例立刻红。
|
||
func TestStartVoiceTTSLaunch_ProbeURLInheritedFromVoiceTTS(t *testing.T) {
|
||
dir := t.TempDir()
|
||
voiceTTS, hits := countingServer(t)
|
||
a, buf := launchTestApp(t, dir)
|
||
|
||
// 合成面把端口改成了自定义端口;自启面**没写** probeUrl。
|
||
writeVoiceTTSPanel(t, a, fmt.Sprintf(`{"enabled":true,"baseUrl":%q,"personas":{"cyrene":"char_1"}}`, voiceTTS.URL))
|
||
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`,
|
||
filepath.Join(dir, "不存在的一键启动.bat")))
|
||
|
||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||
if launcher == nil {
|
||
t.Fatal("启用后应拿到 launcher")
|
||
}
|
||
if got := launcher.Config().ProbeURL; got != voiceTTS.URL {
|
||
t.Fatalf("探活地址 = %q, want voice-tts.json 的 baseUrl %q", got, voiceTTS.URL)
|
||
}
|
||
launcher.Wait()
|
||
if n := atomic.LoadInt32(hits); n == 0 {
|
||
t.Fatal("探活没打到 voice-tts.json 的 baseUrl 端口(回退失效)")
|
||
}
|
||
if out := buf.String(); !strings.Contains(out, "probe_url_source=voice-tts.json") {
|
||
t.Fatalf("启动 Info 行没标出地址是兜底来的: %s", out)
|
||
}
|
||
}
|
||
|
||
// TestStartVoiceTTSLaunch_ProbeURLFallsBackToDefault 钉住第二向:两个文件都没给
|
||
// 地址 ⇒ 默认 `http://127.0.0.1:9880`,且日志标 `default`。
|
||
func TestStartVoiceTTSLaunch_ProbeURLFallsBackToDefault(t *testing.T) {
|
||
dir := t.TempDir()
|
||
a, buf := launchTestApp(t, dir)
|
||
// 只有自启文件,没有 voice-tts.json。
|
||
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`,
|
||
filepath.Join(dir, "不存在的一键启动.bat")))
|
||
|
||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||
if launcher == nil {
|
||
t.Fatal("启用后应拿到 launcher")
|
||
}
|
||
if got := launcher.Config().ProbeURL; got != ttslaunch.DefaultProbeURL {
|
||
t.Fatalf("探活地址 = %q, want 默认 %q", got, ttslaunch.DefaultProbeURL)
|
||
}
|
||
launcher.Wait()
|
||
if out := buf.String(); !strings.Contains(out, "probe_url_source=default") {
|
||
t.Fatalf("启动 Info 行没标出地址用的是默认值: %s", out)
|
||
}
|
||
}
|
||
|
||
// TestStartVoiceTTSLaunch_ExplicitProbeURLWins 钉住"显式压过兜底":launch 文件写了
|
||
// `probeUrl` ⇒ 用它,**永不**去问 voice-tts.json 的 baseUrl(后者一次都不该被探)。
|
||
func TestStartVoiceTTSLaunch_ExplicitProbeURLWins(t *testing.T) {
|
||
dir := t.TempDir()
|
||
explicit, explicitHits := countingServer(t)
|
||
inherited, inheritedHits := countingServer(t)
|
||
a, buf := launchTestApp(t, dir)
|
||
|
||
writeVoiceTTSPanel(t, a, fmt.Sprintf(`{"enabled":true,"baseUrl":%q}`, inherited.URL))
|
||
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"probeUrl":%q,"scriptPath":%q}`,
|
||
explicit.URL, filepath.Join(dir, "不存在的一键启动.bat")))
|
||
|
||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||
if launcher == nil {
|
||
t.Fatal("启用后应拿到 launcher")
|
||
}
|
||
if got := launcher.Config().ProbeURL; got != explicit.URL {
|
||
t.Fatalf("探活地址 = %q, want 显式 %q", got, explicit.URL)
|
||
}
|
||
launcher.Wait()
|
||
if atomic.LoadInt32(explicitHits) == 0 {
|
||
t.Fatal("显式 probeUrl 没被用来探活")
|
||
}
|
||
if n := atomic.LoadInt32(inheritedHits); n != 0 {
|
||
t.Fatalf("显式 probeUrl 仍去问了 voice-tts.json 的地址 %d 次", n)
|
||
}
|
||
if out := buf.String(); !strings.Contains(out, "probe_url_source=voice-tts-launch.json") {
|
||
t.Fatalf("启动 Info 行没标出地址是显式配置的: %s", out)
|
||
}
|
||
}
|
||
|
||
// countingServer 起一个"数请求次数"的 httptest 服务:用来证明探活**真的打到了**
|
||
// 某个地址,而不只是配置里写着那个字符串。
|
||
func countingServer(t *testing.T) (*httptest.Server, *int32) {
|
||
t.Helper()
|
||
var hits int32
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
atomic.AddInt32(&hits, 1)
|
||
w.WriteHeader(http.StatusOK)
|
||
}))
|
||
t.Cleanup(srv.Close)
|
||
return srv, &hits
|
||
}
|