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:
1 parent
92333ff1c3
commit
df9a9b30cf
20 files changed
+1639
-63
No files matched your search
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user