Files
talk/internal/provider/chain_test.go
T
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

395 lines
13 KiB
Go

package provider
import (
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
)
// fakePlayer records Play calls (the name drives the chain's null
// decision).
type fakePlayer struct {
name string
played [][]byte
}
func (f *fakePlayer) Play(_ context.Context, data []byte) error {
f.played = append(f.played, data)
return nil
}
func (f *fakePlayer) Name() string { return f.name }
// countingTTSServer responds with status + body and counts the requests
// (atomic; the server may see requests from several tiers).
func countingTTSServer(t *testing.T, status int, body []byte) (url string, hits *atomic.Int32) {
t.Helper()
var n atomic.Int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n.Add(1)
w.WriteHeader(status)
w.Write(body)
}))
t.Cleanup(ts.Close)
return ts.URL, &n
}
// chainEnv builds the Getenv seam for tests with faked cloud endpoints.
func chainEnv(kv map[string]string) func(string) string {
return func(k string) string { return kv[k] }
}
// fakeProcBin writes an executable shell script (same mechanism as in
// osbuiltin_test.go: real process, PATH manipulation).
func fakeProcBin(t *testing.T, dir, name, body string) {
t.Helper()
script := "#!/bin/sh\n" + body + "\n"
if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
}
// cloudEnv returns the env map for a chain whose elevenlabs tier points at
// elevenURL and whose openai tier points at openaiURL (edge is dead so that
// no test touches the real Microsoft endpoint).
func cloudEnv(elevenURL, openaiURL string, extra map[string]string) map[string]string {
kv := map[string]string{
EnvElevenLabsKey: "k",
EnvElevenLabsEndpoint: elevenURL,
EnvOpenAIKey: "k",
EnvOpenAIEndpoint: openaiURL,
EnvEdgeEndpoint: "ws://127.0.0.1:1/",
}
for k, v := range extra {
kv[k] = v
}
return kv
}
// TestChainFallbackFirst500SecondHit (acceptance criterion): tier 1
// (elevenlabs) answers 500 → the chain falls through to tier 2 (openai),
// whose bytes come back; the fallback is visible in the log, never silent.
func TestChainFallbackFirst500SecondHit(t *testing.T) {
eleven, elHits := countingTTSServer(t, 500, []byte("boom"))
openai, oaHits := countingTTSServer(t, 200, fakeMP3)
var log bytes.Buffer
ch, err := NewChain(ChainConfig{
Getenv: chainEnv(cloudEnv(eleven, openai, nil)),
Log: &log,
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
data, err := ch.Synthesize(context.Background(), "Hello world", "de")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if string(data) != string(fakeMP3) {
t.Errorf("Synthesize = %q, want bytes of the openai tier", data)
}
if elHits.Load() != 1 || oaHits.Load() != 1 {
t.Errorf("hits: elevenlabs = %d, openai = %d; want 1 each (500 → fallback)", elHits.Load(), oaHits.Load())
}
for _, want := range []string{"elevenlabs", "500", "falling back", "spoken via openai"} {
if !strings.Contains(log.String(), want) {
t.Errorf("log = %q, want %q", log.String(), want)
}
}
}
// TestChainEverythingFails: both cloud tiers 500, edge dead, osbuiltin not
// available → the error names the failed tiers; the log shows each one.
func TestChainEverythingFails(t *testing.T) {
eleven, _ := countingTTSServer(t, 500, []byte("el-boom"))
openai, _ := countingTTSServer(t, 500, []byte("oa-boom"))
// Empty PATH: osbuiltin not available.
t.Setenv("PATH", t.TempDir())
var log bytes.Buffer
ch, err := NewChain(ChainConfig{
Getenv: chainEnv(cloudEnv(eleven, openai, nil)),
Log: &log,
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
_, err = ch.Synthesize(context.Background(), "Hello", "de")
if err == nil {
t.Fatal("Synthesize with all tiers broken: no error")
}
for _, want := range []string{"elevenlabs", "openai", "osbuiltin", "500"} {
if !strings.Contains(err.Error(), want) && !strings.Contains(log.String(), want) {
t.Errorf("neither error (%v) nor log mention %q", err, want)
}
}
}
// TestChainWithoutKeysEdgeOsbuiltin (acceptance criterion): without any
// key and with a dead edge, the chain picks osbuiltin; with a faked espeak
// its bytes come back.
func TestChainWithoutKeysEdgeOsbuiltin(t *testing.T) {
dir := t.TempDir()
fakeProcBin(t, dir, "espeak", `printf 'RIFFxxxxWAVEfmt '`)
t.Setenv("PATH", dir)
var log bytes.Buffer
ch, err := NewChain(ChainConfig{
Getenv: chainEnv(map[string]string{EnvEdgeEndpoint: "ws://127.0.0.1:1/"}),
Log: &log,
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
data, err := ch.Synthesize(context.Background(), "Hello", "de")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if !bytes.HasPrefix(data, []byte("RIFF")) {
t.Errorf("Synthesize = %q, want WAV bytes of the fake espeak", data)
}
if !strings.Contains(log.String(), "skipped (not available)") {
t.Errorf("log = %q, want skip notes for the key tiers", log.String())
}
}
// TestChainPin: --provider/config pin the chain to exactly one tier; other
// tiers are not even requested.
func TestChainPin(t *testing.T) {
eleven, elHits := countingTTSServer(t, 200, fakeMP3)
openai, oaHits := countingTTSServer(t, 500, []byte("boom"))
ch, err := NewChain(ChainConfig{
Pin: "openai",
Getenv: chainEnv(cloudEnv(eleven, openai, nil)),
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if _, err := ch.Synthesize(context.Background(), "Hello", "de"); err == nil {
t.Fatal("pinned broken tier should fail (no fallback when pinned)")
}
if elHits.Load() != 0 || oaHits.Load() != 1 {
t.Errorf("hits: elevenlabs = %d (want 0), openai = %d (want 1)", elHits.Load(), oaHits.Load())
}
}
// TestChainUnknownPin is a clear error listing the names.
func TestChainUnknownPin(t *testing.T) {
if _, err := NewChain(ChainConfig{Pin: "doesnotexist"}); err == nil || !strings.Contains(err.Error(), "elevenlabs") {
t.Errorf("NewChain error = %v, want the available names", err)
}
}
// TestChainSpeakBytesViaPlayer: a successful synthesis is played via the
// Player.
func TestChainSpeakBytesViaPlayer(t *testing.T) {
eleven, _ := countingTTSServer(t, 200, fakeMP3)
player := &fakePlayer{name: "oto"}
ch, err := NewChain(ChainConfig{
Getenv: chainEnv(cloudEnv(eleven, eleven, nil)),
Player: player,
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hello", "de"); err != nil {
t.Fatalf("Speak: %v", err)
}
if len(player.played) != 1 || string(player.played[0]) != string(fakeMP3) {
t.Errorf("Player.Play = %x, want the synthesis bytes", player.played)
}
}
// TestChainDirectWithNullPlayer: when the chain falls back to the direct
// tier (osbuiltin without espeak), a null player discards the announcement
// silently and SUCCEEDS; spd-say is not called.
func TestChainDirectWithNullPlayer(t *testing.T) {
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
t.Setenv("PATH", dir) // no espeak → no bytes, but spd-say present
var log bytes.Buffer
ch, err := NewChain(ChainConfig{
Pin: "osbuiltin",
Getenv: chainEnv(nil),
Player: &fakePlayer{name: NullPlayerName},
Log: &log,
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hello world", "de"); err != nil {
t.Fatalf("Speak (null player): %v", err)
}
if _, err := os.Stat(seen); err == nil {
t.Error("spd-say was called; the null player should have discarded")
}
if !strings.Contains(log.String(), "discarded") {
t.Errorf("log = %q, want discard line", log.String())
}
}
// TestChainDirectWithSoundPlayer: with a real player (name != null) the
// direct announcement runs via SpeakDirect, blocking (-w) with language and
// text.
func TestChainDirectWithSoundPlayer(t *testing.T) {
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
t.Setenv("PATH", dir)
player := &fakePlayer{name: "oto"}
ch, err := NewChain(ChainConfig{
Pin: "osbuiltin",
Getenv: chainEnv(nil),
Player: player,
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hello world", "de"); err != nil {
t.Fatalf("Speak (direct): %v", err)
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("fake spd-say not called: %v", err)
}
if got := strings.TrimSpace(string(args)); got != "-w -l de Hello world" {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hello world")
}
if len(player.played) != 0 {
t.Errorf("Player.Play was called (%x); a direct announcement bypasses the player", player.played)
}
}
// TestChainSynthesizeErrNoByteOutput: the chain as a Provider reports
// ErrNoByteOutput when only a direct tier remains; the DirectSpeaker
// contract is preserved towards the caller.
func TestChainSynthesizeErrNoByteOutput(t *testing.T) {
dir := t.TempDir()
fakeProcBin(t, dir, "spd-say", "true")
t.Setenv("PATH", dir)
ch, err := NewChain(ChainConfig{Pin: "osbuiltin", Getenv: chainEnv(nil)})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
_, err = ch.Synthesize(context.Background(), "Hello", "de")
if !errors.Is(err, ErrNoByteOutput) {
t.Fatalf("Synthesize error = %v, want ErrNoByteOutput", err)
}
// Chain itself is a DirectSpeaker.
if _, ok := any(ch).(DirectSpeaker); !ok {
t.Error("Chain does not implement DirectSpeaker")
}
}
// TestChainAvailableAndNames: Available is true as soon as one tier is
// available; the default order is the binding one from the spec.
func TestChainAvailableAndNames(t *testing.T) {
ch, err := NewChain(ChainConfig{Getenv: chainEnv(map[string]string{
EnvEdgeEndpoint: "wss://edge.example.com", // edge: always available with an endpoint
})})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if !ch.Available(context.Background()) {
t.Error("Available = false despite an available edge tier")
}
want := []string{"elevenlabs", "openai", "edge", "osbuiltin"}
if len(ch.Tiers()) != len(want) {
t.Fatalf("Tiers = %d, want %d", len(ch.Tiers()), len(want))
}
for i, name := range want {
if ch.Tiers()[i].Name() != name {
t.Errorf("Tiers[%d] = %s, want %s", i, ch.Tiers()[i].Name(), name)
}
}
}
// TestChainVoicePinForwarded: ChainConfig.Voice is pinned on the tiers via
// VoicePinner; observable in the request path of the ElevenLabs fake.
func TestChainVoicePinForwarded(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
ch, err := NewChain(ChainConfig{
Pin: "elevenlabs",
Voice: "ChainVoice",
Getenv: chainEnv(map[string]string{
EnvElevenLabsKey: "k",
EnvElevenLabsEndpoint: ts.URL,
}),
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if _, err := ch.Synthesize(context.Background(), "Hello", "de"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/ChainVoice"; rec.path != want {
t.Errorf("path = %q, want %q", rec.path, want)
}
}
// TestChainDirectEndgameSingleWalk (review fix): in the DirectSpeaker
// endgame the chain walks exactly ONCE; the failed cloud tier is requested
// only once (no duplicate POSTs to paid APIs, no doubled latency), every
// tier logs its failure exactly once, and the direct announcement happens.
func TestChainDirectEndgameSingleWalk(t *testing.T) {
eleven, elHits := countingTTSServer(t, 500, []byte("boom"))
// elevenlabs: key + fake (500). openai: endpoint but NO key → skipped.
// edge: dead loopback port. osbuiltin: PATH with only a fake spd-say (no
// espeak) → no bytes, direct announcement.
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
t.Setenv("PATH", dir)
var log bytes.Buffer
ch, err := NewChain(ChainConfig{
Getenv: chainEnv(map[string]string{
EnvElevenLabsKey: "k",
EnvElevenLabsEndpoint: eleven,
EnvOpenAIEndpoint: eleven, // skipped anyway without a key
EnvEdgeEndpoint: "ws://127.0.0.1:1/",
}),
Player: &fakePlayer{name: "oto"},
Log: &log,
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hello world", "de"); err != nil {
t.Fatalf("Speak (direct endgame): %v", err)
}
if got := elHits.Load(); got != 1 {
t.Errorf("elevenlabs was requested %d times, want exactly 1 (no second walk)", got)
}
for _, tier := range []string{"elevenlabs", "edge"} {
if got := strings.Count(log.String(), "tier "+tier+" failed"); got != 1 {
t.Errorf("\"tier %s failed\" appears %d times in the log, want exactly 1:\n%s", tier, got, log.String())
}
}
if got := strings.Count(log.String(), "tier openai skipped"); got != 1 {
t.Errorf("openai skip appears %d times in the log, want exactly 1", got)
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("fake spd-say not called: %v", err)
}
if got := strings.TrimSpace(string(args)); got != "-w -l de Hello world" {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hello world")
}
}