package test import ( "os" "path/filepath" "strings" "sync" "testing" "time" ) // 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. 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() res := runTalk(t, env, "say", "long-running announcement") elapsed := time.Since(start) if res.code != 0 { t.Fatalf("talk say: exit %d, stderr: %s", res.code, res.stderr) } if elapsed >= 500*time.Millisecond { t.Fatalf("talk say was blocking (%s; announcement takes 600 ms) — the default must be non-blocking", elapsed) } id := strings.TrimSpace(res.stdout) if id == "" || strings.Contains(id, "\n") { t.Errorf("stdout = %q, want exactly one line with the item ID", res.stdout) } // The announcement still runs to completion (the daemon owns it now). hits := fake.waitHits(t, 1) if hits[0].Text != "long-running announcement" { t.Errorf("hit = %q", hits[0].Text) } waitStatusIdle(t, d, 3*time.Second) } // TestSayWait: `talk say --wait` returns only after the announcement ends. 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() res := runTalk(t, env, "say", "--wait", "wait for me") elapsed := time.Since(start) if res.code != 0 { t.Fatalf("talk say --wait: exit %d, stderr: %s", res.code, res.stderr) } if elapsed < 350*time.Millisecond { t.Fatalf("talk say --wait returned too early (%s; announcement takes 400 ms)", elapsed) } if hits := fake.waitHits(t, 1); hits[0].Text != "wait for me" { t.Errorf("hit = %q", hits[0].Text) } waitStatusIdle(t, d, 3*time.Second) } // 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) { root := t.TempDir() fake := newFakeProvider(t, 0) env := daemonEnv(root, 1, fake.srv.URL, nil) // port 1: daemon guaranteed dead res := runTalk(t, env, "say", "offline announcement") if res.code != 0 { t.Fatalf("talk say (direct): exit %d, stderr: %s", res.code, res.stderr) } if hits := fake.waitHits(t, 1); hits[0].Text != "offline announcement" { t.Errorf("hit = %q", hits[0].Text) } if !strings.Contains(res.stderr, "speaking directly") { t.Errorf("stderr = %q, want the direct-path note (nothing fails silently)", res.stderr) } if strings.Contains(res.stderr, "daemon started") { t.Errorf("stderr = %q — autostart was off, no daemon may be started", res.stderr) } } // 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) { root := t.TempDir() fake := newFakeProvider(t, 0) env := daemonEnv(root, 1, fake.srv.URL, nil) // Mute file written straight into the config dir (state "a daemon was // here once"). 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) } res := runTalk(t, env, "say", "should stay silent") if res.code != 0 { t.Fatalf("talk say (mute on): exit %d, stderr: %s", res.code, res.stderr) } if res.stdout != "muted\n" { t.Errorf("stdout = %q, want muted", res.stdout) } fake.assertNoHits(t, 300*time.Millisecond) // Unmute (offline) → plays again. res = runTalk(t, env, "unmute") if res.code != 0 { t.Fatalf("talk unmute (offline): exit %d, stderr: %s", res.code, res.stderr) } res = runTalk(t, env, "say", "back again") if res.code != 0 { t.Fatalf("talk say (mute off): exit %d, stderr: %s", res.code, res.stderr) } if hits := fake.waitHits(t, 1); hits[0].Text != "back again" { t.Errorf("hit = %q", hits[0].Text) } } // TestCliMuteUnmuteStatusCancel: the control commands as daemon calls with // plain output — mute/unmute as a token, status as a block, cancel with a // number. 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) } // say while muted: discarded, reported plainly, no provider contact. res = runTalk(t, env, "say", "silence") if res.code != 0 || res.stdout != "muted\n" { t.Fatalf("talk say (mute on): %d %q — stderr: %s", res.code, res.stdout, res.stderr) } fake.assertNoHits(t, 300*time.Millisecond) res = runTalk(t, env, "status") if res.code != 0 { t.Fatalf("talk status: exit %d — stderr: %s", res.code, res.stderr) } for _, want := range []string{"version dev", "queue: 0", "mute: on", "provider: elevenlabs"} { if !strings.Contains(res.stdout, want) { t.Errorf("talk status stdout = %q, want %q", res.stdout, want) } } 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" { t.Fatalf("talk cancel (empty): %d %q — stderr: %s", res.code, res.stdout, res.stderr) } res = runTalk(t, env, "status") if !strings.Contains(res.stdout, "mute: off") { t.Errorf("talk status after unmute = %q, want mute off", res.stdout) } } // TestCliCancelWithQueue: cancel via the CLI aborts the running + the // waiting announcement and reports the count. func TestCliCancelWithQueue(t *testing.T) { 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) runTalk(t, env, "say", "waiting for cancel") // non-blocking enqueue 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) speak(t, d, `{"text":"afterwards"}`) hits := fake.waitCompleted(t, 1) if hits[0].Text != "afterwards" { t.Errorf("hit = %q, want afterwards", hits[0].Text) } } // 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) { 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 for i, text := range []string{"first", "second", "third"} { 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 { t.Fatalf("parallel talk call %d: exit %d — stderr: %s", i, res.code, res.stderr) } } hits := fake.waitCompleted(t, 3) played := map[string]bool{} for _, h := range hits { played[h.Text] = true } for _, text := range []string{"first", "second", "third"} { if !played[text] { t.Errorf("announcement %q missing (hits: %+v)", text, hits) } } 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("announcements %d/%d overlapped: start %v < end %v", i-1, i, hits[i].Start, hits[i-1].End) } } waitStatusIdle(t, d, 3*time.Second) } // TestCliControlCommandsWithoutDaemon: status/cancel without a daemon are // clear errors (no silent failure) — say, by contrast, speaks directly. func TestCliControlCommandsWithoutDaemon(t *testing.T) { root := t.TempDir() fake := newFakeProvider(t, 0) env := daemonEnv(root, 1, fake.srv.URL, nil) 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) } 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) } } // TestSayDefaultPathLikeSay: `talk "text"` takes the same daemon path as // `talk say "text"` (flags before and after the text). func TestSayDefaultPathLikeSay(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, "default call") if res.code != 0 { t.Fatalf("talk \"text\": exit %d — stderr: %s", res.code, res.stderr) } if id := strings.TrimSpace(res.stdout); id == "" { t.Errorf("stdout = %q, want item ID", res.stdout) } // Flag after the text (parseInterspersed) on the daemon path. res = runTalk(t, env, "another text", "--lang=en") if res.code != 0 { t.Fatalf("talk text --lang=en: exit %d — stderr: %s", res.code, res.stderr) } hits := fake.waitHits(t, 2) if !strings.Contains(hits[1].Text, "another text") { t.Errorf("second hit = %q", hits[1].Text) } waitStatusIdle(t, d, 3*time.Second) }