2026-10-18 15:38:56 +02:00
|
|
|
package config
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func env(kv map[string]string) func(string) string {
|
|
|
|
|
return func(k string) string { return kv[k] }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestDirForPlattformpfade(t *testing.T) {
|
|
|
|
|
cases := []struct {
|
|
|
|
|
name string
|
|
|
|
|
goos string
|
|
|
|
|
env map[string]string
|
|
|
|
|
want string
|
|
|
|
|
errMsg string
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
name: "linux mit HOME",
|
|
|
|
|
goos: "linux",
|
|
|
|
|
env: map[string]string{"HOME": "/home/u"},
|
|
|
|
|
want: "/home/u/.config/talk",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "linux XDG hat Vorrang",
|
|
|
|
|
goos: "linux",
|
|
|
|
|
env: map[string]string{"HOME": "/home/u", "XDG_CONFIG_HOME": "/xdg"},
|
|
|
|
|
want: "/xdg/talk",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "linux relativer XDG wird ignoriert",
|
|
|
|
|
goos: "linux",
|
|
|
|
|
env: map[string]string{"HOME": "/home/u", "XDG_CONFIG_HOME": "relativ"},
|
|
|
|
|
want: "/home/u/.config/talk",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "linux ohne HOME ist Fehler",
|
|
|
|
|
goos: "linux",
|
|
|
|
|
env: map[string]string{},
|
|
|
|
|
errMsg: ErrNoHome.Error(),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "macos",
|
|
|
|
|
goos: "darwin",
|
|
|
|
|
env: map[string]string{"HOME": "/Users/u"},
|
|
|
|
|
want: "/Users/u/Library/Application Support/talk",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "macos ohne HOME ist Fehler",
|
|
|
|
|
goos: "darwin",
|
|
|
|
|
env: map[string]string{},
|
|
|
|
|
errMsg: ErrNoHome.Error(),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "windows",
|
|
|
|
|
goos: "windows",
|
|
|
|
|
env: map[string]string{"APPDATA": `C:\Users\u\AppData\Roaming`},
|
|
|
|
|
want: filepath.Join(`C:\Users\u\AppData\Roaming`, "talk"),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "windows ohne APPDATA ist Fehler",
|
|
|
|
|
goos: "windows",
|
|
|
|
|
env: map[string]string{},
|
|
|
|
|
errMsg: ErrNoHome.Error(),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
got, err := dirFor(tc.goos, env(tc.env))
|
|
|
|
|
if tc.errMsg != "" {
|
|
|
|
|
if err == nil || err.Error() != tc.errMsg {
|
|
|
|
|
t.Fatalf("dirFor(%q) Fehler = %v, want %q", tc.goos, err, tc.errMsg)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("dirFor(%q): unerwarteter Fehler %v", tc.goos, err)
|
|
|
|
|
}
|
|
|
|
|
if got != tc.want {
|
|
|
|
|
t.Errorf("dirFor(%q) = %q, want %q", tc.goos, got, tc.want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestLoadMerge ist tischgesteuert für die Schichtenfolge
|
|
|
|
|
// Defaults → TOML-Datei → Env-Override.
|
|
|
|
|
func TestLoadMerge(t *testing.T) {
|
|
|
|
|
writeTOML := func(t *testing.T, content string) string {
|
|
|
|
|
t.Helper()
|
|
|
|
|
path := filepath.Join(t.TempDir(), "config.toml")
|
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
return path
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
t.Run("nur Defaults", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{
|
|
|
|
|
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
|
|
|
|
|
Getenv: env(nil),
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Audio.Sink != SinkOto {
|
|
|
|
|
t.Errorf("Audio.Sink = %q, want Default %q", cfg.Audio.Sink, SinkOto)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("TOML überschreibt Default", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{
|
|
|
|
|
File: writeTOML(t, "[audio]\nsink = \"null\"\n"),
|
|
|
|
|
Getenv: env(nil),
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Audio.Sink != SinkNull {
|
|
|
|
|
t.Errorf("Audio.Sink = %q, want %q aus TOML", cfg.Audio.Sink, SinkNull)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("leere TOML-Sektion lässt Default stehen", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{
|
|
|
|
|
File: writeTOML(t, "# nur ein Kommentar\n[audio]\n"),
|
|
|
|
|
Getenv: env(nil),
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Audio.Sink != SinkOto {
|
|
|
|
|
t.Errorf("Audio.Sink = %q, want Default %q", cfg.Audio.Sink, SinkOto)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("Env schlägt Datei", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{
|
|
|
|
|
File: writeTOML(t, "[audio]\nsink = \"oto\"\n"),
|
|
|
|
|
Getenv: env(map[string]string{"TALK_AUDIO_SINK": "null"}),
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Audio.Sink != SinkNull {
|
|
|
|
|
t.Errorf("Audio.Sink = %q, want Env-Override %q", cfg.Audio.Sink, SinkNull)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("Env allein ohne Datei", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{
|
|
|
|
|
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
|
|
|
|
|
Getenv: env(map[string]string{"TALK_AUDIO_SINK": "null"}),
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Audio.Sink != SinkNull {
|
|
|
|
|
t.Errorf("Audio.Sink = %q, want %q", cfg.Audio.Sink, SinkNull)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("ungültiger Sink-Wert in Datei ist klarer Fehler", func(t *testing.T) {
|
|
|
|
|
_, err := Load(Options{
|
|
|
|
|
File: writeTOML(t, "[audio]\nsink = \"mpv\"\n"),
|
|
|
|
|
Getenv: env(nil),
|
|
|
|
|
})
|
|
|
|
|
if err == nil || !strings.Contains(err.Error(), "audio.sink") {
|
|
|
|
|
t.Fatalf("Load-Fehler = %v, will klaren audio.sink-Fehler", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("ungültiger Env-Wert ist klarer Fehler", func(t *testing.T) {
|
|
|
|
|
_, err := Load(Options{
|
|
|
|
|
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
|
|
|
|
|
Getenv: env(map[string]string{"TALK_AUDIO_SINK": "bogus"}),
|
|
|
|
|
})
|
|
|
|
|
if err == nil || !strings.Contains(err.Error(), "TALK") && !strings.Contains(err.Error(), "audio.sink") {
|
|
|
|
|
t.Fatalf("Load-Fehler = %v, will klaren audio.sink-Fehler", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("kaputte TOML-Syntax ist klarer Fehler", func(t *testing.T) {
|
|
|
|
|
_, err := Load(Options{
|
|
|
|
|
File: writeTOML(t, "[audio\nsink = \n"),
|
|
|
|
|
Getenv: env(nil),
|
|
|
|
|
})
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("Load bei kaputter TOML: kein Fehler")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("ohne Home läuft Load mit Defaults weiter", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{Getenv: env(nil)})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load ohne HOME: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Audio.Sink != SinkOto {
|
|
|
|
|
t.Errorf("Audio.Sink = %q, want Default %q", cfg.Audio.Sink, SinkOto)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-10-18 16:34:24 +02:00
|
|
|
|
|
|
|
|
// TestLoadProviderKette deckt die Task-03-Felder ab: Provider-Pin per
|
|
|
|
|
// Datei/Env, Provider-Endpunkte/-Modelle und das Voice-Mapping.
|
|
|
|
|
func TestLoadProviderKette(t *testing.T) {
|
|
|
|
|
writeTOML := func(t *testing.T, content string) string {
|
|
|
|
|
t.Helper()
|
|
|
|
|
path := filepath.Join(t.TempDir(), "config.toml")
|
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
return path
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toml := `provider = "openai"
|
|
|
|
|
|
|
|
|
|
[providers.elevenlabs]
|
|
|
|
|
endpoint = "https://eleven.example.com"
|
|
|
|
|
|
|
|
|
|
[providers.openai]
|
|
|
|
|
endpoint = "https://openai.example.com"
|
|
|
|
|
model = "gpt-4o-mini-tts"
|
|
|
|
|
|
|
|
|
|
[providers.edge]
|
|
|
|
|
endpoint = "wss://edge.example.com/synth"
|
|
|
|
|
|
|
|
|
|
[voice.elevenlabs]
|
|
|
|
|
de = "MeineStimme"
|
|
|
|
|
en = "MyVoice"
|
|
|
|
|
|
|
|
|
|
[voice.edge]
|
|
|
|
|
de = "de-DE-KatjaNeural"
|
|
|
|
|
`
|
|
|
|
|
|
|
|
|
|
t.Run("TOML liefert Pin, Endpunkte und Voice-Mapping", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{File: writeTOML(t, toml), Getenv: env(nil)})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Provider != "openai" {
|
|
|
|
|
t.Errorf("Provider = %q, want %q", cfg.Provider, "openai")
|
|
|
|
|
}
|
|
|
|
|
if got := cfg.Providers["elevenlabs"].Endpoint; got != "https://eleven.example.com" {
|
|
|
|
|
t.Errorf("Providers[elevenlabs].Endpoint = %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := cfg.Providers["openai"].Model; got != "gpt-4o-mini-tts" {
|
|
|
|
|
t.Errorf("Providers[openai].Model = %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := cfg.Providers["edge"].Endpoint; got != "wss://edge.example.com/synth" {
|
|
|
|
|
t.Errorf("Providers[edge].Endpoint = %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := cfg.Voice["elevenlabs"]["de"]; got != "MeineStimme" {
|
|
|
|
|
t.Errorf("Voice[elevenlabs][de] = %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := cfg.Voice["elevenlabs"]["en"]; got != "MyVoice" {
|
|
|
|
|
t.Errorf("Voice[elevenlabs][en] = %q", got)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("TALK_PROVIDER schlägt Datei-Pin", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{
|
|
|
|
|
File: writeTOML(t, toml),
|
|
|
|
|
Getenv: env(map[string]string{"TALK_PROVIDER": "edge"}),
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Provider != "edge" {
|
|
|
|
|
t.Errorf("Provider = %q, want Env-Override %q", cfg.Provider, "edge")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("ohne alles: keine Pins, keine Endpunkte", func(t *testing.T) {
|
|
|
|
|
cfg, err := Load(Options{
|
|
|
|
|
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
|
|
|
|
|
Getenv: env(nil),
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Load: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Provider != "" || cfg.Providers != nil || cfg.Voice != nil {
|
|
|
|
|
t.Errorf("Provider-Konfiguration = %+v, will leer", cfg)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|