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

+50 -50
View File
@@ -6,9 +6,9 @@ import (
"time"
)
// TestTruncateText: Kürzung auf max Runen an einer Wortgrenze (nicht in der
// ersten Hälfte), ohne hängende Satzzeichen; kurze Texte und max 0 bleiben
// unangetastet; Runen zählen, nicht Bytes.
// TestTruncateText: cut to max runes at a word boundary (not in the first
// half), without dangling punctuation; short texts and max 0 stay untouched;
// runes count, not bytes.
func TestTruncateText(t *testing.T) {
cases := []struct {
name string
@@ -17,13 +17,13 @@ func TestTruncateText(t *testing.T) {
want string
truncated bool
}{
{"kurz bleibt", "Build fertig", 400, "Build fertig", false},
{"genau max bleibt", "abcde", 5, "abcde", false},
{"unbegrenzt", strings.Repeat("x", 1000), 0, strings.Repeat("x", 1000), false},
{"wortgrenze", "Der Build ist fertig und bereit", 14, "Der Build ist", true},
{"satzzeichen weg", "Fertig, aber dann kam noch mehr", 8, "Fertig", true},
{"keine wortgrenze in zweiter hälfte → hart", "abcdefghijklmnop qrs", 10, "abcdefghij", true},
{"umlaute als eine rune", "Übergröße ärgert", 9, "Übergröße", true},
{"short stays", "Build done", 400, "Build done", false},
{"exactly max stays", "abcde", 5, "abcde", false},
{"unlimited", strings.Repeat("x", 1000), 0, strings.Repeat("x", 1000), false},
{"word boundary", "The build is done and ready", 14, "The build is", true},
{"punctuation dropped", "Done, but then there was more", 6, "Done", true},
{"no boundary in second half → hard cut", "abcdefghijklmnop qrs", 10, "abcdefghij", true},
{"umlauts count as one rune", "Übergröße ärgert", 9, "Übergröße", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -32,83 +32,83 @@ func TestTruncateText(t *testing.T) {
t.Errorf("truncateText(%q, %d) = %q/%v, want %q/%v", c.in, c.max, got, tr, c.want, c.truncated)
}
if c.max > 0 && len([]rune(got)) > c.max {
t.Errorf("Ergebnis %q länger als %d Runen", got, c.max)
t.Errorf("result %q longer than %d runes", got, c.max)
}
})
}
}
// TestEnqueueKuerztText: Das Gesetz MaxText greift beim Eintritt — die
// gekürzte Fassung wird gesprochen, Admission meldet Truncated.
func TestEnqueueKuerztText(t *testing.T) {
g := neueSpielerin()
// TestEnqueueTruncatesText: the MaxText law applies on admission; the
// shortened version is spoken and Admission reports Truncated.
func TestEnqueueTruncatesText(t *testing.T) {
g := newSpeaker()
q := New(Laws{MaxText: 20}, g.speak, nil)
defer q.Close()
adm, err := q.Enqueue(Item{Text: "Dieser Text ist deutlich länger als zwanzig Zeichen"})
adm, err := q.Enqueue(Item{Text: "This text is clearly longer than twenty characters"})
if err != nil || adm.ID == "" || !adm.Truncated {
t.Fatalf("Enqueue = %+v, %v; want aufgenommen + Truncated", adm, err)
t.Fatalf("Enqueue = %+v, %v; want admitted + Truncated", adm, err)
}
items := g.warteBis(t, 1)
if items[0].Text != "Dieser Text ist" {
t.Errorf("gesprochen = %q, want gekürzte Fassung", items[0].Text)
items := g.waitUntil(t, 1)
if items[0].Text != "This text is clearly" {
t.Errorf("spoken = %q, want the shortened version", items[0].Text)
}
g.step <- struct{}{}
adm, _ = q.Enqueue(Item{Text: "kurz"})
adm, _ = q.Enqueue(Item{Text: "short"})
if adm.Truncated {
t.Error("kurzer Text als gekürzt gemeldet")
t.Error("short text reported as truncated")
}
g.warteBis(t, 2)
g.waitUntil(t, 2)
g.step <- struct{}{}
}
// TestDecideExpired: Ein Item, das länger als MaxAge gewartet hat, wird
// verworfen — vor Ruhezeit und Rate-Limit, aber nach now (now bricht aus).
// TestDecideExpired: an item that waited longer than MaxAge is discarded,
// before quiet hours and rate limit but after now (now breaks out).
func TestDecideExpired(t *testing.T) {
laws := Laws{MaxAge: time.Minute, RateLimit: time.Hour}
now := time.Now()
alt := Item{Text: "alt", Priority: PriorityNormal, enqueued: now.Add(-2 * time.Minute)}
frisch := Item{Text: "frisch", Priority: PriorityNormal, enqueued: now.Add(-10 * time.Second)}
old := Item{Text: "old", Priority: PriorityNormal, enqueued: now.Add(-2 * time.Minute)}
fresh := Item{Text: "fresh", Priority: PriorityNormal, enqueued: now.Add(-10 * time.Second)}
if v := laws.decide(alt, now, true); v != verdictDiscardExpired {
t.Errorf("altes Item bei freiem Slot = %v, want verdictDiscardExpired", v)
if v := laws.decide(old, now, true); v != verdictDiscardExpired {
t.Errorf("old item with open slot = %v, want verdictDiscardExpired", v)
}
if v := laws.decide(frisch, now, true); v != verdictPlay {
t.Errorf("frisches Item bei freiem Slot = %v, want verdictPlay", v)
if v := laws.decide(fresh, now, true); v != verdictPlay {
t.Errorf("fresh item with open slot = %v, want verdictPlay", v)
}
if v := laws.decide(frisch, now, false); v != verdictWait {
t.Errorf("frisches Item ohne Slot = %v, want verdictWait", v)
if v := laws.decide(fresh, now, false); v != verdictWait {
t.Errorf("fresh item without slot = %v, want verdictWait", v)
}
altNow := alt
altNow.Now = true
if v := laws.decide(altNow, now, false); v != verdictPlay {
t.Errorf("altes now-Item = %v, want verdictPlay (now bricht aus)", v)
oldNow := old
oldNow.Now = true
if v := laws.decide(oldNow, now, false); v != verdictPlay {
t.Errorf("old now item = %v, want verdictPlay (now breaks out)", v)
}
if v := (Laws{}).decide(alt, now, true); v != verdictPlay {
t.Errorf("MaxAge 0 (unbegrenzt) verwirft altes Item: %v", v)
if v := (Laws{}).decide(old, now, true); v != verdictPlay {
t.Errorf("MaxAge 0 (unlimited) discards old item: %v", v)
}
}
// TestGeparktesItemVerfaellt: Ein normal-Item parkt auf den Rate-Limit-Slot;
// ist es beim Fälligwerden älter als MaxAge, wird es verworfen (Protokoll
// nennt max_age) und nicht mehr gesprochen.
func TestGeparktesItemVerfaellt(t *testing.T) {
// TestParkedItemExpires: a normal item parks for the rate-limit slot; if it
// is older than MaxAge when it comes due, it is discarded (log names
// max_age) and never spoken.
func TestParkedItemExpires(t *testing.T) {
logb := &syncLog{}
g := neueSpielerin()
g := newSpeaker()
q := New(Laws{RateLimit: 300 * time.Millisecond, MaxAge: 150 * time.Millisecond}, g.speak, logb)
defer q.Close()
q.Enqueue(Item{Text: "erste"}) // bucht das Fenster
g.warteBis(t, 1)
q.Enqueue(Item{Text: "first"}) // books the window
g.waitUntil(t, 1)
g.step <- struct{}{}
adm, _ := q.Enqueue(Item{Text: "parkt zu lange"}) // parkt ~300 ms > MaxAge
adm, _ := q.Enqueue(Item{Text: "parks too long"}) // parks ~300 ms > MaxAge
if adm.ID == "" || adm.Discarded != "" {
t.Fatalf("beim Eintritt darf nichts verfallen: %+v", adm)
t.Fatalf("nothing may expire on admission: %+v", adm)
}
waitFor(t, func() bool { return strings.Contains(logb.String(), "max_age") })
g.keineWeitere(t)
g.noFurther(t)
if !strings.Contains(logb.String(), adm.ID) {
t.Errorf("Protokoll nennt die verfallene Ansage nicht:\n%s", logb.String())
t.Errorf("log does not name the expired announcement:\n%s", logb.String())
}
}