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:
1 parent
e1a3981d17
commit
1ae1f1262c
4 files changed
+1146
No files matched your search
@@ -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)
|
||||||
|
}
|
||||||
@@ -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/<voice>):
|
||||||
|
// 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 <root>/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/<root> → <root>/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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user