test: Prozessgrenzen-Tests — talkd + talk als echte Prozesse gegen Fake-Provider und sink null

Primäre Seam laut Spec: talkd/talk gebuildet und als Prozesse gefahren,
ElevenLabs-Fake per httptest (Ankünfte + Antworten mit Zeiten), TALK_PORT
auf freie Ports (nie 7800), isoliertes Config-Dir. Getestet:
Serialisierung/Priorität/now-Sprung, cancel (Synthese-Abbruch + Zahl),
non-blocking + --wait, mute-Persistenz über Restart + offline geschriebene
Datei, Degraded Mode (Lock-Serialisierung, mute, Lock-Freigabe),
3 parallele talk-Aufrufe, HTTP-Validierung (400/405), Status-Vertrag.
This commit is contained in:
m3ta-chiron committed 2026-10-18 17:10:36 +02:00
1 parent e1a3981d17
commit 1ae1f1262c
4 files changed
+1146

No files matched your search

+323
View File
@@ -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)
}