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>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 10:08:44 +02:00
1 parent 92333ff1c3
commit df9a9b30cf
20 files changed
+1639 -63

No files matched your search

+163
View File
@@ -0,0 +1,163 @@
package main
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"github.com/BurntSushi/toml"
)
func readFile(path string) ([]byte, error) { return os.ReadFile(path) }
// setupEnv stellt den Wizard auf „interaktiv“ und pinnt die Kette auf einen
// Fake-ElevenLabs-Endpunkt (sink null) — Probehören und Testsatz laufen
// gegen den Fake. Rückgabe: Zähler der Synthese-Aufrufe.
func setupEnv(t *testing.T, root string, extra map[string]string) *atomic.Int32 {
t.Helper()
var calls atomic.Int32
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.Header().Set("Content-Type", "audio/mpeg")
_, _ = w.Write([]byte("mp3"))
}))
t.Cleanup(fake.Close)
kv := map[string]string{
"XDG_CONFIG_HOME": root,
"TALK_AUDIO_SINK": "null",
"TALK_PROVIDER": "elevenlabs",
"TALK_ELEVENLABS_KEY": "setup-key",
"TALK_ELEVENLABS_ENDPOINT": fake.URL,
}
for k, v := range extra {
kv[k] = v
}
setGetenv(t, kv)
setupInteractive = func(io.Reader) bool { return true }
t.Cleanup(func() { setupInteractive = isCharDevice })
return &calls
}
// TestSetupSchreibtConfig: Antworten über stdin — Stimme 2 für Deutsch
// (probehören, übernehmen), Englisch Default behalten, Ruhezeit setzen —
// ergeben eine config.toml mit genau diesen Werten; Probehören und
// Testsatz treffen den Fake.
func TestSetupSchreibtConfig(t *testing.T) {
root := t.TempDir()
calls := setupEnv(t, root, nil)
stdin := strings.NewReader("2\nj\nj\n\nn\nj\n22:00-07:00\n")
var out, errBuf bytes.Buffer
code := run([]string{"setup"}, stdin, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk setup: Exit %d\nstdout: %s\nstderr: %s", code, out.String(), errBuf.String())
}
path := filepath.Join(root, "talk", "config.toml")
var got struct {
QuietHours string `toml:"quiet_hours"`
Voice map[string]map[string]string `toml:"voice"`
}
if _, err := toml.DecodeFile(path, &got); err != nil {
t.Fatalf("config lesen: %v", err)
}
if got.QuietHours != "22:00-07:00" {
t.Errorf("quiet_hours = %q", got.QuietHours)
}
if got.Voice["elevenlabs"]["de"] != "pNInz6obpgDQGcFmaJgB" { // Nr. 2 = Adam
t.Errorf("voice.elevenlabs.de = %q, want Adam", got.Voice["elevenlabs"]["de"])
}
if _, ok := got.Voice["elevenlabs"]["en"]; ok {
t.Errorf("voice.elevenlabs.en gesetzt, obwohl Default behalten wurde: %v", got.Voice)
}
if n := calls.Load(); n != 2 { // 1× Probehören de + 1× Testsatz
t.Errorf("Fake-Aufrufe = %d, want 2 (Probehören + Testsatz)", n)
}
if _, err := os.Stat(path + ".bak"); !os.IsNotExist(err) {
t.Errorf("Sicherung ohne Vorgänger-Datei angelegt")
}
if !strings.Contains(out.String(), "Gespeichert") || !strings.Contains(out.String(), "Gesprochen über elevenlabs") {
t.Errorf("stdout = %q", out.String())
}
}
// TestSetupMergtBestehendeConfig: Vorhandene Werte (anderer Port, andere
// Provider-Stimme) bleiben erhalten, es gibt eine .bak, und „keine“ löscht
// eine vorhandene Ruhezeit.
func TestSetupMergtBestehendeConfig(t *testing.T) {
root := t.TempDir()
setupEnv(t, root, nil)
dir := filepath.Join(root, "talk")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "config.toml")
orig := "quiet_hours = \"18:00-08:00\"\n\n[server]\nport = 7900\n\n[voice.edge]\nde = \"de-DE-ConradNeural\"\n"
if err := os.WriteFile(path, []byte(orig), 0o644); err != nil {
t.Fatal(err)
}
// Das Setup selbst muss auf den Default-Port 7800 zeigen, nicht 7900 aus
// der Datei — sonst würde die Daemon-Erkennung einen fremden Port
// anfassen. TALK_PORT=1 aus setGetenv schlägt die Datei.
stdin := strings.NewReader("1\nn\nj\n1\nn\nj\nkeine\n")
var out, errBuf bytes.Buffer
if code := run([]string{"setup"}, stdin, &out, &errBuf); code != exitOK {
t.Fatalf("talk setup: Exit %d\n%s\n%s", code, out.String(), errBuf.String())
}
bak, err := os.ReadFile(path + ".bak")
if err != nil || string(bak) != orig {
t.Errorf(".bak = %q (%v), want Original", bak, err)
}
var got map[string]any
if _, err := toml.DecodeFile(path, &got); err != nil {
t.Fatal(err)
}
if _, ok := got["quiet_hours"]; ok {
t.Errorf("quiet_hours nicht entfernt: %v", got)
}
if srv, _ := got["server"].(map[string]any); srv["port"] != int64(7900) {
t.Errorf("server.port verloren: %v", got["server"])
}
voice, _ := got["voice"].(map[string]any)
if edge, _ := voice["edge"].(map[string]any); edge["de"] != "de-DE-ConradNeural" {
t.Errorf("voice.edge.de verloren: %v", voice)
}
// Default behalten (Nr. 1 = Rachel = Repo-Default) → kein elevenlabs-Eintrag.
if _, ok := voice["elevenlabs"]; ok {
t.Errorf("voice.elevenlabs geschrieben, obwohl Default behalten: %v", voice)
}
}
// TestSetupAbbruchSchreibtNichts: „q“ bricht ab, keine Datei entsteht.
func TestSetupAbbruchSchreibtNichts(t *testing.T) {
root := t.TempDir()
setupEnv(t, root, nil)
var out, errBuf bytes.Buffer
if code := run([]string{"setup"}, strings.NewReader("q\n"), &out, &errBuf); code != exitOK {
t.Fatalf("Exit %d, stderr %q", code, errBuf.String())
}
if !strings.Contains(out.String(), "Abgebrochen") {
t.Errorf("stdout = %q", out.String())
}
if _, err := os.Stat(filepath.Join(root, "talk", "config.toml")); !os.IsNotExist(err) {
t.Error("config.toml trotz Abbruch angelegt")
}
}
// TestSetupOhneTerminal: Pipe → keine Fragen, wirksame Konfiguration, Exit 0.
func TestSetupOhneTerminal(t *testing.T) {
root := t.TempDir()
setGetenv(t, map[string]string{"XDG_CONFIG_HOME": root, "TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
if code := runCLI([]string{"setup"}, &out, &errBuf); code != exitOK {
t.Fatalf("Exit %d, stderr %q", code, errBuf.String())
}
if !strings.Contains(out.String(), "Wirksame Konfiguration") || !strings.Contains(out.String(), "ruhezeit: keine") {
t.Errorf("stdout = %q", out.String())
}
}