2026-10-18 17:10:36 +02:00
|
|
|
package test
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-18 17:10:36 +02:00
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 250*time.Millisecond)
|
|
|
|
|
d := startDaemon(t, root, fake.srv.URL, nil)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
code, first := speak(t, d, `{"text":"first announcement"}`)
|
2026-10-18 17:10:36 +02:00
|
|
|
if code != 202 || first["id"] == "" || first["muted"] == true {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("first: status=%d body=%v, want 202 with id", code, first)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 17:10:36 +02:00
|
|
|
fake.waitArrived(t, 1)
|
2026-10-24 11:15:35 +02:00
|
|
|
speak(t, d, `{"text":"second announcement"}`)
|
|
|
|
|
speak(t, d, `{"text":"third announcement"}`)
|
2026-10-18 17:10:36 +02:00
|
|
|
|
|
|
|
|
hits := fake.waitHits(t, 3)
|
|
|
|
|
|
|
|
|
|
var texts []string
|
|
|
|
|
for _, h := range hits {
|
|
|
|
|
texts = append(texts, h.Text)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if strings.Join(texts, "|") != "first announcement|second announcement|third announcement" {
|
|
|
|
|
t.Errorf("playback order = %v, want FIFO", texts)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
// Serialization: the next synthesis starts only after the response to
|
|
|
|
|
// the previous one (small tolerance for clock jitter).
|
2026-10-18 17:10:36 +02:00
|
|
|
const slack = 25 * time.Millisecond
|
|
|
|
|
for i := 1; i < len(hits); i++ {
|
|
|
|
|
if hits[i].Start.Before(hits[i-1].End.Add(-slack)) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("announcement %d overlaps announcement %d: start %v < end %v", i, i-1, hits[i].Start, hits[i-1].End)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Wait for idle first: the third discard line appears daemon-side only
|
|
|
|
|
// shortly AFTER the response recorded at the fake.
|
2026-10-18 17:10:36 +02:00
|
|
|
waitStatusIdle(t, d, 3*time.Second)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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())
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestPriority: during a long announcement low/normal/high are enqueued —
|
|
|
|
|
// processing follows high > normal > low (FIFO per level).
|
|
|
|
|
func TestPriority(t *testing.T) {
|
2026-10-18 17:10:36 +02:00
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 200*time.Millisecond)
|
|
|
|
|
d := startDaemon(t, root, fake.srv.URL, nil)
|
|
|
|
|
|
|
|
|
|
speak(t, d, `{"text":"blocker"}`)
|
2026-10-24 11:15:35 +02:00
|
|
|
fake.waitArrived(t, 1) // blocker running, worker bound.
|
2026-10-18 17:10:36 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Enqueued in mixed class order; the queue view reports IDs — so
|
|
|
|
|
// collect text→id and check the order via IDs.
|
2026-10-18 17:10:36 +02:00
|
|
|
enq := make(map[string]string)
|
|
|
|
|
for _, tc := range []struct{ body, text string }{
|
2026-10-24 11:15:35 +02:00
|
|
|
{`{"text":"low one","priority":"low"}`, "low one"},
|
|
|
|
|
{`{"text":"normal-one"}`, "normal-one"},
|
|
|
|
|
{`{"text":"normal-two"}`, "normal-two"},
|
|
|
|
|
{`{"text":"urgent","priority":"high"}`, "urgent"},
|
2026-10-18 17:10:36 +02:00
|
|
|
} {
|
|
|
|
|
code, m := speak(t, d, tc.body)
|
|
|
|
|
if code != 202 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("speak %s: status %d", tc.body, code)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
id, _ := m["id"].(string)
|
|
|
|
|
if id == "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("speak %s: no ID in %v", tc.body, m)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
enq[tc.text] = id
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
wantOrder := []string{"urgent", "normal-one", "normal-two", "low one"}
|
2026-10-18 17:10:36 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Status shows the current item and the waiting ones in processing
|
|
|
|
|
// order (deterministic: the blocker holds the worker).
|
2026-10-18 17:10:36 +02:00
|
|
|
st := getStatus(t, d)
|
|
|
|
|
if st.Current == nil || st.Current.Text != "blocker" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("current = %+v, want blocker", st.Current)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
if st.Queue != 4 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("queue length = %d, want 4", st.Queue)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
var wantIDs []string
|
|
|
|
|
for _, text := range wantOrder {
|
|
|
|
|
wantIDs = append(wantIDs, enq[text])
|
|
|
|
|
}
|
|
|
|
|
if fmt.Sprint(st.QueueIDs) != fmt.Sprint(wantIDs) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("queue_ids = %v, want %v (processing order)", st.QueueIDs, wantIDs)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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, "|") {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("playback order = %v, want %v", texts, want)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
waitStatusIdle(t, d, 3*time.Second)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-18 17:10:36 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
speak(t, d, `{"text":"later"}`)
|
2026-10-18 17:10:36 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
res := runTalk(t, env, "say", "--now", "right away")
|
2026-10-18 17:10:36 +02:00
|
|
|
if res.code != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("talk say --now: exit %d, stderr: %s", res.code, res.stderr)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
if id := strings.TrimSpace(res.stdout); id == "" || strings.Contains(id, "\n") {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("talk say --now stdout = %q, want one line with the item ID", res.stdout)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hits := fake.waitHits(t, 3)
|
|
|
|
|
var texts []string
|
|
|
|
|
for _, h := range hits {
|
|
|
|
|
texts = append(texts, h.Text)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
waitStatusIdle(t, d, 3*time.Second)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestCancel: aborts the running announcement (synthesis request is
|
|
|
|
|
// cancelled), empties the queue, reports the total count — and the daemon
|
|
|
|
|
// remains functional afterwards.
|
2026-10-18 17:10:36 +02:00
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("speak: status %d", code)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
fake.waitArrived(t, 1) // blocker is stuck in synthesis (5 s fake delay).
|
2026-10-18 17:10:36 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
code, second := speak(t, d, `{"text":"waiting"}`)
|
2026-10-18 17:10:36 +02:00
|
|
|
if code != 202 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("speak: status %d", code)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Status contract: current item + one waiting.
|
2026-10-18 17:10:36 +02:00
|
|
|
st := getStatus(t, d)
|
|
|
|
|
if st.Current == nil || st.Current.ID != first["id"] {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("current = %+v, want the blocker (%v)", st.Current, first["id"])
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
if st.Queue != 1 || len(st.QueueIDs) != 1 || st.QueueIDs[0] != second["id"] {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("queue = %v (%d), want the waiting item", st.QueueIDs, st.Queue)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
code, m := postPath(t, d, "/cancel")
|
|
|
|
|
if code != 200 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("POST /cancel: status %d", code)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
if m["cancelled"] != float64(2) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("cancelled = %v, want 2 (running + waiting)", m["cancelled"])
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// The daemon becomes idle immediately (synthesis cancelled) and accepts
|
|
|
|
|
// new announcements — cancel does not damage the daemon.
|
2026-10-18 17:10:36 +02:00
|
|
|
waitStatusIdle(t, d, 2*time.Second)
|
|
|
|
|
fake.setDelay(50 * time.Millisecond)
|
2026-10-24 11:15:35 +02:00
|
|
|
speak(t, d, `{"text":"afterwards"}`)
|
2026-10-18 17:10:36 +02:00
|
|
|
hits := fake.waitCompleted(t, 1)
|
2026-10-24 11:15:35 +02:00
|
|
|
if hits[0].Text != "afterwards" {
|
|
|
|
|
t.Errorf("first completed hit = %q, want afterwards (cancelled ones do not count)", hits[0].Text)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
waitStatusIdle(t, d, 3*time.Second)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestSpeakValidation: the HTTP contract rejects broken requests with 400
|
|
|
|
|
// (and 405 for the wrong method) without damaging the daemon.
|
|
|
|
|
func TestSpeakValidation(t *testing.T) {
|
2026-10-18 17:10:36 +02:00
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 0)
|
|
|
|
|
d := startDaemon(t, root, fake.srv.URL, nil)
|
|
|
|
|
|
|
|
|
|
cases := []struct {
|
|
|
|
|
name string
|
|
|
|
|
body string
|
|
|
|
|
}{
|
2026-10-24 11:15:35 +02:00
|
|
|
{"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"}`},
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
code, m := speak(t, d, tc.body)
|
|
|
|
|
if code != 400 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("status = %d, want 400 (body: %v)", code, m)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
if _, ok := m["error"]; !ok {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("response without error field: %v", m)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Wrong method → 405 (method-bound routes).
|
2026-10-18 17:10:36 +02:00
|
|
|
resp, err := http.Post(d.baseURL+"/status", "application/json", nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
resp.Body.Close()
|
|
|
|
|
if resp.StatusCode != http.StatusMethodNotAllowed {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("POST /status: status = %d, want 405", resp.StatusCode)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Afterwards the daemon is still healthy and speakable.
|
|
|
|
|
code, m := speak(t, d, `{"text":"healthy"}`)
|
2026-10-18 17:10:36 +02:00
|
|
|
if code != 202 || m["id"] == "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("after validation errors: speak = %d %v, want 202 with id", code, m)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
fake.waitHits(t, 1)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestStatusContract: fields of the status response when idle (version,
|
|
|
|
|
// mute off, no current, provider resolved).
|
|
|
|
|
func TestStatusContract(t *testing.T) {
|
2026-10-18 17:10:36 +02:00
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 0)
|
|
|
|
|
d := startDaemon(t, root, fake.srv.URL, nil)
|
|
|
|
|
|
|
|
|
|
st := getStatus(t, d)
|
|
|
|
|
if st.Version != "dev" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("version = %q, want dev (built without ldflags)", st.Version)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
if st.Muted {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("muted = true on a fresh start, want false")
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
if st.Current != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("current = %+v, want null when idle", st.Current)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
if st.Queue != 0 {
|
|
|
|
|
t.Errorf("queue = %d, want 0", st.Queue)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
// Active provider: elevenlabs first, because key + fake endpoint are set.
|
2026-10-18 17:10:36 +02:00
|
|
|
if st.Provider != "elevenlabs" {
|
|
|
|
|
t.Errorf("provider = %q, want elevenlabs", st.Provider)
|
|
|
|
|
}
|
|
|
|
|
}
|