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>
168 lines
5.8 KiB
Go
168 lines
5.8 KiB
Go
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())
|
||
}
|
||
}
|