Files
talk/test/cli_test.go
m3ta-chironandClaude Fable 5.1 85fa64cfe6 refactor: switch the whole project to English
talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.

Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.

Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 11:15:35 +02:00

291 lines
9.7 KiB
Go

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