package runtimeconfig import ( "errors" "os" "path/filepath" "testing" ) // TestLoadVoiceTTSTuning 锁定 voice-tts.json 的四条读取口径: // 缺失 = 没配过(不是错误)、全空白 = 没配过、合法 = 逐键读出、坏文件 = 可分类错误。 func TestLoadVoiceTTSTuning(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "voice-tts.json") // ① 缺失 if tuning, err := LoadVoiceTTSTuning(path); err != nil || tuning.EnabledOn() { t.Fatalf("缺失: tuning=%+v err=%v, want 零值 + nil", tuning, err) } // ② 全空白 if err := os.WriteFile(path, []byte(" \n\t "), 0o600); err != nil { t.Fatalf("write: %v", err) } if tuning, err := LoadVoiceTTSTuning(path); err != nil || tuning.EnabledOn() { t.Fatalf("空白: tuning=%+v err=%v, want 零值 + nil", tuning, err) } // ③ 合法(新形状:personas 映射 + default;旧的全局 character 不再参与) body := `{"enabled":true,"baseUrl":"http://127.0.0.1:9880", "personas":{"cyrene":"char_84fb88a3","furina":"char_5314489e"}, "default":"char_84fb88a3","timeoutSec":45}` if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("write: %v", err) } tuning, err := LoadVoiceTTSTuning(path) if err != nil { t.Fatalf("合法文件: %v", err) } if !tuning.EnabledOn() || tuning.TimeoutSec != 45 { t.Fatalf("读取结果不对: %+v", tuning) } if len(tuning.Personas) != 2 || tuning.Personas["furina"] != "char_5314489e" { t.Fatalf("personas 没有被读出: %+v", tuning.Personas) } if tuning.Default != "char_84fb88a3" { t.Fatalf("default = %q", tuning.Default) } // ③c 旧文件里若还留着 `format`,它**不再有任何效果**(结构体里没有这个键, // json 当作未知字段忽略):加载仍成功,且交付格式只由字节嗅探决定。 if err := os.WriteFile(path, []byte(`{"enabled":true,"format":"mp3","personas":{"cyrene":"char_84fb88a3"}}`), 0o600); err != nil { t.Fatalf("write: %v", err) } if tuning, err = LoadVoiceTTSTuning(path); err != nil { t.Fatalf("带废弃 format 键的文件不该解不开: %v", err) } if !tuning.EnabledOn() || tuning.Personas["cyrene"] != "char_84fb88a3" { t.Fatalf("废弃键影响了其余字段: %+v", tuning) } // ③b 旧的全局 character 仍能读出来(供启动日志提示"已废弃"),但它**不是** // personas/default 的一部分 —— 组合根绝不把它当发音人。 if err := os.WriteFile(path, []byte(`{"enabled":true,"character":"char_old"}`), 0o600); err != nil { t.Fatalf("write: %v", err) } if tuning, err = LoadVoiceTTSTuning(path); err != nil { t.Fatalf("旧键: %v", err) } if tuning.LegacyGlobalCharacter() != "char_old" { t.Fatalf("LegacyGlobalCharacter = %q", tuning.LegacyGlobalCharacter()) } if len(tuning.Personas) != 0 || tuning.Default != "" { t.Fatalf("旧全局 character 混进了 personas/default: %+v", tuning) } // ④ 显式 false 与"没配"同效,但指针让它在读取侧可区分。 if err := os.WriteFile(path, []byte(`{"enabled":false}`), 0o600); err != nil { t.Fatalf("write: %v", err) } if tuning, err = LoadVoiceTTSTuning(path); err != nil || tuning.EnabledOn() || tuning.Enabled == nil { t.Fatalf("显式 false: tuning=%+v err=%v", tuning, err) } // ⑤ 坏文件 ⇒ 可分类错误(调用方据此回落"不启用",且绝不覆盖原文件) if err := os.WriteFile(path, []byte(`{"enabled":`), 0o600); err != nil { t.Fatalf("write: %v", err) } if _, err := LoadVoiceTTSTuning(path); !errors.Is(err, ErrInvalidVoiceTTS) { t.Fatalf("坏文件 err = %v, want ErrInvalidVoiceTTS", err) } if raw, err := os.ReadFile(path); err != nil || string(raw) != `{"enabled":` { t.Fatalf("坏文件被覆盖了: %q err=%v", string(raw), err) } } // TestDefaultVoiceTTSPath 钉住落点:`$DATA_DIR/config/voice-tts.json`(零新 .env 键)。 func TestDefaultVoiceTTSPath(t *testing.T) { got := DefaultVoiceTTSPath(filepath.Join("data", "root")) want := filepath.Join("data", "root", "config", "voice-tts.json") if got != want { t.Fatalf("path = %q, want %q", got, want) } if VoiceTTSConfigRelPath != "config/voice-tts.json" { t.Fatalf("RelPath = %q", VoiceTTSConfigRelPath) } }