Files
talk/test/queue_test.go
T
m3ta-chironandClaude Fable 5.1 85fa64cfe6 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>
2026-10-24 11:15:35 +02:00

274 lines
8.8 KiB
Go

package test
import (
"fmt"
"net/http"
"strings"
"testing"
"time"
)
// 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":"first announcement"}`)
if code != 202 || first["id"] == "" || first["muted"] == true {
t.Fatalf("first: status=%d body=%v, want 202 with id", code, first)
}
// 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":"second announcement"}`)
speak(t, d, `{"text":"third announcement"}`)
hits := fake.waitHits(t, 3)
var texts []string
for _, h := range hits {
texts = append(texts, h.Text)
}
if strings.Join(texts, "|") != "first announcement|second announcement|third announcement" {
t.Errorf("playback order = %v, want FIFO", texts)
}
// 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("announcement %d overlaps announcement %d: start %v < end %v", i, i-1, hits[i].Start, hits[i-1].End)
}
}
// 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)
// 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())
}
}
// 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 running, worker bound.
// 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":"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)
}
id, _ := m["id"].(string)
if id == "" {
t.Fatalf("speak %s: no ID in %v", tc.body, m)
}
enq[tc.text] = id
}
wantOrder := []string{"urgent", "normal-one", "normal-two", "low one"}
// 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, want blocker", st.Current)
}
if st.Queue != 4 {
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 (processing order)", st.QueueIDs, wantIDs)
}
hits := fake.waitHits(t, 5)
var texts []string
for _, h := range hits {
texts = append(texts, h.Text)
}
want := append([]string{"blocker"}, wantOrder...)
if strings.Join(texts, "|") != strings.Join(want, "|") {
t.Errorf("playback order = %v, want %v", texts, want)
}
waitStatusIdle(t, d, 3*time.Second)
}
// 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)
env := daemonEnv(root, d.port, fake.srv.URL, nil)
speak(t, d, `{"text":"blocker"}`)
fake.waitArrived(t, 1)
speak(t, d, `{"text":"later"}`)
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)
}
if id := strings.TrimSpace(res.stdout); id == "" || strings.Contains(id, "\n") {
t.Errorf("talk say --now stdout = %q, want one line with the item ID", res.stdout)
}
hits := fake.waitHits(t, 3)
var texts []string
for _, h := range hits {
texts = append(texts, h.Text)
}
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: 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)
d := startDaemon(t, root, fake.srv.URL, nil)
code, first := speak(t, d, `{"text":"blocker"}`)
if code != 202 {
t.Fatalf("speak: status %d", code)
}
fake.waitArrived(t, 1) // blocker is stuck in synthesis (5 s fake delay).
code, second := speak(t, d, `{"text":"waiting"}`)
if code != 202 {
t.Fatalf("speak: status %d", code)
}
// Status contract: current item + one waiting.
st := getStatus(t, d)
if st.Current == nil || st.Current.ID != 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), want the waiting item", st.QueueIDs, st.Queue)
}
code, m := postPath(t, d, "/cancel")
if code != 200 {
t.Fatalf("POST /cancel: status %d", code)
}
if m["cancelled"] != float64(2) {
t.Fatalf("cancelled = %v, want 2 (running + waiting)", m["cancelled"])
}
// 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":"afterwards"}`)
hits := fake.waitCompleted(t, 1)
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)
}
// 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)
cases := []struct {
name string
body string
}{
{"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)
}
if _, ok := m["error"]; !ok {
t.Errorf("response without error field: %v", m)
}
})
}
// 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)
}
// Afterwards the daemon is still healthy and speakable.
code, m := speak(t, d, `{"text":"healthy"}`)
if code != 202 || m["id"] == "" {
t.Fatalf("after validation errors: speak = %d %v, want 202 with id", code, m)
}
fake.waitHits(t, 1)
}
// 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 (built without ldflags)", st.Version)
}
if st.Muted {
t.Error("muted = true on a fresh start, want false")
}
if st.Current != nil {
t.Errorf("current = %+v, want null when idle", st.Current)
}
if st.Queue != 0 {
t.Errorf("queue = %d, want 0", st.Queue)
}
// Active provider: elevenlabs first, because key + fake endpoint are set.
if st.Provider != "elevenlabs" {
t.Errorf("provider = %q, want elevenlabs", st.Provider)
}
}