diff --git a/test/cli_test.go b/test/cli_test.go new file mode 100644 index 0000000..b78d136 --- /dev/null +++ b/test/cli_test.go @@ -0,0 +1,323 @@ +package test + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// TestSayNonBlocking (Abnahmekriterium non-blocking): `talk say` kehrt +// deutlich vor Ende der Ansage zurück (Exit 0, Item-ID auf stdout), die +// Ansage läuft im Daemon weiter zu Ende. +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", "langlaufende ansage") + 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 war blockierend (%s; Ansage dauert 600 ms) — Default muss non-blocking sein", elapsed) + } + id := strings.TrimSpace(res.stdout) + if id == "" || strings.Contains(id, "\n") { + t.Errorf("stdout = %q, will genau eine Zeile mit der Item-ID", res.stdout) + } + + // Die Ansage läuft trotzdem zu Ende (Daemon besitzt sie jetzt). + hits := fake.waitHits(t, 1) + if hits[0].Text != "langlaufende ansage" { + t.Errorf("Treffer = %q", hits[0].Text) + } + waitStatusIdle(t, d, 3*time.Second) +} + +// TestSayWait: `talk say --wait` kehrt erst nach Ende der Ansage zurück. +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", "warten auf mich") + 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 kam zu früh zurück (%s; Ansage dauert 400 ms)", elapsed) + } + if hits := fake.waitHits(t, 1); hits[0].Text != "warten auf mich" { + t.Errorf("Treffer = %q", hits[0].Text) + } + waitStatusIdle(t, d, 3*time.Second) +} + +// TestSayDegradedMode (Abnahmekriterium Degraded Mode): Daemon tot → +// Lock-Datei im Config-Dir, sequenzielles lokales Abspielen (hier: direkt +// gegen den Fake-Provider), Lock danach wieder freigegeben. +func TestSayDegradedMode(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 0) + env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot + + res := runTalk(t, env, "say", "offline ansage") + if res.code != 0 { + t.Fatalf("talk say (Degraded): Exit %d, stderr: %s", res.code, res.stderr) + } + if hits := fake.waitHits(t, 1); hits[0].Text != "offline ansage" { + t.Errorf("Treffer = %q", hits[0].Text) + } + if !strings.Contains(res.stderr, "Degraded Mode") { + t.Errorf("stderr = %q, will Degraded-Hinweis (nichts bricht still)", res.stderr) + } + + // Lock-Datei wurde nach dem Abspielen freigegeben. + lockPath := filepath.Join(configDir(root), "talk.lock") + if _, err := os.Stat(lockPath); !os.IsNotExist(err) { + t.Errorf("Lock-Datei %s nicht freigegeben (err=%v)", lockPath, err) + } +} + +// TestSayDegradedSerialisiertUeberLock: zwei gleichzeitige talk-Prozesse +// ohne Daemon spielen nacheinander (Kollision = warten), nicht überlappend. +func TestSayDegradedSerialisiertUeberLock(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 250*time.Millisecond) + env := daemonEnv(root, 1, fake.srv.URL, nil) + + var wg sync.WaitGroup + results := make([]talkResult, 2) + for i, text := range []string{"eins", "zwei"} { + 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("talk %d (Degraded, parallel): Exit %d, stderr: %s", i, res.code, res.stderr) + } + } + + // Reihenfolge offen (wer zuerst das Lock nimmt), aber serialisiert: + // die zweite Synthese startet erst nach der Antwort der ersten. + hits := fake.waitHits(t, 2) + const slack = 25 * time.Millisecond + if hits[1].Start.Before(hits[0].End.Add(-slack)) { + t.Errorf("Degraded-Ansagen überlappten: Start(2)=%v < Ende(1)=%v — Lock-Serialisierung kaputt", hits[1].Start, hits[0].End) + } +} + +// TestSayDegradedRespektiertMute: ohne Daemon verwirft der Degraded Mode +// Ansagen laut Mute-Datei tonlos (und spielt nach Unmute wieder). +func TestSayDegradedRespektiertMute(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 0) + env := daemonEnv(root, 1, fake.srv.URL, nil) + + // Mute-Datei direkt ins Config-Dir (Zustand „Daemon war mal da"). + 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", "sollte stumm bleiben") + if res.code != 0 { + t.Fatalf("talk say (mute an): 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) → spielt wieder. + 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", "jetzt wieder") + if res.code != 0 { + t.Fatalf("talk say (mute aus): Exit %d, stderr: %s", res.code, res.stderr) + } + if hits := fake.waitHits(t, 1); hits[0].Text != "jetzt wieder" { + t.Errorf("Treffer = %q", hits[0].Text) + } +} + +// TestCliMuteUnmuteStatusCancel: die Steuer-Befehle als Daemon-Aufrufe mit +// plain Output — mute/unmute als Token, status als Block, cancel mit Zahl. +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 bei Mute: verworfen, plain gemeldet, kein Provider-Kontakt. + res = runTalk(t, env, "say", "stille") + if res.code != 0 || res.stdout != "muted\n" { + t.Fatalf("talk say (mute an): %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: an", "provider: elevenlabs"} { + if !strings.Contains(res.stdout, want) { + t.Errorf("talk status stdout = %q, will %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 (leer): %d %q — stderr: %s", res.code, res.stdout, res.stderr) + } + + res = runTalk(t, env, "status") + if !strings.Contains(res.stdout, "mute: aus") { + t.Errorf("talk status nach unmute = %q, will mute aus", res.stdout) + } +} + +// TestCliCancelMitQueue: cancel über die CLI bricht laufende + wartende +// Ansage ab und meldet die Zahl. +func TestCliCancelMitQueue(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", "wartet auf 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":"danach"}`) + hits := fake.waitCompleted(t, 1) + if hits[0].Text != "danach" { + t.Errorf("Treffer = %q, want danach", hits[0].Text) + } +} + +// TestDreiParalleleTalkAufrufe (Abnahmekriterium Task 04): `talkd &` + +// drei parallele `talk`-Aufrufe → drei Ansagen nacheinander. Die Enqueue- +// Reihenfolge der parallelen Prozesse ist offen — geprüft wird, dass alle +// drei abgespielt werden und KEINE zwei überlappen. +func TestDreiParalleleTalkAufrufe(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{"erste", "zweite", "dritte"} { + 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("paralleler talk-Aufruf %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{"erste", "zweite", "dritte"} { + if !played[text] { + t.Errorf("Ansage %q fehlt (Treffer: %+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("Ansagen %d/%d überlappten: Start %v < Ende %v", i-1, i, hits[i].Start, hits[i-1].End) + } + } + waitStatusIdle(t, d, 3*time.Second) +} + +// TestCliSteuerbefehleOhneDaemon: status/cancel ohne Daemon sind klare +// Fehler (kein Stillversagen) — say fällt dagegen in den Degraded Mode. +func TestCliSteuerbefehleOhneDaemon(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, "nicht erreichbar") { + t.Errorf("talk status ohne Daemon: %d %q", res.code, res.stderr) + } + if res := runTalk(t, env, "cancel"); res.code != 1 || !strings.Contains(res.stderr, "nichts zu abbrechen") { + t.Errorf("talk cancel ohne Daemon: %d %q", res.code, res.stderr) + } +} + +// TestSayDefaultPfadWieSay: `talk "Text"` nimmt denselben Daemon-Pfad wie +// `talk say "Text"` (Flags vor und nach dem Text). +func TestSayDefaultPfadWieSay(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 aufruf") + 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, will Item-ID", res.stdout) + } + + // Flag nach dem Text (parseInterspersed) auf dem Daemon-Pfad. + res = runTalk(t, env, "noch ein 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, "noch ein text") { + t.Errorf("zweiter Treffer = %q", hits[1].Text) + } + waitStatusIdle(t, d, 3*time.Second) +} diff --git a/test/harness_test.go b/test/harness_test.go new file mode 100644 index 0000000..cc63f6f --- /dev/null +++ b/test/harness_test.go @@ -0,0 +1,447 @@ +// Package test enthält die Prozessgrenzen-Tests von talk 2.0 — die primäre +// Test-Seam laut Spec: talkd und talk laufen als echte Prozesse (gebuildet +// aus diesem Repo), angetrieben über HTTP und die CLI, mit zwei injizierten +// Fakes: einem ElevenLabs-Fake-Endpunkt (httptest-Server mit Audio-Bytes) +// und audio.sink=null. Getestet wird nur extern beobachtbares Verhalten: +// HTTP-Antworten, CLI-Exit-Codes/-Output, Fake-Treffer (Reihenfolge und +// Zeit), Dateien im Config-Dir. Tests binden NIEMALS den Default-Port 7800 +// und hängen nie von einem echten Daemon ab — talkd bekommt einen freien +// Port per TALK_PORT, CLI-Prozesse denselben (oder Port 1 für „Daemon tot"). +package test + +import ( + "bytes" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +// Die Binaries werden einmal pro Testlauf gebaut (Build-Cache macht es +// billig) und von allen Tests geteilt. +var ( + talkBin string + talkdBin string +) + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "talk-bins-*") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + repo, err := filepath.Abs("..") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + for _, b := range []struct{ out, pkg string }{ + {"talk", "./cmd/talk"}, + {"talkd", "./cmd/talkd"}, + } { + cmd := exec.Command("go", "build", "-o", filepath.Join(dir, b.out), b.pkg) + cmd.Dir = repo + if out, err := cmd.CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "go build %s: %v\n%s", b.pkg, err, out) + os.RemoveAll(dir) + os.Exit(1) + } + } + talkBin, talkdBin = filepath.Join(dir, "talk"), filepath.Join(dir, "talkd") + + code := m.Run() + os.RemoveAll(dir) + os.Exit(code) +} + +// syncBuffer ist ein nebenläufigkeitssicherer Buffer (Daemon-stderr wird +// gelesen, während der Prozess läuft). +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// fakeHit ist ein beobachteter Synthese-Request am Fake-Provider. +type fakeHit struct { + Text string // Text aus dem Request-Body + Start time.Time // Ankunft am Fake + End time.Time // Antwort abgeschickt bzw. Abbruch gesehen + Completed bool // true: Antwort geschrieben; false: Client hat abgebrochen +} + +// fakeProvider ist ein ElevenLabs-Fake (POST /v1/text-to-speech/): +// Er antwortet mit ein paar Bytes „Audio" und protokolliert jeden Request +// mit Ankunfts- und Antwort-Zeit — daraus sind Reihenfolge (Textfolge) und +// Serialisierung (Ankunft des nächsten nach Antwort des vorigen) ablesbar. +// delay ist zur Laufzeit umschaltbar, um blockierende Synthesen zu +// simulieren (Serialisierung, Priorität, cancel). Ankünfte werden separat +// und sofort protokolliert: Sie zeigen, dass der Daemon mit einer Synthese +// BESCHÄFTIGT ist (Queue deterministisch befüllbar), noch bevor die +// (verzögerte) Antwort als Treffer landet. +type fakeProvider struct { + srv *httptest.Server + mu sync.Mutex + hits []fakeHit + arrived []string + delay time.Duration +} + +func newFakeProvider(t *testing.T, delay time.Duration) *fakeProvider { + t.Helper() + f := &fakeProvider{delay: delay} + f.srv = httptest.NewServer(http.HandlerFunc(f.serve)) + t.Cleanup(f.srv.Close) + return f +} + +func (f *fakeProvider) serve(w http.ResponseWriter, r *http.Request) { + var body struct { + Text string `json:"text"` + } + start := time.Now() + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + f.mu.Lock() + f.arrived = append(f.arrived, body.Text) + d := f.delay + f.mu.Unlock() + if d > 0 { + select { + case <-time.After(d): + case <-r.Context().Done(): // Client (Daemon) hat abgebrochen + f.record(fakeHit{Text: body.Text, Start: start, End: time.Now()}) + return + } + } + _, _ = w.Write([]byte("\xff\xfb\x90\x00 fake-mp3-bytes")) + f.record(fakeHit{Text: body.Text, Start: start, End: time.Now(), Completed: true}) +} + +func (f *fakeProvider) record(h fakeHit) { + f.mu.Lock() + defer f.mu.Unlock() + f.hits = append(f.hits, h) +} + +func (f *fakeProvider) setDelay(d time.Duration) { + f.mu.Lock() + f.delay = d + f.mu.Unlock() +} + +func (f *fakeProvider) snapshot() []fakeHit { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]fakeHit, len(f.hits)) + copy(out, f.hits) + return out +} + +// completed liefert nur die abgeschlossenen (nicht abgebrochenen) Treffer. +func completed(hits []fakeHit) []fakeHit { + var out []fakeHit + for _, h := range hits { + if h.Completed { + out = append(out, h) + } + } + return out +} + +// waitArrived pollt, bis n Synthese-Requests angekommen sind — der Daemon +// ist damit nachweisbar beschäftigt und die Queue deterministisch befüllbar. +func (f *fakeProvider) waitArrived(t *testing.T, n int) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + f.mu.Lock() + count := len(f.arrived) + f.mu.Unlock() + if count >= n { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("nur %d von %d Ankünften am Fake innerhalb 5 s", len(f.snapshot()), n) +} + +// waitHits pollt, bis n Requests abgeschlossen wurden (Abbrüche zählen). +func (f *fakeProvider) waitHits(t *testing.T, n int) []fakeHit { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if hits := f.snapshot(); len(hits) >= n { + return hits + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("nur %d von %d Fake-Treffern innerhalb 5 s (Treffer: %+v)", len(f.snapshot()), n, f.snapshot()) + return nil +} + +// waitCompleted pollt, bis n Requests ERFOLGREICH abgeschlossen wurden +// (abgebrochene Synthesen zählen nicht). +func (f *fakeProvider) waitCompleted(t *testing.T, n int) []fakeHit { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if hits := completed(f.snapshot()); len(hits) >= n { + return hits + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("nur %d von %d abgeschlossenen Treffern innerhalb 5 s (alle: %+v)", len(completed(f.snapshot())), n, f.snapshot()) + return nil +} + +// assertNoHits prüft, dass innerhalb der Frist kein Request ankam. +func (f *fakeProvider) assertNoHits(t *testing.T, wait time.Duration) { + t.Helper() + time.Sleep(wait) + if hits := f.snapshot(); len(hits) > 0 { + t.Fatalf("unerwartete Fake-Treffer: %+v", hits) + } +} + +// daemonProc ist ein laufender talkd-Prozess. +type daemonProc struct { + cmd *exec.Cmd + port int + stderr *syncBuffer + baseURL string +} + +// startDaemon startet talkd als echten Prozess (freier Port per TALK_PORT, +// nie der Default-Port) und wartet, bis GET /status antwortet. Stop per +// t.Cleanup: SIGINT, nach Frist Kill. +func startDaemon(t *testing.T, root, fakeURL string, extra map[string]string) *daemonProc { + t.Helper() + port := freePort(t) + env := daemonEnv(root, port, fakeURL, extra) + stderr := &syncBuffer{} + cmd := exec.Command(talkdBin) + cmd.Env = env + cmd.Stderr = stderr + if err := cmd.Start(); err != nil { + t.Fatalf("talkd starten: %v", err) + } + d := &daemonProc{ + cmd: cmd, + port: port, + stderr: stderr, + baseURL: fmt.Sprintf("http://127.0.0.1:%d", port), + } + + client := &http.Client{Timeout: time.Second} + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + resp, err := client.Get(d.baseURL + "/status") + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + t.Cleanup(func() { d.stop(t) }) + return d + } + } + time.Sleep(25 * time.Millisecond) + } + t.Fatalf("talkd antwortet nicht auf %s — stderr:\n%s", d.baseURL+"/status", stderr.String()) + return nil +} + +// stop fährt den Daemon-Prozess herunter (SIGINT wie ein menschlicher +// Stop; nach 3 s Kill-Fallback) und wartet auf sein Ende. +func (d *daemonProc) stop(t *testing.T) { + t.Helper() + if d.cmd.Process == nil { + return + } + done := make(chan error, 1) + go func() { done <- d.cmd.Wait() }() + _ = d.cmd.Process.Signal(os.Interrupt) + select { + case <-done: + case <-time.After(3 * time.Second): + _ = d.cmd.Process.Kill() + <-done + } +} + +// daemonEnv baut die Prozess-Umgebung für talk- und talkd-Prozesse: +// geerbtes Environment ohne fremde TALK_*-Variablen (Isolation von Keys/ +// Ports der Außenwelt), isoliertes HOME/XDG_CONFIG_HOME (Config-Dir liegt +// damit unter /talk), audio.sink=null und der Fake-Provider als +// ElevenLabs-Endpunkt. port=1 heißt „garantiert toter Daemon" (Verbindung +// wird sofort abgelehnt). +func daemonEnv(root string, port int, fakeURL string, extra map[string]string) []string { + env := make([]string, 0, 32) + for _, kv := range os.Environ() { + if strings.HasPrefix(kv, "TALK_") { + continue + } + env = append(env, kv) + } + set := map[string]string{ + "HOME": root, + "XDG_CONFIG_HOME": root, + "TALK_AUDIO_SINK": "null", + "TALK_ELEVENLABS_KEY": "prozesstest-key", + "TALK_ELEVENLABS_ENDPOINT": fakeURL, + "TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/", // Kette soll nie auf echte Fremd-Endpunkte fallen + "TALK_PORT": strconv.Itoa(port), + } + for k, v := range extra { + set[k] = v + } + for k, v := range set { + env = append(env, k+"="+v) + } + return env +} + +// freePort besorgt einen freien Loopback-Port (bind-and-close; der Daemon +// bindet ihn sofort danach neu). +func freePort(t *testing.T) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("freien Port finden: %v", err) + } + defer ln.Close() + return ln.Addr().(*net.TCPAddr).Port +} + +// configDir ist das Config-Verzeichnis unter dem isolierten Root +// (Linux-Pfadlogik: XDG_CONFIG_HOME/ → /talk). +func configDir(root string) string { + return filepath.Join(root, "talk") +} + +// talkResult ist das Beobachtungsergebnis eines talk-Prozesses. +type talkResult struct { + code int + stdout string + stderr string +} + +// runTalk führt die talk-CLI als echten Prozess aus. +func runTalk(t *testing.T, env []string, args ...string) talkResult { + t.Helper() + cmd := exec.Command(talkBin, args...) + cmd.Env = env + var out, errBuf syncBuffer + cmd.Stdout = &out + cmd.Stderr = &errBuf + err := cmd.Run() + code := 0 + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } else if err != nil { + t.Fatalf("talk ausführen: %v (stderr: %s)", err, errBuf.String()) + } + return talkResult{code: code, stdout: out.String(), stderr: errBuf.String()} +} + +// statusBody ist die dekodierte GET /status-Antwort. +type statusBody struct { + Version string `json:"version"` + Queue int `json:"queue"` + QueueIDs []string `json:"queue_ids"` + Current *struct { + ID string `json:"id"` + Text string `json:"text"` + Priority string `json:"priority"` + } `json:"current"` + Muted bool `json:"muted"` + Provider string `json:"provider"` +} + +// getStatus ruft GET /status auf (Deadline: eine Sekunde — der Daemon ist +// lokal und muss antworten, sonst ist das selbst ein Testergebnis). +func getStatus(t *testing.T, d *daemonProc) statusBody { + t.Helper() + client := &http.Client{Timeout: time.Second} + resp, err := client.Get(d.baseURL + "/status") + if err != nil { + t.Fatalf("GET /status: %v — stderr:\n%s", err, d.stderr.String()) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /status: Status %d", resp.StatusCode) + } + var st statusBody + if err := json.NewDecoder(resp.Body).Decode(&st); err != nil { + t.Fatalf("GET /status dekodieren: %v", err) + } + return st +} + +// waitStatusIdle pollt, bis weder ein Item läuft noch wartet. +func waitStatusIdle(t *testing.T, d *daemonProc, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for { + st := getStatus(t, d) + if st.Current == nil && st.Queue == 0 { + return + } + if time.Now().After(deadline) { + t.Fatalf("Queue wird nicht idle: %+v — stderr:\n%s", st, d.stderr.String()) + } + time.Sleep(25 * time.Millisecond) + } +} + +// speak enqueue per rohem HTTP (Prozessgrenze ohne CLI). +func speak(t *testing.T, d *daemonProc, body string) (int, map[string]any) { + t.Helper() + resp, err := http.Post(d.baseURL+"/speak", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("POST /speak: %v", err) + } + defer resp.Body.Close() + var m map[string]any + if err := json.NewDecoder(resp.Body).Decode(&m); err != nil { + t.Fatalf("POST /speak Antwort dekodieren: %v", err) + } + return resp.StatusCode, m +} + +// postPath macht einen leeren POST auf einen Daemon-Endpunkt (/mute, +// /unmute, /cancel) und dekodiert die JSON-Antwort. +func postPath(t *testing.T, d *daemonProc, path string) (int, map[string]any) { + t.Helper() + resp, err := http.Post(d.baseURL+path, "application/json", strings.NewReader("{}")) + if err != nil { + t.Fatalf("POST %s: %v", path, err) + } + defer resp.Body.Close() + var m map[string]any + if err := json.NewDecoder(resp.Body).Decode(&m); err != nil { + t.Fatalf("POST %s Antwort dekodieren: %v", path, err) + } + return resp.StatusCode, m +} diff --git a/test/mute_test.go b/test/mute_test.go new file mode 100644 index 0000000..28f9c8a --- /dev/null +++ b/test/mute_test.go @@ -0,0 +1,103 @@ +package test + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestMutePersistenzUeberRestart (Abnahmekriterium): `talk mute` → Daemon- +// Restart → weiter stumm. Mute verwirft Ansagen dokumentiert mit +// 202 {id:"", muted:true} und ohne jeden Provider-Kontakt. +func TestMutePersistenzUeberRestart(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 0) + d := startDaemon(t, root, fake.srv.URL, nil) + + // Über die HTTP-API stummschalten: Datei mute liegt im Config-Dir. + code, m := postPath(t, d, "/mute") + if code != 200 || m["muted"] != true { + t.Fatalf("POST /mute: %d %v", code, m) + } + muteFile := filepath.Join(configDir(root), "mute") + b, err := os.ReadFile(muteFile) + if err != nil { + t.Fatalf("Mute-Datei fehlt im Config-Dir: %v", err) + } + if strings.TrimSpace(string(b)) != "true" { + t.Errorf("Mute-Datei = %q, want true", b) + } + + // Ansagen werden verworfen: 202, muted:true, leere ID, kein Treffer. + code, m = speak(t, d, `{"text":"stille"}`) + if code != 202 || m["muted"] != true || m["id"] != "" { + t.Fatalf("speak bei Mute: %d %v, want 202 {id:, muted:true}", code, m) + } + fake.assertNoHits(t, 300*time.Millisecond) + if st := getStatus(t, d); !st.Muted { + t.Fatal("/status meldet muted=false") + } + + // Daemon-Restart mit demselben Config-Dir: weiter stumm. + d.stop(t) + d2 := startDaemon(t, root, fake.srv.URL, nil) + + if st := getStatus(t, d2); !st.Muted { + t.Fatalf("nach Restart muted = false, want true (persistent)") + } + code, m = speak(t, d2, `{"text":"immer noch stumm"}`) + if code != 202 || m["muted"] != true { + t.Fatalf("speak nach Restart bei Mute: %d %v", code, m) + } + fake.assertNoHits(t, 300*time.Millisecond) + + // Unmute: Ansagen laufen wieder. + code, m = postPath(t, d2, "/unmute") + if code != 200 || m["muted"] != false { + t.Fatalf("POST /unmute: %d %v", code, m) + } + code, m = speak(t, d2, `{"text":"wieder da"}`) + if code != 202 || m["id"] == "" { + t.Fatalf("speak nach unmute: %d %v, want 202 mit id", code, m) + } + hits := fake.waitHits(t, 1) + if hits[0].Text != "wieder da" { + t.Errorf("Treffer = %q, want wieder da", hits[0].Text) + } +} + +// TestDegradedMuteDateiWirdVonDaemonGelesen: eine offline (CLI, ohne +// Daemon) geschriebene Mute-Datei wirkt sofort im Daemon — beide lesen +// dieselbe Quelle. +func TestDegradedMuteDateiWirdVonDaemonGelesen(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 0) + d := startDaemon(t, root, fake.srv.URL, nil) + deadEnv := daemonEnv(root, 1, fake.srv.URL, nil) + + // Offline stummschalten (talk mute ohne Daemon → Datei direkt). + res := runTalk(t, deadEnv, "mute") + if res.code != 0 || res.stdout != "muted\n" { + t.Fatalf("talk mute (offline): %d %q — stderr: %s", res.code, res.stdout, res.stderr) + } + + // Der laufende Daemon sieht den Zustand bei der nächsten Ansage. + if st := getStatus(t, d); !st.Muted { + t.Fatal("Daemon sieht offline geschriebene Mute-Datei nicht") + } + code, m := speak(t, d, `{"text":"soll verworfen werden"}`) + if code != 202 || m["muted"] != true { + t.Fatalf("speak: %d %v, want verworfen", code, m) + } + fake.assertNoHits(t, 300*time.Millisecond) + + res = runTalk(t, deadEnv, "unmute") + if res.code != 0 { + t.Fatalf("talk unmute (offline): %d — stderr: %s", res.code, res.stderr) + } + if st := getStatus(t, d); st.Muted { + t.Fatal("Daemon sieht offline geschriebene Unmute-Datei nicht") + } +} diff --git a/test/queue_test.go b/test/queue_test.go new file mode 100644 index 0000000..fd19f11 --- /dev/null +++ b/test/queue_test.go @@ -0,0 +1,273 @@ +package test + +import ( + "fmt" + "net/http" + "strings" + "testing" + "time" +) + +// TestSerialisierung: zwei schnell hintereinander eingereihte Ansagen +// spielen nacheinander — die zweite Synthese erreicht den Fake-Provider +// erst NACH der Antwort der ersten (kein Überlappen), und die Textfolge +// bleibt enqueue-stabil. Der null-Sink verwirft jede Ansage sichtbar. +func TestSerialisierung(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 250*time.Millisecond) + d := startDaemon(t, root, fake.srv.URL, nil) + + code, first := speak(t, d, `{"text":"erste ansage"}`) + if code != 202 || first["id"] == "" || first["muted"] == true { + t.Fatalf("erste: Status=%d Body=%v, want 202 mit id", code, first) + } + + // Sobald die erste Synthese beim Fake angekommen ist (Daemon + // beschäftigt), zwei weitere schnell hinterher — sie müssen sich + // hinter der laufenden einreihen. + fake.waitArrived(t, 1) + speak(t, d, `{"text":"zweite ansage"}`) + speak(t, d, `{"text":"dritte ansage"}`) + + hits := fake.waitHits(t, 3) + + var texts []string + for _, h := range hits { + texts = append(texts, h.Text) + } + if strings.Join(texts, "|") != "erste ansage|zweite ansage|dritte ansage" { + t.Errorf("Abspiel-Reihenfolge = %v, want FIFO", texts) + } + // Serialisierung: die nächste Synthese startet erst nach der Antwort + // der vorherigen (kleine Toleranz für Uhr-Jitter). + 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("Ansage %d überlappt mit Ansage %d: Start %v < Ende %v", i, i-1, hits[i].Start, hits[i-1].End) + } + } + + // Erst idle warten: Die dritte Verwurf-Zeile entsteht daemon-seitig + // erst kurz NACH der beim Fake aufgezeichneten Antwort. + waitStatusIdle(t, d, 3*time.Second) + + // Der null-Sink hat alle drei verworfen (tonlos, aber sichtbar). + if n := strings.Count(d.stderr.String(), "verworfen (null sink)"); n < 3 { + t.Errorf("null-Sink-Verwürfe im Daemon-Log = %d, want >= 3 (stderr: %s)", n, d.stderr.String()) + } +} + +// TestPrioritaet: während einer langen Ansage werden low/normal/high +// eingereiht — die Abarbeitung folgt high > normal > low (FIFO je Level). +func TestPrioritaet(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 200*time.Millisecond) + d := startDaemon(t, root, fake.srv.URL, nil) + + speak(t, d, `{"text":"blocker"}`) + fake.waitArrived(t, 1) // Blocker läuft, Worker gebunden. + + // Eingereiht wird in Klassen-Durcheinander; die Queue-Sicht meldet + // IDs — also text→id einsammeln und die Reihenfolge über IDs prüfen. + enq := make(map[string]string) + for _, tc := range []struct{ body, text string }{ + {`{"text":"niedrig","priority":"low"}`, "niedrig"}, + {`{"text":"normal-eins"}`, "normal-eins"}, + {`{"text":"normal-zwei"}`, "normal-zwei"}, + {`{"text":"dringend","priority":"high"}`, "dringend"}, + } { + code, m := speak(t, d, tc.body) + if code != 202 { + t.Fatalf("speak %s: Status %d", tc.body, code) + } + id, _ := m["id"].(string) + if id == "" { + t.Fatalf("speak %s: keine ID in %v", tc.body, m) + } + enq[tc.text] = id + } + wantOrder := []string{"dringend", "normal-eins", "normal-zwei", "niedrig"} + + // Status zeigt aktuelles Item und die wartenden in Abarbeitungs- + // Reihenfolge (deterministisch: der Blocker hält den Worker). + st := getStatus(t, d) + if st.Current == nil || st.Current.Text != "blocker" { + t.Fatalf("current = %+v, will blocker", st.Current) + } + if st.Queue != 4 { + t.Fatalf("Queue-Länge = %d, want 4", st.Queue) + } + var wantIDs []string + for _, text := range wantOrder { + wantIDs = append(wantIDs, enq[text]) + } + if fmt.Sprint(st.QueueIDs) != fmt.Sprint(wantIDs) { + t.Errorf("queue_ids = %v, want %v (Abarbeitungs-Reihenfolge)", st.QueueIDs, wantIDs) + } + + hits := fake.waitHits(t, 5) + var texts []string + for _, h := range hits { + texts = append(texts, h.Text) + } + want := append([]string{"blocker"}, wantOrder...) + if strings.Join(texts, "|") != strings.Join(want, "|") { + t.Errorf("Abspiel-Reihenfolge = %v, want %v", texts, want) + } + waitStatusIdle(t, d, 3*time.Second) +} + +// TestNowSpringtVor: --now über die CLI setzt now:true — die Ansage +// springt vor alle wartenden normal-Items (unterbricht aber nicht den +// laufenden Blocker). +func TestNowSpringtVor(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) + + speak(t, d, `{"text":"blocker"}`) + fake.waitArrived(t, 1) + + speak(t, d, `{"text":"spaeter"}`) + + res := runTalk(t, env, "say", "--now", "sofort") + if res.code != 0 { + t.Fatalf("talk say --now: Exit %d, stderr: %s", res.code, res.stderr) + } + if id := strings.TrimSpace(res.stdout); id == "" || strings.Contains(id, "\n") { + t.Errorf("talk say --now stdout = %q, will eine Zeile mit der Item-ID", res.stdout) + } + + hits := fake.waitHits(t, 3) + var texts []string + for _, h := range hits { + texts = append(texts, h.Text) + } + if strings.Join(texts, "|") != "blocker|sofort|spaeter" { + t.Errorf("Abspiel-Reihenfolge = %v, want [blocker sofort spaeter] — now muss vor normal springen", texts) + } + waitStatusIdle(t, d, 3*time.Second) +} + +// TestCancel: bricht die laufende Ansage ab (Synthese-Request wird +// abgebrochen), leert die Queue, meldet die Gesamtzahl — und der Daemon +// bleibt danach funktionstüchtig. +func TestCancel(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 5*time.Second) + d := startDaemon(t, root, fake.srv.URL, nil) + + code, first := speak(t, d, `{"text":"blocker"}`) + if code != 202 { + t.Fatalf("speak: Status %d", code) + } + fake.waitArrived(t, 1) // Blocker steckt in der Synthese (5 s Fake-Delay). + + code, second := speak(t, d, `{"text":"wartet"}`) + if code != 202 { + t.Fatalf("speak: Status %d", code) + } + + // Status-Vertrag: aktuelles Item + ein wartendes. + st := getStatus(t, d) + if st.Current == nil || st.Current.ID != first["id"] { + t.Fatalf("current = %+v, will den Blocker (%v)", st.Current, first["id"]) + } + if st.Queue != 1 || len(st.QueueIDs) != 1 || st.QueueIDs[0] != second["id"] { + t.Fatalf("queue = %v (%d), will das wartende Item", st.QueueIDs, st.Queue) + } + + code, m := postPath(t, d, "/cancel") + if code != 200 { + t.Fatalf("POST /cancel: Status %d", code) + } + if m["cancelled"] != float64(2) { + t.Fatalf("cancelled = %v, want 2 (laufende + wartende)", m["cancelled"]) + } + + // Der Daemon wird sofort idle (Synthese abgebrochen) und nimmt neue + // Ansagen an — cancel beschädigt den Daemon nicht. + waitStatusIdle(t, d, 2*time.Second) + fake.setDelay(50 * time.Millisecond) + speak(t, d, `{"text":"nachher"}`) + hits := fake.waitCompleted(t, 1) + if hits[0].Text != "nachher" { + t.Errorf("erster abgeschlossener Treffer = %q, will nachher (abgebrochene zählen nicht)", hits[0].Text) + } + waitStatusIdle(t, d, 3*time.Second) +} + +// TestSpeakValidierung: der HTTP-Vertrag lehnt kaputte Anfragen mit 400 +// ab (und 405 bei falscher Methode), ohne den Daemon zu beschädigen. +func TestSpeakValidierung(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 0) + d := startDaemon(t, root, fake.srv.URL, nil) + + cases := []struct { + name string + body string + }{ + {"kein JSON", `kein json`}, + {"text fehlt", `{}`}, + {"text leer", `{"text":" "}`}, + {"priority Müll", `{"text":"x","priority":"sofort"}`}, + {"provider unbekannt", `{"text":"x","provider":"gibtsnicht"}`}, + {"lang Müll", `{"text":"x","lang":"fr"}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code, m := speak(t, d, tc.body) + if code != 400 { + t.Fatalf("Status = %d, want 400 (Body: %v)", code, m) + } + if _, ok := m["error"]; !ok { + t.Errorf("Antwort ohne error-Feld: %v", m) + } + }) + } + + // Falsche Methode → 405 (Methoden-gebundene Routen). + resp, err := http.Post(d.baseURL+"/status", "application/json", nil) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("POST /status: Status = %d, want 405", resp.StatusCode) + } + + // Danach ist der Daemon weiterhin gesund und speakbar. + code, m := speak(t, d, `{"text":"gesund"}`) + if code != 202 || m["id"] == "" { + t.Fatalf("nach Validierungs-Fehlern: speak = %d %v, want 202 mit id", code, m) + } + fake.waitHits(t, 1) +} + +// TestStatusVertrag: Felder der Status-Antwort im Leerlauf (Version, +// Mute aus, kein current, Provider aufgelöst). +func TestStatusVertrag(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 0) + d := startDaemon(t, root, fake.srv.URL, nil) + + st := getStatus(t, d) + if st.Version != "dev" { + t.Errorf("version = %q, want dev (ohne ldflags gebaut)", st.Version) + } + if st.Muted { + t.Error("muted = true im Frischstart, want false") + } + if st.Current != nil { + t.Errorf("current = %+v, want null im Leerlauf", st.Current) + } + if st.Queue != 0 { + t.Errorf("queue = %d, want 0", st.Queue) + } + // Aktiver Provider: elfenlabs zuerst, weil Key + Fake-Endpunkt gesetzt. + if st.Provider != "elevenlabs" { + t.Errorf("provider = %q, want elevenlabs", st.Provider) + } +}