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
+45
-12
@@ -81,24 +81,31 @@ var commands = []command{
|
||||
handler: runCancel,
|
||||
},
|
||||
{
|
||||
name: "voices",
|
||||
short: "Verfügbare Stimmen anzeigen",
|
||||
detail: "Listet die Stimmen aller Provider (Defaults + Config-Overrides) als Tabelle.",
|
||||
name: "voices",
|
||||
short: "Verfügbare Stimmen anzeigen",
|
||||
detail: "Listet bekannte Stimmen je Provider und Sprache als Tabelle; „*“ markiert die Stimme, die talk ohne --voice nimmt (config voice.<provider>.<lang> oder Repo-Default). Filter: --provider, --lang. --json für Maschinen. Jede Voice-ID des Providers funktioniert auch ohne Katalog-Eintrag.",
|
||||
register: addVoicesFlags,
|
||||
handler: runVoices,
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
short: "Test-Ansage ausgeben",
|
||||
detail: "Spricht eine feste Testphrase und zeigt den verwendeten Provider samt Latenz.",
|
||||
name: "test",
|
||||
short: "Test-Ansage ausgeben",
|
||||
detail: "Spricht eine feste Testphrase direkt in diesem Prozess (ohne Daemon, auch bei Mute) und zeigt, welcher Provider gesprochen hat und wie lange Synthese und Wiedergabe gedauert haben. --lang de|en wählt die Phrase, --provider/--voice pinnen wie bei say.",
|
||||
register: addTestFlags,
|
||||
handler: runTest,
|
||||
},
|
||||
{
|
||||
name: "doctor",
|
||||
short: "Setup-Diagnose anzeigen",
|
||||
detail: "Prüft Daemon, Audio-Sink, Provider-Keys und Config und berichtet zeilenweise mit ✔/✘/⚠.",
|
||||
name: "doctor",
|
||||
short: "Setup-Diagnose anzeigen",
|
||||
detail: "Prüft Config, Daemon, Audio-Ausgabe, jede Provider-Stufe, Mute und Gesetze und berichtet zeilenweise mit ✔ (ok), ⚠ (Hinweis), ✘ (Fehler). Exit 0 ohne ✘, sonst 1; ein nicht laufender Daemon ist ⚠, kein Fehler. --probe macht echte Synthese-Proben bei allen verfügbaren Providern (Netz). --json für Maschinen.",
|
||||
register: addDoctorFlags,
|
||||
handler: runDoctor,
|
||||
},
|
||||
{
|
||||
name: "setup",
|
||||
short: "Geführte Ersteinrichtung (Wizard)",
|
||||
detail: "Interaktiver Wizard: Provider-Keys, Stimmen probehören, config.toml schreiben. Ohne TTY: plain.",
|
||||
name: "setup",
|
||||
short: "Geführte Ersteinrichtung",
|
||||
detail: "Fragt in wenigen Schritten nach dem, was talk zum Loslegen braucht: Keys (optional — ohne Key spricht die kostenlose Edge-Stimme), Stimme je Sprache mit Probehören, Ruhezeit — und schreibt die config.toml (bestehende Datei wird zusammengeführt, Sicherung .bak). Ohne Terminal: zeigt nur die wirksame Konfiguration.",
|
||||
handler: runSetup,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -122,6 +129,32 @@ func addSayFlags(fs *flag.FlagSet) *sayOptions {
|
||||
return o
|
||||
}
|
||||
|
||||
// addVoicesFlags registriert die Flags von voices (Filter + JSON).
|
||||
func addVoicesFlags(fs *flag.FlagSet) *sayOptions {
|
||||
o := &sayOptions{}
|
||||
fs.StringVar(&o.provider, "provider", "", "nur dieser Provider: elevenlabs | openai | edge | osbuiltin")
|
||||
fs.StringVar(&o.lang, "lang", "", "nur diese Sprache: de | en")
|
||||
fs.BoolVar(&o.jsonOut, "json", false, "maschinenlesbare JSON-Ausgabe")
|
||||
return o
|
||||
}
|
||||
|
||||
// addTestFlags registriert die Flags von test (Phrase + Pins).
|
||||
func addTestFlags(fs *flag.FlagSet) *sayOptions {
|
||||
o := &sayOptions{}
|
||||
fs.StringVar(&o.lang, "lang", "", "Sprache der Testphrase: de | en (Default de)")
|
||||
fs.StringVar(&o.provider, "provider", "", "Provider pinnen: elevenlabs | openai | edge | osbuiltin")
|
||||
fs.StringVar(&o.voice, "voice", "", "Stimme als provider-spezifische ID")
|
||||
return o
|
||||
}
|
||||
|
||||
// addDoctorFlags registriert die Flags von doctor.
|
||||
func addDoctorFlags(fs *flag.FlagSet) *sayOptions {
|
||||
o := &sayOptions{}
|
||||
fs.BoolVar(&o.jsonOut, "json", false, "maschinenlesbare JSON-Ausgabe")
|
||||
fs.BoolVar(&o.probe, "probe", false, "echte Synthese-Probe bei jedem verfügbaren Provider (Netz-Aufrufe)")
|
||||
return o
|
||||
}
|
||||
|
||||
// run ist der Einstiegspunkt der CLI (Argumente ohne Programmnamen). Alle
|
||||
// Ausgaben gehen auf die übergebenen Writer, stdin ist als Reader injiziert
|
||||
// — damit ist run direkt und ohne Prozessstart testbar (auch der stdin-
|
||||
|
||||
@@ -14,14 +14,6 @@ var wantSubcommands = []string{
|
||||
"say", "wrap", "mute", "unmute", "status", "cancel", "voices", "test", "doctor", "setup",
|
||||
}
|
||||
|
||||
// wantStubCommands ist wantSubcommands ohne die implementierten Befehle —
|
||||
// say (Task 02/04), mute/unmute/status/cancel (Task 04, Daemon-Aufrufe)
|
||||
// und wrap (Task 07, Befehlsummichtung mit Ansage) sind real; die
|
||||
// restlichen bleiben Stubs bis zu ihren Tasks.
|
||||
var wantStubCommands = []string{
|
||||
"voices", "test", "doctor", "setup",
|
||||
}
|
||||
|
||||
// runCLI ist der Test-Einstieg in run mit leerem stdin (kein Text über
|
||||
// Pipe) — dem üblichen Aufruf ohne Eingabe.
|
||||
func runCLI(argv []string, stdout, stderr io.Writer) int {
|
||||
@@ -70,27 +62,6 @@ func TestVersionStringMitCommit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubcommandStubs(t *testing.T) {
|
||||
for _, name := range wantStubCommands {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{name}, &out, &errBuf)
|
||||
if code == exitOK {
|
||||
t.Errorf("talk %s: Exit-Code 0, want non-zero für Stub", name)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "not implemented yet") {
|
||||
t.Errorf("talk %s: stderr = %q, will Hinweis auf nicht implementiert", name, errBuf.String())
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), name) {
|
||||
t.Errorf("talk %s: stderr nennt den Befehlsnamen nicht: %q", name, errBuf.String())
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Errorf("talk %s: unerwartete stdout-Ausgabe %q", name, out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSayHelpZeigtFlags(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"say", "--help"}, &out, &errBuf)
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/api"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/audio"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/daemon"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/provider"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/speak"
|
||||
)
|
||||
|
||||
// Status einer doctor-Prüfung.
|
||||
const (
|
||||
statusOK = "ok" // ✔ in Ordnung
|
||||
statusWarn = "warn" // ⚠ Hinweis — funktioniert, aber eingeschränkt oder beachtenswert
|
||||
statusFail = "fail" // ✘ Fehler — so wird nichts zu hören sein (Exit 1)
|
||||
)
|
||||
|
||||
// check ist eine Zeile des doctor-Berichts (auch die JSON-Form).
|
||||
type check struct {
|
||||
Check string `json:"check"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
// probeTimeout ist das Budget je Synthese-Probe (--probe).
|
||||
const probeTimeout = 20 * time.Second
|
||||
|
||||
// runDoctor implementiert `talk doctor`: eine Zeile je Prüfpunkt — Config,
|
||||
// Daemon, Audio, jede Provider-Stufe, Mute, Gesetze — mit klarem Satz, was
|
||||
// Sache ist und was zu tun wäre. Exit 0 ohne ✘, sonst 1; ein nicht
|
||||
// laufender Daemon ist ⚠ (er startet beim nächsten Sprechbefehl). --probe
|
||||
// macht echte Synthese-Proben (Netz); --json liefert die Liste als JSON.
|
||||
func runDoctor(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Writer) int {
|
||||
if len(args) > 0 {
|
||||
fmt.Fprintln(stderr, "talk doctor: keine Argumente erwartet (Flags: --probe, --json)")
|
||||
return exitUsage
|
||||
}
|
||||
checks := doctorChecks(opts.probe)
|
||||
failed := false
|
||||
for _, c := range checks {
|
||||
if c.Status == statusFail {
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
if opts.jsonOut {
|
||||
enc := json.NewEncoder(stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(checks)
|
||||
} else {
|
||||
for _, c := range checks {
|
||||
fmt.Fprintf(stdout, "%s %-11s %s\n", symbol(c.Status), c.Check, c.Detail)
|
||||
}
|
||||
if failed {
|
||||
fmt.Fprintln(stdout, "\nMindestens ein Punkt verhindert Ton (✘). Behebe ihn und prüfe mit `talk test`.")
|
||||
} else {
|
||||
fmt.Fprintln(stdout, "\nAlles bereit. Probiere: talk test")
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
return exitFailure
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// symbol rendert den Status: Unicode-Zeichen, auf Windows-Konsolen (oft
|
||||
// ohne UTF-8) kurze Wörter.
|
||||
func symbol(status string) string {
|
||||
win := runtime.GOOS == "windows"
|
||||
switch status {
|
||||
case statusOK:
|
||||
if win {
|
||||
return "[OK] "
|
||||
}
|
||||
return "✔"
|
||||
case statusWarn:
|
||||
if win {
|
||||
return "[WARN]"
|
||||
}
|
||||
return "⚠"
|
||||
default:
|
||||
if win {
|
||||
return "[FAIL]"
|
||||
}
|
||||
return "✘"
|
||||
}
|
||||
}
|
||||
|
||||
// doctorChecks führt alle Prüfungen aus. Jede Prüfung ist unabhängig: Ein
|
||||
// Fehler in einer (z. B. kaputte Config) verhindert die anderen nicht —
|
||||
// dann laufen sie mit Defaults, damit der Bericht vollständig bleibt.
|
||||
func doctorChecks(probe bool) []check {
|
||||
var out []check
|
||||
|
||||
// 1. Config
|
||||
cfg, cfgCheck := checkConfig()
|
||||
out = append(out, cfgCheck)
|
||||
|
||||
// 2. Daemon
|
||||
out = append(out, checkDaemon(cfg))
|
||||
|
||||
// 3. Audio
|
||||
out = append(out, checkAudio(cfg))
|
||||
|
||||
// 4. Provider-Stufen (+ 5. Gesamt-Verfügbarkeit)
|
||||
out = append(out, checkProviders(cfg, probe)...)
|
||||
|
||||
// 6. Mute
|
||||
out = append(out, checkMute())
|
||||
|
||||
// 7. Gesetze
|
||||
out = append(out, checkLaws(cfg))
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func checkConfig() (config.Config, check) {
|
||||
path, perr := config.Path(getenv)
|
||||
cfg, err := config.Load(config.Options{Getenv: getenv})
|
||||
switch {
|
||||
case err != nil:
|
||||
// Mit Defaults weiterprüfen, aber den Fehler klar nennen.
|
||||
cfg, _ = config.Load(config.Options{Getenv: func(string) string { return "" }})
|
||||
return cfg, check{"Config", statusFail, fmt.Sprintf("%v — bitte beheben, talk läuft sonst nicht", err)}
|
||||
case perr != nil:
|
||||
return cfg, check{"Config", statusWarn, "kein Config-Verzeichnis (HOME bzw. APPDATA fehlt) — Defaults + Env; Mute und Daemon-Dateien deaktiviert"}
|
||||
}
|
||||
if _, statErr := os.Stat(path); statErr != nil {
|
||||
return cfg, check{"Config", statusOK, fmt.Sprintf("keine Datei unter %s — Defaults gelten (talk setup legt eine an)", path)}
|
||||
}
|
||||
return cfg, check{"Config", statusOK, path + " geladen"}
|
||||
}
|
||||
|
||||
func checkDaemon(cfg config.Config) check {
|
||||
client := api.NewClient(cfg.Server.Port)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), daemonProbeTimeout)
|
||||
defer cancel()
|
||||
st, err := client.Status(ctx)
|
||||
if err == nil {
|
||||
detail := fmt.Sprintf("läuft unter %s (version %s, %d in der Queue)", client.BaseURL, st.Version, st.Queue)
|
||||
if dir, derr := config.Dir(getenv); derr == nil {
|
||||
detail += ", Log: " + filepath.Join(dir, daemon.LogFileName)
|
||||
}
|
||||
return check{"Daemon", statusOK, detail}
|
||||
}
|
||||
var httpErr *api.HTTPError
|
||||
if errors.As(err, &httpErr) {
|
||||
return check{"Daemon", statusFail, fmt.Sprintf("unter %s antwortet ein fremder Dienst (HTTP %d) — anderen Port wählen: [server] port bzw. TALK_PORT", client.BaseURL, httpErr.Status)}
|
||||
}
|
||||
if cfg.Daemon.Autostart {
|
||||
return check{"Daemon", statusWarn, fmt.Sprintf("läuft nicht (%s) — startet beim nächsten Sprechbefehl von selbst", client.BaseURL)}
|
||||
}
|
||||
return check{"Daemon", statusWarn, fmt.Sprintf("läuft nicht (%s), Autostart ist aus — `talk daemon start` oder Dienst einrichten; bis dahin spricht talk direkt", client.BaseURL)}
|
||||
}
|
||||
|
||||
func checkAudio(cfg config.Config) check {
|
||||
if cfg.Audio.Sink == config.SinkNull {
|
||||
return check{"Audio", statusWarn, "audio.sink = null — kein Ton (für CI/Server gedacht; TALK_AUDIO_SINK=oto für Wiedergabe)"}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := audio.Check(ctx, cfg.Audio.Sink); err != nil {
|
||||
return check{"Audio", statusFail, fmt.Sprintf("%v — kein Audiogerät oder Audio-Bibliothek fehlt (Linux: PulseAudio/ALSA)", err)}
|
||||
}
|
||||
return check{"Audio", statusOK, "Audiogerät bereit (oto)"}
|
||||
}
|
||||
|
||||
func checkProviders(cfg config.Config, probe bool) []check {
|
||||
var out []check
|
||||
engine, err := speak.New(cfg, getenv, io.Discard)
|
||||
if err != nil {
|
||||
return []check{{"Provider", statusFail, err.Error()}}
|
||||
}
|
||||
available := 0
|
||||
for _, p := range engine.Chain().Tiers() {
|
||||
c := checkTier(p, cfg)
|
||||
if p.Available(context.Background()) {
|
||||
available++
|
||||
if probe {
|
||||
c.Detail += "; " + probeTier(p)
|
||||
}
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
if available == 0 {
|
||||
out = append(out, check{"Provider", statusFail, "keine Stufe verfügbar — ohne Key und ohne OS-Stimme kann talk nicht sprechen (espeak-ng installieren oder TALK_ELEVENLABS_KEY/TALK_OPENAI_KEY setzen)"})
|
||||
} else if cfg.Provider != "" {
|
||||
out = append(out, check{"Provider", statusOK, fmt.Sprintf("Kette auf %q gepinnt (config provider) — kein Fallback", cfg.Provider)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func checkTier(p provider.Provider, cfg config.Config) check {
|
||||
name := p.Name()
|
||||
switch name {
|
||||
case "elevenlabs", "openai":
|
||||
envKey := provider.EnvElevenLabsKey
|
||||
if name == "openai" {
|
||||
envKey = provider.EnvOpenAIKey
|
||||
}
|
||||
if !p.Available(context.Background()) {
|
||||
return check{name, statusWarn, fmt.Sprintf("kein Key (%s) — Stufe wird übersprungen", envKey)}
|
||||
}
|
||||
detail := fmt.Sprintf("Key gesetzt (%s)", envKey)
|
||||
if ep := cfg.Providers[name].Endpoint; ep != "" {
|
||||
detail += ", Endpunkt " + ep
|
||||
}
|
||||
if v := provider.DefaultVoice(name, "de", cfg.Voice); v != "" {
|
||||
detail += ", Stimme de: " + voiceLabel(name, v)
|
||||
}
|
||||
return check{name, statusOK, detail}
|
||||
case "edge":
|
||||
if !p.Available(context.Background()) {
|
||||
return check{name, statusWarn, "kein Endpunkt konfiguriert — Stufe wird übersprungen"}
|
||||
}
|
||||
return check{name, statusOK, "kostenlos, ohne Key (braucht Internet), Stimme de: " + voiceLabel(name, provider.DefaultVoice(name, "de", cfg.Voice))}
|
||||
case "osbuiltin":
|
||||
desc := "OS-Stimme"
|
||||
if d, ok := p.(interface{ Describe() string }); ok {
|
||||
desc = d.Describe()
|
||||
}
|
||||
if !p.Available(context.Background()) {
|
||||
return check{name, statusWarn, desc + " — letzte Stufe fehlt; ohne Netz bleibt talk stumm"}
|
||||
}
|
||||
if strings.Contains(desc, "nur Direktansage") {
|
||||
return check{name, statusWarn, desc}
|
||||
}
|
||||
return check{name, statusOK, desc}
|
||||
default:
|
||||
if p.Available(context.Background()) {
|
||||
return check{name, statusOK, "verfügbar"}
|
||||
}
|
||||
return check{name, statusWarn, "nicht verfügbar"}
|
||||
}
|
||||
}
|
||||
|
||||
// voiceLabel rendert eine Voice-ID mit Katalog-Namen, falls bekannt.
|
||||
func voiceLabel(prov, id string) string {
|
||||
if n := provider.VoiceName(prov, id); n != "" {
|
||||
return fmt.Sprintf("%s (%s)", n, id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// probeTier macht eine echte Synthese-Probe (kein Abspielen) und meldet
|
||||
// Bytes und Dauer bzw. den Fehler.
|
||||
func probeTier(p provider.Provider) string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), probeTimeout)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
data, err := p.Synthesize(ctx, "Test", "de")
|
||||
switch {
|
||||
case errors.Is(err, provider.ErrNoByteOutput):
|
||||
return "Probe: liefert keine Bytes (spricht nur direkt) — ok"
|
||||
case err != nil:
|
||||
return "Probe FEHLGESCHLAGEN: " + err.Error()
|
||||
default:
|
||||
return fmt.Sprintf("Probe ok: %d Bytes in %s", len(data), time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
}
|
||||
|
||||
func checkMute() check {
|
||||
dir, err := config.Dir(getenv)
|
||||
if err != nil {
|
||||
return check{"Mute", statusOK, "aus (kein Config-Dir, Mute nicht verfügbar)"}
|
||||
}
|
||||
if (queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}).Muted() {
|
||||
return check{"Mute", statusWarn, "AN — alle Ansagen werden verworfen, bis `talk unmute`"}
|
||||
}
|
||||
return check{"Mute", statusOK, "aus"}
|
||||
}
|
||||
|
||||
func checkLaws(cfg config.Config) check {
|
||||
parts := []string{
|
||||
fmt.Sprintf("rate-limit %s", durationOrOff(cfg.Queue.RateLimit)),
|
||||
fmt.Sprintf("max. %d Zeichen", cfg.Queue.MaxText),
|
||||
fmt.Sprintf("max. Wartezeit %s", durationOrOff(cfg.Queue.MaxAge)),
|
||||
}
|
||||
status := statusOK
|
||||
if cfg.QuietHours == "" {
|
||||
parts = append(parts, "Ruhezeit aus")
|
||||
} else {
|
||||
q, err := queue.ParseQuietHours(cfg.QuietHours)
|
||||
if err == nil && q.Active(time.Now()) {
|
||||
status = statusWarn
|
||||
parts = append(parts, fmt.Sprintf("Ruhezeit %s AKTIV bis %s (modus %s) — Ansagen werden gerade verworfen", cfg.QuietHours, q.Until(time.Now()).Format("15:04"), cfg.QuietMode))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("Ruhezeit %s (modus %s)", cfg.QuietHours, cfg.QuietMode))
|
||||
}
|
||||
}
|
||||
return check{"Gesetze", status, strings.Join(parts, ", ")}
|
||||
}
|
||||
|
||||
func durationOrOff(d time.Duration) string {
|
||||
if d <= 0 {
|
||||
return "aus"
|
||||
}
|
||||
return d.String()
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -27,6 +27,12 @@ type sayOptions struct {
|
||||
voice string
|
||||
lang string
|
||||
provider string
|
||||
|
||||
// Diagnose-Flags (doctor, voices): maschinenlesbare Ausgabe und echte
|
||||
// Netz-Proben. Liegen in derselben Struct, damit die Befehlstabelle
|
||||
// eine Flag-Signatur behält; say ignoriert sie.
|
||||
jsonOut bool
|
||||
probe bool
|
||||
}
|
||||
|
||||
// runSay ist der say-Eingangspfad: Text aus Argumenten oder stdin sammeln
|
||||
@@ -100,7 +106,7 @@ func sayDirect(cfg config.Config, text string, opts *sayOptions, info, stderr io
|
||||
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
err = engine.Speak(context.Background(), speak.Request{
|
||||
_, err = engine.Speak(context.Background(), speak.Request{
|
||||
Text: text,
|
||||
Lang: opts.lang,
|
||||
Provider: opts.provider,
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/api"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/provider"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/speak"
|
||||
)
|
||||
|
||||
// setupInteractive entscheidet, ob der Wizard Fragen stellen darf (stdin
|
||||
// ist ein Terminal). Tests überschreiben die Seam und füttern Antworten
|
||||
// über stdin.
|
||||
var setupInteractive = isCharDevice
|
||||
|
||||
// setupSample ist der Probehör-Satz je Sprache.
|
||||
var setupSample = map[string]string{
|
||||
"de": "Hallo, so klinge ich. Build fertig.",
|
||||
"en": "Hello, this is how I sound. Build finished.",
|
||||
}
|
||||
|
||||
// runSetup implementiert `talk setup`: wenige Fragen, dann config.toml.
|
||||
//
|
||||
// 1. Daemon/Version, Audio.
|
||||
// 2. Keys: gefunden → Premium; keiner → Erklärung, dass die kostenlose
|
||||
// Edge-Stimme (Internet) bzw. die OS-Stimme ohnehin funktionieren.
|
||||
// 3. Stimme je Sprache (de, en) für die aktive Stufe: Nummer aus dem
|
||||
// Katalog wählen, probehören, übernehmen.
|
||||
// 4. Ruhezeit (optional).
|
||||
// 5. config.toml schreiben (bestehende zusammenführen, .bak), Test-Satz.
|
||||
//
|
||||
// Ohne Terminal (Pipe, Agent) stellt setup keine Fragen: Es zeigt die
|
||||
// wirksame Konfiguration und endet mit Exit 0 — nichts bricht, nichts
|
||||
// wartet. Abbruch mit Ctrl-C oder leerer Eingabe bei „q": nichts
|
||||
// halbfertig geschrieben (geschrieben wird erst ganz am Ende).
|
||||
func runSetup(args []string, _ *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
if len(args) > 0 {
|
||||
fmt.Fprintln(stderr, "talk setup: keine Argumente erwartet")
|
||||
return exitUsage
|
||||
}
|
||||
cfg, err := config.Load(config.Options{Getenv: getenv})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk setup: %v\nBitte zuerst die Config-Datei reparieren oder umbenennen.\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
path, perr := config.Path(getenv)
|
||||
if perr != nil {
|
||||
fmt.Fprintf(stderr, "talk setup: %v — ohne Config-Verzeichnis kann setup nichts speichern\n", perr)
|
||||
return exitFailure
|
||||
}
|
||||
|
||||
if !setupInteractive(stdin) {
|
||||
fmt.Fprintln(stdout, "talk setup braucht ein Terminal, um Fragen zu stellen. Wirksame Konfiguration:")
|
||||
printEffective(stdout, cfg, path)
|
||||
return exitOK
|
||||
}
|
||||
|
||||
w := &wizard{in: bufio.NewReader(stdin), out: stdout, err: stderr, cfg: cfg}
|
||||
fmt.Fprintln(stdout, "talk einrichten — ein paar Fragen, Enter übernimmt den Vorschlag in [Klammern], q bricht ab.")
|
||||
fmt.Fprintln(stdout)
|
||||
|
||||
// 1. Umgebung
|
||||
fmt.Fprintf(stdout, "Audio-Ausgabe: %s\n", audioSummary(cfg))
|
||||
client := api.NewClient(cfg.Server.Port)
|
||||
if daemonAlive(client, daemonProbeTimeout) {
|
||||
fmt.Fprintf(stdout, "Daemon: läuft unter %s\n", client.BaseURL)
|
||||
} else {
|
||||
fmt.Fprintln(stdout, "Daemon: startet beim ersten Sprechbefehl von selbst")
|
||||
}
|
||||
|
||||
// 2. Keys
|
||||
engine, err := speak.New(cfg, getenv, io.Discard)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk setup: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
active := engine.ActiveProvider("")
|
||||
fmt.Fprintln(stdout)
|
||||
switch {
|
||||
case getenv(provider.EnvElevenLabsKey) != "":
|
||||
fmt.Fprintln(stdout, "Stimmen: ElevenLabs-Key gefunden (TALK_ELEVENLABS_KEY) — Premium-Stimme.")
|
||||
case getenv(provider.EnvOpenAIKey) != "":
|
||||
fmt.Fprintln(stdout, "Stimmen: OpenAI-Key gefunden (TALK_OPENAI_KEY).")
|
||||
default:
|
||||
fmt.Fprintln(stdout, "Stimmen: kein Key gefunden — das ist ok. talk nutzt die kostenlose Edge-Stimme von")
|
||||
fmt.Fprintln(stdout, " Microsoft (braucht Internet) und sonst die Stimme deines Betriebssystems.")
|
||||
fmt.Fprintln(stdout, " Für Premium-Stimmen später TALK_ELEVENLABS_KEY oder TALK_OPENAI_KEY als")
|
||||
fmt.Fprintln(stdout, " Umgebungsvariable setzen (nie in die config.toml).")
|
||||
}
|
||||
if active == speak.NoProvider {
|
||||
fmt.Fprintln(stderr, "talk setup: derzeit ist keine Stimme verfügbar — `talk doctor` zeigt, was fehlt. Einstellungen werden trotzdem gespeichert.")
|
||||
} else {
|
||||
fmt.Fprintf(stdout, "Aktive Stufe: %s\n", active)
|
||||
}
|
||||
|
||||
// 3. Stimmen
|
||||
voices := map[string]string{}
|
||||
if active != speak.NoProvider && active != "osbuiltin" {
|
||||
for _, lang := range []string{"de", "en"} {
|
||||
id, ok := w.chooseVoice(engine, active, lang)
|
||||
if !ok {
|
||||
fmt.Fprintln(stdout, "Abgebrochen — nichts geschrieben.")
|
||||
return exitOK
|
||||
}
|
||||
if id != "" {
|
||||
voices[lang] = id
|
||||
}
|
||||
}
|
||||
} else if active == "osbuiltin" {
|
||||
fmt.Fprintln(stdout, "\nDie OS-Stimme wählt dein Betriebssystem; es gibt hier nichts einzustellen.")
|
||||
}
|
||||
|
||||
// 4. Ruhezeit
|
||||
fmt.Fprintln(stdout)
|
||||
quiet, ok := w.askQuietHours(cfg.QuietHours)
|
||||
if !ok {
|
||||
fmt.Fprintln(stdout, "Abgebrochen — nichts geschrieben.")
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// 5. Schreiben
|
||||
changes := map[string]any{}
|
||||
if len(voices) > 0 {
|
||||
changes["voice"] = map[string]any{active: toAny(voices)}
|
||||
}
|
||||
changes["quiet_hours"] = quiet
|
||||
backup, err := writeConfig(path, changes)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk setup: config schreiben: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
fmt.Fprintln(stdout)
|
||||
fmt.Fprintf(stdout, "Gespeichert: %s\n", path)
|
||||
if backup != "" {
|
||||
fmt.Fprintf(stdout, "Sicherung: %s\n", backup)
|
||||
}
|
||||
|
||||
// Abschluss: Testsatz über die neue Config.
|
||||
if active != speak.NoProvider {
|
||||
fmt.Fprintln(stdout, "\nTestsatz …")
|
||||
if cfg2, err := config.Load(config.Options{Getenv: getenv}); err == nil {
|
||||
if eng2, err := speak.New(cfg2, getenv, io.Discard); err == nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
if out, err := eng2.Speak(ctx, speak.Request{Text: testPhrase["de"], Lang: "de"}); err != nil {
|
||||
fmt.Fprintf(stderr, "talk setup: Testsatz fehlgeschlagen: %v (siehe talk doctor)\n", err)
|
||||
} else {
|
||||
fmt.Fprintf(stdout, "Gesprochen über %s.\n", out.Provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(stdout, "\nFertig. Probiere: talk \"Hallo Welt\" — Status: talk status — Stumm: talk mute")
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// wizard hält Ein-/Ausgabe des Setups.
|
||||
type wizard struct {
|
||||
in *bufio.Reader
|
||||
out io.Writer
|
||||
err io.Writer
|
||||
cfg config.Config
|
||||
}
|
||||
|
||||
// ask stellt eine Frage mit Vorschlag; Enter liefert den Vorschlag, „q"
|
||||
// bricht ab (ok=false). EOF zählt als Abbruch.
|
||||
func (w *wizard) ask(prompt, def string) (string, bool) {
|
||||
if def != "" {
|
||||
fmt.Fprintf(w.out, "%s [%s]: ", prompt, def)
|
||||
} else {
|
||||
fmt.Fprintf(w.out, "%s: ", prompt)
|
||||
}
|
||||
line, err := w.in.ReadString('\n')
|
||||
if err != nil && line == "" {
|
||||
return "", false
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "q" || line == "Q" {
|
||||
return "", false
|
||||
}
|
||||
if line == "" {
|
||||
return def, true
|
||||
}
|
||||
return line, true
|
||||
}
|
||||
|
||||
// chooseVoice lässt eine Stimme für provider/lang wählen: nummerierte
|
||||
// Katalog-Liste, aktueller Default markiert; Probehören; übernehmen oder
|
||||
// neu wählen. Rückgabe: gewählte ID ("" = Default behalten) und ok=false
|
||||
// bei Abbruch.
|
||||
func (w *wizard) chooseVoice(engine *speak.Engine, prov, lang string) (string, bool) {
|
||||
langName := map[string]string{"de": "Deutsch", "en": "Englisch"}[lang]
|
||||
current := provider.DefaultVoice(prov, lang, w.cfg.Voice)
|
||||
var list []provider.Voice
|
||||
for _, v := range provider.Catalog() {
|
||||
if v.Provider == prov && v.Lang == lang {
|
||||
list = append(list, v)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w.out, "\nStimme für %s (%s):\n", langName, prov)
|
||||
defIdx := ""
|
||||
for i, v := range list {
|
||||
mark := " "
|
||||
if v.ID == current {
|
||||
mark = "*"
|
||||
defIdx = strconv.Itoa(i + 1)
|
||||
}
|
||||
fmt.Fprintf(w.out, " %s %d) %s\n", mark, i+1, v.Name)
|
||||
}
|
||||
if defIdx == "" && current != "" {
|
||||
fmt.Fprintf(w.out, " * aktuell: %s (aus config.toml)\n", current)
|
||||
}
|
||||
for {
|
||||
answer, ok := w.ask("Nummer wählen, oder eine Voice-ID eintippen", defIdx)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
id := answer
|
||||
if n, err := strconv.Atoi(answer); err == nil {
|
||||
if n < 1 || n > len(list) {
|
||||
fmt.Fprintf(w.out, "Bitte 1–%d.\n", len(list))
|
||||
continue
|
||||
}
|
||||
id = list[n-1].ID
|
||||
}
|
||||
if id == "" {
|
||||
return "", true
|
||||
}
|
||||
hear, ok := w.ask("Probehören?", "J")
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if strings.EqualFold(hear, "j") || strings.EqualFold(hear, "ja") || strings.EqualFold(hear, "y") {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
_, err := engine.Speak(ctx, speak.Request{Text: setupSample[lang], Lang: lang, Provider: prov, Voice: id})
|
||||
cancel()
|
||||
if err != nil {
|
||||
fmt.Fprintf(w.err, "Probehören fehlgeschlagen: %v\n", err)
|
||||
}
|
||||
}
|
||||
take, ok := w.ask("Übernehmen?", "J")
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if strings.EqualFold(take, "j") || strings.EqualFold(take, "ja") || strings.EqualFold(take, "y") {
|
||||
if id == current {
|
||||
return "", true // Default behalten — nichts in die Datei schreiben
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// askQuietHours fragt das Ruhezeit-Fenster ab (leer = keine).
|
||||
func (w *wizard) askQuietHours(current string) (string, bool) {
|
||||
fmt.Fprintln(w.out, "Ruhezeit: In diesem Fenster bleibt talk still (z. B. 22:00-07:00). Leer lassen = keine Ruhezeit.")
|
||||
def := current
|
||||
if def == "" {
|
||||
def = "keine"
|
||||
}
|
||||
for {
|
||||
answer, ok := w.ask("Ruhezeit", def)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if answer == "keine" || answer == "-" || answer == "aus" {
|
||||
return "", true
|
||||
}
|
||||
if _, err := queue.ParseQuietHours(answer); err != nil {
|
||||
fmt.Fprintf(w.out, "Format HH:MM-HH:MM, z. B. 22:00-07:00 (%v).\n", err)
|
||||
continue
|
||||
}
|
||||
return answer, true
|
||||
}
|
||||
}
|
||||
|
||||
// writeConfig führt changes in die bestehende config.toml ein (fehlende
|
||||
// Datei: neu) und schreibt sie atomar; eine vorhandene Datei wird vorher
|
||||
// als .bak gesichert. Verschachtelte Tabellen (voice.<provider>) werden
|
||||
// zusammengeführt, nicht ersetzt. Rückgabe: Pfad der Sicherung oder "".
|
||||
func writeConfig(path string, changes map[string]any) (backup string, err error) {
|
||||
existing := map[string]any{}
|
||||
if raw, rerr := os.ReadFile(path); rerr == nil {
|
||||
if _, derr := toml.Decode(string(raw), &existing); derr != nil {
|
||||
return "", fmt.Errorf("bestehende %s unlesbar: %w", path, derr)
|
||||
}
|
||||
backup = path + ".bak"
|
||||
if werr := os.WriteFile(backup, raw, 0o644); werr != nil {
|
||||
return "", werr
|
||||
}
|
||||
} else if !os.IsNotExist(rerr) {
|
||||
return "", rerr
|
||||
}
|
||||
mergeInto(existing, changes)
|
||||
// Leere Ruhezeit heißt: Schlüssel raus, statt "" zu schreiben.
|
||||
if v, ok := existing["quiet_hours"]; ok && v == "" {
|
||||
delete(existing, "quiet_hours")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("# talk — geschrieben von `talk setup`. Keys gehören NICHT hierher (TALK_*-Umgebungsvariablen).\n\n")
|
||||
if err := toml.NewEncoder(&buf).Encode(existing); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, buf.Bytes(), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
return backup, nil
|
||||
}
|
||||
|
||||
// mergeInto schreibt src rekursiv in dst (Tabellen zusammenführen).
|
||||
func mergeInto(dst, src map[string]any) {
|
||||
for k, v := range src {
|
||||
if sv, ok := v.(map[string]any); ok {
|
||||
if dv, ok := dst[k].(map[string]any); ok {
|
||||
mergeInto(dv, sv)
|
||||
continue
|
||||
}
|
||||
}
|
||||
dst[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
func toAny(m map[string]string) map[string]any {
|
||||
out := make(map[string]any, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// printEffective zeigt die wirksame Konfiguration (Nicht-Terminal-Pfad).
|
||||
func printEffective(w io.Writer, cfg config.Config, path string) {
|
||||
fmt.Fprintf(w, " config: %s\n", path)
|
||||
fmt.Fprintf(w, " audio: %s\n", audioSummary(cfg))
|
||||
fmt.Fprintf(w, " provider: %s\n", orDefault(cfg.Provider, "Kette "+strings.Join(provider.DefaultOrder, " → ")))
|
||||
for _, p := range provider.DefaultOrder {
|
||||
for _, lang := range []string{"de", "en"} {
|
||||
if id := cfg.Voice[p][lang]; id != "" {
|
||||
fmt.Fprintf(w, " voice: %s/%s = %s\n", p, lang, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, " ruhezeit: %s\n", orDefault(cfg.QuietHours, "keine"))
|
||||
fmt.Fprintf(w, " autostart: %v\n", cfg.Daemon.Autostart)
|
||||
}
|
||||
|
||||
func audioSummary(cfg config.Config) string {
|
||||
if cfg.Audio.Sink == config.SinkNull {
|
||||
return "null (kein Ton — CI/Server)"
|
||||
}
|
||||
return "eingebaute Wiedergabe (oto)"
|
||||
}
|
||||
|
||||
func orDefault(v, def string) string {
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/speak"
|
||||
)
|
||||
|
||||
// testPhrase ist die feste Testphrase je Sprache.
|
||||
var testPhrase = map[string]string{
|
||||
"de": "Dies ist eine Testansage von talk. Wenn du das hörst, funktioniert alles.",
|
||||
"en": "This is a test announcement from talk. If you can hear this, everything works.",
|
||||
}
|
||||
|
||||
// runTest implementiert `talk test`: spricht die Testphrase DIREKT in diesem
|
||||
// Prozess (nicht über den Daemon) — so sieht der Befehl, welche Stufe
|
||||
// wirklich gesprochen hat und wie lange Synthese und Wiedergabe dauerten.
|
||||
// Mute und Ruhezeit gelten hier bewusst nicht (der Nutzer will gerade
|
||||
// testen); ein aktives Mute wird aber genannt.
|
||||
func runTest(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Writer) int {
|
||||
if len(args) > 0 {
|
||||
fmt.Fprintln(stderr, "talk test: keine Argumente erwartet (Sprache: --lang de|en)")
|
||||
return exitUsage
|
||||
}
|
||||
if code, bad := validateSayOptions(opts, stderr); bad {
|
||||
return code
|
||||
}
|
||||
lang := opts.lang
|
||||
if lang == "" {
|
||||
lang = "de"
|
||||
}
|
||||
cfg, err := config.Load(config.Options{Getenv: getenv})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
if dir, err := config.Dir(getenv); err == nil {
|
||||
if (queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}).Muted() {
|
||||
fmt.Fprintln(stderr, "talk test: Mute ist an — der Test spielt trotzdem; normale Ansagen bleiben stumm (talk unmute)")
|
||||
}
|
||||
}
|
||||
if cfg.Audio.Sink == config.SinkNull {
|
||||
fmt.Fprintln(stderr, "talk test: audio.sink = null — es wird kein Ton ausgegeben")
|
||||
}
|
||||
|
||||
engine, err := speak.New(cfg, getenv, stderr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
out, err := engine.Speak(ctx, speak.Request{Text: testPhrase[lang], Lang: lang, Provider: opts.provider, Voice: opts.voice})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk test: %v\n", err)
|
||||
fmt.Fprintln(stderr, "Hinweis: `talk doctor` zeigt, welche Stufe warum fehlt.")
|
||||
return exitFailure
|
||||
}
|
||||
fmt.Fprintf(stdout, "provider: %s\n", out.Provider)
|
||||
fmt.Fprintf(stdout, "sprache: %s\n", lang)
|
||||
switch {
|
||||
case out.Discarded:
|
||||
fmt.Fprintln(stdout, "wiedergabe: verworfen (audio.sink = null)")
|
||||
case out.Direct:
|
||||
fmt.Fprintf(stdout, "dauer: %s (Direktansage über das OS)\n", out.Synthesis.Round(time.Millisecond))
|
||||
default:
|
||||
fmt.Fprintf(stdout, "synthese: %s (%d Bytes)\n", out.Synthesis.Round(time.Millisecond), out.Bytes)
|
||||
fmt.Fprintf(stdout, "wiedergabe: %s\n", out.Playback.Round(time.Millisecond))
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestTestBefehl: `talk test` spricht direkt über die Kette (hier: fake
|
||||
// espeak → osbuiltin) und nennt den Provider; sink null wird angesagt.
|
||||
func TestTestBefehl(t *testing.T) {
|
||||
seen := withFakeEspeak(t)
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null", "TALK_PROVIDER": "osbuiltin"})
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"test", "--lang", "en"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("talk test: Exit %d, stderr %q", code, errBuf.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "provider: osbuiltin") || !strings.Contains(out.String(), "sprache: en") {
|
||||
t.Errorf("stdout = %q", out.String())
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "kein Ton") {
|
||||
t.Errorf("stderr = %q, will Hinweis auf sink null", errBuf.String())
|
||||
}
|
||||
if _, err := readFile(seen); err != nil {
|
||||
t.Errorf("fake espeak nicht gerufen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTestBefehlUngueltigeSprache: --lang prüft wie say.
|
||||
func TestTestBefehlUngueltigeSprache(t *testing.T) {
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runCLI([]string{"test", "--lang", "fr"}, &out, &errBuf); code == exitOK || !strings.Contains(errBuf.String(), "--lang") {
|
||||
t.Errorf("Exit %d, stderr %q", code, errBuf.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"text/tabwriter"
|
||||
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/provider"
|
||||
)
|
||||
|
||||
// voiceRow ist eine Zeile von `talk voices` (auch die JSON-Form).
|
||||
type voiceRow struct {
|
||||
Provider string `json:"provider"`
|
||||
Lang string `json:"lang"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Default bool `json:"default"`
|
||||
}
|
||||
|
||||
// runVoices implementiert `talk voices`: Katalog der bekannten Stimmen plus
|
||||
// die in der config.toml gesetzten (auch wenn sie nicht im Katalog stehen),
|
||||
// gefiltert nach --provider/--lang; „*“ markiert die effektive Default-
|
||||
// Stimme je Provider und Sprache. osbuiltin erscheint als eine Zeile ohne
|
||||
// ID — das Betriebssystem wählt die Stimme.
|
||||
func runVoices(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Writer) int {
|
||||
if len(args) > 0 {
|
||||
fmt.Fprintln(stderr, "talk voices: keine Argumente erwartet (Filter: --provider, --lang)")
|
||||
return exitUsage
|
||||
}
|
||||
if code, bad := validateSayOptions(opts, stderr); bad {
|
||||
return code
|
||||
}
|
||||
if opts.provider != "" && !slices.Contains(provider.Names(), opts.provider) {
|
||||
fmt.Fprintf(stderr, "talk voices: unbekannter Provider %q (verfügbar: %v)\n", opts.provider, provider.Names())
|
||||
return exitFailure
|
||||
}
|
||||
cfg, err := config.Load(config.Options{Getenv: getenv})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
|
||||
rows := voiceRows(cfg, opts.provider, opts.lang)
|
||||
if opts.jsonOut {
|
||||
enc := json.NewEncoder(stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(rows)
|
||||
return exitOK
|
||||
}
|
||||
tw := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "PROVIDER\tLANG\tVOICE-ID\tNAME\tDEFAULT")
|
||||
for _, r := range rows {
|
||||
mark := ""
|
||||
if r.Default {
|
||||
mark = "*"
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", r.Provider, r.Lang, r.ID, r.Name, mark)
|
||||
}
|
||||
tw.Flush()
|
||||
fmt.Fprintln(stderr, "Stimme setzen: --voice <id> einmalig, oder dauerhaft in der config.toml unter [voice.<provider>] de = \"<id>\".")
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// voiceRows baut die Tabelle: Katalog + Config-Einträge, gefiltert.
|
||||
func voiceRows(cfg config.Config, onlyProvider, onlyLang string) []voiceRow {
|
||||
var rows []voiceRow
|
||||
seen := map[[3]string]bool{}
|
||||
add := func(prov, lang, id, name string) {
|
||||
if (onlyProvider != "" && prov != onlyProvider) || (onlyLang != "" && lang != onlyLang) {
|
||||
return
|
||||
}
|
||||
key := [3]string{prov, lang, id}
|
||||
if seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
rows = append(rows, voiceRow{
|
||||
Provider: prov, Lang: lang, ID: id, Name: name,
|
||||
Default: id != "" && provider.DefaultVoice(prov, lang, cfg.Voice) == id,
|
||||
})
|
||||
}
|
||||
for _, name := range provider.DefaultOrder {
|
||||
for _, lang := range []string{"de", "en"} {
|
||||
// Config-Eintrag zuerst, damit er oben steht (auch ohne Katalog).
|
||||
if id := cfg.Voice[name][lang]; id != "" {
|
||||
label := provider.VoiceName(name, id)
|
||||
if label == "" {
|
||||
label = "(aus config.toml)"
|
||||
}
|
||||
add(name, lang, id, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, v := range provider.Catalog() {
|
||||
add(v.Provider, v.Lang, v.ID, v.Name)
|
||||
}
|
||||
for _, lang := range []string{"de", "en"} {
|
||||
add("osbuiltin", lang, "", "Stimme des Betriebssystems (wählt das OS)")
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
||||
)
|
||||
|
||||
// TestVoiceRowsDefaultUndConfig: Ohne Config markiert „*“ den Repo-Default;
|
||||
// ein Config-Mapping verschiebt die Markierung und steht als eigene Zeile
|
||||
// oben — auch für IDs außerhalb des Katalogs. Filter greifen.
|
||||
func TestVoiceRowsDefaultUndConfig(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
rows := voiceRows(cfg, "edge", "de")
|
||||
if len(rows) == 0 || rows[0].ID != "de-DE-KatjaNeural" || !rows[0].Default {
|
||||
t.Fatalf("edge/de ohne Config: erste Zeile = %+v, want Katja als Default", rows)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.Provider != "edge" || r.Lang != "de" {
|
||||
t.Errorf("Filter verletzt: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
cfg.Voice = map[string]map[string]string{"edge": {"de": "de-DE-EigeneNeural"}}
|
||||
rows = voiceRows(cfg, "edge", "de")
|
||||
if rows[0].ID != "de-DE-EigeneNeural" || !rows[0].Default || rows[0].Name != "(aus config.toml)" {
|
||||
t.Errorf("Config-ID außerhalb des Katalogs: erste Zeile = %+v", rows[0])
|
||||
}
|
||||
defaults := 0
|
||||
for _, r := range rows {
|
||||
if r.Default {
|
||||
defaults++
|
||||
}
|
||||
if r.ID == "de-DE-KatjaNeural" && r.Default {
|
||||
t.Error("Katja weiter als Default markiert, obwohl Config eine andere Stimme setzt")
|
||||
}
|
||||
}
|
||||
if defaults != 1 {
|
||||
t.Errorf("%d Default-Markierungen, want genau 1", defaults)
|
||||
}
|
||||
|
||||
// osbuiltin erscheint ohne ID, nie als Default.
|
||||
all := voiceRows(cfg, "", "")
|
||||
found := false
|
||||
for _, r := range all {
|
||||
if r.Provider == "osbuiltin" {
|
||||
found = true
|
||||
if r.ID != "" || r.Default {
|
||||
t.Errorf("osbuiltin-Zeile = %+v, want leere ID ohne Default", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("osbuiltin fehlt in der Gesamtliste")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoicesCLI: Tabelle mit Kopfzeile, --json als Liste, unbekannter
|
||||
// Provider ist ein Fehler.
|
||||
func TestVoicesCLI(t *testing.T) {
|
||||
setGetenv(t, nil)
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runCLI([]string{"voices", "--lang", "en"}, &out, &errBuf); code != exitOK {
|
||||
t.Fatalf("talk voices: Exit %d, stderr %q", code, errBuf.String())
|
||||
}
|
||||
if !strings.HasPrefix(out.String(), "PROVIDER") || !strings.Contains(out.String(), "en-US-AriaNeural") {
|
||||
t.Errorf("Tabelle = %q", out.String())
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
errBuf.Reset()
|
||||
if code := runCLI([]string{"voices", "--json", "--provider", "openai"}, &out, &errBuf); code != exitOK {
|
||||
t.Fatalf("talk voices --json: Exit %d, stderr %q", code, errBuf.String())
|
||||
}
|
||||
var rows []voiceRow
|
||||
if err := json.Unmarshal(out.Bytes(), &rows); err != nil || len(rows) == 0 {
|
||||
t.Fatalf("JSON = %q (%v)", out.String(), err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.Provider != "openai" {
|
||||
t.Errorf("--provider openai liefert %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
errBuf.Reset()
|
||||
if code := runCLI([]string{"voices", "--provider", "siri"}, &out, &errBuf); code == exitOK || !strings.Contains(errBuf.String(), "unbekannter Provider") {
|
||||
t.Errorf("unbekannter Provider: Exit %d, stderr %q", code, errBuf.String())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user