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>
120 lines
4.2 KiB
Go
120 lines
4.2 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
|
)
|
|
|
|
// These tests cover the control commands without a daemon (in-process via
|
|
// the run seam; TALK_PORT=1 from the setGetenv base makes daemon detection
|
|
// fail immediately). Behaviour WITH a daemon is covered by the process
|
|
// boundary tests in /test.
|
|
|
|
// TestStatusWithoutDaemon: `talk status` without a daemon is a clear error
|
|
// (exit 1) with a note on stderr, and stdout still names the state as a
|
|
// status line so the block is never empty. status does not start the
|
|
// daemon.
|
|
func TestStatusWithoutDaemon(t *testing.T) {
|
|
setGetenv(t, nil)
|
|
var out, errBuf bytes.Buffer
|
|
code := runCLI([]string{"status"}, &out, &errBuf)
|
|
if code != exitFailure {
|
|
t.Fatalf("talk status without daemon: exit code = %d, want %d", code, exitFailure)
|
|
}
|
|
if !strings.Contains(errBuf.String(), "no daemon") {
|
|
t.Errorf("stderr = %q, want a note about the missing daemon", errBuf.String())
|
|
}
|
|
if !strings.HasPrefix(out.String(), "daemon: not running") {
|
|
t.Errorf("stdout = %q, want the status line \"not running\"", out.String())
|
|
}
|
|
}
|
|
|
|
// TestCancelWithoutDaemon: without a daemon there is nothing to cancel; a
|
|
// clear error.
|
|
func TestCancelWithoutDaemon(t *testing.T) {
|
|
setGetenv(t, nil)
|
|
var out, errBuf bytes.Buffer
|
|
code := runCLI([]string{"cancel"}, &out, &errBuf)
|
|
if code != exitFailure {
|
|
t.Fatalf("talk cancel without daemon: exit code = %d, want %d", code, exitFailure)
|
|
}
|
|
if !strings.Contains(errBuf.String(), "nothing to cancel") {
|
|
t.Errorf("stderr = %q, want a note", errBuf.String())
|
|
}
|
|
}
|
|
|
|
// TestMuteWithoutDaemonWritesFile: `talk mute` without a daemon writes the
|
|
// mute file directly, and the direct path of say honours exactly that file.
|
|
func TestMuteWithoutDaemonWritesFile(t *testing.T) {
|
|
root := t.TempDir()
|
|
setGetenv(t, map[string]string{"XDG_CONFIG_HOME": root})
|
|
mutePath := filepath.Join(root, "talk", queue.MuteFileName)
|
|
|
|
var out, errBuf bytes.Buffer
|
|
if code := runCLI([]string{"mute"}, &out, &errBuf); code != exitOK {
|
|
t.Fatalf("talk mute without daemon: exit code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
|
|
}
|
|
if got := out.String(); got != "muted\n" {
|
|
t.Errorf("talk mute stdout = %q, want %q", got, "muted\n")
|
|
}
|
|
if !strings.Contains(errBuf.String(), "directly") {
|
|
t.Errorf("stderr = %q, want a note about writing the file directly", errBuf.String())
|
|
}
|
|
b, err := os.ReadFile(mutePath)
|
|
if err != nil {
|
|
t.Fatalf("mute file missing: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(b)) != "true" {
|
|
t.Errorf("mute file = %q, want true", b)
|
|
}
|
|
|
|
// say on the direct path honours the same file (discarded silently,
|
|
// without needing any provider).
|
|
out.Reset()
|
|
errBuf.Reset()
|
|
if code := runCLI([]string{"say", "quiet please"}, &out, &errBuf); code != exitOK {
|
|
t.Fatalf("talk say (muted): exit code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
|
|
}
|
|
if got := out.String(); got != "muted\n" {
|
|
t.Errorf("talk say (muted) stdout = %q, want %q", got, "muted\n")
|
|
}
|
|
|
|
// unmute lifts it (file false).
|
|
out.Reset()
|
|
errBuf.Reset()
|
|
if code := runCLI([]string{"unmute"}, &out, &errBuf); code != exitOK {
|
|
t.Fatalf("talk unmute: exit code = %d (stderr: %q)", code, errBuf.String())
|
|
}
|
|
if got := out.String(); got != "unmuted\n" {
|
|
t.Errorf("talk unmute stdout = %q, want %q", got, "unmuted\n")
|
|
}
|
|
b, err = os.ReadFile(mutePath)
|
|
if err != nil || strings.TrimSpace(string(b)) != "false" {
|
|
t.Errorf("mute file after unmute = %q (%v), want false", b, err)
|
|
}
|
|
}
|
|
|
|
// TestSayWithoutConfigDir: the direct path without a config directory (no
|
|
// HOME) keeps speaking instead of failing; best effort without mute.
|
|
func TestSayWithoutConfigDir(t *testing.T) {
|
|
seen := withFakeEspeak(t)
|
|
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
|
|
|
var out, errBuf bytes.Buffer
|
|
code := runCLI([]string{"Hello"}, &out, &errBuf)
|
|
if code != exitOK {
|
|
t.Fatalf("talk without HOME: exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
|
}
|
|
if _, err := os.Stat(seen); err != nil {
|
|
t.Errorf("fake espeak not called: %v", err)
|
|
}
|
|
if !strings.Contains(errBuf.String(), "speaking directly") {
|
|
t.Errorf("stderr = %q, want the direct-path note", errBuf.String())
|
|
}
|
|
}
|