Files
talk/cmd/talk/doctor_test.go
T
m3ta-chironandClaude Fable 5.1 df9a9b30cf feat(cli): doctor, test, voices, setup — Einrichtung und Diagnose ohne TUI
Usability-Schicht für Nutzer, die kein Terminal-Handbuch lesen wollen:

- `talk doctor`: eine Zeile je Prüfpunkt mit klarem Satz, was Sache ist und
  was zu tun wäre — Config (kaputt → ✘, die übrigen Prüfungen laufen mit
  Defaults weiter), Daemon (läuft / startet beim nächsten Sprechbefehl /
  fremder Dienst auf dem Port → ✘ mit Port-Hinweis), Audio (oto-Kontext
  wirklich initialisieren; null → ⚠), jede Provider-Stufe (Key-Env-Name,
  Endpunkt, Default-Stimme; OS-Stimme nennt den gefundenen Mechanismus),
  Mute (an → ⚠ mit unmute-Hinweis), Gesetze inkl. gerade aktiver Ruhezeit.
  --probe macht echte Synthese-Proben, --json liefert die Liste. Exit 0
  ohne ✘. Windows-Konsolen bekommen [OK]/[WARN]/[FAIL].
- `talk test`: Testsatz direkt über den Sprech-Kern (nicht über den
  Daemon), nennt die Stufe, die wirklich gesprochen hat, Synthese- und
  Wiedergabezeit; Mute/Ruhezeit gelten bewusst nicht, Mute wird genannt.
- `talk voices`: kuratierter Katalog mit Namen (ElevenLabs-Premade, OpenAI,
  Edge-Neural de/en-Locales) als Tabelle, „*“ = effektive Default-Stimme
  (Config vor Repo-Default), Config-IDs außerhalb des Katalogs als eigene
  Zeile, Filter --provider/--lang, --json.
- `talk setup`: wenige Fragen statt Bubble Tea — Keys nur als Hinweis
  (ohne Key läuft die Edge-Stimme), Stimme je Sprache aus dem Katalog mit
  Probehören, Ruhezeit; schreibt die config.toml zusammengeführt mit
  bestehenden Werten (.bak), spricht einen Testsatz. q bricht ab, bevor
  etwas geschrieben wird; ohne Terminal keine Fragen, nur die wirksame
  Konfiguration.

Unterbau: provider.Catalog/VoiceName/DefaultVoice, Chain.SpeakOutcome
(Stufe, Bytes, Zeiten) durch speak.Engine bis in Daemon-Log und test,
audio.Check (oto-Initialisierung ohne Ton), config.Path,
OSBuiltin.Describe. README: Abschnitt „Erste Schritte“.

Tests: voices-Zeilen (Default-Markierung, Config-Override, Filter, JSON),
doctor (ohne Daemon, Mute/Key via JSON, fremder Dienst → Exit 1, kaputte
Config → ✘ aber vollständiger Bericht), test (fake espeak), setup
(stdin-Antworten → config.toml-Inhalt, Merge mit .bak, Abbruch schreibt
nichts, Pipe stellt keine Fragen), Prozessgrenze: doctor/--probe/voices/
test gegen echten Daemon.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 10:08:44 +02:00

117 lines
4.2 KiB
Go

