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>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 11:15:35 +02:00
1 parent 4d30bd95ad
commit 85fa64cfe6
95 files changed
+4707 -4854

No files matched your search

+91 -91
View File
@@ -8,26 +8,26 @@ import (
"time"
)
// TestSerialisierung: zwei schnell hintereinander eingereihte Ansagen
// spielen nacheinander — die zweite Synthese erreicht den Fake-Provider
// erst NACH der Antwort der ersten (kein Überlappen), und die Textfolge
// bleibt enqueue-stabil. Der null-Sink verwirft jede Ansage sichtbar.
func TestSerialisierung(t *testing.T) {
// TestSerialization: two announcements enqueued in quick succession play one
// after another — the second synthesis reaches the fake provider only AFTER
// the response to the first (no overlap), and the text order stays
// enqueue-stable. The null sink visibly discards every announcement.
func TestSerialization(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 250*time.Millisecond)
d := startDaemon(t, root, fake.srv.URL, nil)
code, first := speak(t, d, `{"text":"erste ansage"}`)
code, first := speak(t, d, `{"text":"first announcement"}`)
if code != 202 || first["id"] == "" || first["muted"] == true {
t.Fatalf("erste: Status=%d Body=%v, want 202 mit id", code, first)
t.Fatalf("first: status=%d body=%v, want 202 with id", code, first)
}
// Sobald die erste Synthese beim Fake angekommen ist (Daemon
// beschäftigt), zwei weitere schnell hinterher — sie müssen sich
// hinter der laufenden einreihen.
// As soon as the first synthesis has arrived at the fake (daemon busy),
// two more in quick succession — they must line up behind the running
// one.
fake.waitArrived(t, 1)
speak(t, d, `{"text":"zweite ansage"}`)
speak(t, d, `{"text":"dritte ansage"}`)
speak(t, d, `{"text":"second announcement"}`)
speak(t, d, `{"text":"third announcement"}`)
hits := fake.waitHits(t, 3)
@@ -35,74 +35,74 @@ func TestSerialisierung(t *testing.T) {
for _, h := range hits {
texts = append(texts, h.Text)
}
if strings.Join(texts, "|") != "erste ansage|zweite ansage|dritte ansage" {
t.Errorf("Abspiel-Reihenfolge = %v, want FIFO", texts)
if strings.Join(texts, "|") != "first announcement|second announcement|third announcement" {
t.Errorf("playback order = %v, want FIFO", texts)
}
// Serialisierung: die nächste Synthese startet erst nach der Antwort
// der vorherigen (kleine Toleranz für Uhr-Jitter).
// Serialization: the next synthesis starts only after the response to
// the previous one (small tolerance for clock jitter).
const slack = 25 * time.Millisecond
for i := 1; i < len(hits); i++ {
if hits[i].Start.Before(hits[i-1].End.Add(-slack)) {
t.Errorf("Ansage %d überlappt mit Ansage %d: Start %v < Ende %v", i, i-1, hits[i].Start, hits[i-1].End)
t.Errorf("announcement %d overlaps announcement %d: start %v < end %v", i, i-1, hits[i].Start, hits[i-1].End)
}
}
// Erst idle warten: Die dritte Verwurf-Zeile entsteht daemon-seitig
// erst kurz NACH der beim Fake aufgezeichneten Antwort.
// Wait for idle first: the third discard line appears daemon-side only
// shortly AFTER the response recorded at the fake.
waitStatusIdle(t, d, 3*time.Second)
// Der null-Sink hat alle drei verworfen (tonlos, aber sichtbar).
if n := strings.Count(d.stderr.String(), "verworfen (null sink)"); n < 3 {
t.Errorf("null-Sink-Verwürfe im Daemon-Log = %d, want >= 3 (stderr: %s)", n, d.stderr.String())
// The null sink discarded all three (silent, but visible).
if n := strings.Count(d.stderr.String(), "discarded (null sink)"); n < 3 {
t.Errorf("null-sink discards in the daemon log = %d, want >= 3 (stderr: %s)", n, d.stderr.String())
}
}
// TestPrioritaet: während einer langen Ansage werden low/normal/high
// eingereiht — die Abarbeitung folgt high > normal > low (FIFO je Level).
func TestPrioritaet(t *testing.T) {
// TestPriority: during a long announcement low/normal/high are enqueued —
// processing follows high > normal > low (FIFO per level).
func TestPriority(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 200*time.Millisecond)
d := startDaemon(t, root, fake.srv.URL, nil)
speak(t, d, `{"text":"blocker"}`)
fake.waitArrived(t, 1) // Blocker läuft, Worker gebunden.
fake.waitArrived(t, 1) // blocker running, worker bound.
// Eingereiht wird in Klassen-Durcheinander; die Queue-Sicht meldet
// IDs — also text→id einsammeln und die Reihenfolge über IDs prüfen.
// Enqueued in mixed class order; the queue view reports IDs — so
// collect text→id and check the order via IDs.
enq := make(map[string]string)
for _, tc := range []struct{ body, text string }{
{`{"text":"niedrig","priority":"low"}`, "niedrig"},
{`{"text":"normal-eins"}`, "normal-eins"},
{`{"text":"normal-zwei"}`, "normal-zwei"},
{`{"text":"dringend","priority":"high"}`, "dringend"},
{`{"text":"low one","priority":"low"}`, "low one"},
{`{"text":"normal-one"}`, "normal-one"},
{`{"text":"normal-two"}`, "normal-two"},
{`{"text":"urgent","priority":"high"}`, "urgent"},
} {
code, m := speak(t, d, tc.body)
if code != 202 {
t.Fatalf("speak %s: Status %d", tc.body, code)
t.Fatalf("speak %s: status %d", tc.body, code)
}
id, _ := m["id"].(string)
if id == "" {
t.Fatalf("speak %s: keine ID in %v", tc.body, m)
t.Fatalf("speak %s: no ID in %v", tc.body, m)
}
enq[tc.text] = id
}
wantOrder := []string{"dringend", "normal-eins", "normal-zwei", "niedrig"}
wantOrder := []string{"urgent", "normal-one", "normal-two", "low one"}
// Status zeigt aktuelles Item und die wartenden in Abarbeitungs-
// Reihenfolge (deterministisch: der Blocker hält den Worker).
// Status shows the current item and the waiting ones in processing
// order (deterministic: the blocker holds the worker).
st := getStatus(t, d)
if st.Current == nil || st.Current.Text != "blocker" {
t.Fatalf("current = %+v, will blocker", st.Current)
t.Fatalf("current = %+v, want blocker", st.Current)
}
if st.Queue != 4 {
t.Fatalf("Queue-Länge = %d, want 4", st.Queue)
t.Fatalf("queue length = %d, want 4", st.Queue)
}
var wantIDs []string
for _, text := range wantOrder {
wantIDs = append(wantIDs, enq[text])
}
if fmt.Sprint(st.QueueIDs) != fmt.Sprint(wantIDs) {
t.Errorf("queue_ids = %v, want %v (Abarbeitungs-Reihenfolge)", st.QueueIDs, wantIDs)
t.Errorf("queue_ids = %v, want %v (processing order)", st.QueueIDs, wantIDs)
}
hits := fake.waitHits(t, 5)
@@ -112,15 +112,15 @@ func TestPrioritaet(t *testing.T) {
}
want := append([]string{"blocker"}, wantOrder...)
if strings.Join(texts, "|") != strings.Join(want, "|") {
t.Errorf("Abspiel-Reihenfolge = %v, want %v", texts, want)
t.Errorf("playback order = %v, want %v", texts, want)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestNowSpringtVor: --now über die CLI setzt now:true — die Ansage
// springt vor alle wartenden normal-Items (unterbricht aber nicht den
// laufenden Blocker).
func TestNowSpringtVor(t *testing.T) {
// TestNowJumpsAhead: --now via the CLI sets now:true — the announcement
// jumps ahead of all waiting normal items (but does not interrupt the
// running blocker).
func TestNowJumpsAhead(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 600*time.Millisecond)
d := startDaemon(t, root, fake.srv.URL, nil)
@@ -129,14 +129,14 @@ func TestNowSpringtVor(t *testing.T) {
speak(t, d, `{"text":"blocker"}`)
fake.waitArrived(t, 1)
speak(t, d, `{"text":"spaeter"}`)
speak(t, d, `{"text":"later"}`)
res := runTalk(t, env, "say", "--now", "sofort")
res := runTalk(t, env, "say", "--now", "right away")
if res.code != 0 {
t.Fatalf("talk say --now: Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say --now: exit %d, stderr: %s", res.code, res.stderr)
}
if id := strings.TrimSpace(res.stdout); id == "" || strings.Contains(id, "\n") {
t.Errorf("talk say --now stdout = %q, will eine Zeile mit der Item-ID", res.stdout)
t.Errorf("talk say --now stdout = %q, want one line with the item ID", res.stdout)
}
hits := fake.waitHits(t, 3)
@@ -144,15 +144,15 @@ func TestNowSpringtVor(t *testing.T) {
for _, h := range hits {
texts = append(texts, h.Text)
}
if strings.Join(texts, "|") != "blocker|sofort|spaeter" {
t.Errorf("Abspiel-Reihenfolge = %v, want [blocker sofort spaeter] — now muss vor normal springen", texts)
if strings.Join(texts, "|") != "blocker|right away|later" {
t.Errorf("playback order = %v, want [blocker right away later] — now must jump ahead of normal", texts)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestCancel: bricht die laufende Ansage ab (Synthese-Request wird
// abgebrochen), leert die Queue, meldet die Gesamtzahl — und der Daemon
// bleibt danach funktionstüchtig.
// TestCancel: aborts the running announcement (synthesis request is
// cancelled), empties the queue, reports the total count — and the daemon
// remains functional afterwards.
func TestCancel(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 5*time.Second)
@@ -160,47 +160,47 @@ func TestCancel(t *testing.T) {
code, first := speak(t, d, `{"text":"blocker"}`)
if code != 202 {
t.Fatalf("speak: Status %d", code)
t.Fatalf("speak: status %d", code)
}
fake.waitArrived(t, 1) // Blocker steckt in der Synthese (5 s Fake-Delay).
fake.waitArrived(t, 1) // blocker is stuck in synthesis (5 s fake delay).
code, second := speak(t, d, `{"text":"wartet"}`)
code, second := speak(t, d, `{"text":"waiting"}`)
if code != 202 {
t.Fatalf("speak: Status %d", code)
t.Fatalf("speak: status %d", code)
}
// Status-Vertrag: aktuelles Item + ein wartendes.
// Status contract: current item + one waiting.
st := getStatus(t, d)
if st.Current == nil || st.Current.ID != first["id"] {
t.Fatalf("current = %+v, will den Blocker (%v)", st.Current, first["id"])
t.Fatalf("current = %+v, want the blocker (%v)", st.Current, first["id"])
}
if st.Queue != 1 || len(st.QueueIDs) != 1 || st.QueueIDs[0] != second["id"] {
t.Fatalf("queue = %v (%d), will das wartende Item", st.QueueIDs, st.Queue)
t.Fatalf("queue = %v (%d), want the waiting item", st.QueueIDs, st.Queue)
}
code, m := postPath(t, d, "/cancel")
if code != 200 {
t.Fatalf("POST /cancel: Status %d", code)
t.Fatalf("POST /cancel: status %d", code)
}
if m["cancelled"] != float64(2) {
t.Fatalf("cancelled = %v, want 2 (laufende + wartende)", m["cancelled"])
t.Fatalf("cancelled = %v, want 2 (running + waiting)", m["cancelled"])
}
// Der Daemon wird sofort idle (Synthese abgebrochen) und nimmt neue
// Ansagen an — cancel beschädigt den Daemon nicht.
// The daemon becomes idle immediately (synthesis cancelled) and accepts
// new announcements — cancel does not damage the daemon.
waitStatusIdle(t, d, 2*time.Second)
fake.setDelay(50 * time.Millisecond)
speak(t, d, `{"text":"nachher"}`)
speak(t, d, `{"text":"afterwards"}`)
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "nachher" {
t.Errorf("erster abgeschlossener Treffer = %q, will nachher (abgebrochene zählen nicht)", hits[0].Text)
if hits[0].Text != "afterwards" {
t.Errorf("first completed hit = %q, want afterwards (cancelled ones do not count)", hits[0].Text)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestSpeakValidierung: der HTTP-Vertrag lehnt kaputte Anfragen mit 400
// ab (und 405 bei falscher Methode), ohne den Daemon zu beschädigen.
func TestSpeakValidierung(t *testing.T) {
// TestSpeakValidation: the HTTP contract rejects broken requests with 400
// (and 405 for the wrong method) without damaging the daemon.
func TestSpeakValidation(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
@@ -209,64 +209,64 @@ func TestSpeakValidierung(t *testing.T) {
name string
body string
}{
{"kein JSON", `kein json`},
{"text fehlt", `{}`},
{"text leer", `{"text":" "}`},
{"priority Müll", `{"text":"x","priority":"sofort"}`},
{"provider unbekannt", `{"text":"x","provider":"gibtsnicht"}`},
{"lang Müll", `{"text":"x","lang":"fr"}`},
{"not JSON", `not json`},
{"text missing", `{}`},
{"text empty", `{"text":" "}`},
{"priority garbage", `{"text":"x","priority":"immediately"}`},
{"provider unknown", `{"text":"x","provider":"doesnotexist"}`},
{"lang garbage", `{"text":"x","lang":"fr"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, m := speak(t, d, tc.body)
if code != 400 {
t.Fatalf("Status = %d, want 400 (Body: %v)", code, m)
t.Fatalf("status = %d, want 400 (body: %v)", code, m)
}
if _, ok := m["error"]; !ok {
t.Errorf("Antwort ohne error-Feld: %v", m)
t.Errorf("response without error field: %v", m)
}
})
}
// Falsche Methode → 405 (Methoden-gebundene Routen).
// Wrong method → 405 (method-bound routes).
resp, err := http.Post(d.baseURL+"/status", "application/json", nil)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("POST /status: Status = %d, want 405", resp.StatusCode)
t.Errorf("POST /status: status = %d, want 405", resp.StatusCode)
}
// Danach ist der Daemon weiterhin gesund und speakbar.
code, m := speak(t, d, `{"text":"gesund"}`)
// Afterwards the daemon is still healthy and speakable.
code, m := speak(t, d, `{"text":"healthy"}`)
if code != 202 || m["id"] == "" {
t.Fatalf("nach Validierungs-Fehlern: speak = %d %v, want 202 mit id", code, m)
t.Fatalf("after validation errors: speak = %d %v, want 202 with id", code, m)
}
fake.waitHits(t, 1)
}
// TestStatusVertrag: Felder der Status-Antwort im Leerlauf (Version,
// Mute aus, kein current, Provider aufgelöst).
func TestStatusVertrag(t *testing.T) {
// TestStatusContract: fields of the status response when idle (version,
// mute off, no current, provider resolved).
func TestStatusContract(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
st := getStatus(t, d)
if st.Version != "dev" {
t.Errorf("version = %q, want dev (ohne ldflags gebaut)", st.Version)
t.Errorf("version = %q, want dev (built without ldflags)", st.Version)
}
if st.Muted {
t.Error("muted = true im Frischstart, want false")
t.Error("muted = true on a fresh start, want false")
}
if st.Current != nil {
t.Errorf("current = %+v, want null im Leerlauf", st.Current)
t.Errorf("current = %+v, want null when idle", st.Current)
}
if st.Queue != 0 {
t.Errorf("queue = %d, want 0", st.Queue)
}
// Aktiver Provider: elfenlabs zuerst, weil Key + Fake-Endpunkt gesetzt.
// Active provider: elevenlabs first, because key + fake endpoint are set.
if st.Provider != "elevenlabs" {
t.Errorf("provider = %q, want elevenlabs", st.Provider)
}