2026-10-18 17:10:36 +02:00
|
|
|
package test
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestSayNonBlocking (acceptance criterion non-blocking): `talk say` returns
|
|
|
|
|
// well before the announcement ends (exit 0, item ID on stdout); the
|
|
|
|
|
// announcement keeps running to completion in the daemon.
|
2026-10-18 17:10:36 +02:00
|
|
|
func TestSayNonBlocking(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)
|
|
|
|
|
|
|
|
|
|
start := time.Now()
|
2026-10-24 11:15:35 +02:00
|
|
|
res := runTalk(t, env, "say", "long-running announcement")
|
2026-10-18 17:10:36 +02:00
|
|
|
elapsed := time.Since(start)
|
|
|
|
|
|
|
|
|
|
if res.code != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("talk say: exit %d, stderr: %s", res.code, res.stderr)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
if elapsed >= 500*time.Millisecond {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("talk say was blocking (%s; announcement takes 600 ms) — the default must be non-blocking", elapsed)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
id := strings.TrimSpace(res.stdout)
|
|
|
|
|
if id == "" || strings.Contains(id, "\n") {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("stdout = %q, want exactly one line with the item ID", res.stdout)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// The announcement still runs to completion (the daemon owns it now).
|
2026-10-18 17:10:36 +02:00
|
|
|
hits := fake.waitHits(t, 1)
|
2026-10-24 11:15:35 +02:00
|
|
|
if hits[0].Text != "long-running announcement" {
|
|
|
|
|
t.Errorf("hit = %q", 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
|
|
|
// TestSayWait: `talk say --wait` returns only after the announcement ends.
|
2026-10-18 17:10:36 +02:00
|
|
|
func TestSayWait(t *testing.T) {
|
|
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 400*time.Millisecond)
|
|
|
|
|
d := startDaemon(t, root, fake.srv.URL, nil)
|
|
|
|
|
env := daemonEnv(root, d.port, fake.srv.URL, nil)
|
|
|
|
|
|
|
|
|
|
start := time.Now()
|
2026-10-24 11:15:35 +02:00
|
|
|
res := runTalk(t, env, "say", "--wait", "wait for me")
|
2026-10-18 17:10:36 +02:00
|
|
|
elapsed := time.Since(start)
|
|
|
|
|
|
|
|
|
|
if res.code != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("talk say --wait: exit %d, stderr: %s", res.code, res.stderr)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
if elapsed < 350*time.Millisecond {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("talk say --wait returned too early (%s; announcement takes 400 ms)", elapsed)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if hits := fake.waitHits(t, 1); hits[0].Text != "wait for me" {
|
|
|
|
|
t.Errorf("hit = %q", 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
|
|
|
// TestSayWithoutDaemonSpeaksDirectly: daemon dead and autostart off → talk
|
|
|
|
|
// speaks directly in its own process (here: against the fake provider) and
|
|
|
|
|
// says so on stderr — nothing fails silently.
|
|
|
|
|
func TestSayWithoutDaemonSpeaksDirectly(t *testing.T) {
|
2026-10-18 17:10:36 +02:00
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 0)
|
2026-10-24 11:15:35 +02:00
|
|
|
env := daemonEnv(root, 1, fake.srv.URL, nil) // port 1: daemon guaranteed dead
|
2026-10-18 17:10:36 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
res := runTalk(t, env, "say", "offline announcement")
|
2026-10-18 17:10:36 +02:00
|
|
|
if res.code != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("talk say (direct): exit %d, stderr: %s", res.code, res.stderr)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if hits := fake.waitHits(t, 1); hits[0].Text != "offline announcement" {
|
|
|
|
|
t.Errorf("hit = %q", hits[0].Text)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if !strings.Contains(res.stderr, "speaking directly") {
|
|
|
|
|
t.Errorf("stderr = %q, want the direct-path note (nothing fails silently)", res.stderr)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if strings.Contains(res.stderr, "daemon started") {
|
|
|
|
|
t.Errorf("stderr = %q — autostart was off, no daemon may be started", res.stderr)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestSayWithoutDaemonRespectsMute: without a daemon the direct path
|
|
|
|
|
// discards announcements silently according to the mute file (and plays
|
|
|
|
|
// again after unmute).
|
|
|
|
|
func TestSayWithoutDaemonRespectsMute(t *testing.T) {
|
2026-10-18 17:10:36 +02:00
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 0)
|
|
|
|
|
env := daemonEnv(root, 1, fake.srv.URL, nil)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Mute file written straight into the config dir (state "a daemon was
|
|
|
|
|
// here once").
|
2026-10-18 17:10:36 +02:00
|
|
|
if err := os.MkdirAll(configDir(root), 0o755); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.WriteFile(filepath.Join(configDir(root), "mute"), []byte("true\n"), 0o644); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
res := runTalk(t, env, "say", "should stay silent")
|
2026-10-18 17:10:36 +02:00
|
|
|
if res.code != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("talk say (mute on): exit %d, stderr: %s", res.code, res.stderr)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
if res.stdout != "muted\n" {
|
|
|
|
|
t.Errorf("stdout = %q, want muted", res.stdout)
|
|
|
|
|
}
|
|
|
|
|
fake.assertNoHits(t, 300*time.Millisecond)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Unmute (offline) → plays again.
|
2026-10-18 17:10:36 +02:00
|
|
|
res = runTalk(t, env, "unmute")
|
|
|
|
|
if res.code != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("talk unmute (offline): exit %d, stderr: %s", res.code, res.stderr)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
res = runTalk(t, env, "say", "back again")
|
2026-10-18 17:10:36 +02:00
|
|
|
if res.code != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("talk say (mute off): exit %d, stderr: %s", res.code, res.stderr)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if hits := fake.waitHits(t, 1); hits[0].Text != "back again" {
|
|
|
|
|
t.Errorf("hit = %q", hits[0].Text)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestCliMuteUnmuteStatusCancel: the control commands as daemon calls with
|
|
|
|
|
// plain output — mute/unmute as a token, status as a block, cancel with a
|
|
|
|
|
// number.
|
2026-10-18 17:10:36 +02:00
|
|
|
func TestCliMuteUnmuteStatusCancel(t *testing.T) {
|
|
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 0)
|
|
|
|
|
d := startDaemon(t, root, fake.srv.URL, nil)
|
|
|
|
|
env := daemonEnv(root, d.port, fake.srv.URL, nil)
|
|
|
|
|
|
|
|
|
|
res := runTalk(t, env, "mute")
|
|
|
|
|
if res.code != 0 || res.stdout != "muted\n" {
|
|
|
|
|
t.Fatalf("talk mute: %d %q — stderr: %s", res.code, res.stdout, res.stderr)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// say while muted: discarded, reported plainly, no provider contact.
|
|
|
|
|
res = runTalk(t, env, "say", "silence")
|
2026-10-18 17:10:36 +02:00
|
|
|
if res.code != 0 || res.stdout != "muted\n" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("talk say (mute on): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
fake.assertNoHits(t, 300*time.Millisecond)
|
|
|
|
|
|
|
|
|
|
res = runTalk(t, env, "status")
|
|
|
|
|
if res.code != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("talk status: exit %d — stderr: %s", res.code, res.stderr)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
for _, want := range []string{"version dev", "queue: 0", "mute: on", "provider: elevenlabs"} {
|
2026-10-18 17:10:36 +02:00
|
|
|
if !strings.Contains(res.stdout, want) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("talk status stdout = %q, want %q", res.stdout, want)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res = runTalk(t, env, "unmute")
|
|
|
|
|
if res.code != 0 || res.stdout != "unmuted\n" {
|
|
|
|
|
t.Fatalf("talk unmute: %d %q — stderr: %s", res.code, res.stdout, res.stderr)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res = runTalk(t, env, "cancel")
|
|
|
|
|
if res.code != 0 || res.stdout != "cancelled: 0\n" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("talk cancel (empty): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res = runTalk(t, env, "status")
|
2026-10-24 11:15:35 +02:00
|
|
|
if !strings.Contains(res.stdout, "mute: off") {
|
|
|
|
|
t.Errorf("talk status after unmute = %q, want mute off", res.stdout)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestCliCancelWithQueue: cancel via the CLI aborts the running + the
|
|
|
|
|
// waiting announcement and reports the count.
|
|
|
|
|
func TestCliCancelWithQueue(t *testing.T) {
|
2026-10-18 17:10:36 +02:00
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 5*time.Second)
|
|
|
|
|
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
|
|
|
runTalk(t, env, "say", "waiting for cancel") // non-blocking enqueue
|
2026-10-18 17:10:36 +02:00
|
|
|
|
|
|
|
|
res := runTalk(t, env, "cancel")
|
|
|
|
|
if res.code != 0 || res.stdout != "cancelled: 2\n" {
|
|
|
|
|
t.Fatalf("talk cancel: %d %q — stderr: %s", res.code, res.stdout, res.stderr)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fake.setDelay(50 * time.Millisecond)
|
|
|
|
|
waitStatusIdle(t, d, 2*time.Second)
|
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("hit = %q, want afterwards", hits[0].Text)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestThreeParallelTalkCalls (acceptance criterion Task 04): `talkd &` +
|
|
|
|
|
// three parallel `talk` calls → three announcements one after another. The
|
|
|
|
|
// enqueue order of the parallel processes is open — what is checked is that
|
|
|
|
|
// all three are played and NO two overlap.
|
|
|
|
|
func TestThreeParallelTalkCalls(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)
|
|
|
|
|
env := daemonEnv(root, d.port, fake.srv.URL, nil)
|
|
|
|
|
|
|
|
|
|
results := make([]talkResult, 3)
|
|
|
|
|
var wg sync.WaitGroup
|
2026-10-24 11:15:35 +02:00
|
|
|
for i, text := range []string{"first", "second", "third"} {
|
2026-10-18 17:10:36 +02:00
|
|
|
wg.Add(1)
|
|
|
|
|
go func(i int, text string) {
|
|
|
|
|
defer wg.Done()
|
|
|
|
|
results[i] = runTalk(t, env, "say", text)
|
|
|
|
|
}(i, text)
|
|
|
|
|
}
|
|
|
|
|
wg.Wait()
|
|
|
|
|
for i, res := range results {
|
|
|
|
|
if res.code != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("parallel talk call %d: exit %d — stderr: %s", i, res.code, res.stderr)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hits := fake.waitCompleted(t, 3)
|
|
|
|
|
played := map[string]bool{}
|
|
|
|
|
for _, h := range hits {
|
|
|
|
|
played[h.Text] = true
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
for _, text := range []string{"first", "second", "third"} {
|
2026-10-18 17:10:36 +02:00
|
|
|
if !played[text] {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("announcement %q missing (hits: %+v)", text, hits)
|
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("announcements %d/%d overlapped: start %v < end %v", i-1, i, hits[i].Start, hits[i-1].End)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
waitStatusIdle(t, d, 3*time.Second)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestCliControlCommandsWithoutDaemon: status/cancel without a daemon are
|
|
|
|
|
// clear errors (no silent failure) — say, by contrast, speaks directly.
|
|
|
|
|
func TestCliControlCommandsWithoutDaemon(t *testing.T) {
|
2026-10-18 17:10:36 +02:00
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 0)
|
|
|
|
|
env := daemonEnv(root, 1, fake.srv.URL, nil)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
if res := runTalk(t, env, "status"); res.code != 1 || !strings.Contains(res.stderr, "no daemon") || !strings.Contains(res.stdout, "not running") {
|
|
|
|
|
t.Errorf("talk status without daemon: %d stdout %q stderr %q", res.code, res.stdout, res.stderr)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if res := runTalk(t, env, "cancel"); res.code != 1 || !strings.Contains(res.stderr, "nothing to cancel") {
|
|
|
|
|
t.Errorf("talk cancel without daemon: %d %q", res.code, res.stderr)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestSayDefaultPathLikeSay: `talk "text"` takes the same daemon path as
|
|
|
|
|
// `talk say "text"` (flags before and after the text).
|
|
|
|
|
func TestSayDefaultPathLikeSay(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)
|
|
|
|
|
env := daemonEnv(root, d.port, fake.srv.URL, nil)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
res := runTalk(t, env, "default call")
|
2026-10-18 17:10:36 +02:00
|
|
|
if res.code != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("talk \"text\": exit %d — stderr: %s", res.code, res.stderr)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
if id := strings.TrimSpace(res.stdout); id == "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("stdout = %q, want item ID", res.stdout)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Flag after the text (parseInterspersed) on the daemon path.
|
|
|
|
|
res = runTalk(t, env, "another text", "--lang=en")
|
2026-10-18 17:10:36 +02:00
|
|
|
if res.code != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("talk text --lang=en: exit %d — stderr: %s", res.code, res.stderr)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hits := fake.waitHits(t, 2)
|
2026-10-24 11:15:35 +02:00
|
|
|
if !strings.Contains(hits[1].Text, "another text") {
|
|
|
|
|
t.Errorf("second hit = %q", hits[1].Text)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
waitStatusIdle(t, d, 3*time.Second)
|
|
|
|
|
}
|