package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func checkByName(t *testing.T, checks []check, name string) check {
t.Helper()
for _, c := range checks {
if c.Check == name {
return c
}
}
t.Fatalf("Prüfung %q fehlt in %+v", name, checks)
return check{}
}
// TestDoctorOhneDaemon: toter Daemon ist ⚠ (kein Fehler), sink null ist ⚠,
// Cloud-Stufen ohne Key ⚠, edge ✔ — Exit 0, Abschlusszeile „Alles bereit".
func TestDoctorOhneDaemon(t *testing.T) {
root := t.TempDir()
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null", "XDG_CONFIG_HOME": root})
var out, errBuf bytes.Buffer
code := runCLI([]string{"doctor"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk doctor: Exit %d, stdout %q, stderr %q", code, out.String(), errBuf.String())
}
for _, want := range []string{"⚠ Daemon", "läuft nicht", "⚠ Audio", "⚠ elevenlabs", "TALK_ELEVENLABS_KEY", "✔ edge", "✔ Mute", "✔ Gesetze", "Alles bereit"} {
if !strings.Contains(out.String(), want) {
t.Errorf("stdout ohne %q:\n%s", want, out.String())
}
}
}
// TestDoctorJSONUndMute: --json liefert die Prüfliste; eine Mute-Datei
// macht Mute zu warn; ein Key macht die Stufe ok.
func TestDoctorJSONUndMute(t *testing.T) {
root := t.TempDir()
setGetenv(t, map[string]string{
"TALK_AUDIO_SINK": "null",
"XDG_CONFIG_HOME": root,
"TALK_ELEVENLABS_KEY": "k",
})
if err := os.MkdirAll(filepath.Join(root, "talk"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "talk", "mute"), []byte("true\n"), 0o644); err != nil {
t.Fatal(err)
}
var out, errBuf bytes.Buffer
if code := runCLI([]string{"doctor", "--json"}, &out, &errBuf); code != exitOK {
t.Fatalf("talk doctor --json: Exit %d, stderr %q", code, errBuf.String())
}
var checks []check
if err := json.Unmarshal(out.Bytes(), &checks); err != nil {
t.Fatalf("JSON = %q (%v)", out.String(), err)
}
if c := checkByName(t, checks, "Mute"); c.Status != statusWarn || !strings.Contains(c.Detail, "unmute") {
t.Errorf("Mute = %+v, want warn mit unmute-Hinweis", c)
}
if c := checkByName(t, checks, "elevenlabs"); c.Status != statusOK || !strings.Contains(c.Detail, "Rachel") {
t.Errorf("elevenlabs = %+v, want ok mit Default-Stimme", c)
}
if c := checkByName(t, checks, "Daemon"); c.Status != statusWarn {
t.Errorf("Daemon = %+v, want warn", c)
}
}
// TestDoctorFremderDienstAufPort: Antwortet unter dem Port etwas anderes als
// talk, ist das ✘ mit Port-Hinweis — Exit 1.
func TestDoctorFremderDienstAufPort(t *testing.T) {
fremd := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(fremd.Close)
port := fremd.URL[strings.LastIndex(fremd.URL, ":")+1:]
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null", "TALK_PORT": port})
var out, errBuf bytes.Buffer
code := runCLI([]string{"doctor"}, &out, &errBuf)
if code != exitFailure {
t.Fatalf("talk doctor (fremder Dienst): Exit %d, want 1\n%s", code, out.String())
}
if !strings.Contains(out.String(), "✘ Daemon") || !strings.Contains(out.String(), "fremder Dienst") || !strings.Contains(out.String(), "TALK_PORT") {
t.Errorf("stdout = %q", out.String())
}
}
// TestDoctorKaputteConfig: Eine unlesbare config.toml ist ✘, die übrigen
// Prüfungen laufen trotzdem (mit Defaults) — der Bericht bleibt vollständig.
func TestDoctorKaputteConfig(t *testing.T) {
root := t.TempDir()
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null", "XDG_CONFIG_HOME": root})
if err := os.MkdirAll(filepath.Join(root, "talk"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "talk", "config.toml"), []byte("audio = [broken\n"), 0o644); err != nil {
t.Fatal(err)
}
var out, errBuf bytes.Buffer
code := runCLI([]string{"doctor"}, &out, &errBuf)
if code != exitFailure || !strings.Contains(out.String(), "✘ Config") {
t.Errorf("Exit %d, stdout %q — want ✘ Config und Exit 1", code, out.String())
}
for _, want := range []string{"Daemon", "edge", "Mute", "Gesetze"} {
if !strings.Contains(out.String(), want) {
t.Errorf("Prüfung %q fehlt trotz Config-Fehler:\n%s", want, out.String())
}
}
}