Files
talk/test/queue_test.go
m3ta-chironandClaude Fable 5.1 00eb76128b feat(notify): Cancel button on Windows toasts via the talk: protocol handler
Windows toasts cannot call back into the daemon, so the Cancel button opens
a talk: link instead: `talk:cancel?id=<id>&port=<port>`. The daemon
registers the talk: URL protocol for the current user (HKCU, no admin) and
points it at `talk uri "%1"`; Windows starts that on the click, and `talk
uri` posts to the new endpoint POST /cancel-item, which cancels only the
announcement with that id (queue.CancelItem). The toast is tagged and
removed again when the announcement ends. For now the toast is shown under
Windows PowerShell's app identity, which unpackaged programs can use
without a Start Menu shortcut.

Verified on the Windows 11 VM: registry entry written by the daemon,
Start-Process "talk:cancel?id=…&port=7800" (what the button does) stops
the playing announcement within two seconds, no toast errors in the log.

Tests: `talk uri` against a real handler on a loopback port (playing vs
waiting id, dead daemon, talk:test, garbage links), POST /cancel-item
process test (only the playing item, waiting one plays next, 400 without
id).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 12:14:38 +02:00

310 lines
10 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)
}
}
// TestCancelItemEndpoint: POST /cancel-item cancels only the playing
// announcement with that id; waiting items stay and play next; a stale id
// yields cancelled:false, a missing id 400.
func TestCancelItemEndpoint(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 5*time.Second)
d := startDaemon(t, root, fake.srv.URL, nil)
_, first := speak(t, d, `{"text":"blocker"}`)
fake.waitArrived(t, 1)
_, second := speak(t, d, `{"text":"waiting"}`)
code, m := postJSON(t, d, "/cancel-item", `{"id":"`+second["id"].(string)+`"}`)
if code != 200 || m["cancelled"] != false {
t.Fatalf("cancel-item on the waiting id: %d %v, want cancelled:false", code, m)
}
code, m = postJSON(t, d, "/cancel-item", `{"id":"`+first["id"].(string)+`"}`)
if code != 200 || m["cancelled"] != true {
t.Fatalf("cancel-item on the playing id: %d %v, want cancelled:true", code, m)
}
hits := fake.waitHits(t, 1)
if hits[0].Completed {
t.Errorf("blocker synthesis completed although cancelled: %+v", hits[0])
}
// The waiting item plays next (the queue was not emptied).
fake.setDelay(50 * time.Millisecond)
done := fake.waitCompleted(t, 1)
if done[0].Text != "waiting" {
t.Errorf("next completed = %q, want the waiting announcement", done[0].Text)
}
if code, m := postJSON(t, d, "/cancel-item", `{}`); code != 400 || m["error"] == nil {
t.Errorf("cancel-item without id: %d %v, want 400 with error", code, m)
}
waitStatusIdle(t, d, 3*time.Second)
}