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) }