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>
115 lines
4.0 KiB
Go
115 lines
4.0 KiB
Go
package queue
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// 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
|
|
in string
|
|
max int
|
|
want string
|
|
truncated bool
|
|
}{
|
|
{"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) {
|
|
got, tr := truncateText(c.in, c.max)
|
|
if got != c.want || tr != c.truncated {
|
|
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("result %q longer than %d runes", got, c.max)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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: "This text is clearly longer than twenty characters"})
|
|
if err != nil || adm.ID == "" || !adm.Truncated {
|
|
t.Fatalf("Enqueue = %+v, %v; want admitted + Truncated", adm, err)
|
|
}
|
|
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: "short"})
|
|
if adm.Truncated {
|
|
t.Error("short text reported as truncated")
|
|
}
|
|
g.waitUntil(t, 2)
|
|
g.step <- struct{}{}
|
|
}
|
|
|
|
// 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()
|
|
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(old, now, true); v != verdictDiscardExpired {
|
|
t.Errorf("old item with open slot = %v, want verdictDiscardExpired", 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(fresh, now, false); v != verdictWait {
|
|
t.Errorf("fresh item without slot = %v, want verdictWait", 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(old, now, true); v != verdictPlay {
|
|
t.Errorf("MaxAge 0 (unlimited) discards old item: %v", v)
|
|
}
|
|
}
|
|
|
|
// 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 := newSpeaker()
|
|
q := New(Laws{RateLimit: 300 * time.Millisecond, MaxAge: 150 * time.Millisecond}, g.speak, logb)
|
|
defer q.Close()
|
|
|
|
q.Enqueue(Item{Text: "first"}) // books the window
|
|
g.waitUntil(t, 1)
|
|
g.step <- struct{}{}
|
|
adm, _ := q.Enqueue(Item{Text: "parks too long"}) // parks ~300 ms > MaxAge
|
|
if adm.ID == "" || adm.Discarded != "" {
|
|
t.Fatalf("nothing may expire on admission: %+v", adm)
|
|
}
|
|
waitFor(t, func() bool { return strings.Contains(logb.String(), "max_age") })
|
|
g.noFurther(t)
|
|
if !strings.Contains(logb.String(), adm.ID) {
|
|
t.Errorf("log does not name the expired announcement:\n%s", logb.String())
|
|
}
|
|
}
|