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

+90 -87
View File
@@ -9,9 +9,9 @@ import (
"time"
)
// TestSayNonBlocking (Abnahmekriterium non-blocking): `talk say` kehrt
// deutlich vor Ende der Ansage zurück (Exit 0, Item-ID auf stdout), die
// Ansage läuft im Daemon weiter zu Ende.
// TestSayNonBlocking (acceptance criterion non-blocking): `talk say` returns
// well before the announcement ends (exit 0, item ID on stdout); the
// announcement keeps running to completion in the daemon.
func TestSayNonBlocking(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 600*time.Millisecond)
@@ -19,29 +19,29 @@ func TestSayNonBlocking(t *testing.T) {
env := daemonEnv(root, d.port, fake.srv.URL, nil)
start := time.Now()
res := runTalk(t, env, "say", "langlaufende ansage")
res := runTalk(t, env, "say", "long-running announcement")
elapsed := time.Since(start)
if res.code != 0 {
t.Fatalf("talk say: Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say: exit %d, stderr: %s", res.code, res.stderr)
}
if elapsed >= 500*time.Millisecond {
t.Fatalf("talk say war blockierend (%s; Ansage dauert 600 ms) — Default muss non-blocking sein", elapsed)
t.Fatalf("talk say was blocking (%s; announcement takes 600 ms) — the default must be non-blocking", elapsed)
}
id := strings.TrimSpace(res.stdout)
if id == "" || strings.Contains(id, "\n") {
t.Errorf("stdout = %q, will genau eine Zeile mit der Item-ID", res.stdout)
t.Errorf("stdout = %q, want exactly one line with the item ID", res.stdout)
}
// Die Ansage läuft trotzdem zu Ende (Daemon besitzt sie jetzt).
// The announcement still runs to completion (the daemon owns it now).
hits := fake.waitHits(t, 1)
if hits[0].Text != "langlaufende ansage" {
t.Errorf("Treffer = %q", hits[0].Text)
if hits[0].Text != "long-running announcement" {
t.Errorf("hit = %q", hits[0].Text)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestSayWait: `talk say --wait` kehrt erst nach Ende der Ansage zurück.
// TestSayWait: `talk say --wait` returns only after the announcement ends.
func TestSayWait(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 400*time.Millisecond)
@@ -49,52 +49,54 @@ func TestSayWait(t *testing.T) {
env := daemonEnv(root, d.port, fake.srv.URL, nil)
start := time.Now()
res := runTalk(t, env, "say", "--wait", "warten auf mich")
res := runTalk(t, env, "say", "--wait", "wait for me")
elapsed := time.Since(start)
if res.code != 0 {
t.Fatalf("talk say --wait: Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say --wait: exit %d, stderr: %s", res.code, res.stderr)
}
if elapsed < 350*time.Millisecond {
t.Fatalf("talk say --wait kam zu früh zurück (%s; Ansage dauert 400 ms)", elapsed)
t.Fatalf("talk say --wait returned too early (%s; announcement takes 400 ms)", elapsed)
}
if hits := fake.waitHits(t, 1); hits[0].Text != "warten auf mich" {
t.Errorf("Treffer = %q", hits[0].Text)
if hits := fake.waitHits(t, 1); hits[0].Text != "wait for me" {
t.Errorf("hit = %q", hits[0].Text)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestSayOhneDaemonSprichtDirekt: Daemon tot und Autostart aus → talk
// spricht direkt im eigenen Prozess (hier: gegen den Fake-Provider) und
// sagt das auf stderr — nichts bricht still.
func TestSayOhneDaemonSprichtDirekt(t *testing.T) {
// TestSayWithoutDaemonSpeaksDirectly: daemon dead and autostart off → talk
// speaks directly in its own process (here: against the fake provider) and
// says so on stderr — nothing fails silently.
func TestSayWithoutDaemonSpeaksDirectly(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot
env := daemonEnv(root, 1, fake.srv.URL, nil) // port 1: daemon guaranteed dead
res := runTalk(t, env, "say", "offline ansage")
res := runTalk(t, env, "say", "offline announcement")
if res.code != 0 {
t.Fatalf("talk say (direkt): Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say (direct): exit %d, stderr: %s", res.code, res.stderr)
}
if hits := fake.waitHits(t, 1); hits[0].Text != "offline ansage" {
t.Errorf("Treffer = %q", hits[0].Text)
if hits := fake.waitHits(t, 1); hits[0].Text != "offline announcement" {
t.Errorf("hit = %q", hits[0].Text)
}
if !strings.Contains(res.stderr, "spreche direkt") {
t.Errorf("stderr = %q, will Direkt-Hinweis (nichts bricht still)", res.stderr)
if !strings.Contains(res.stderr, "speaking directly") {
t.Errorf("stderr = %q, want the direct-path note (nothing fails silently)", res.stderr)
}
if strings.Contains(res.stderr, "Daemon gestartet") {
t.Errorf("stderr = %q — Autostart war aus, es darf kein Daemon gestartet werden", res.stderr)
if strings.Contains(res.stderr, "daemon started") {
t.Errorf("stderr = %q — autostart was off, no daemon may be started", res.stderr)
}
}
// TestSayOhneDaemonRespektiertMute: ohne Daemon verwirft der Direkt-Pfad
// Ansagen laut Mute-Datei tonlos (und spielt nach Unmute wieder).
func TestSayOhneDaemonRespektiertMute(t *testing.T) {
// TestSayWithoutDaemonRespectsMute: without a daemon the direct path
// discards announcements silently according to the mute file (and plays
// again after unmute).
func TestSayWithoutDaemonRespectsMute(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env := daemonEnv(root, 1, fake.srv.URL, nil)
// Mute-Datei direkt ins Config-Dir (Zustand „Daemon war mal da").
// Mute file written straight into the config dir (state "a daemon was
// here once").
if err := os.MkdirAll(configDir(root), 0o755); err != nil {
t.Fatal(err)
}
@@ -102,31 +104,32 @@ func TestSayOhneDaemonRespektiertMute(t *testing.T) {
t.Fatal(err)
}
res := runTalk(t, env, "say", "sollte stumm bleiben")
res := runTalk(t, env, "say", "should stay silent")
if res.code != 0 {
t.Fatalf("talk say (mute an): Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say (mute on): exit %d, stderr: %s", res.code, res.stderr)
}
if res.stdout != "muted\n" {
t.Errorf("stdout = %q, want muted", res.stdout)
}
fake.assertNoHits(t, 300*time.Millisecond)
// Unmute (offline) → spielt wieder.
// Unmute (offline) → plays again.
res = runTalk(t, env, "unmute")
if res.code != 0 {
t.Fatalf("talk unmute (offline): Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk unmute (offline): exit %d, stderr: %s", res.code, res.stderr)
}
res = runTalk(t, env, "say", "jetzt wieder")
res = runTalk(t, env, "say", "back again")
if res.code != 0 {
t.Fatalf("talk say (mute aus): Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say (mute off): exit %d, stderr: %s", res.code, res.stderr)
}
if hits := fake.waitHits(t, 1); hits[0].Text != "jetzt wieder" {
t.Errorf("Treffer = %q", hits[0].Text)
if hits := fake.waitHits(t, 1); hits[0].Text != "back again" {
t.Errorf("hit = %q", hits[0].Text)
}
}
// TestCliMuteUnmuteStatusCancel: die Steuer-Befehle als Daemon-Aufrufe mit
// plain Output — mute/unmute als Token, status als Block, cancel mit Zahl.
// TestCliMuteUnmuteStatusCancel: the control commands as daemon calls with
// plain output — mute/unmute as a token, status as a block, cancel with a
// number.
func TestCliMuteUnmuteStatusCancel(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
@@ -138,20 +141,20 @@ func TestCliMuteUnmuteStatusCancel(t *testing.T) {
t.Fatalf("talk mute: %d %q — stderr: %s", res.code, res.stdout, res.stderr)
}
// say bei Mute: verworfen, plain gemeldet, kein Provider-Kontakt.
res = runTalk(t, env, "say", "stille")
// say while muted: discarded, reported plainly, no provider contact.
res = runTalk(t, env, "say", "silence")
if res.code != 0 || res.stdout != "muted\n" {
t.Fatalf("talk say (mute an): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
t.Fatalf("talk say (mute on): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
}
fake.assertNoHits(t, 300*time.Millisecond)
res = runTalk(t, env, "status")
if res.code != 0 {
t.Fatalf("talk status: Exit %d — stderr: %s", res.code, res.stderr)
t.Fatalf("talk status: exit %d — stderr: %s", res.code, res.stderr)
}
for _, want := range []string{"version dev", "queue: 0", "mute: an", "provider: elevenlabs"} {
for _, want := range []string{"version dev", "queue: 0", "mute: on", "provider: elevenlabs"} {
if !strings.Contains(res.stdout, want) {
t.Errorf("talk status stdout = %q, will %q", res.stdout, want)
t.Errorf("talk status stdout = %q, want %q", res.stdout, want)
}
}
@@ -162,18 +165,18 @@ func TestCliMuteUnmuteStatusCancel(t *testing.T) {
res = runTalk(t, env, "cancel")
if res.code != 0 || res.stdout != "cancelled: 0\n" {
t.Fatalf("talk cancel (leer): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
t.Fatalf("talk cancel (empty): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
}
res = runTalk(t, env, "status")
if !strings.Contains(res.stdout, "mute: aus") {
t.Errorf("talk status nach unmute = %q, will mute aus", res.stdout)
if !strings.Contains(res.stdout, "mute: off") {
t.Errorf("talk status after unmute = %q, want mute off", res.stdout)
}
}
// TestCliCancelMitQueue: cancel über die CLI bricht laufende + wartende
// Ansage ab und meldet die Zahl.
func TestCliCancelMitQueue(t *testing.T) {
// TestCliCancelWithQueue: cancel via the CLI aborts the running + the
// waiting announcement and reports the count.
func TestCliCancelWithQueue(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 5*time.Second)
d := startDaemon(t, root, fake.srv.URL, nil)
@@ -181,7 +184,7 @@ func TestCliCancelMitQueue(t *testing.T) {
speak(t, d, `{"text":"blocker"}`)
fake.waitArrived(t, 1)
runTalk(t, env, "say", "wartet auf cancel") // non-blocking enqueue
runTalk(t, env, "say", "waiting for cancel") // non-blocking enqueue
res := runTalk(t, env, "cancel")
if res.code != 0 || res.stdout != "cancelled: 2\n" {
@@ -190,18 +193,18 @@ func TestCliCancelMitQueue(t *testing.T) {
fake.setDelay(50 * time.Millisecond)
waitStatusIdle(t, d, 2*time.Second)
speak(t, d, `{"text":"danach"}`)
speak(t, d, `{"text":"afterwards"}`)
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "danach" {
t.Errorf("Treffer = %q, want danach", hits[0].Text)
if hits[0].Text != "afterwards" {
t.Errorf("hit = %q, want afterwards", hits[0].Text)
}
}
// TestDreiParalleleTalkAufrufe (Abnahmekriterium Task 04): `talkd &` +
// drei parallele `talk`-Aufrufe → drei Ansagen nacheinander. Die Enqueue-
// Reihenfolge der parallelen Prozesse ist offen — geprüft wird, dass alle
// drei abgespielt werden und KEINE zwei überlappen.
func TestDreiParalleleTalkAufrufe(t *testing.T) {
// TestThreeParallelTalkCalls (acceptance criterion Task 04): `talkd &` +
// three parallel `talk` calls → three announcements one after another. The
// enqueue order of the parallel processes is open — what is checked is that
// all three are played and NO two overlap.
func TestThreeParallelTalkCalls(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 200*time.Millisecond)
d := startDaemon(t, root, fake.srv.URL, nil)
@@ -209,7 +212,7 @@ func TestDreiParalleleTalkAufrufe(t *testing.T) {
results := make([]talkResult, 3)
var wg sync.WaitGroup
for i, text := range []string{"erste", "zweite", "dritte"} {
for i, text := range []string{"first", "second", "third"} {
wg.Add(1)
go func(i int, text string) {
defer wg.Done()
@@ -219,7 +222,7 @@ func TestDreiParalleleTalkAufrufe(t *testing.T) {
wg.Wait()
for i, res := range results {
if res.code != 0 {
t.Fatalf("paralleler talk-Aufruf %d: Exit %d — stderr: %s", i, res.code, res.stderr)
t.Fatalf("parallel talk call %d: exit %d — stderr: %s", i, res.code, res.stderr)
}
}
@@ -228,60 +231,60 @@ func TestDreiParalleleTalkAufrufe(t *testing.T) {
for _, h := range hits {
played[h.Text] = true
}
for _, text := range []string{"erste", "zweite", "dritte"} {
for _, text := range []string{"first", "second", "third"} {
if !played[text] {
t.Errorf("Ansage %q fehlt (Treffer: %+v)", text, hits)
t.Errorf("announcement %q missing (hits: %+v)", text, hits)
}
}
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("Ansagen %d/%d überlappten: Start %v < Ende %v", i-1, i, hits[i].Start, hits[i-1].End)
t.Errorf("announcements %d/%d overlapped: start %v < end %v", i-1, i, hits[i].Start, hits[i-1].End)
}
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestCliSteuerbefehleOhneDaemon: status/cancel ohne Daemon sind klare
// Fehler (kein Stillversagen) — say spricht dagegen direkt.
func TestCliSteuerbefehleOhneDaemon(t *testing.T) {
// TestCliControlCommandsWithoutDaemon: status/cancel without a daemon are
// clear errors (no silent failure) — say, by contrast, speaks directly.
func TestCliControlCommandsWithoutDaemon(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env := daemonEnv(root, 1, fake.srv.URL, nil)
if res := runTalk(t, env, "status"); res.code != 1 || !strings.Contains(res.stderr, "kein Daemon") || !strings.Contains(res.stdout, "läuft nicht") {
t.Errorf("talk status ohne Daemon: %d stdout %q stderr %q", res.code, res.stdout, res.stderr)
if res := runTalk(t, env, "status"); res.code != 1 || !strings.Contains(res.stderr, "no daemon") || !strings.Contains(res.stdout, "not running") {
t.Errorf("talk status without daemon: %d stdout %q stderr %q", res.code, res.stdout, res.stderr)
}
if res := runTalk(t, env, "cancel"); res.code != 1 || !strings.Contains(res.stderr, "nichts zu abbrechen") {
t.Errorf("talk cancel ohne Daemon: %d %q", res.code, res.stderr)
if res := runTalk(t, env, "cancel"); res.code != 1 || !strings.Contains(res.stderr, "nothing to cancel") {
t.Errorf("talk cancel without daemon: %d %q", res.code, res.stderr)
}
}
// TestSayDefaultPfadWieSay: `talk "Text"` nimmt denselben Daemon-Pfad wie
// `talk say "Text"` (Flags vor und nach dem Text).
func TestSayDefaultPfadWieSay(t *testing.T) {
// TestSayDefaultPathLikeSay: `talk "text"` takes the same daemon path as
// `talk say "text"` (flags before and after the text).
func TestSayDefaultPathLikeSay(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
env := daemonEnv(root, d.port, fake.srv.URL, nil)
res := runTalk(t, env, "default aufruf")
res := runTalk(t, env, "default call")
if res.code != 0 {
t.Fatalf("talk \"Text\": Exit %d — stderr: %s", res.code, res.stderr)
t.Fatalf("talk \"text\": exit %d — stderr: %s", res.code, res.stderr)
}
if id := strings.TrimSpace(res.stdout); id == "" {
t.Errorf("stdout = %q, will Item-ID", res.stdout)
t.Errorf("stdout = %q, want item ID", res.stdout)
}
// Flag nach dem Text (parseInterspersed) auf dem Daemon-Pfad.
res = runTalk(t, env, "noch ein text", "--lang=en")
// Flag after the text (parseInterspersed) on the daemon path.
res = runTalk(t, env, "another text", "--lang=en")
if res.code != 0 {
t.Fatalf("talk Text --lang=en: Exit %d — stderr: %s", res.code, res.stderr)
t.Fatalf("talk text --lang=en: exit %d — stderr: %s", res.code, res.stderr)
}
hits := fake.waitHits(t, 2)
if !strings.Contains(hits[1].Text, "noch ein text") {
t.Errorf("zweiter Treffer = %q", hits[1].Text)
if !strings.Contains(hits[1].Text, "another text") {
t.Errorf("second hit = %q", hits[1].Text)
}
waitStatusIdle(t, d, 3*time.Second)
}