2026-10-18 17:10:36 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 09:49:16 +02:00
|
|
|
// TestSayOhneDaemonSprichtDirekt: Daemon tot und Autostart aus → talk
|
|
|
|
|
// spricht direkt im eigenen Prozess (hier: gegen den Fake-Provider) und
|
|
|
|
|
// sagt das auf stderr — nichts bricht still.
|
|
|
|
|
func TestSayOhneDaemonSprichtDirekt(t *testing.T) {
|
2026-10-18 17:10:36 +02:00
|
|
|
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 {
|
2026-10-24 09:49:16 +02:00
|
|
|
t.Fatalf("talk say (direkt): Exit %d, stderr: %s", res.code, res.stderr)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
if hits := fake.waitHits(t, 1); hits[0].Text != "offline ansage" {
|
|
|
|
|
t.Errorf("Treffer = %q", hits[0].Text)
|
|
|
|
|
}
|
2026-10-24 09:49:16 +02:00
|
|
|
if !strings.Contains(res.stderr, "spreche direkt") {
|
|
|
|
|
t.Errorf("stderr = %q, will Direkt-Hinweis (nichts bricht still)", res.stderr)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
2026-10-24 09:49:16 +02:00
|
|
|
if strings.Contains(res.stderr, "Daemon gestartet") {
|
|
|
|
|
t.Errorf("stderr = %q — Autostart war aus, es darf kein Daemon gestartet werden", res.stderr)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 09:49:16 +02:00
|
|
|
// TestSayOhneDaemonRespektiertMute: ohne Daemon verwirft der Direkt-Pfad
|
2026-10-18 17:10:36 +02:00
|
|
|
// Ansagen laut Mute-Datei tonlos (und spielt nach Unmute wieder).
|
2026-10-24 09:49:16 +02:00
|
|
|
func TestSayOhneDaemonRespektiertMute(t *testing.T) {
|
2026-10-18 17:10:36 +02:00
|
|
|
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
|
2026-10-24 09:49:16 +02:00
|
|
|
// Fehler (kein Stillversagen) — say spricht dagegen direkt.
|
2026-10-18 17:10:36 +02:00
|
|
|
func TestCliSteuerbefehleOhneDaemon(t *testing.T) {
|
|
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 0)
|
|
|
|
|
env := daemonEnv(root, 1, fake.srv.URL, nil)
|
|
|
|
|
|
2026-10-24 09:49:16 +02:00
|
|
|
if res := runTalk(t, env, "status"); res.code != 1 || !strings.Contains(res.stderr, "kein Daemon") || !strings.Contains(res.stdout, "läuft nicht") {
|
|
|
|
|
t.Errorf("talk status ohne Daemon: %d stdout %q stderr %q", res.code, res.stdout, res.stderr)
|
2026-10-18 17:10:36 +02:00
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
}
|