Files
talk/cmd/talk/setup_test.go
T
m3ta-chironandClaude Fable 5.1 85fa64cfe6 refactor: switch the whole project to English
talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.

Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.

Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 11:15:35 +02:00

168 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"github.com/BurntSushi/toml"
)
func readFile(path string) ([]byte, error) { return os.ReadFile(path) }
// setupEnv makes the wizard "interactive" and pins the chain to a fake
// ElevenLabs endpoint (sink null): preview and test sentence hit the fake.
// Returns the counter of synthesis calls.
func setupEnv(t *testing.T, root string, extra map[string]string) *atomic.Int32 {
t.Helper()
var calls atomic.Int32
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.Header().Set("Content-Type", "audio/mpeg")
_, _ = w.Write([]byte("mp3"))
}))
t.Cleanup(fake.Close)
kv := map[string]string{
"XDG_CONFIG_HOME": root,
"TALK_AUDIO_SINK": "null",
"TALK_PROVIDER": "elevenlabs",
"TALK_ELEVENLABS_KEY": "setup-key",
"TALK_ELEVENLABS_ENDPOINT": fake.URL,
}
for k, v := range extra {
kv[k] = v
}
setGetenv(t, kv)
setupInteractive = func(io.Reader) bool { return true }
t.Cleanup(func() { setupInteractive = isCharDevice })
return &calls
}
// TestSetupWritesConfig: answers via stdin (language en; voice 2 for
// English, preview, accept; German keeps the default; quiet hours) yield a
// config.toml with exactly those values; preview and test sentence hit the
// fake.
func TestSetupWritesConfig(t *testing.T) {
root := t.TempDir()
calls := setupEnv(t, root, nil)
stdin := strings.NewReader("en\n2\ny\ny\n\nn\ny\n22:00-07:00\n")
var out, errBuf bytes.Buffer
code := run([]string{"setup"}, stdin, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk setup: exit %d\nstdout: %s\nstderr: %s", code, out.String(), errBuf.String())
}
path := filepath.Join(root, "talk", "config.toml")
var got struct {
Lang string `toml:"lang"`
QuietHours string `toml:"quiet_hours"`
Voice map[string]map[string]string `toml:"voice"`
}
if _, err := toml.DecodeFile(path, &got); err != nil {
t.Fatalf("reading config: %v", err)
}
if got.Lang != "en" {
t.Errorf("lang = %q, want en", got.Lang)
}
if got.QuietHours != "22:00-07:00" {
t.Errorf("quiet_hours = %q", got.QuietHours)
}
if got.Voice["elevenlabs"]["en"] != "pNInz6obpgDQGcFmaJgB" { // no. 2 = Adam
t.Errorf("voice.elevenlabs.en = %q, want Adam", got.Voice["elevenlabs"]["en"])
}
if _, ok := got.Voice["elevenlabs"]["de"]; ok {
t.Errorf("voice.elevenlabs.de set although the default was kept: %v", got.Voice)
}
if n := calls.Load(); n != 2 { // 1× preview en + 1× test sentence
t.Errorf("fake calls = %d, want 2 (preview + test sentence)", n)
}
if _, err := os.Stat(path + ".bak"); !os.IsNotExist(err) {
t.Errorf("backup created without a previous file")
}
if !strings.Contains(out.String(), "Saved") || !strings.Contains(out.String(), "Spoken via elevenlabs") {
t.Errorf("stdout = %q", out.String())
}
}
// TestSetupMergesExistingConfig: existing values (other port, another
// provider's voice) survive, there is a .bak, and "none" removes an existing
// quiet window.
func TestSetupMergesExistingConfig(t *testing.T) {
root := t.TempDir()
setupEnv(t, root, nil)
dir := filepath.Join(root, "talk")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "config.toml")
orig := "quiet_hours = \"18:00-08:00\"\n\n[server]\nport = 7900\n\n[voice.edge]\nde = \"de-DE-ConradNeural\"\n"
if err := os.WriteFile(path, []byte(orig), 0o644); err != nil {
t.Fatal(err)
}
// The setup itself must not touch port 7900 from the file: TALK_PORT=1
// from setGetenv beats the file.
stdin := strings.NewReader("\n1\nn\ny\n1\nn\ny\nnone\n")
var out, errBuf bytes.Buffer
if code := run([]string{"setup"}, stdin, &out, &errBuf); code != exitOK {
t.Fatalf("talk setup: exit %d\n%s\n%s", code, out.String(), errBuf.String())
}
bak, err := os.ReadFile(path + ".bak")
if err != nil || string(bak) != orig {
t.Errorf(".bak = %q (%v), want the original", bak, err)
}
var got map[string]any
if _, err := toml.DecodeFile(path, &got); err != nil {
t.Fatal(err)
}
if _, ok := got["quiet_hours"]; ok {
t.Errorf("quiet_hours not removed: %v", got)
}
if srv, _ := got["server"].(map[string]any); srv["port"] != int64(7900) {
t.Errorf("server.port lost: %v", got["server"])
}
voice, _ := got["voice"].(map[string]any)
if edge, _ := voice["edge"].(map[string]any); edge["de"] != "de-DE-ConradNeural" {
t.Errorf("voice.edge.de lost: %v", voice)
}
// Default kept (no. 1 = Rachel = built-in default) → no elevenlabs entry.
if _, ok := voice["elevenlabs"]; ok {
t.Errorf("voice.elevenlabs written although the default was kept: %v", voice)
}
}
// TestSetupAbortWritesNothing: "q" aborts, no file is created.
func TestSetupAbortWritesNothing(t *testing.T) {
root := t.TempDir()
setupEnv(t, root, nil)
var out, errBuf bytes.Buffer
if code := run([]string{"setup"}, strings.NewReader("q\n"), &out, &errBuf); code != exitOK {
t.Fatalf("exit %d, stderr %q", code, errBuf.String())
}
if !strings.Contains(out.String(), "Aborted") {
t.Errorf("stdout = %q", out.String())
}
if _, err := os.Stat(filepath.Join(root, "talk", "config.toml")); !os.IsNotExist(err) {
t.Error("config.toml created despite abort")
}
}
// TestSetupWithoutTerminal: a pipe → no questions, effective configuration,
// exit 0.
func TestSetupWithoutTerminal(t *testing.T) {
root := t.TempDir()
setGetenv(t, map[string]string{"XDG_CONFIG_HOME": root, "TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
if code := runCLI([]string{"setup"}, &out, &errBuf); code != exitOK {
t.Fatalf("exit %d, stderr %q", code, errBuf.String())
}
if !strings.Contains(out.String(), "Effective configuration") || !strings.Contains(out.String(), "quiet hours: none") {
t.Errorf("stdout = %q", out.String())
}
}