Files

290 lines
9.7 KiB
Go
Raw Permalink Normal View History

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)
}