refactor: switch the whole project to English
talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.
Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.
Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
4d30bd95ad
commit
85fa64cfe6
95 files changed
+4707
-4854
No files matched your search
+116
-141
@@ -8,162 +8,153 @@ import (
|
||||
"slices"
|
||||
)
|
||||
|
||||
// Exit-Codes.
|
||||
// Exit codes.
|
||||
const (
|
||||
exitOK = 0 // Erfolg (auch --help, --version)
|
||||
exitFailure = 1 // Befehl fehlgeschlagen oder noch nicht implementiert
|
||||
exitUsage = 2 // Falsche Benutzung (unbekannte Flags, kein Befehl)
|
||||
exitOK = 0 // success (also --help, --version)
|
||||
exitFailure = 1 // command failed
|
||||
exitUsage = 2 // wrong usage (unknown flags, missing command)
|
||||
)
|
||||
|
||||
// command ist ein Eintrag der Befehlstabelle. Die Tabelle ist bewusst
|
||||
// datengetrieben: Spätere Tasks (z. B. die TUI-Befehle aus Task 09) ergänzen
|
||||
// hier Einträge bzw. Register-Funktionen, ohne die Dispatch-Logik in run()
|
||||
// anzufassen. register hängt die Flags des Befehls an den FlagSet und liefert
|
||||
// die gebundenen say-Options (derzeit der einzige Befehl mit eigenen Flags;
|
||||
// erhält ein weiterer Befehl eigene Flags, wird die Signatur verallgemeinert).
|
||||
// command is an entry of the command table. The table is data-driven on
|
||||
// purpose: new commands add an entry instead of touching the dispatch in
|
||||
// run(). register attaches the command's flags to the FlagSet and returns the
|
||||
// bound options; handler executes the command.
|
||||
type command struct {
|
||||
name string // Subcommand-Name (bindende CLI-Surface aus der Spec)
|
||||
short string // Kurzbeschreibung für `talk --help`
|
||||
args string // Positionsargumente-Hinweis für die Usage-Zeile
|
||||
detail string // Beschreibung für `talk <Befehl> --help`
|
||||
register func(fs *flag.FlagSet) *sayOptions // hängt die Flags des Befehls an den FlagSet
|
||||
handler func(args []string, o *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int // Ausführung (fehlt sie: Stub)
|
||||
name string // subcommand name (part of the CLI surface)
|
||||
short string // one-liner for `talk --help`
|
||||
args string // positional arguments hint for the usage line
|
||||
detail string // description for `talk <command> --help`
|
||||
register func(fs *flag.FlagSet) *sayOptions // attaches the command's flags to the FlagSet
|
||||
handler func(args []string, o *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int // execution
|
||||
}
|
||||
|
||||
// commands ist die vollständige Befehlstabelle. Die Namen sind Teil der
|
||||
// bindenden CLI-Surface (Spec: „CLI-Surface (exakt)“) — nicht umbenennen.
|
||||
// commands is the complete command table. The names are part of the CLI
|
||||
// surface; do not rename.
|
||||
var commands = []command{
|
||||
{
|
||||
name: "say",
|
||||
short: "Text ansagen (Default-Befehl)",
|
||||
args: `"Text"`,
|
||||
detail: "Spricht den Text über die Provider-Kette aus. Ohne Text-Argument wird stdin gelesen. `talk \"Text\"` ist die Abkürzung für `talk say \"Text\"`.",
|
||||
short: "speak a text (default command)",
|
||||
args: `"text"`,
|
||||
detail: "Speaks the text through the provider chain. Without a text argument, stdin is read. `talk \"text\"` is shorthand for `talk say \"text\"`.",
|
||||
register: addSayFlags,
|
||||
handler: runSay,
|
||||
},
|
||||
{
|
||||
name: "wrap",
|
||||
short: "Befehl ausführen und Ergebnis ansagen",
|
||||
args: "-- <Befehl> [Argumente …]",
|
||||
detail: "Führt den Befehl aus (stdin/stdout/stderr werden durchgereicht) und spricht danach Erfolg bzw. Misserfolg und Dauer. Das erste „--\" schirmt den Befehl komplett von den talk-Flags ab: `talk wrap -- foo --bar` reicht --bar an foo weiter. Der Exit-Code des Befehls wird durchgereicht; scheitert die Ansage selbst, bleibt er trotzdem. Label der Ansage: config `wrap.label` oder der Basisname des Befehls. wrap erbt die say-Flags (--wait, --now, --priority, --voice, --lang, --provider).",
|
||||
short: "run a command and announce the result",
|
||||
args: "-- <command> [args …]",
|
||||
detail: "Runs the command (stdin/stdout/stderr are passed through) and then announces success or failure and the duration. The first \"--\" shields the command completely from talk's flags: `talk wrap -- foo --bar` passes --bar to foo. The command's exit code is passed through; if the announcement itself fails, the code still stands. Announcement label: config `wrap.label` or the command's base name. wrap inherits the say flags (--wait, --now, --priority, --voice, --lang, --provider).",
|
||||
register: addSayFlags,
|
||||
handler: runWrap,
|
||||
},
|
||||
{
|
||||
name: "mute",
|
||||
short: "Ansagen dauerhaft stummschalten",
|
||||
detail: "Aktiviert den persistenten Mute-State (Datei `mute` im Config-Verzeichnis). Läuft der Daemon, wird er darüber informiert; andernfalls schreibt talk die Datei direkt — der Zustand überlebt Daemon-Restarts.",
|
||||
short: "mute all announcements persistently",
|
||||
detail: "Turns on the persistent mute state (file `mute` in the config directory). A running daemon is told; otherwise talk writes the file directly. The state survives daemon restarts.",
|
||||
handler: runMute,
|
||||
},
|
||||
{
|
||||
name: "unmute",
|
||||
short: "Stummschaltung aufheben",
|
||||
detail: "Hebt den persistenten Mute-State wieder auf (Daemon-Aufruf; ohne Daemon direkt über die Datei).",
|
||||
short: "turn announcements back on",
|
||||
detail: "Clears the persistent mute state (daemon call; without a daemon directly via the file).",
|
||||
handler: runUnmute,
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
short: "Daemon-Status anzeigen",
|
||||
detail: "Zeigt Queue, aktuelles Item, Mute-State, aktiven Provider und Daemon-Version (plain, eine Zeile pro Feld).",
|
||||
short: "show the daemon status",
|
||||
detail: "Shows queue, current item, mute state, active provider, laws and daemon version (plain, one line per field). Does not start the daemon.",
|
||||
handler: runStatus,
|
||||
},
|
||||
{
|
||||
name: "daemon",
|
||||
short: "Hintergrundprozess steuern (run | start | stop)",
|
||||
short: "control the background process (run | start | stop)",
|
||||
args: "[run|start|stop]",
|
||||
detail: "run (Default) betreibt den Daemon im Vordergrund — für systemd, launchd oder ein Terminal. start startet ihn im Hintergrund (Log: daemon.log im Config-Verzeichnis), stop fährt einen laufenden Daemon herunter. Normalerweise nicht nötig: talk startet den Daemon beim ersten Sprechbefehl selbst (daemon.autostart, Default an).",
|
||||
detail: "run (default) runs the daemon in the foreground, for systemd, launchd or a terminal. start launches it in the background (log: daemon.log in the config directory), stop shuts a running daemon down. Normally not needed: talk starts the daemon itself on the first speak command (daemon.autostart, default on).",
|
||||
handler: runDaemon,
|
||||
},
|
||||
{
|
||||
name: "cancel",
|
||||
short: "Laufende und geplante Ansagen abbrechen",
|
||||
detail: "Bricht die laufende Ansage ab und leert die Queue des Daemons; Ausgabe ist die Zahl der abgebrochenen Ansagen.",
|
||||
short: "cancel the playing and all queued announcements",
|
||||
detail: "Aborts the playing announcement and empties the daemon's queue; prints the number of cancelled announcements.",
|
||||
handler: runCancel,
|
||||
},
|
||||
{
|
||||
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.",
|
||||
short: "list known voices",
|
||||
detail: "Lists known voices per provider and language as a table; \"*\" marks the voice talk uses without --voice (config voice.<provider>.<lang> or the built-in default). Filters: --provider, --lang. --json for machines. Any voice ID the provider knows works without a catalog entry.",
|
||||
register: addVoicesFlags,
|
||||
handler: runVoices,
|
||||
},
|
||||
{
|
||||
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.",
|
||||
short: "speak a test sentence",
|
||||
detail: "Speaks a fixed test sentence directly in this process (not via the daemon, even when muted) and shows which provider spoke and how long synthesis and playback took. --lang de|en picks the sentence, --provider/--voice pin like say.",
|
||||
register: addTestFlags,
|
||||
handler: runTest,
|
||||
},
|
||||
{
|
||||
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.",
|
||||
short: "diagnose the setup",
|
||||
detail: "Checks config, daemon, audio output, every provider tier, notifications, mute and laws and reports one line each with ✔ (ok), ⚠ (note), ✘ (error). Exit 0 without ✘, else 1; a daemon that is not running is ⚠, not an error. --probe runs real synthesis probes against every available provider (network). --json for machines.",
|
||||
register: addDoctorFlags,
|
||||
handler: runDoctor,
|
||||
},
|
||||
{
|
||||
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.",
|
||||
short: "guided first-time setup",
|
||||
detail: "Asks a few questions for what talk needs to get going: keys (optional; without a key the free Edge voice speaks), a voice per language with preview, quiet hours, and writes config.toml (an existing file is merged, backup .bak). Without a terminal: only shows the effective configuration.",
|
||||
handler: runSetup,
|
||||
},
|
||||
}
|
||||
|
||||
// addSayFlags registriert die Flags des say-Befehls und bindet sie an die
|
||||
// zurückgegebene Options-Struct. Sie gelten auch für den Default-Pfad
|
||||
// `talk "Text"` (gleiche Flag-Oberfläche, festgelegt in der Spec). Verhalten
|
||||
// seit Task 04: läuft ein Daemon, wird enqueue (non-blocking); --wait pollt
|
||||
// bis zur Fertigstellung, --now springt vor. Ohne Daemon: Degraded Mode.
|
||||
//
|
||||
// Rückgabe ist bewusst ein Pointer: flag.Var bindet an die Felder genau
|
||||
// dieser Struct — eine Wertkopie hätte nach dem Parse weiterhin die
|
||||
// Defaults.
|
||||
// addSayFlags registers the say flags and binds them to the returned options
|
||||
// struct. They also apply to the default form `talk "text"`. A pointer is
|
||||
// returned on purpose: flag.Var binds to the fields of exactly this struct.
|
||||
func addSayFlags(fs *flag.FlagSet) *sayOptions {
|
||||
o := &sayOptions{}
|
||||
fs.BoolVar(&o.wait, "wait", false, "warten, bis die Ansage fertig abgespielt ist")
|
||||
fs.BoolVar(&o.now, "now", false, "Ansage sofort ausgeben (springt die Warteschlange)")
|
||||
fs.StringVar(&o.priority, "priority", "normal", "Priorität: low | normal | high")
|
||||
fs.StringVar(&o.voice, "voice", "", "Stimme als provider-spezifische ID")
|
||||
fs.StringVar(&o.lang, "lang", "", "Sprache: de | en (Default: automatische Erkennung)")
|
||||
fs.StringVar(&o.provider, "provider", "", "Provider pinnen: elevenlabs | openai | edge | osbuiltin")
|
||||
fs.BoolVar(&o.wait, "wait", false, "wait until the announcement has been spoken")
|
||||
fs.BoolVar(&o.now, "now", false, "speak immediately (jumps the queue; breaks out of rate limit and quiet hours)")
|
||||
fs.StringVar(&o.priority, "priority", "normal", "priority: low | normal | high")
|
||||
fs.StringVar(&o.voice, "voice", "", "voice as a provider-specific ID")
|
||||
fs.StringVar(&o.lang, "lang", "", "language: de | en (default: detected)")
|
||||
fs.StringVar(&o.provider, "provider", "", "pin a provider: elevenlabs | openai | edge | osbuiltin")
|
||||
return o
|
||||
}
|
||||
|
||||
// addVoicesFlags registriert die Flags von voices (Filter + JSON).
|
||||
// addVoicesFlags registers the voices flags (filters + 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")
|
||||
fs.StringVar(&o.provider, "provider", "", "only this provider: elevenlabs | openai | edge | osbuiltin")
|
||||
fs.StringVar(&o.lang, "lang", "", "only this language: de | en")
|
||||
fs.BoolVar(&o.jsonOut, "json", false, "machine-readable JSON output")
|
||||
return o
|
||||
}
|
||||
|
||||
// addTestFlags registriert die Flags von test (Phrase + Pins).
|
||||
// addTestFlags registers the test flags (sentence + 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")
|
||||
fs.StringVar(&o.lang, "lang", "", "language of the test sentence: de | en (default: configured lang)")
|
||||
fs.StringVar(&o.provider, "provider", "", "pin a provider: elevenlabs | openai | edge | osbuiltin")
|
||||
fs.StringVar(&o.voice, "voice", "", "voice as a provider-specific ID")
|
||||
return o
|
||||
}
|
||||
|
||||
// addDoctorFlags registriert die Flags von doctor.
|
||||
// addDoctorFlags registers the doctor flags.
|
||||
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)")
|
||||
fs.BoolVar(&o.jsonOut, "json", false, "machine-readable JSON output")
|
||||
fs.BoolVar(&o.probe, "probe", false, "real synthesis probe for every available provider (network calls)")
|
||||
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-
|
||||
// Pfad aus Task 02). Rückgabe ist der Exit-Code.
|
||||
// run is the CLI entry point (arguments without the program name). All
|
||||
// output goes to the given writers, stdin is injected, so run is testable
|
||||
// without starting a process. The return value is the exit code.
|
||||
func run(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
if len(argv) == 0 {
|
||||
// Kein Befehl und kein Argument: Der Text könnte über stdin
|
||||
// kommen (Pipe). An einem Terminal gibt es keinen Text — dann
|
||||
// Benutzungsfehler, damit talk nie auf Eingaben wartet.
|
||||
// No command and no argument: the text may come via stdin (pipe).
|
||||
// At a terminal there is no text, so this is a usage error; talk
|
||||
// must never sit waiting for input.
|
||||
if isCharDevice(stdin) {
|
||||
writeUsage(stderr)
|
||||
return exitUsage
|
||||
@@ -177,7 +168,7 @@ func run(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
writeCommandHelp(cmd, stdout)
|
||||
return exitOK
|
||||
}
|
||||
fmt.Fprintf(stderr, "talk: unbekannter Befehl %q\n", argv[1])
|
||||
fmt.Fprintf(stderr, "talk: unknown command %q\n", argv[1])
|
||||
return exitUsage
|
||||
}
|
||||
writeUsage(stdout)
|
||||
@@ -188,30 +179,25 @@ func run(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
return runCommand(cmd, argv[1:], stdin, stdout, stderr)
|
||||
}
|
||||
|
||||
// Kein bekannter Befehl: Default-Pfad `talk "Text"` (mappt auf say)
|
||||
// bzw. Root-Flags (--version, --help).
|
||||
// No known command: default form `talk "text"` (maps to say) or root
|
||||
// flags (--version, --help).
|
||||
return runDefault(argv, stdin, stdout, stderr)
|
||||
}
|
||||
|
||||
// parseInterspersed parst Flags, die überall in argv stehen dürfen. Die
|
||||
// Go-stdlib-flag stoppt beim ersten Nicht-Flag-Argument; die CLI-Surface
|
||||
// verlangt aber `talk "Text" --provider=x` (Abnahmekriterium Task 03)
|
||||
// genauso wie `talk --provider=x "Text"`. Trick: Parse über den Rest
|
||||
// wiederholen und jeweils das Positionsargument vor dem Weiterparsen
|
||||
// entnehmen. Fehler (unbekannte Flags, -h) kommen wie gewohnt vom FlagSet.
|
||||
// parseInterspersed parses flags that may appear anywhere in argv. The
|
||||
// standard flag package stops at the first non-flag argument, but the CLI
|
||||
// accepts `talk "text" --provider=x` as well as `talk --provider=x "text"`.
|
||||
// Trick: repeat the parse over the rest, taking one positional argument
|
||||
// before each round. Errors (unknown flags, -h) come from the FlagSet.
|
||||
//
|
||||
// Seit Task 07 schirmt das erste „--" (als eigenständiges Argument) alles
|
||||
// dahinter vollständig ab — die Stdlib-Terminator-Semantik, die der
|
||||
// Interspersed-Loop zuvor aushebelte: Er nahm nach jedem Positionsargument
|
||||
// das Parsen wieder auf und fraß damit Flags, die für den NACHFOLGENDEN
|
||||
// Befehl gemeint waren (`talk wrap -- foo --bar` wäre an --bar als
|
||||
// vermeintlichem talk-Flag gescheitert). Jetzt wird argv am ersten „--"
|
||||
// getrennt: Nur der Teil davor wird interspersed geparst, der Rest ist
|
||||
// bedingungslos Positionsargument — für say (`talk -- "Text --flag"`)
|
||||
// genauso wie für wrap (`talk wrap -- foo --bar`). Die Abnahme-Form
|
||||
// `talk "Text" --provider=x` (Flag nach dem Text, ohne „--") ist davon
|
||||
// unberührt. Ein „--" als Flag-Wert (unmittelbar nach --voice) trifft
|
||||
// diese Trennung nicht, weil nur ein allein stehendes „--" trennt.
|
||||
// The first standalone "--" shields everything after it completely (the
|
||||
// standard terminator semantics the interspersed loop used to defeat: it
|
||||
// resumed parsing after every positional and ate flags meant for the
|
||||
// wrapped command; `talk wrap -- foo --bar` failed on --bar). argv is split
|
||||
// at the first "--": only the part before it is parsed interspersed, the
|
||||
// rest is positional unconditionally, for say (`talk -- "text --flag"`) as
|
||||
// well as wrap (`talk wrap -- foo --bar`). A "--" as a flag VALUE (right
|
||||
// after --voice) does not split, because only a standalone "--" does.
|
||||
func parseInterspersed(fs *flag.FlagSet, argv []string) ([]string, error) {
|
||||
if i := slices.Index(argv, "--"); i >= 0 {
|
||||
positional, err := parseInterspersedLoop(fs, argv[:i])
|
||||
@@ -223,8 +209,8 @@ func parseInterspersed(fs *flag.FlagSet, argv []string) ([]string, error) {
|
||||
return parseInterspersedLoop(fs, argv)
|
||||
}
|
||||
|
||||
// parseInterspersedLoop ist der Interspersed-Durchlauf — argv enthält kein
|
||||
// allein stehendes „--" mehr (parseInterspersed hat davor getrennt).
|
||||
// parseInterspersedLoop is the interspersed pass; argv holds no standalone
|
||||
// "--" any more (parseInterspersed split before).
|
||||
func parseInterspersedLoop(fs *flag.FlagSet, argv []string) ([]string, error) {
|
||||
var positional []string
|
||||
rest := argv
|
||||
@@ -236,7 +222,7 @@ func parseInterspersedLoop(fs *flag.FlagSet, argv []string) ([]string, error) {
|
||||
if len(args) == 0 {
|
||||
return positional, nil
|
||||
}
|
||||
// args[0] ist das Argument, an dem Parse gestoppt hat.
|
||||
// args[0] is the argument Parse stopped at.
|
||||
positional = append(positional, args[0])
|
||||
rest = args[1:]
|
||||
}
|
||||
@@ -251,10 +237,9 @@ func lookupCommand(name string) (command, bool) {
|
||||
return command{}, false
|
||||
}
|
||||
|
||||
// newCommandFlagSet baut den FlagSet eines Befehls: eigene Flags plus ein
|
||||
// explizites --help und die gebundenen say-Options. Die Default-Usage wird
|
||||
// stummgeschaltet, damit Hilfetext ausschließlich über writeCommandHelp
|
||||
// (eine Quelle) ausgegeben wird.
|
||||
// newCommandFlagSet builds a command's FlagSet: its own flags plus an
|
||||
// explicit --help and the bound options. The default usage is silenced so
|
||||
// help text comes from writeCommandHelp only (one source).
|
||||
func newCommandFlagSet(cmd command, out io.Writer) (*flag.FlagSet, *bool, *sayOptions) {
|
||||
fs := flag.NewFlagSet("talk "+cmd.name, flag.ContinueOnError)
|
||||
fs.SetOutput(out)
|
||||
@@ -263,24 +248,22 @@ func newCommandFlagSet(cmd command, out io.Writer) (*flag.FlagSet, *bool, *sayOp
|
||||
if cmd.register != nil {
|
||||
sayOpts = cmd.register(fs)
|
||||
}
|
||||
help := fs.Bool("help", false, "Hilfe zu diesem Befehl anzeigen")
|
||||
help := fs.Bool("help", false, "show help for this command")
|
||||
return fs, help, sayOpts
|
||||
}
|
||||
|
||||
// runCommand führt einen benannten Subcommand aus. Implementiert: say
|
||||
// (Task 02/04) sowie mute/unmute/status/cancel als Daemon-Aufrufe (Task
|
||||
// 04); die restlichen sind Stubs bis zu ihren Tasks. Flags dürfen auch
|
||||
// nach dem Text stehen (parseInterspersed).
|
||||
// runCommand executes a named subcommand. Flags may follow the text
|
||||
// (parseInterspersed).
|
||||
func runCommand(cmd command, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
fs, help, sayOpts := newCommandFlagSet(cmd, stderr)
|
||||
positional, err := parseInterspersed(fs, args)
|
||||
if err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) { // z. B. nacktes -h
|
||||
if errors.Is(err, flag.ErrHelp) { // for example a bare -h
|
||||
writeCommandHelp(cmd, stdout)
|
||||
return exitOK
|
||||
}
|
||||
// Die Fehlermeldung selbst hat der FlagSet bereits ausgegeben.
|
||||
fmt.Fprintf(stderr, "Hinweis: `talk %s --help` zeigt die Verwendung.\n", cmd.name)
|
||||
// The FlagSet has already printed the error itself.
|
||||
fmt.Fprintf(stderr, "hint: `talk %s --help` shows the usage.\n", cmd.name)
|
||||
return exitUsage
|
||||
}
|
||||
if *help {
|
||||
@@ -290,17 +273,18 @@ func runCommand(cmd command, args []string, stdin io.Reader, stdout, stderr io.W
|
||||
if cmd.handler != nil {
|
||||
return cmd.handler(positional, sayOpts, stdin, stdout, stderr)
|
||||
}
|
||||
return notImplemented(cmd.name, stderr)
|
||||
fmt.Fprintf(stderr, "talk %s is not implemented yet\n", cmd.name)
|
||||
return exitFailure
|
||||
}
|
||||
|
||||
// runDefault behandelt Aufrufe ohne bekannten Subcommand: `talk "Text"`,
|
||||
// `talk --version`, `talk --help` sowie say-Flags vor oder nach dem Text.
|
||||
// runDefault handles calls without a known subcommand: `talk "text"`,
|
||||
// `talk --version`, `talk --help`, and say flags before or after the text.
|
||||
func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
fs := flag.NewFlagSet("talk", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
fs.Usage = func() {}
|
||||
showVersion := fs.Bool("version", false, "Version anzeigen")
|
||||
help := fs.Bool("help", false, "diese Hilfe anzeigen")
|
||||
showVersion := fs.Bool("version", false, "show the version")
|
||||
help := fs.Bool("help", false, "show this help")
|
||||
sayOpts := addSayFlags(fs)
|
||||
|
||||
positional, err := parseInterspersed(fs, argv)
|
||||
@@ -309,7 +293,7 @@ func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
writeUsage(stdout)
|
||||
return exitOK
|
||||
}
|
||||
fmt.Fprintln(stderr, "Hinweis: `talk --help` zeigt die Verwendung.")
|
||||
fmt.Fprintln(stderr, "hint: `talk --help` shows the usage.")
|
||||
return exitUsage
|
||||
}
|
||||
if *showVersion {
|
||||
@@ -323,50 +307,44 @@ func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
return runSay(positional, sayOpts, stdin, stdout, stderr)
|
||||
}
|
||||
|
||||
// versionString rendert die Versionsausgabe: `talk version <version>` plus
|
||||
// Commit-Hash in Klammern, falls per ldflags injiziert.
|
||||
// versionString renders the version output: `talk version <version>` plus
|
||||
// the commit hash in parentheses when injected.
|
||||
func versionString() string {
|
||||
return "talk version " + buildVersion()
|
||||
}
|
||||
|
||||
// notImplemented meldet einen noch nicht implementierten Befehl auf stderr
|
||||
// und liefert den dafür vorgesehenen Exit-Code (non-zero).
|
||||
func notImplemented(name string, stderr io.Writer) int {
|
||||
fmt.Fprintf(stderr, "talk %s is not implemented yet\n", name)
|
||||
return exitFailure
|
||||
}
|
||||
|
||||
// writeUsage schreibt die Gesamt-Hilfe; die Befehlsliste wird direkt aus der
|
||||
// Befehlstabelle generiert, damit Hilfe und Dispatch nicht auseinanderlaufen.
|
||||
// writeUsage writes the overall help; the command list is generated from the
|
||||
// command table so help and dispatch never drift apart.
|
||||
func writeUsage(w io.Writer) {
|
||||
fmt.Fprint(w, `talk — Sprach-Ausgaben für Skripte und KI-Agenten
|
||||
fmt.Fprint(w, `talk: voice notifications for scripts and AI agents
|
||||
|
||||
Usage:
|
||||
talk [Flags] "Text" Text ansagen (Abkürzung für: talk say)
|
||||
talk <Befehl> [Flags] Befehl ausführen
|
||||
talk help [Befehl] Hilfe anzeigen
|
||||
talk [flags] "text" speak a text (shorthand for: talk say)
|
||||
talk <command> [flags] run a command
|
||||
talk help [command] show help
|
||||
|
||||
Befehle:
|
||||
Commands:
|
||||
`)
|
||||
for _, cmd := range commands {
|
||||
fmt.Fprintf(w, " %-8s %s\n", cmd.name, cmd.short)
|
||||
}
|
||||
fmt.Fprint(w, `
|
||||
Flags:
|
||||
--version Version anzeigen
|
||||
--help diese Hilfe anzeigen
|
||||
--version show the version
|
||||
--help show this help
|
||||
|
||||
Die Flags von say gelten auch für talk "Text"; Details: talk say --help
|
||||
The say flags also apply to talk "text"; details: talk say --help
|
||||
Getting started: talk doctor, then talk test, then talk setup.
|
||||
`)
|
||||
}
|
||||
|
||||
// writeCommandHelp schreibt die Hilfe eines einzelnen Befehls inkl. Flags.
|
||||
// writeCommandHelp writes the help of a single command including its flags.
|
||||
func writeCommandHelp(cmd command, w io.Writer) {
|
||||
fmt.Fprintf(w, "Usage:\n talk %s", cmd.name)
|
||||
if cmd.args != "" {
|
||||
fmt.Fprintf(w, " %s", cmd.args)
|
||||
}
|
||||
fmt.Fprintln(w, " [Flags]")
|
||||
fmt.Fprintln(w, " [flags]")
|
||||
fmt.Fprintln(w)
|
||||
if cmd.detail != "" {
|
||||
fmt.Fprintf(w, "%s\n\n", cmd.detail)
|
||||
@@ -374,7 +352,4 @@ func writeCommandHelp(cmd command, w io.Writer) {
|
||||
fmt.Fprintln(w, "Flags:")
|
||||
fs, _, _ := newCommandFlagSet(cmd, w)
|
||||
fs.PrintDefaults()
|
||||
if cmd.handler == nil {
|
||||
fmt.Fprintln(w, "\nStatus: noch nicht implementiert (WIP).")
|
||||
}
|
||||
}
|
||||
+28
-35
@@ -7,24 +7,24 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// wantSubcommands ist die bindende CLI-Surface aus der Spec — bewusst als
|
||||
// hartkodierte Liste getestet (nicht aus der Befehlstabelle abgeleitet),
|
||||
// damit die Tests anschlagen, wenn Tabelle und Spec auseinanderlaufen.
|
||||
// wantSubcommands is the CLI surface, deliberately tested as a hard-coded
|
||||
// list (not derived from the command table) so the tests fire when table and
|
||||
// documentation drift apart.
|
||||
var wantSubcommands = []string{
|
||||
"say", "wrap", "mute", "unmute", "status", "cancel", "voices", "test", "doctor", "setup",
|
||||
"say", "wrap", "mute", "unmute", "status", "daemon", "cancel", "voices", "test", "doctor", "setup",
|
||||
}
|
||||
|
||||
// runCLI ist der Test-Einstieg in run mit leerem stdin (kein Text über
|
||||
// Pipe) — dem üblichen Aufruf ohne Eingabe.
|
||||
// runCLI is the test entry into run with empty stdin (no text via pipe), the
|
||||
// usual call without input.
|
||||
func runCLI(argv []string, stdout, stderr io.Writer) int {
|
||||
return run(argv, strings.NewReader(""), stdout, stderr)
|
||||
}
|
||||
|
||||
func TestHelpListetAlleSubcommands(t *testing.T) {
|
||||
func TestHelpListsAllSubcommands(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"--help"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("--help: Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
|
||||
t.Fatalf("--help: exit code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
|
||||
}
|
||||
lines := strings.Split(out.String(), "\n")
|
||||
for _, name := range wantSubcommands {
|
||||
@@ -36,7 +36,7 @@ func TestHelpListetAlleSubcommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("--help listet Befehl %q nicht (Output: %q)", name, out.String())
|
||||
t.Errorf("--help does not list command %q (output: %q)", name, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,14 +45,14 @@ func TestVersionDefault(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"--version"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("--version: Exit-Code = %d, want %d", code, exitOK)
|
||||
t.Fatalf("--version: exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
if got, want := out.String(), "talk version dev\n"; got != want {
|
||||
t.Errorf("--version Output = %q, want %q", got, want)
|
||||
t.Errorf("--version output = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionStringMitCommit(t *testing.T) {
|
||||
func TestVersionStringWithCommit(t *testing.T) {
|
||||
oldCommit := commit
|
||||
commit = "c0ffee1"
|
||||
defer func() { commit = oldCommit }()
|
||||
@@ -62,70 +62,63 @@ func TestVersionStringMitCommit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSayHelpZeigtFlags(t *testing.T) {
|
||||
func TestSayHelpShowsFlags(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"say", "--help"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("talk say --help: Exit-Code = %d, want %d", code, exitOK)
|
||||
t.Fatalf("talk say --help: exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
for _, f := range []string{"-wait", "-now", "-priority", "-voice", "-lang", "-provider"} {
|
||||
if !strings.Contains(out.String(), f) {
|
||||
t.Errorf("talk say --help zeigt Flag %s nicht (Output: %q)", f, out.String())
|
||||
t.Errorf("talk say --help does not show flag %s (output: %q)", f, out.String())
|
||||
}
|
||||
}
|
||||
if strings.Contains(out.String(), "noch nicht implementiert") {
|
||||
t.Errorf("talk say --help soll say nicht mehr als Stub markieren: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapHelpZeigtSayFlags: wrap erbt die say-Flags (mindestens --wait)
|
||||
// und ist kein Stub mehr.
|
||||
func TestWrapHelpZeigtSayFlags(t *testing.T) {
|
||||
// TestWrapHelpShowsSayFlags: wrap inherits the say flags (at least --wait).
|
||||
func TestWrapHelpShowsSayFlags(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"wrap", "--help"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("talk wrap --help: Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
|
||||
t.Fatalf("talk wrap --help: exit code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
|
||||
}
|
||||
for _, f := range []string{"-wait", "-provider"} {
|
||||
if !strings.Contains(out.String(), f) {
|
||||
t.Errorf("talk wrap --help zeigt Flag %s nicht (Output: %q)", f, out.String())
|
||||
t.Errorf("talk wrap --help does not show flag %s (output: %q)", f, out.String())
|
||||
}
|
||||
}
|
||||
if strings.Contains(out.String(), "noch nicht implementiert") {
|
||||
t.Errorf("talk wrap --help soll wrap nicht als Stub markieren: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpSubcommand(t *testing.T) {
|
||||
t.Run("ohne Argument", func(t *testing.T) {
|
||||
t.Run("without argument", func(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runCLI([]string{"help"}, &out, &errBuf); code != exitOK {
|
||||
t.Fatalf("talk help: Exit-Code = %d, want %d", code, exitOK)
|
||||
t.Fatalf("talk help: exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
for _, name := range wantSubcommands {
|
||||
if !strings.Contains(out.String(), name) {
|
||||
t.Errorf("talk help listet %q nicht", name)
|
||||
t.Errorf("talk help does not list %q", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("mit Befehl", func(t *testing.T) {
|
||||
t.Run("with command", func(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runCLI([]string{"help", "mute"}, &out, &errBuf); code != exitOK {
|
||||
t.Fatalf("talk help mute: Exit-Code = %d, want %d", code, exitOK)
|
||||
t.Fatalf("talk help mute: exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
if !strings.Contains(out.String(), "talk mute") {
|
||||
t.Errorf("talk help mute zeigt Usage von mute nicht: %q", out.String())
|
||||
t.Errorf("talk help mute does not show mute's usage: %q", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUnbekanntesFlag(t *testing.T) {
|
||||
func TestUnknownFlag(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"--bogus"}, &out, &errBuf)
|
||||
if code != exitUsage {
|
||||
t.Fatalf("talk --bogus: Exit-Code = %d, want %d", code, exitUsage)
|
||||
t.Fatalf("talk --bogus: exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "bogus") {
|
||||
t.Errorf("talk --bogus: Fehlermeldung fehlt auf stderr: %q", errBuf.String())
|
||||
t.Errorf("talk --bogus: error message missing on stderr: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
+51
-53
@@ -12,26 +12,26 @@ import (
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||||
)
|
||||
|
||||
// Die Steuer-Befehle (Task 04) sind Daemon-Aufrufe mit plain Output:
|
||||
// stdout führt eine maschinenlesbare Zeile (muted/unmuted, cancelled: N
|
||||
// bzw. den Status-Block), stderr menschliche Hinweise.
|
||||
// The control commands are daemon calls with plain output: stdout carries a
|
||||
// machine-readable line (muted/unmuted, cancelled: N, or the status block),
|
||||
// stderr human notes. They deliberately do NOT start the daemon (only
|
||||
// speaking does): muting needs no running process, and status should show
|
||||
// reality rather than change it.
|
||||
|
||||
// runMute implementiert `talk mute`: POST /mute an den Daemon. Ist kein
|
||||
// Daemon erreichbar, wird die Mute-Datei direkt geschrieben — dieselbe
|
||||
// Persistenz-Quelle, die der Daemon bei jeder Ansage neu liest; der
|
||||
// Zustand greift also auch offline und überlebt Daemon-Restarts. Die
|
||||
// Steuer-Befehle starten den Daemon bewusst NICHT (nur Sprechen tut das):
|
||||
// Stummschalten braucht keinen laufenden Prozess.
|
||||
// runMute implements `talk mute`: POST /mute to the daemon. When no daemon
|
||||
// is reachable, the mute file is written directly; it is the same
|
||||
// persistence the daemon re-reads on every announcement, so the state takes
|
||||
// effect offline too and survives daemon restarts.
|
||||
func runMute(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
return setMute(true, stdout, stderr)
|
||||
}
|
||||
|
||||
// runUnmute implementiert `talk unmute` (analog zu runMute).
|
||||
// runUnmute implements `talk unmute` (like runMute).
|
||||
func runUnmute(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
return setMute(false, stdout, stderr)
|
||||
}
|
||||
|
||||
// setMute ist der gemeinsame Kern von mute/unmute.
|
||||
// setMute is the shared core of mute/unmute.
|
||||
func setMute(muted bool, stdout, stderr io.Writer) int {
|
||||
cfg, err := config.Load(config.Options{Getenv: getenv})
|
||||
if err != nil {
|
||||
@@ -46,24 +46,23 @@ func setMute(muted bool, stdout, stderr io.Writer) int {
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// Daemon-Aufruf fehlgeschlagen (meist: nicht erreichbar): Datei direkt
|
||||
// schreiben — der Daemon (und der Degraded Mode) liest sie bei jeder
|
||||
// Ansage neu.
|
||||
fmt.Fprintf(stderr, "talk: Daemon-Aufruf fehlgeschlagen (%v) — Mute-Datei wird direkt geschrieben\n", err)
|
||||
// Daemon call failed (usually: not running): write the file directly;
|
||||
// the daemon (and the direct path) re-read it on every announcement.
|
||||
fmt.Fprintf(stderr, "talk: daemon call failed (%v); writing the mute file directly\n", err)
|
||||
dir, err := config.Dir(getenv)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
if err := (queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}).Set(muted); err != nil {
|
||||
fmt.Fprintf(stderr, "talk: Mute-Datei schreiben: %v\n", err)
|
||||
fmt.Fprintf(stderr, "talk: writing the mute file: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
printMuteState(muted, stdout)
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// printMuteState schreibt den Zustand als plain Token auf stdout.
|
||||
// printMuteState writes the state as a plain token to stdout.
|
||||
func printMuteState(muted bool, stdout io.Writer) {
|
||||
if muted {
|
||||
fmt.Fprintln(stdout, "muted")
|
||||
@@ -72,11 +71,10 @@ func printMuteState(muted bool, stdout io.Writer) {
|
||||
}
|
||||
}
|
||||
|
||||
// runStatus implementiert `talk status`: plain Block aus GET /status —
|
||||
// Daemon-Adresse + Version, Queue-Länge, aktuelles Item, Mute-State,
|
||||
// aktiver Provider, Gesetz-State (Rate-Limit, Ruhezeit). Läuft kein Daemon,
|
||||
// sagt status genau das (Exit 1) und startet ihn nicht — status soll die
|
||||
// Wirklichkeit zeigen, nicht verändern.
|
||||
// runStatus implements `talk status`: a plain block from GET /status (daemon
|
||||
// address + version, queue length, current item, mute state, active
|
||||
// provider, laws). When no daemon runs, status says exactly that (exit 1)
|
||||
// without starting one.
|
||||
func runStatus(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
cfg, err := config.Load(config.Options{Getenv: getenv})
|
||||
if err != nil {
|
||||
@@ -88,44 +86,44 @@ func runStatus(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr
|
||||
defer cancel()
|
||||
st, err := client.Status(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stdout, "daemon: läuft nicht (%s)\n", client.BaseURL)
|
||||
fmt.Fprintln(stderr, "talk: kein Daemon — er startet beim nächsten Sprechbefehl von selbst (oder: talk daemon start)")
|
||||
fmt.Fprintf(stdout, "daemon: not running (%s)\n", client.BaseURL)
|
||||
fmt.Fprintln(stderr, "talk: no daemon; it starts by itself on the next speak command (or: talk daemon start)")
|
||||
return exitFailure
|
||||
}
|
||||
|
||||
fmt.Fprintf(stdout, "daemon: %s (version %s)\n", client.BaseURL, st.Version)
|
||||
fmt.Fprintf(stdout, "queue: %d\n", st.Queue)
|
||||
fmt.Fprintf(stdout, "daemon: %s (version %s)\n", client.BaseURL, st.Version)
|
||||
fmt.Fprintf(stdout, "queue: %d\n", st.Queue)
|
||||
if st.Current != nil {
|
||||
fmt.Fprintf(stdout, "current: %s %q (Priorität %s)\n", st.Current.ID, st.Current.Text, st.Current.Priority)
|
||||
fmt.Fprintf(stdout, "current: %s %q (priority %s)\n", st.Current.ID, st.Current.Text, st.Current.Priority)
|
||||
} else {
|
||||
fmt.Fprintln(stdout, "current: –")
|
||||
fmt.Fprintln(stdout, "current: -")
|
||||
}
|
||||
muteState := "aus"
|
||||
muteState := "off"
|
||||
if st.Muted {
|
||||
muteState = "an"
|
||||
muteState = "on"
|
||||
}
|
||||
fmt.Fprintf(stdout, "mute: %s\n", muteState)
|
||||
fmt.Fprintf(stdout, "provider: %s\n", st.Provider)
|
||||
fmt.Fprintf(stdout, "mute: %s\n", muteState)
|
||||
fmt.Fprintf(stdout, "provider: %s\n", st.Provider)
|
||||
printLawState(stdout, st)
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// printLawState schreibt den Gesetz-State (Task 05) aus GET /status:
|
||||
// Rate-Limit mit Burst-Recht und nächstem Slot sowie das Ruhezeit-Fenster.
|
||||
// printLawState writes the law state from GET /status: rate limit with
|
||||
// burst rights and next slot, quiet window, length and age limits.
|
||||
func printLawState(stdout io.Writer, st *api.StatusResponse) {
|
||||
if st.RateLimit != nil {
|
||||
interval := st.RateLimit.Interval
|
||||
if interval == "0s" {
|
||||
interval = "aus (unbegrenzt)"
|
||||
interval = "off (unlimited)"
|
||||
}
|
||||
burst := "verboten"
|
||||
burst := "not allowed"
|
||||
if st.RateLimit.AllowHighBurst {
|
||||
burst = "erlaubt"
|
||||
burst = "allowed"
|
||||
}
|
||||
line := fmt.Sprintf("rate-limit: %s (high-burst %s)", interval, burst)
|
||||
line := fmt.Sprintf("rate-limit: %s (high burst %s)", interval, burst)
|
||||
if st.RateLimit.NextSlot != nil {
|
||||
if ts, err := time.Parse(time.RFC3339, *st.RateLimit.NextSlot); err == nil {
|
||||
line += fmt.Sprintf(", nächster slot %s", ts.Format("15:04:05"))
|
||||
line += fmt.Sprintf(", next slot %s", ts.Format("15:04:05"))
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(stdout, line)
|
||||
@@ -133,35 +131,35 @@ func printLawState(stdout io.Writer, st *api.StatusResponse) {
|
||||
if st.QuietHours != nil {
|
||||
window := st.QuietHours.Window
|
||||
if window == "" {
|
||||
window = "aus"
|
||||
window = "off"
|
||||
}
|
||||
state := "inaktiv"
|
||||
state := "inactive"
|
||||
if st.QuietHours.Active {
|
||||
state = "aktiv"
|
||||
state = "active"
|
||||
if st.QuietHours.Until != nil {
|
||||
if ts, err := time.Parse(time.RFC3339, *st.QuietHours.Until); err == nil {
|
||||
state += " bis " + ts.Format("15:04")
|
||||
state += " until " + ts.Format("15:04")
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(stdout, "ruhezeit: %s (modus %s, %s)\n", window, st.QuietHours.Mode, state)
|
||||
fmt.Fprintf(stdout, "quiet-hours: %s (mode %s, %s)\n", window, st.QuietHours.Mode, state)
|
||||
}
|
||||
if st.Limits != nil {
|
||||
text := fmt.Sprintf("max. %d Zeichen", st.Limits.MaxText)
|
||||
text := fmt.Sprintf("max %d characters", st.Limits.MaxText)
|
||||
if st.Limits.MaxText == 0 {
|
||||
text = "Länge unbegrenzt"
|
||||
text = "unlimited length"
|
||||
}
|
||||
age := "max. Wartezeit " + st.Limits.MaxAge
|
||||
age := "max wait " + st.Limits.MaxAge
|
||||
if st.Limits.MaxAge == "0s" {
|
||||
age = "Wartezeit unbegrenzt"
|
||||
age = "unlimited wait"
|
||||
}
|
||||
fmt.Fprintf(stdout, "limits: %s, %s\n", text, age)
|
||||
fmt.Fprintf(stdout, "limits: %s, %s\n", text, age)
|
||||
}
|
||||
}
|
||||
|
||||
// runCancel implementiert `talk cancel`: POST /cancel — bricht die laufende
|
||||
// Ansage ab und leert die Queue; stdout meldet die Zahl der abgebrochenen
|
||||
// Ansagen. Ohne Daemon gibt es nichts, was talk abbrechen könnte.
|
||||
// runCancel implements `talk cancel`: POST /cancel aborts the playing
|
||||
// announcement and empties the queue; stdout reports the number of cancelled
|
||||
// announcements. Without a daemon there is nothing to cancel.
|
||||
func runCancel(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
cfg, err := config.Load(config.Options{Getenv: getenv})
|
||||
if err != nil {
|
||||
@@ -173,7 +171,7 @@ func runCancel(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr
|
||||
defer cancel()
|
||||
n, err := client.Cancel(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk: Daemon nicht erreichbar unter %s — nichts zu abbrechen\n", client.BaseURL)
|
||||
fmt.Fprintf(stderr, "talk: no daemon at %s; nothing to cancel\n", client.BaseURL)
|
||||
return exitFailure
|
||||
}
|
||||
fmt.Fprintf(stdout, "cancelled: %d\n", n)
|
||||
|
||||
+44
-45
@@ -10,111 +10,110 @@ import (
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||||
)
|
||||
|
||||
// Diese Tests decken die Steuer-Befehle ohne Daemon ab (in-process über
|
||||
// die run-Seam; TALK_PORT=1 aus der setGetenv-Grundausstattung → Daemon-
|
||||
// Erkennung schlägt sofort fehl). Das Verhalten MIT Daemon testen die
|
||||
// Prozessgrenzen-Tests in /test.
|
||||
// These tests cover the control commands without a daemon (in-process via
|
||||
// the run seam; TALK_PORT=1 from the setGetenv base makes daemon detection
|
||||
// fail immediately). Behaviour WITH a daemon is covered by the process
|
||||
// boundary tests in /test.
|
||||
|
||||
// TestStatusOhneDaemon: `talk status` ohne Daemon ist ein klarer Fehler
|
||||
// (Exit 1) mit Hinweis auf stderr — und stdout nennt den Zustand trotzdem
|
||||
// als Status-Zeile, damit der Block nie leer bleibt. Status startet den
|
||||
// Daemon nicht.
|
||||
func TestStatusOhneDaemon(t *testing.T) {
|
||||
// TestStatusWithoutDaemon: `talk status` without a daemon is a clear error
|
||||
// (exit 1) with a note on stderr, and stdout still names the state as a
|
||||
// status line so the block is never empty. status does not start the
|
||||
// daemon.
|
||||
func TestStatusWithoutDaemon(t *testing.T) {
|
||||
setGetenv(t, nil)
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"status"}, &out, &errBuf)
|
||||
if code != exitFailure {
|
||||
t.Fatalf("talk status ohne Daemon: Exit-Code = %d, want %d", code, exitFailure)
|
||||
t.Fatalf("talk status without daemon: exit code = %d, want %d", code, exitFailure)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "kein Daemon") {
|
||||
t.Errorf("stderr = %q, will Hinweis auf fehlenden Daemon", errBuf.String())
|
||||
if !strings.Contains(errBuf.String(), "no daemon") {
|
||||
t.Errorf("stderr = %q, want a note about the missing daemon", errBuf.String())
|
||||
}
|
||||
if !strings.HasPrefix(out.String(), "daemon: läuft nicht") {
|
||||
t.Errorf("stdout = %q, will Status-Zeile „läuft nicht“", out.String())
|
||||
if !strings.HasPrefix(out.String(), "daemon: not running") {
|
||||
t.Errorf("stdout = %q, want the status line \"not running\"", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelOhneDaemon: ohne Daemon gibt es nichts zu abbrechen — klarer
|
||||
// Fehler.
|
||||
func TestCancelOhneDaemon(t *testing.T) {
|
||||
// TestCancelWithoutDaemon: without a daemon there is nothing to cancel; a
|
||||
// clear error.
|
||||
func TestCancelWithoutDaemon(t *testing.T) {
|
||||
setGetenv(t, nil)
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"cancel"}, &out, &errBuf)
|
||||
if code != exitFailure {
|
||||
t.Fatalf("talk cancel ohne Daemon: Exit-Code = %d, want %d", code, exitFailure)
|
||||
t.Fatalf("talk cancel without daemon: exit code = %d, want %d", code, exitFailure)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "nichts zu abbrechen") {
|
||||
t.Errorf("stderr = %q, will Hinweis", errBuf.String())
|
||||
if !strings.Contains(errBuf.String(), "nothing to cancel") {
|
||||
t.Errorf("stderr = %q, want a note", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestMuteOhneDaemonSchreibtDatei: `talk mute` ohne Daemon schreibt die
|
||||
// Mute-Datei direkt — und genau diese Datei respektiert der Degraded Mode
|
||||
// von say anschließend.
|
||||
func TestMuteOhneDaemonSchreibtDatei(t *testing.T) {
|
||||
// TestMuteWithoutDaemonWritesFile: `talk mute` without a daemon writes the
|
||||
// mute file directly, and the direct path of say honours exactly that file.
|
||||
func TestMuteWithoutDaemonWritesFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
setGetenv(t, map[string]string{"XDG_CONFIG_HOME": root})
|
||||
mutePath := filepath.Join(root, "talk", queue.MuteFileName)
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runCLI([]string{"mute"}, &out, &errBuf); code != exitOK {
|
||||
t.Fatalf("talk mute ohne Daemon: Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
|
||||
t.Fatalf("talk mute without daemon: exit code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
|
||||
}
|
||||
if got := out.String(); got != "muted\n" {
|
||||
t.Errorf("talk mute stdout = %q, want %q", got, "muted\n")
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "direkt") {
|
||||
t.Errorf("stderr = %q, will Hinweis auf direkten Datei-Schreib", errBuf.String())
|
||||
if !strings.Contains(errBuf.String(), "directly") {
|
||||
t.Errorf("stderr = %q, want a note about writing the file directly", errBuf.String())
|
||||
}
|
||||
b, err := os.ReadFile(mutePath)
|
||||
if err != nil {
|
||||
t.Fatalf("Mute-Datei fehlt: %v", err)
|
||||
t.Fatalf("mute file missing: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(b)) != "true" {
|
||||
t.Errorf("Mute-Datei = %q, want true", b)
|
||||
t.Errorf("mute file = %q, want true", b)
|
||||
}
|
||||
|
||||
// say im Degraded Mode respektiert dieselbe Datei (tonlos verworfen,
|
||||
// ohne irgendeinen Provider zu brauchen).
|
||||
// say on the direct path honours the same file (discarded silently,
|
||||
// without needing any provider).
|
||||
out.Reset()
|
||||
errBuf.Reset()
|
||||
if code := runCLI([]string{"say", "stille bitte"}, &out, &errBuf); code != exitOK {
|
||||
t.Fatalf("talk say (mute an): Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
|
||||
if code := runCLI([]string{"say", "quiet please"}, &out, &errBuf); code != exitOK {
|
||||
t.Fatalf("talk say (muted): exit code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
|
||||
}
|
||||
if got := out.String(); got != "muted\n" {
|
||||
t.Errorf("talk say (mute an) stdout = %q, want %q", got, "muted\n")
|
||||
t.Errorf("talk say (muted) stdout = %q, want %q", got, "muted\n")
|
||||
}
|
||||
|
||||
// unmute hebt es auf (Datei false).
|
||||
// unmute lifts it (file false).
|
||||
out.Reset()
|
||||
errBuf.Reset()
|
||||
if code := runCLI([]string{"unmute"}, &out, &errBuf); code != exitOK {
|
||||
t.Fatalf("talk unmute: Exit-Code = %d (stderr: %q)", code, errBuf.String())
|
||||
t.Fatalf("talk unmute: exit code = %d (stderr: %q)", code, errBuf.String())
|
||||
}
|
||||
if got := out.String(); got != "unmuted\n" {
|
||||
t.Errorf("talk unmute stdout = %q, want %q", got, "unmuted\n")
|
||||
}
|
||||
b, err = os.ReadFile(mutePath)
|
||||
if err != nil || strings.TrimSpace(string(b)) != "false" {
|
||||
t.Errorf("Mute-Datei nach unmute = %q (%v), want false", b, err)
|
||||
t.Errorf("mute file after unmute = %q (%v), want false", b, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayOhneConfigDir: Degraded Mode ohne Config-Dir (kein HOME) spielt
|
||||
// weiter statt zu scheitern — Best-effort ohne Lock und Mute.
|
||||
func TestSayOhneConfigDir(t *testing.T) {
|
||||
// TestSayWithoutConfigDir: the direct path without a config directory (no
|
||||
// HOME) keeps speaking instead of failing; best effort without mute.
|
||||
func TestSayWithoutConfigDir(t *testing.T) {
|
||||
seen := withFakeEspeak(t)
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"Hallo"}, &out, &errBuf)
|
||||
code := runCLI([]string{"Hello"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("talk ohne HOME: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
t.Fatalf("talk without HOME: exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
}
|
||||
if _, err := os.Stat(seen); err != nil {
|
||||
t.Errorf("fake espeak nicht gerufen: %v", err)
|
||||
t.Errorf("fake espeak not called: %v", err)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "spreche direkt") {
|
||||
t.Errorf("stderr = %q, will Degraded-Hinweis", errBuf.String())
|
||||
if !strings.Contains(errBuf.String(), "speaking directly") {
|
||||
t.Errorf("stderr = %q, want the direct-path note", errBuf.String())
|
||||
}
|
||||
}
|
||||
+57
-59
@@ -15,21 +15,21 @@ import (
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||||
)
|
||||
|
||||
// Zeit-Budgets der Daemon-Kommunikation. Die Daemon-Erkennung (GET /status)
|
||||
// hält kurz: Auf einem freien Port wird die Verbindung sofort abgelehnt;
|
||||
// das Budget deckt nur unglückliche Fälle (gefilterte Ports), damit talk
|
||||
// ohne Daemon nicht spürbar trödelt.
|
||||
// Time budgets for talking to the daemon. Detection (GET /status) is short:
|
||||
// on a free port the connection is refused immediately; the budget only
|
||||
// covers unlucky cases (filtered ports) so talk never dawdles without a
|
||||
// daemon.
|
||||
const (
|
||||
daemonProbeTimeout = time.Second // Daemon-Erkennung (GET /status)
|
||||
daemonCallTimeout = 5 * time.Second // einzelne Daemon-Aufrufe
|
||||
daemonStartTimeout = 5 * time.Second // Autostart: so lange auf /status warten
|
||||
daemonStartPoll = 50 * time.Millisecond // Autostart-Poll-Frequenz
|
||||
waitPollInterval = 100 * time.Millisecond // --wait-Poll-Frequenz
|
||||
daemonProbeTimeout = time.Second // daemon detection (GET /status)
|
||||
daemonCallTimeout = 5 * time.Second // individual daemon calls
|
||||
daemonStartTimeout = 5 * time.Second // autostart: how long to wait for /status
|
||||
daemonStartPoll = 50 * time.Millisecond // autostart poll interval
|
||||
waitPollInterval = 100 * time.Millisecond // --wait poll interval
|
||||
)
|
||||
|
||||
// daemonAlive meldet, ob unter dem Client ein Daemon antwortet (GET /status
|
||||
// mit 200). Jeder Fehler — Verbindung abgelehnt, Timeout, fremder Dienst
|
||||
// mit anderem Status — zählt als „kein Daemon".
|
||||
// daemonAlive reports whether a daemon answers under the client (GET
|
||||
// /status with 200). Every error (connection refused, timeout, a foreign
|
||||
// service with another status) counts as "no daemon".
|
||||
func daemonAlive(client *api.Client, timeout time.Duration) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
@@ -37,11 +37,11 @@ func daemonAlive(client *api.Client, timeout time.Duration) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ensureDaemon liefert einen Client zu einem laufenden Daemon und startet
|
||||
// ihn bei Bedarf selbst (daemon.autostart, Default an). ok=false heißt:
|
||||
// kein Daemon erreichbar und keiner startbar (Autostart aus, Start
|
||||
// gescheitert) — der Aufrufer spricht dann direkt. Gründe stehen auf
|
||||
// stderr; der Daemon selbst protokolliert in daemon.log im Config-Dir.
|
||||
// ensureDaemon returns a client to a running daemon, starting one if needed
|
||||
// (daemon.autostart, default on). ok=false means: no daemon reachable and
|
||||
// none startable (autostart off, start failed); the caller then speaks
|
||||
// directly. Reasons go to stderr; the daemon itself logs to daemon.log in
|
||||
// the config directory.
|
||||
func ensureDaemon(cfg config.Config, stderr io.Writer) (client *api.Client, ok bool) {
|
||||
client = api.NewClient(cfg.Server.Port)
|
||||
if daemonAlive(client, daemonProbeTimeout) {
|
||||
@@ -53,14 +53,14 @@ func ensureDaemon(cfg config.Config, stderr io.Writer) (client *api.Client, ok b
|
||||
return client, startDaemon(client, stderr)
|
||||
}
|
||||
|
||||
// startDaemon startet den Daemon im Hintergrund (dasselbe Binary mit
|
||||
// `daemon run`) und wartet, bis er auf /status antwortet. Endet das Kind
|
||||
// vorher (typisch: Port belegt, weil ein paralleler talk-Aufruf schneller
|
||||
// war), wird noch einmal geprüft — der Daemon des anderen zählt genauso.
|
||||
// startDaemon starts the daemon in the background (the same binary with
|
||||
// `daemon run`) and waits until it answers on /status. If the child exits
|
||||
// first (typically: port taken because a parallel talk call was faster), we
|
||||
// probe once more: the other call's daemon counts just as well.
|
||||
func startDaemon(client *api.Client, stderr io.Writer) bool {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk: eigenes Binary nicht bestimmbar — Daemon-Start ausgesetzt (%v)\n", err)
|
||||
fmt.Fprintf(stderr, "talk: cannot determine own binary; daemon start skipped (%v)\n", err)
|
||||
return false
|
||||
}
|
||||
logPath := ""
|
||||
@@ -77,50 +77,49 @@ func startDaemon(client *api.Client, stderr io.Writer) bool {
|
||||
}
|
||||
logHint := ""
|
||||
if logPath != "" {
|
||||
logHint = " (Log: " + logPath + ")"
|
||||
logHint = " (log: " + logPath + ")"
|
||||
}
|
||||
deadline := time.Now().Add(daemonStartTimeout)
|
||||
for {
|
||||
if daemonAlive(client, daemonStartPoll*4) {
|
||||
fmt.Fprintf(stderr, "talk: Daemon gestartet unter %s%s\n", client.BaseURL, logHint)
|
||||
fmt.Fprintf(stderr, "talk: daemon started at %s%s\n", client.BaseURL, logHint)
|
||||
return true
|
||||
}
|
||||
select {
|
||||
case err := <-exited:
|
||||
if daemonAlive(client, daemonProbeTimeout) {
|
||||
return true // ein anderer Aufruf hat den Daemon gestartet
|
||||
return true // another call started the daemon
|
||||
}
|
||||
fmt.Fprintf(stderr, "talk: Daemon-Start fehlgeschlagen (%v)%s\n", exitReason(err), logHint)
|
||||
fmt.Fprintf(stderr, "talk: daemon start failed (%s)%s\n", exitReason(err), logHint)
|
||||
return false
|
||||
case <-time.After(daemonStartPoll):
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
fmt.Fprintf(stderr, "talk: Daemon antwortet nicht binnen %s%s\n", daemonStartTimeout, logHint)
|
||||
fmt.Fprintf(stderr, "talk: daemon not answering within %s%s\n", daemonStartTimeout, logHint)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// exitReason macht das Wait-Ergebnis eines beendeten Kindes lesbar.
|
||||
// exitReason makes the Wait result of an exited child readable.
|
||||
func exitReason(err error) string {
|
||||
if err == nil {
|
||||
return "Prozess hat sich sofort beendet"
|
||||
return "process exited immediately"
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// sayViaDaemon reicht eine Ansage beim Daemon ein (POST /speak). Default:
|
||||
// sofort zurückkehren (non-blocking), info erhält die Item-ID; --wait pollt
|
||||
// /status, bis die Ansage weder läuft noch wartet. Bei aktivem Mute
|
||||
// verwirft der Daemon — info sagt „muted"; Gesetz-Verwürfe nennen den
|
||||
// Grund. info ist der Writer für diese Informations-Token: say reicht
|
||||
// stdout, wrap stderr durch (stdout eines gewrappten Befehls darf nicht um
|
||||
// talk-Zeilen verunreinigt werden).
|
||||
// sayViaDaemon submits an announcement to the daemon (POST /speak). Default:
|
||||
// return at once (non-blocking), info receives the item ID; --wait polls
|
||||
// /status until the announcement is neither playing nor waiting. When muted
|
||||
// the daemon discards and info says "muted"; law discards name the reason.
|
||||
// info is the writer for these information tokens: say passes stdout, wrap
|
||||
// stderr (a wrapped command's stdout must not be polluted by talk lines).
|
||||
//
|
||||
// handled=false heißt: Netz-Fehler (Daemon zwischen Erkennung und Aufruf
|
||||
// gestorben) → der Aufrufer spricht direkt. Eine Daemon-Antwort mit
|
||||
// Fehlerstatus (z. B. 400) wird dagegen angezeigt — die Ansage war beim
|
||||
// Daemon angekommen und wurde bewusst abgelehnt.
|
||||
// handled=false means: network error (daemon died between detection and
|
||||
// call), so the caller speaks directly. A daemon response with an error
|
||||
// status (for example 400) is reported instead: the announcement reached
|
||||
// the daemon and was rejected deliberately.
|
||||
func sayViaDaemon(client *api.Client, text string, opts *sayOptions, info, stderr io.Writer) (code int, handled bool) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
|
||||
defer cancel()
|
||||
@@ -135,20 +134,20 @@ func sayViaDaemon(client *api.Client, text string, opts *sayOptions, info, stder
|
||||
if err != nil {
|
||||
var httpErr *api.HTTPError
|
||||
if errors.As(err, &httpErr) {
|
||||
fmt.Fprintf(stderr, "talk: Daemon hat die Ansage abgelehnt: %v\n", err)
|
||||
fmt.Fprintf(stderr, "talk: the daemon rejected the announcement: %v\n", err)
|
||||
return exitFailure, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
if resp.Muted {
|
||||
fmt.Fprintln(info, "muted")
|
||||
fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen")
|
||||
fmt.Fprintln(stderr, "talk: muted; announcement discarded")
|
||||
return exitOK, true
|
||||
}
|
||||
if resp.Discarded != "" {
|
||||
// Gesetz-Verwurf (Rate-Limit-Budget / Ruhezeit): dokumentiertes
|
||||
// Verhalten wie bei Mute — plain Token statt Fehler; --wait hat
|
||||
// nichts zu warten, es gibt keine Item-ID.
|
||||
// Law discard (rate limit budget / quiet hours): documented behaviour
|
||||
// like mute, a plain token instead of an error; --wait has nothing to
|
||||
// wait for, there is no item ID.
|
||||
line := "discarded: " + resp.Discarded
|
||||
if resp.Toast {
|
||||
line += " (toast)"
|
||||
@@ -159,7 +158,7 @@ func sayViaDaemon(client *api.Client, text string, opts *sayOptions, info, stder
|
||||
}
|
||||
fmt.Fprintln(info, resp.ID)
|
||||
if resp.Truncated {
|
||||
fmt.Fprintln(stderr, "talk: Text war zu lang und wurde gekürzt (queue.max_text) — Ansagen sind 1–2 Sätze")
|
||||
fmt.Fprintln(stderr, "talk: text was too long and has been truncated (queue.max_text); announcements are 1 to 2 sentences")
|
||||
}
|
||||
if !opts.wait {
|
||||
return exitOK, true
|
||||
@@ -167,36 +166,35 @@ func sayViaDaemon(client *api.Client, text string, opts *sayOptions, info, stder
|
||||
return waitForItem(client, resp.ID, stderr), true
|
||||
}
|
||||
|
||||
// discardHint übersetzt den Verwurfs-Grund der Daemon-Antwort in einen
|
||||
// menschlichen Hinweis.
|
||||
// discardHint translates the daemon's discard reason into a human note.
|
||||
func discardHint(reason string, toast bool) string {
|
||||
switch reason {
|
||||
case queue.DiscardQuietHours:
|
||||
if toast {
|
||||
return "Ruhezeit aktiv — Ansage durch Toast ersetzt (kein Audio)"
|
||||
return "quiet hours active; announcement replaced by a toast (no audio)"
|
||||
}
|
||||
return "Ruhezeit aktiv — Ansage verworfen (quiet_mode = discard; now bricht bewusst aus)"
|
||||
return "quiet hours active; announcement discarded (quiet_mode = discard; --now deliberately breaks out)"
|
||||
case queue.DiscardRateLimit:
|
||||
return "Rate-Limit — Ansage verworfen (Priorität low über dem Fenster-Budget)"
|
||||
return "rate limit; announcement discarded (priority low over the window budget)"
|
||||
case queue.DiscardExpired:
|
||||
return "Ansage verworfen — sie hat länger gewartet als queue.max_age erlaubt (veraltet)"
|
||||
return "announcement discarded; it waited longer than queue.max_age allows (stale)"
|
||||
default:
|
||||
return "Ansage verworfen (" + reason + ")"
|
||||
return "announcement discarded (" + reason + ")"
|
||||
}
|
||||
}
|
||||
|
||||
// waitForItem pollt /status, bis die Ansage id weder läuft noch wartet —
|
||||
// sie ist dann fertig (oder abgebrochen; in beiden Fällen hat der Aufrufer
|
||||
// nichts mehr zu warten). Stirbt der Daemon während des Wartens, ist das
|
||||
// ein Fehler: Der Zustand der Ansage ist unbekannt, und still warten wäre
|
||||
// das Gegenteil von --wait.
|
||||
// waitForItem polls /status until the announcement id is neither playing
|
||||
// nor waiting; it is then done (or cancelled; either way there is nothing
|
||||
// left to wait for). If the daemon dies while waiting, that is an error:
|
||||
// the announcement's state is unknown, and waiting silently would be the
|
||||
// opposite of --wait.
|
||||
func waitForItem(client *api.Client, id string, stderr io.Writer) int {
|
||||
for {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
|
||||
st, err := client.Status(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk: Daemon beim Warten nicht mehr erreichbar (%v)\n", err)
|
||||
fmt.Fprintf(stderr, "talk: daemon no longer reachable while waiting (%v)\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
if !st.PendingOrCurrent(id) {
|
||||
|
||||
+20
-21
@@ -14,27 +14,26 @@ import (
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/daemon"
|
||||
)
|
||||
|
||||
// daemonStopTimeout ist die Frist, in der ein per /shutdown angestoßener
|
||||
// Daemon verschwunden sein muss.
|
||||
// daemonStopTimeout is how long a daemon told to shut down via /shutdown may
|
||||
// take to disappear.
|
||||
const daemonStopTimeout = 5 * time.Second
|
||||
|
||||
// runDaemon implementiert `talk daemon [run|start|stop]`:
|
||||
// runDaemon implements `talk daemon [run|start|stop]`:
|
||||
//
|
||||
// - run (Default): Daemon im Vordergrund bis SIGINT/SIGTERM — der Modus
|
||||
// für systemd/launchd/Task Scheduler oder ein Terminal. Protokoll auf
|
||||
// stderr.
|
||||
// - start: Daemon im Hintergrund starten (dasselbe wie der Autostart von
|
||||
// say, nur explizit und unabhängig von daemon.autostart) und melden,
|
||||
// wo er läuft. Läuft schon einer: kein Fehler.
|
||||
// - stop: laufenden Daemon per POST /shutdown herunterfahren und warten,
|
||||
// bis er weg ist. Läuft keiner: Hinweis, Exit 0 (idempotent).
|
||||
// - run (default): the daemon in the foreground until SIGINT/SIGTERM; the
|
||||
// mode for systemd/launchd/Task Scheduler or a terminal. Log on stderr.
|
||||
// - start: start the daemon in the background (the same as say's
|
||||
// autostart, only explicit and independent of daemon.autostart) and
|
||||
// report where it runs. Already running: no error.
|
||||
// - stop: shut a running daemon down via POST /shutdown and wait until it
|
||||
// is gone. None running: a note, exit 0 (idempotent).
|
||||
func runDaemon(args []string, _ *sayOptions, _ io.Reader, stdout, stderr io.Writer) int {
|
||||
sub := "run"
|
||||
if len(args) > 0 {
|
||||
sub = args[0]
|
||||
}
|
||||
if len(args) > 1 {
|
||||
fmt.Fprintln(stderr, "talk daemon: zu viele Argumente — Verwendung: talk daemon [run|start|stop]")
|
||||
fmt.Fprintln(stderr, "talk daemon: too many arguments; usage: talk daemon [run|start|stop]")
|
||||
return exitUsage
|
||||
}
|
||||
cfg, err := config.Load(config.Options{Getenv: getenv})
|
||||
@@ -50,7 +49,7 @@ func runDaemon(args []string, _ *sayOptions, _ io.Reader, stdout, stderr io.Writ
|
||||
case "stop":
|
||||
return daemonStop(cfg, stdout, stderr)
|
||||
default:
|
||||
fmt.Fprintf(stderr, "talk daemon: unbekanntes Unterkommando %q (erlaubt: run, start, stop)\n", sub)
|
||||
fmt.Fprintf(stderr, "talk daemon: unknown subcommand %q (allowed: run, start, stop)\n", sub)
|
||||
return exitUsage
|
||||
}
|
||||
}
|
||||
@@ -74,20 +73,20 @@ func daemonRun(cfg config.Config, stderr io.Writer) int {
|
||||
func daemonStart(cfg config.Config, stdout, stderr io.Writer) int {
|
||||
client := api.NewClient(cfg.Server.Port)
|
||||
if daemonAlive(client, daemonProbeTimeout) {
|
||||
fmt.Fprintf(stdout, "daemon: läuft bereits unter %s\n", client.BaseURL)
|
||||
fmt.Fprintf(stdout, "daemon: already running at %s\n", client.BaseURL)
|
||||
return exitOK
|
||||
}
|
||||
if !startDaemon(client, stderr) {
|
||||
return exitFailure
|
||||
}
|
||||
fmt.Fprintf(stdout, "daemon: gestartet unter %s\n", client.BaseURL)
|
||||
fmt.Fprintf(stdout, "daemon: started at %s\n", client.BaseURL)
|
||||
return exitOK
|
||||
}
|
||||
|
||||
func daemonStop(cfg config.Config, stdout, stderr io.Writer) int {
|
||||
client := api.NewClient(cfg.Server.Port)
|
||||
if !daemonAlive(client, daemonProbeTimeout) {
|
||||
fmt.Fprintf(stdout, "daemon: läuft nicht (%s)\n", client.BaseURL)
|
||||
fmt.Fprintf(stdout, "daemon: not running (%s)\n", client.BaseURL)
|
||||
return exitOK
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
|
||||
@@ -96,9 +95,9 @@ func daemonStop(cfg config.Config, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintf(stderr, "talk daemon stop: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
// Warten, bis der Daemon wirklich weg ist: erst verstummt HTTP, dann
|
||||
// bricht er die laufende Ansage ab und löscht als Letztes die PID-Datei.
|
||||
// Ohne Config-Dir (keine PID-Datei) genügt das HTTP-Ende.
|
||||
// Wait until the daemon is really gone: first HTTP falls silent, then it
|
||||
// aborts the playing announcement and deletes the PID file as its last
|
||||
// act. Without a config directory (no PID file) the HTTP end suffices.
|
||||
pidDir := ""
|
||||
if dir, err := config.Dir(getenv); err == nil {
|
||||
pidDir = dir
|
||||
@@ -106,12 +105,12 @@ func daemonStop(cfg config.Config, stdout, stderr io.Writer) int {
|
||||
deadline := time.Now().Add(daemonStopTimeout)
|
||||
for daemonAlive(client, daemonStartPoll*4) || (pidDir != "" && daemon.ReadPID(pidDir) != 0) {
|
||||
if time.Now().After(deadline) {
|
||||
fmt.Fprintf(stderr, "talk daemon stop: Daemon ist nach %s noch nicht beendet — PID siehe %s im Config-Verzeichnis\n",
|
||||
fmt.Fprintf(stderr, "talk daemon stop: daemon has not exited after %s; see %s in the config directory for its PID\n",
|
||||
daemonStopTimeout, daemon.PIDFileName)
|
||||
return exitFailure
|
||||
}
|
||||
time.Sleep(daemonStartPoll)
|
||||
}
|
||||
fmt.Fprintln(stdout, "daemon: gestoppt")
|
||||
fmt.Fprintln(stdout, "daemon: stopped")
|
||||
return exitOK
|
||||
}
|
||||
+68
-82
@@ -22,31 +22,31 @@ import (
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/speak"
|
||||
)
|
||||
|
||||
// Status einer doctor-Prüfung.
|
||||
// Status of a doctor check.
|
||||
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)
|
||||
statusOK = "ok" // ✔ fine
|
||||
statusWarn = "warn" // ⚠ note: works, but limited or worth knowing
|
||||
statusFail = "fail" // ✘ error: nothing will be heard like this (exit 1)
|
||||
)
|
||||
|
||||
// check ist eine Zeile des doctor-Berichts (auch die JSON-Form).
|
||||
// check is one line of the doctor report (also the 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).
|
||||
// probeTimeout is the budget per synthesis 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.
|
||||
// runDoctor implements `talk doctor`: one line per check (config, daemon,
|
||||
// audio, every provider tier, notifications, mute, laws) with a plain
|
||||
// sentence saying what is up and what to do. Exit 0 without ✘, else 1; a
|
||||
// daemon that is not running is ⚠ (it starts on the next speak command).
|
||||
// --probe runs real synthesis probes (network); --json returns the list.
|
||||
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)")
|
||||
fmt.Fprintln(stderr, "talk doctor: no arguments expected (flags: --probe, --json)")
|
||||
return exitUsage
|
||||
}
|
||||
checks := doctorChecks(opts.probe)
|
||||
@@ -65,9 +65,9 @@ func runDoctor(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.W
|
||||
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`.")
|
||||
fmt.Fprintln(stdout, "\nAt least one item prevents sound (✘). Fix it, then check with `talk test`.")
|
||||
} else {
|
||||
fmt.Fprintln(stdout, "\nAlles bereit. Probiere: talk test")
|
||||
fmt.Fprintln(stdout, "\nAll set. Try: talk test")
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
@@ -76,8 +76,8 @@ func runDoctor(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.W
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// symbol rendert den Status: Unicode-Zeichen, auf Windows-Konsolen (oft
|
||||
// ohne UTF-8) kurze Wörter.
|
||||
// symbol renders the status: Unicode marks, short words on Windows consoles
|
||||
// (often without UTF-8).
|
||||
func symbol(status string) string {
|
||||
win := runtime.GOOS == "windows"
|
||||
switch status {
|
||||
@@ -99,34 +99,19 @@ func symbol(status string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// doctorChecks runs all checks. Each one is independent: a failure in one
|
||||
// (for example a broken config) does not prevent the others; they run with
|
||||
// defaults so the report stays complete.
|
||||
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. Benachrichtigungen (Ruhezeit-Toast)
|
||||
out = append(out, checkNotify(cfg))
|
||||
|
||||
// 7. Mute
|
||||
out = append(out, checkMute())
|
||||
|
||||
// 8. Gesetze
|
||||
out = append(out, checkLaws(cfg))
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -135,16 +120,16 @@ func checkConfig() (config.Config, check) {
|
||||
cfg, err := config.Load(config.Options{Getenv: getenv})
|
||||
switch {
|
||||
case err != nil:
|
||||
// Mit Defaults weiterprüfen, aber den Fehler klar nennen.
|
||||
// Keep checking with defaults, but name the error clearly.
|
||||
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)}
|
||||
return cfg, check{"Config", statusFail, fmt.Sprintf("%v; fix it, talk does not run otherwise", err)}
|
||||
case perr != nil:
|
||||
return cfg, check{"Config", statusWarn, "kein Config-Verzeichnis (HOME bzw. APPDATA fehlt) — Defaults + Env; Mute und Daemon-Dateien deaktiviert"}
|
||||
return cfg, check{"Config", statusWarn, "no config directory (HOME or APPDATA not set); defaults + env, mute and daemon files disabled"}
|
||||
}
|
||||
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, fmt.Sprintf("no file at %s; defaults apply (talk setup creates one)", path)}
|
||||
}
|
||||
return cfg, check{"Config", statusOK, path + " geladen"}
|
||||
return cfg, check{"Config", statusOK, path + " loaded"}
|
||||
}
|
||||
|
||||
func checkDaemon(cfg config.Config) check {
|
||||
@@ -153,32 +138,32 @@ func checkDaemon(cfg config.Config) check {
|
||||
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)
|
||||
detail := fmt.Sprintf("running at %s (version %s, %d queued)", client.BaseURL, st.Version, st.Queue)
|
||||
if dir, derr := config.Dir(getenv); derr == nil {
|
||||
detail += ", Log: " + filepath.Join(dir, daemon.LogFileName)
|
||||
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)}
|
||||
return check{"Daemon", statusFail, fmt.Sprintf("a foreign service answers at %s (HTTP %d); choose another port: [server] port or 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("not running (%s); starts by itself on the next speak command", 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)}
|
||||
return check{"Daemon", statusWarn, fmt.Sprintf("not running (%s), autostart is off; `talk daemon start` or set up a service; until then talk speaks directly", 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)"}
|
||||
return check{"Audio", statusWarn, "audio.sink = null; no sound (meant for CI/servers; TALK_AUDIO_SINK=oto for playback)"}
|
||||
}
|
||||
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", statusFail, fmt.Sprintf("%v; no audio device, or the audio library is missing (Linux: PulseAudio/ALSA)", err)}
|
||||
}
|
||||
return check{"Audio", statusOK, "Audiogerät bereit (oto)"}
|
||||
return check{"Audio", statusOK, "audio device ready (oto)"}
|
||||
}
|
||||
|
||||
func checkProviders(cfg config.Config, probe bool) []check {
|
||||
@@ -199,9 +184,9 @@ func checkProviders(cfg config.Config, probe bool) []check {
|
||||
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)"})
|
||||
out = append(out, check{"Provider", statusFail, "no tier available; without a key and without an OS voice talk cannot speak (install espeak-ng or set TALK_ELEVENLABS_KEY/TALK_OPENAI_KEY)"})
|
||||
} else if cfg.Provider != "" {
|
||||
out = append(out, check{"Provider", statusOK, fmt.Sprintf("Kette auf %q gepinnt (config provider) — kein Fallback", cfg.Provider)})
|
||||
out = append(out, check{"Provider", statusOK, fmt.Sprintf("chain pinned to %q (config provider); no fallback", cfg.Provider)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -215,42 +200,42 @@ func checkTier(p provider.Provider, cfg config.Config) check {
|
||||
envKey = provider.EnvOpenAIKey
|
||||
}
|
||||
if !p.Available(context.Background()) {
|
||||
return check{name, statusWarn, fmt.Sprintf("kein Key (%s) — Stufe wird übersprungen", envKey)}
|
||||
return check{name, statusWarn, fmt.Sprintf("no key (%s); tier is skipped", envKey)}
|
||||
}
|
||||
detail := fmt.Sprintf("Key gesetzt (%s)", envKey)
|
||||
detail := fmt.Sprintf("key set (%s)", envKey)
|
||||
if ep := cfg.Providers[name].Endpoint; ep != "" {
|
||||
detail += ", Endpunkt " + ep
|
||||
detail += ", endpoint " + ep
|
||||
}
|
||||
if v := provider.DefaultVoice(name, "de", cfg.Voice); v != "" {
|
||||
detail += ", Stimme de: " + voiceLabel(name, v)
|
||||
if v := provider.DefaultVoice(name, cfg.Lang, cfg.Voice); v != "" {
|
||||
detail += fmt.Sprintf(", voice %s: %s", cfg.Lang, 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, statusWarn, "no endpoint configured; tier is skipped"}
|
||||
}
|
||||
return check{name, statusOK, "kostenlos, ohne Key (braucht Internet), Stimme de: " + voiceLabel(name, provider.DefaultVoice(name, "de", cfg.Voice))}
|
||||
return check{name, statusOK, fmt.Sprintf("free, no key (needs internet), voice %s: %s", cfg.Lang, voiceLabel(name, provider.DefaultVoice(name, cfg.Lang, cfg.Voice)))}
|
||||
case "osbuiltin":
|
||||
desc := "OS-Stimme"
|
||||
desc := "OS voice"
|
||||
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"}
|
||||
return check{name, statusWarn, desc + "; the last tier is missing, talk stays silent without network"}
|
||||
}
|
||||
if strings.Contains(desc, "nur Direktansage") {
|
||||
if strings.Contains(desc, "direct speech only") {
|
||||
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, statusOK, "available"}
|
||||
}
|
||||
return check{name, statusWarn, "nicht verfügbar"}
|
||||
return check{name, statusWarn, "not available"}
|
||||
}
|
||||
}
|
||||
|
||||
// voiceLabel rendert eine Voice-ID mit Katalog-Namen, falls bekannt.
|
||||
// voiceLabel renders a voice ID with its catalog name when known.
|
||||
func voiceLabel(prov, id string) string {
|
||||
if n := provider.VoiceName(prov, id); n != "" {
|
||||
return fmt.Sprintf("%s (%s)", n, id)
|
||||
@@ -258,68 +243,69 @@ func voiceLabel(prov, id string) string {
|
||||
return id
|
||||
}
|
||||
|
||||
// probeTier macht eine echte Synthese-Probe (kein Abspielen) und meldet
|
||||
// Bytes und Dauer bzw. den Fehler.
|
||||
// probeTier runs a real synthesis probe (no playback) and reports bytes and
|
||||
// duration, or the error.
|
||||
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")
|
||||
data, err := p.Synthesize(ctx, "Test", "en")
|
||||
switch {
|
||||
case errors.Is(err, provider.ErrNoByteOutput):
|
||||
return "Probe: liefert keine Bytes (spricht nur direkt) — ok"
|
||||
return "probe: yields no bytes (speaks directly only); ok"
|
||||
case err != nil:
|
||||
return "Probe FEHLGESCHLAGEN: " + err.Error()
|
||||
return "probe FAILED: " + err.Error()
|
||||
default:
|
||||
return fmt.Sprintf("Probe ok: %d Bytes in %s", len(data), time.Since(start).Round(time.Millisecond))
|
||||
return fmt.Sprintf("probe ok: %d bytes in %s", len(data), time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
}
|
||||
|
||||
func checkNotify(cfg config.Config) check {
|
||||
if notify.Available() {
|
||||
return check{"Toast", statusOK, notify.Describe() + " — für quiet_mode = toast"}
|
||||
return check{"Toast", statusOK, notify.Describe() + "; used for quiet_mode = toast"}
|
||||
}
|
||||
if cfg.QuietMode == queue.QuietModeToast {
|
||||
return check{"Toast", statusWarn, notify.Describe() + " — quiet_mode = toast wird nur protokolliert"}
|
||||
return check{"Toast", statusWarn, notify.Describe() + "; quiet_mode = toast is only logged"}
|
||||
}
|
||||
return check{"Toast", statusOK, notify.Describe() + " (nur für quiet_mode = toast relevant)"}
|
||||
return check{"Toast", statusOK, notify.Describe() + " (only relevant for quiet_mode = toast)"}
|
||||
}
|
||||
|
||||
func checkMute() check {
|
||||
dir, err := config.Dir(getenv)
|
||||
if err != nil {
|
||||
return check{"Mute", statusOK, "aus (kein Config-Dir, Mute nicht verfügbar)"}
|
||||
return check{"Mute", statusOK, "off (no config directory, mute unavailable)"}
|
||||
}
|
||||
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", statusWarn, "ON; every announcement is discarded until `talk unmute`"}
|
||||
}
|
||||
return check{"Mute", statusOK, "aus"}
|
||||
return check{"Mute", statusOK, "off"}
|
||||
}
|
||||
|
||||
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)),
|
||||
fmt.Sprintf("rate limit %s", durationOrOff(cfg.Queue.RateLimit)),
|
||||
fmt.Sprintf("max %d characters", cfg.Queue.MaxText),
|
||||
fmt.Sprintf("max wait %s", durationOrOff(cfg.Queue.MaxAge)),
|
||||
fmt.Sprintf("fallback language %s", cfg.Lang),
|
||||
}
|
||||
status := statusOK
|
||||
if cfg.QuietHours == "" {
|
||||
parts = append(parts, "Ruhezeit aus")
|
||||
parts = append(parts, "quiet hours off")
|
||||
} 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))
|
||||
parts = append(parts, fmt.Sprintf("quiet hours %s ACTIVE until %s (mode %s); announcements are being discarded right now", 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))
|
||||
parts = append(parts, fmt.Sprintf("quiet hours %s (mode %s)", cfg.QuietHours, cfg.QuietMode))
|
||||
}
|
||||
}
|
||||
return check{"Gesetze", status, strings.Join(parts, ", ")}
|
||||
return check{"Laws", status, strings.Join(parts, ", ")}
|
||||
}
|
||||
|
||||
func durationOrOff(d time.Duration) string {
|
||||
if d <= 0 {
|
||||
return "aus"
|
||||
return "off"
|
||||
}
|
||||
return d.String()
|
||||
}
|
||||
+27
-27
@@ -18,30 +18,30 @@ func checkByName(t *testing.T, checks []check, name string) check {
|
||||
return c
|
||||
}
|
||||
}
|
||||
t.Fatalf("Prüfung %q fehlt in %+v", name, checks)
|
||||
t.Fatalf("check %q missing 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) {
|
||||
// TestDoctorWithoutDaemon: a dead daemon is ⚠ (no error), sink null is ⚠,
|
||||
// cloud tiers without keys ⚠, edge ✔; exit 0, closing line "All set".
|
||||
func TestDoctorWithoutDaemon(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())
|
||||
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"} {
|
||||
for _, want := range []string{"⚠ Daemon", "not running", "⚠ Audio", "⚠ elevenlabs", "TALK_ELEVENLABS_KEY", "✔ edge", "✔ Mute", "✔ Laws", "All set"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Errorf("stdout ohne %q:\n%s", want, out.String())
|
||||
t.Errorf("stdout without %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) {
|
||||
// TestDoctorJSONAndMute: --json yields the check list; a mute file makes
|
||||
// Mute warn; a key makes the tier ok.
|
||||
func TestDoctorJSONAndMute(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
setGetenv(t, map[string]string{
|
||||
"TALK_AUDIO_SINK": "null",
|
||||
@@ -56,45 +56,45 @@ func TestDoctorJSONUndMute(t *testing.T) {
|
||||
}
|
||||
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())
|
||||
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)
|
||||
t.Errorf("Mute = %+v, want warn with an unmute hint", 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)
|
||||
t.Errorf("elevenlabs = %+v, want ok with the default voice", 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) {
|
||||
// TestDoctorForeignServiceOnPort: if something other than talk answers on
|
||||
// the port, that is ✘ with a port hint; exit 1.
|
||||
func TestDoctorForeignServiceOnPort(t *testing.T) {
|
||||
foreign := 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:]
|
||||
t.Cleanup(foreign.Close)
|
||||
port := foreign.URL[strings.LastIndex(foreign.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())
|
||||
t.Fatalf("talk doctor (foreign service): 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") {
|
||||
if !strings.Contains(out.String(), "✘ Daemon") || !strings.Contains(out.String(), "foreign service") || !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) {
|
||||
// TestDoctorBrokenConfig: an unreadable config.toml is ✘, the other checks
|
||||
// still run (with defaults); the report stays complete.
|
||||
func TestDoctorBrokenConfig(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 {
|
||||
@@ -106,11 +106,11 @@ func TestDoctorKaputteConfig(t *testing.T) {
|
||||
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())
|
||||
t.Errorf("exit %d, stdout %q; want ✘ Config and exit 1", code, out.String())
|
||||
}
|
||||
for _, want := range []string{"Daemon", "edge", "Mute", "Gesetze"} {
|
||||
for _, want := range []string{"Daemon", "edge", "Mute", "Laws"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Errorf("Prüfung %q fehlt trotz Config-Fehler:\n%s", want, out.String())
|
||||
t.Errorf("check %q missing despite the config error:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-14
@@ -1,14 +1,14 @@
|
||||
// talk ist das eine Binary von talk 2.0: CLI und Daemon.
|
||||
// talk is the single binary of talk: CLI and daemon.
|
||||
//
|
||||
// say (und der Default-Aufruf `talk "Text"`) reicht Ansagen in den Daemon
|
||||
// ein — non-blocking, --wait pollt bis zur Fertigstellung, --now springt
|
||||
// vor. Läuft kein Daemon, startet talk ihn selbst im Hintergrund
|
||||
// (daemon.autostart); gelingt auch das nicht, spricht talk direkt (ohne
|
||||
// Warteschlange, mit Hinweis) — nichts bricht still. `talk daemon run`
|
||||
// betreibt den Daemon im Vordergrund, start/stop steuern ihn explizit.
|
||||
// mute/unmute/status/cancel sind Daemon-Aufrufe mit plain Output;
|
||||
// `talk wrap -- <Befehl>` führt Befehle aus und sagt das Ergebnis an. Die
|
||||
// übrigen Befehle sind Stubs.
|
||||
// say (and the default form `talk "Text"`) hands announcements to the
|
||||
// daemon: non-blocking, --wait polls until spoken, --now jumps the queue.
|
||||
// When no daemon runs, talk starts one itself in the background
|
||||
// (daemon.autostart); if that fails too, talk speaks directly (no queue,
|
||||
// with a note). Nothing fails silently. `talk daemon run` runs the daemon in
|
||||
// the foreground, start/stop control it explicitly. mute/unmute/status/
|
||||
// cancel are daemon calls with plain output; `talk wrap -- <command>` runs
|
||||
// commands and announces the result; doctor/test/voices/setup cover
|
||||
// diagnostics and onboarding.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -16,18 +16,18 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// Build-Informationen. Werden beim Release per -ldflags injiziert, z. B.
|
||||
// Build information, injected at release time via -ldflags, for example
|
||||
//
|
||||
// go build -ldflags "-X main.version=1.0.0 -X main.commit=abc1234" ./cmd/talk
|
||||
//
|
||||
// Defaults: version "dev", commit leer (siehe auch .goreleaser.yaml, Task 11).
|
||||
// Defaults: version "dev", commit empty (see scripts/release.sh).
|
||||
var (
|
||||
version = "dev"
|
||||
commit = ""
|
||||
)
|
||||
|
||||
// buildVersion rendert Version plus Commit-Hash in Klammern, falls injiziert
|
||||
// — der Wert, den der Daemon in /status und MCP serverInfo meldet.
|
||||
// buildVersion renders the version plus the commit hash in parentheses when
|
||||
// injected; the value the daemon reports in /status and MCP serverInfo.
|
||||
func buildVersion() string {
|
||||
if commit == "" {
|
||||
return version
|
||||
|
||||
+23
-24
@@ -7,12 +7,11 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParseInterspersedDoubleDash (Regression, Task 07): Das erste allein
|
||||
// stehende „--" schirmt alles Folgenden von der Flag-Auswertung ab — der
|
||||
// Kern der wrap-Aufrufform `talk wrap -- foo --bar`. Vorher nahm der
|
||||
// Interspersed-Loop nach jedem Positionsargument das Parsen wieder auf und
|
||||
// fraß damit Flags des nachfolgenden Befehls. Gleichzeitig bleibt die
|
||||
// Abnahme-Form „Flag nach dem Text" (ohne „--") intakt.
|
||||
// TestParseInterspersedDoubleDash (regression): the first standalone "--"
|
||||
// shields everything after it from flag parsing, the core of the wrap form
|
||||
// `talk wrap -- foo --bar`. Previously the interspersed loop resumed parsing
|
||||
// after every positional and ate the wrapped command's flags. At the same
|
||||
// time the "flag after the text" form (without "--") stays intact.
|
||||
func TestParseInterspersedDoubleDash(t *testing.T) {
|
||||
newWrapFS := func() (*flag.FlagSet, *sayOptions) {
|
||||
fs := flag.NewFlagSet("talk wrap", flag.ContinueOnError)
|
||||
@@ -21,78 +20,78 @@ func TestParseInterspersedDoubleDash(t *testing.T) {
|
||||
return fs, addSayFlags(fs)
|
||||
}
|
||||
|
||||
t.Run("Doppelminus schirmt Befehls-Flags", func(t *testing.T) {
|
||||
t.Run("double dash shields command flags", func(t *testing.T) {
|
||||
fs, opts := newWrapFS()
|
||||
positional, err := parseInterspersed(fs, []string{"--", "foo", "--bar"})
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if strings.Join(positional, " ") != "foo --bar" {
|
||||
t.Errorf("Positionsargumente = %q, want %q", positional, []string{"foo", "--bar"})
|
||||
t.Errorf("positional = %q, want %q", positional, []string{"foo", "--bar"})
|
||||
}
|
||||
if opts.wait {
|
||||
t.Error("--bar darf kein talk-Flag geworden sein (Shielding kaputt)")
|
||||
t.Error("--bar must not have become a talk flag (shielding broken)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("talk-Flags vor dem Doppelminus gelten", func(t *testing.T) {
|
||||
t.Run("talk flags before the double dash apply", func(t *testing.T) {
|
||||
fs, opts := newWrapFS()
|
||||
positional, err := parseInterspersed(fs, []string{"--wait", "--", "sleep", "1"})
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if !opts.wait {
|
||||
t.Error("--wait vor dem -- muss als talk-Flag gelten")
|
||||
t.Error("--wait before the -- must count as a talk flag")
|
||||
}
|
||||
if strings.Join(positional, " ") != "sleep 1" {
|
||||
t.Errorf("Positionsargumente = %q, want %q", positional, []string{"sleep", "1"})
|
||||
t.Errorf("positional = %q, want %q", positional, []string{"sleep", "1"})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Positional vor dem Doppelminus wird mitgeschleppt", func(t *testing.T) {
|
||||
t.Run("positional before the double dash is carried along", func(t *testing.T) {
|
||||
fs, _ := newWrapFS()
|
||||
positional, err := parseInterspersed(fs, []string{"foo", "--", "--bar"})
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if strings.Join(positional, " ") != "foo --bar" {
|
||||
t.Errorf("Positionsargumente = %q, want %q", positional, []string{"foo", "--bar"})
|
||||
t.Errorf("positional = %q, want %q", positional, []string{"foo", "--bar"})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("abgeschirmtes talk-Flag setzt den Flag nicht", func(t *testing.T) {
|
||||
t.Run("shielded talk flag does not set the flag", func(t *testing.T) {
|
||||
fs, opts := newWrapFS()
|
||||
positional, err := parseInterspersed(fs, []string{"--", "--wait", "true"})
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if opts.wait {
|
||||
t.Error("--wait NACH dem -- gehört dem Befehl, nicht talk")
|
||||
t.Error("--wait AFTER the -- belongs to the command, not talk")
|
||||
}
|
||||
if strings.Join(positional, " ") != "--wait true" {
|
||||
t.Errorf("Positionsargumente = %q, want %q", positional, []string{"--wait", "true"})
|
||||
t.Errorf("positional = %q, want %q", positional, []string{"--wait", "true"})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Flag nach dem Text ohne Doppelminus bleibt erlaubt", func(t *testing.T) {
|
||||
t.Run("flag after the text without double dash stays allowed", func(t *testing.T) {
|
||||
fs, opts := newWrapFS()
|
||||
positional, err := parseInterspersed(fs, []string{"Text", "--lang=en"})
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v (Abnahme-Form Flag-nach-Text kaputt)", err)
|
||||
t.Fatalf("parse: %v (flag-after-text form broken)", err)
|
||||
}
|
||||
if strings.Join(positional, " ") != "Text" || opts.lang != "en" {
|
||||
t.Errorf("Positionsargumente = %q, lang = %q — Flag-nach-Text muss weitergehen", positional, opts.lang)
|
||||
t.Errorf("positional = %q, lang = %q; flag-after-text must keep working", positional, opts.lang)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Text nach dem Doppelminus kommt vollständig durch (say)", func(t *testing.T) {
|
||||
t.Run("text after the double dash comes through completely (say)", func(t *testing.T) {
|
||||
fs, _ := newWrapFS()
|
||||
positional, err := parseInterspersed(fs, []string{"--", "Bericht", "mit --flag", "fertig"})
|
||||
positional, err := parseInterspersed(fs, []string{"--", "Report", "with --flag", "done"})
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if strings.Join(positional, " ") != "Bericht mit --flag fertig" {
|
||||
t.Errorf("Positionsargumente = %q, want Wörter unverändert", positional)
|
||||
if strings.Join(positional, " ") != "Report with --flag done" {
|
||||
t.Errorf("positional = %q, want the words unchanged", positional)
|
||||
}
|
||||
})
|
||||
}
|
||||
+34
-35
@@ -13,13 +13,13 @@ import (
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/speak"
|
||||
)
|
||||
|
||||
// getenv ist die Env-Seam von run (Tests injizieren hier TALK_AUDIO_SINK,
|
||||
// TALK_PORT etc., ohne den Prozess-Environment anzufassen). Default:
|
||||
// getenv is the CLI's environment seam (tests inject TALK_AUDIO_SINK,
|
||||
// TALK_PORT etc. without touching the process environment). Default:
|
||||
// os.Getenv.
|
||||
var getenv = os.Getenv
|
||||
|
||||
// sayOptions bündelt die Flags von say (identisch im Default-Pfad
|
||||
// `talk "Text"`).
|
||||
// sayOptions bundles the say flags (identical for the default form
|
||||
// `talk "text"`).
|
||||
type sayOptions struct {
|
||||
wait bool
|
||||
now bool
|
||||
@@ -28,40 +28,39 @@ type sayOptions struct {
|
||||
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.
|
||||
// Diagnostic flags (doctor, voices): machine-readable output and real
|
||||
// network probes. They live in the same struct so the command table
|
||||
// keeps one flag signature; say ignores them.
|
||||
jsonOut bool
|
||||
probe bool
|
||||
}
|
||||
|
||||
// runSay ist der say-Eingangspfad: Text aus Argumenten oder stdin sammeln
|
||||
// und validieren, dann über den Daemon sprechen — non-blocking als
|
||||
// Default, --wait pollt bis zur Fertigstellung, --now springt vor; Läuft
|
||||
// kein Daemon, startet talk ihn selbst (daemon.autostart). Geht auch das
|
||||
// nicht, spricht talk direkt im eigenen Prozess (sayDirect) — ohne
|
||||
// Warteschlange, aber nie still.
|
||||
// runSay is the say entry point: collect the text from arguments or stdin,
|
||||
// validate, then speak through the daemon (non-blocking by default, --wait
|
||||
// polls until spoken, --now jumps the queue). When no daemon runs, talk
|
||||
// starts one itself (daemon.autostart). If that fails too, talk speaks
|
||||
// directly in this process (sayDirect): no queue, but never silent.
|
||||
func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
text := strings.Join(args, " ")
|
||||
if text == "" {
|
||||
if isCharDevice(stdin) {
|
||||
fmt.Fprintln(stderr, "talk: kein Text — bitte als Argument übergeben oder über stdin pipen")
|
||||
fmt.Fprintln(stderr, "talk: no text given; pass it as an argument or pipe it via stdin")
|
||||
return exitUsage
|
||||
}
|
||||
b, err := io.ReadAll(stdin)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk: stdin lesen: %v\n", err)
|
||||
fmt.Fprintf(stderr, "talk: reading stdin: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
text = strings.TrimSpace(string(b))
|
||||
if text == "" {
|
||||
fmt.Fprintln(stderr, "talk: kein Text (Argument und stdin sind leer)")
|
||||
fmt.Fprintln(stderr, "talk: no text given (argument and stdin are empty)")
|
||||
return exitFailure
|
||||
}
|
||||
}
|
||||
|
||||
// Validierung vor jedem Daemon-Kontakt (auch wrap erbt die say-Flags):
|
||||
// Benutzungsfehler sollen schnell und ohne Netzwerk-Umwege auffallen.
|
||||
// Validate before any daemon contact (wrap inherits the say flags):
|
||||
// usage errors should fail fast and without network round trips.
|
||||
if code, bad := validateSayOptions(opts, stderr); bad {
|
||||
return code
|
||||
}
|
||||
@@ -74,30 +73,30 @@ func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.
|
||||
return announce(cfg, text, opts, stdout, stderr)
|
||||
}
|
||||
|
||||
// announce ist der gemeinsame Ansage-Weg von say und wrap: Daemon sicher-
|
||||
// stellen (ggf. starten) und einreihen; ohne Daemon direkt sprechen. info
|
||||
// ist der Writer für Informations-Token (Item-ID, muted, discarded).
|
||||
// announce is the shared announcement path of say and wrap: ensure a daemon
|
||||
// (start one if needed) and enqueue; without a daemon speak directly. info
|
||||
// is the writer for the information tokens (item ID, muted, discarded).
|
||||
func announce(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) int {
|
||||
if client, ok := ensureDaemon(cfg, stderr); ok {
|
||||
if code, handled := sayViaDaemon(client, text, opts, info, stderr); handled {
|
||||
return code
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(stderr, "talk: kein Daemon erreichbar — spreche direkt (ohne Warteschlange)")
|
||||
fmt.Fprintln(stderr, "talk: no daemon reachable; speaking directly (no queue)")
|
||||
return sayDirect(cfg, text, opts, info, stderr)
|
||||
}
|
||||
|
||||
// sayDirect ist der Notfallpfad ohne Daemon: Mute respektieren (dieselbe
|
||||
// Datei, die der Daemon liest), dann über den Sprech-Kern sprechen —
|
||||
// blockierend im CLI-Prozess, unserialisiert (zwei gleichzeitige Aufrufe
|
||||
// können überlappen; der Daemon ist der Normalfall). Ohne Config-Dir läuft
|
||||
// es ohne Mute weiter, statt zu scheitern.
|
||||
// sayDirect is the emergency path without a daemon: honour mute (the same
|
||||
// file the daemon reads), then speak via the engine, blocking in the CLI
|
||||
// process and unserialized (two simultaneous calls may overlap; the daemon
|
||||
// is the normal case). Without a config directory it runs without mute
|
||||
// rather than failing.
|
||||
func sayDirect(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) int {
|
||||
if dir, err := config.Dir(getenv); err == nil {
|
||||
mute := queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}
|
||||
if mute.Muted() {
|
||||
fmt.Fprintln(info, "muted")
|
||||
fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen")
|
||||
fmt.Fprintln(stderr, "talk: muted; announcement discarded")
|
||||
return exitOK
|
||||
}
|
||||
}
|
||||
@@ -119,23 +118,23 @@ func sayDirect(cfg config.Config, text string, opts *sayOptions, info, stderr io
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// validateSayOptions prüft die say-Flags (wrap erbt sie): --lang auf de |
|
||||
// en, --priority auf low | normal | high. Rückgabe (code, true) heißt:
|
||||
// Benutzungsfehler, bereits auf stderr gemeldet.
|
||||
// validateSayOptions checks the say flags (wrap inherits them): --lang must
|
||||
// be de | en, --priority low | normal | high. (code, true) means: usage
|
||||
// error, already reported on stderr.
|
||||
func validateSayOptions(opts *sayOptions, stderr io.Writer) (int, bool) {
|
||||
if opts.lang != "" && opts.lang != "de" && opts.lang != "en" {
|
||||
fmt.Fprintf(stderr, "talk: --lang: ungültiger Wert %q (erlaubt: de, en)\n", opts.lang)
|
||||
fmt.Fprintf(stderr, "talk: --lang: invalid value %q (allowed: de, en)\n", opts.lang)
|
||||
return exitFailure, true
|
||||
}
|
||||
if opts.priority != "" && !queue.Priority(opts.priority).Valid() {
|
||||
fmt.Fprintf(stderr, "talk: --priority: ungültiger Wert %q (erlaubt: low, normal, high)\n", opts.priority)
|
||||
fmt.Fprintf(stderr, "talk: --priority: invalid value %q (allowed: low, normal, high)\n", opts.priority)
|
||||
return exitFailure, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// isCharDevice meldet, ob r ein Terminal ist (dann darf talk nicht auf
|
||||
// stdin-Input warten, sondern meldet einen Benutzungsfehler).
|
||||
// isCharDevice reports whether r is a terminal (then talk must not wait for
|
||||
// stdin input but report a usage error).
|
||||
func isCharDevice(r io.Reader) bool {
|
||||
f, ok := r.(*os.File)
|
||||
if !ok {
|
||||
|
||||
+149
-146
@@ -10,22 +10,21 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setGetenv überschreibt die Env-Seam der CLI für einen Test. Grundaus-
|
||||
// stattung jedes Tests: TALK_EDGE_ENDPOINT zeigt auf einen garantiert
|
||||
// toten Loopback-Port (Verbindung wird sofort abgelehnt) — die Default-
|
||||
// Kette enthält edge, und kein Test darf je den echten Microsoft-Endpunkt
|
||||
// anfassen. Dasselbe gilt seit Task 04 für TALK_PORT: Die Daemon-Erkennung
|
||||
// von say zeigt auf einen toten Port, damit die Tests hier Deterministisch
|
||||
// den Direkt-Pfad laufen (und nie einen zufällig laufenden echten Daemon
|
||||
// treffen); TALK_AUTOSTART=false verhindert, dass ein Test das Test-Binary
|
||||
// selbst als Daemon spawnt. Wer einen dieser Ports bewusst anders braucht, überschreibt
|
||||
// den Key.
|
||||
// setGetenv overrides the CLI's environment seam for one test. Base kit of
|
||||
// every test: TALK_EDGE_ENDPOINT points at a guaranteed dead loopback port
|
||||
// (connection refused at once); the default chain contains edge, and no
|
||||
// test may ever touch the real Microsoft endpoint. The same goes for
|
||||
// TALK_PORT: daemon detection points at a dead port so the tests here run
|
||||
// the direct path deterministically (and never hit a real daemon that
|
||||
// happens to run); TALK_AUTOSTART=false prevents a test from spawning the
|
||||
// test binary as a daemon. Whoever needs one of these different overrides
|
||||
// the key.
|
||||
func setGetenv(t *testing.T, kv map[string]string) {
|
||||
t.Helper()
|
||||
base := map[string]string{
|
||||
"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/",
|
||||
"TALK_PORT": "1",
|
||||
"TALK_AUTOSTART": "false", // In-Prozess-Tests dürfen nie das Test-Binary als Daemon starten
|
||||
"TALK_AUTOSTART": "false", // in-process tests must never start the test binary as a daemon
|
||||
}
|
||||
for k, v := range kv {
|
||||
base[k] = v
|
||||
@@ -40,9 +39,9 @@ func setGetenv(t *testing.T, kv map[string]string) {
|
||||
t.Cleanup(func() { getenv = old })
|
||||
}
|
||||
|
||||
// fakeTTSBin schreibt ein ausführbares Shell-Skript namens name in dir
|
||||
// (gleicher Mechanismus wie im provider-Paket: echter Prozess, PATH-
|
||||
// Manipulation, kein Mock-Framework).
|
||||
// fakeTTSBin writes an executable shell script named name into dir (same
|
||||
// mechanism as in the provider package: real process, PATH manipulation, no
|
||||
// mock framework).
|
||||
func fakeTTSBin(t *testing.T, dir, name, body string) {
|
||||
t.Helper()
|
||||
script := "#!/bin/sh\n" + body + "\n"
|
||||
@@ -52,9 +51,8 @@ func fakeTTSBin(t *testing.T, dir, name, body string) {
|
||||
}
|
||||
}
|
||||
|
||||
// withFakeEspeak stellt ein fake espeak bereit, das WAV-Bytes auf stdout
|
||||
// schreibt und den Aufruf in seen protokolliert. Zurück kommt der Pfad der
|
||||
// Protokolldatei.
|
||||
// withFakeEspeak provides a fake espeak that writes WAV bytes to stdout and
|
||||
// records the call in seen. Returns the path of the record file.
|
||||
func withFakeEspeak(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
@@ -69,79 +67,86 @@ cat "`+wav+`"`)
|
||||
return seen
|
||||
}
|
||||
|
||||
// TestSayArgumentMitNullSink: `talk "Hallo Welt"` läuft mit
|
||||
// TALK_AUDIO_SINK=null tonlos durch (Abnahmekriterium) — Synthese läuft
|
||||
// (fake espeak wurde gerufen), und die Wiedergabe wird mit sichtbarer
|
||||
// Verwurf-Zeile auf stderr verworfen (extern beobachtbares Verhalten des
|
||||
// null-Sinks auf dem CLI-Pfad).
|
||||
func TestSayArgumentMitNullSink(t *testing.T) {
|
||||
// TestSayArgumentWithNullSink: `talk "Hello world"` runs silently with
|
||||
// TALK_AUDIO_SINK=null: synthesis happens (fake espeak was called) and
|
||||
// playback is discarded with a visible line on stderr (observable behaviour
|
||||
// of the null sink on the CLI path).
|
||||
func TestSayArgumentWithNullSink(t *testing.T) {
|
||||
seen := withFakeEspeak(t)
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"Hallo Welt"}, &out, &errBuf)
|
||||
code := runCLI([]string{"Hello world"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf(`talk "Hallo Welt" (sink=null): Exit-Code = %d, want %d; stderr = %q`, code, exitOK, errBuf.String())
|
||||
t.Fatalf(`talk "Hello world" (sink=null): exit code = %d, want %d; stderr = %q`, code, exitOK, errBuf.String())
|
||||
}
|
||||
if _, err := os.Stat(seen); err != nil {
|
||||
t.Errorf("Synthese lief nicht (fake espeak nicht gerufen): %v", err)
|
||||
t.Errorf("synthesis did not run (fake espeak not called): %v", err)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "verworfen (null sink)") {
|
||||
t.Errorf("stderr = %q, will Verwurf-Zeile des null-Sinks (Synthese war erfolgreich)", errBuf.String())
|
||||
if !strings.Contains(errBuf.String(), "discarded (null sink)") {
|
||||
t.Errorf("stderr = %q, want the null sink's discard line (synthesis succeeded)", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaySubcommandWieDefault: `talk say "Text"` verhält sich wie der
|
||||
// Default-Pfad.
|
||||
func TestSaySubcommandWieDefault(t *testing.T) {
|
||||
// TestSaySubcommandLikeDefault: `talk say "text"` behaves like the default
|
||||
// path.
|
||||
func TestSaySubcommandLikeDefault(t *testing.T) {
|
||||
seen := withFakeEspeak(t)
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runCLI([]string{"say", "Hallo"}, &out, &errBuf); code != exitOK {
|
||||
t.Fatalf("talk say: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
if code := runCLI([]string{"say", "Hello"}, &out, &errBuf); code != exitOK {
|
||||
t.Fatalf("talk say: exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
}
|
||||
if _, err := os.Stat(seen); err != nil {
|
||||
t.Errorf("fake espeak nicht gerufen: %v", err)
|
||||
t.Errorf("fake espeak not called: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayViaStdin: ohne Text-Argument wird stdin gelesen.
|
||||
// TestSayViaStdin: without a text argument stdin is read.
|
||||
func TestSayViaStdin(t *testing.T) {
|
||||
withFakeEspeak(t)
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := run([]string{}, strings.NewReader("Hallo Welt\n"), &out, &errBuf)
|
||||
code := run([]string{}, strings.NewReader("Hello world\n"), &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("talk < stdin: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
t.Fatalf("talk < stdin: exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaySprachwahl: englisches Argument übergibt -v en an den Provider,
|
||||
// deutsches -v de; --lang überschreibt die Erkennung.
|
||||
func TestSaySprachwahl(t *testing.T) {
|
||||
// TestSayLanguageChoice: an English argument passes -v en to the provider, a
|
||||
// German one -v de; undecidable text takes the configured fallback; --lang
|
||||
// overrides detection.
|
||||
func TestSayLanguageChoice(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
argv []string
|
||||
want string
|
||||
}{
|
||||
{"erkannt de", []string{"Der Build ist fertig"}, "-v de --stdout Der Build ist fertig"},
|
||||
{"erkannt en", []string{"The build is done"}, "-v en --stdout The build is done"},
|
||||
{"flag pinnt en", []string{"--lang=en", "Guten Morgen"}, "-v en --stdout Guten Morgen"},
|
||||
{"detected de", nil, []string{"Der Build ist fertig"}, "-v de --stdout Der Build ist fertig"},
|
||||
{"detected en", nil, []string{"The build is done"}, "-v en --stdout The build is done"},
|
||||
{"undecidable → default en", nil, []string{"Build 42"}, "-v en --stdout Build 42"},
|
||||
{"undecidable → configured de", map[string]string{"TALK_LANG": "de"}, []string{"Build 42"}, "-v de --stdout Build 42"},
|
||||
{"flag pins en", nil, []string{"--lang=en", "Guten Morgen"}, "-v en --stdout Guten Morgen"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
seen := withFakeEspeak(t)
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
kv := map[string]string{"TALK_AUDIO_SINK": "null"}
|
||||
for k, v := range tc.env {
|
||||
kv[k] = v
|
||||
}
|
||||
setGetenv(t, kv)
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runCLI(tc.argv, &out, &errBuf); code != exitOK {
|
||||
t.Fatalf("Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
}
|
||||
args, err := os.ReadFile(seen)
|
||||
if err != nil {
|
||||
t.Fatalf("fake espeak nicht gerufen: %v", err)
|
||||
t.Fatalf("fake espeak not called: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(string(args)); got != tc.want {
|
||||
t.Errorf("espeak ARGS = %q, want %q", got, tc.want)
|
||||
@@ -150,135 +155,135 @@ func TestSaySprachwahl(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayOhneText: leeres stdin und kein Argument → klarer Fehler.
|
||||
func TestSayOhneText(t *testing.T) {
|
||||
// TestSayWithoutText: empty stdin and no argument is a clear error.
|
||||
func TestSayWithoutText(t *testing.T) {
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI(nil, &out, &errBuf)
|
||||
if code != exitFailure {
|
||||
t.Fatalf("talk ohne Text: Exit-Code = %d, want %d", code, exitFailure)
|
||||
t.Fatalf("talk without text: exit code = %d, want %d", code, exitFailure)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "kein Text") {
|
||||
t.Errorf("stderr = %q, will klaren Hinweis auf fehlenden Text", errBuf.String())
|
||||
if !strings.Contains(errBuf.String(), "no text") {
|
||||
t.Errorf("stderr = %q, want a clear note about the missing text", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayUngueltigeSprache: --lang mit Unsinn ist ein klarer Fehler.
|
||||
func TestSayUngueltigeSprache(t *testing.T) {
|
||||
// TestSayInvalidLanguage: --lang with garbage is a clear error.
|
||||
func TestSayInvalidLanguage(t *testing.T) {
|
||||
withFakeEspeak(t)
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"--lang=fr", "Bonjour"}, &out, &errBuf)
|
||||
if code != exitFailure {
|
||||
t.Fatalf("talk --lang=fr: Exit-Code = %d, want %d", code, exitFailure)
|
||||
t.Fatalf("talk --lang=fr: exit code = %d, want %d", code, exitFailure)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "--lang") {
|
||||
t.Errorf("stderr = %q, will --lang-Fehler", errBuf.String())
|
||||
t.Errorf("stderr = %q, want a --lang error", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayUnbekannterProvider: --provider mit unbekanntem Namen ist ein
|
||||
// klarer Fehler, der die verfügbaren Provider nennt (Task 03: Kette).
|
||||
func TestSayUnbekannterProvider(t *testing.T) {
|
||||
// TestSayUnknownProvider: --provider with an unknown name is a clear error
|
||||
// naming the available providers.
|
||||
func TestSayUnknownProvider(t *testing.T) {
|
||||
withFakeEspeak(t)
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"--provider=gibtsnicht", "Test"}, &out, &errBuf)
|
||||
code := runCLI([]string{"--provider=nosuch", "Test"}, &out, &errBuf)
|
||||
if code != exitFailure {
|
||||
t.Fatalf("talk --provider=gibtsnicht: Exit-Code = %d, want %d", code, exitFailure)
|
||||
t.Fatalf("talk --provider=nosuch: exit code = %d, want %d", code, exitFailure)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "gibtsnicht") || !strings.Contains(errBuf.String(), "osbuiltin") || !strings.Contains(errBuf.String(), "edge") {
|
||||
t.Errorf("stderr = %q, will Provider-Fehler mit verfügbaren Namen", errBuf.String())
|
||||
if !strings.Contains(errBuf.String(), "nosuch") || !strings.Contains(errBuf.String(), "osbuiltin") || !strings.Contains(errBuf.String(), "edge") {
|
||||
t.Errorf("stderr = %q, want a provider error with the available names", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayUngueltigerSink: TALK_AUDIO_SINK=Müll ist ein klarer Fehler aus
|
||||
// der Config-Validierung.
|
||||
func TestSayUngueltigerSink(t *testing.T) {
|
||||
// TestSayInvalidSink: TALK_AUDIO_SINK=garbage is a clear error from config
|
||||
// validation.
|
||||
func TestSayInvalidSink(t *testing.T) {
|
||||
withFakeEspeak(t)
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "mpv"})
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"Test"}, &out, &errBuf)
|
||||
if code != exitFailure {
|
||||
t.Fatalf("talk mit TALK_AUDIO_SINK=mpv: Exit-Code = %d, want %d", code, exitFailure)
|
||||
t.Fatalf("talk with TALK_AUDIO_SINK=mpv: exit code = %d, want %d", code, exitFailure)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "audio.sink") {
|
||||
t.Errorf("stderr = %q, will audio.sink-Validierungsfehler", errBuf.String())
|
||||
t.Errorf("stderr = %q, want an audio.sink validation error", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayOhneTTS: ohne jedes TTS-Binary ist Available false → klarer
|
||||
// Fehler, kein Panik.
|
||||
func TestSayOhneTTS(t *testing.T) {
|
||||
// TestSayWithoutTTS: without any TTS binary Available is false; a clear
|
||||
// error, no panic.
|
||||
func TestSayWithoutTTS(t *testing.T) {
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"Hallo"}, &out, &errBuf)
|
||||
code := runCLI([]string{"Hello"}, &out, &errBuf)
|
||||
if code != exitFailure {
|
||||
t.Fatalf("talk ohne TTS: Exit-Code = %d, want %d", code, exitFailure)
|
||||
t.Fatalf("talk without TTS: exit code = %d, want %d", code, exitFailure)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "nicht verfügbar") {
|
||||
t.Errorf("stderr = %q, will Verfügbarkeits-Fehler", errBuf.String())
|
||||
if !strings.Contains(errBuf.String(), "not available") {
|
||||
t.Errorf("stderr = %q, want an availability error", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayDirektesFallbeckenOhneEspeak: Linux ohne espeak (nur fake spd-say)
|
||||
// und sink=null verwirft tonlos mit Exit 0 — das spd-say wird NICHT gerufen
|
||||
// (null-Sink darf nie Ton erzeugen).
|
||||
func TestSayDirektesFallbeckenOhneEspeak(t *testing.T) {
|
||||
// TestSayDirectFallbackWithoutEspeak: Linux without espeak (only fake
|
||||
// spd-say) and sink=null discards silently with exit 0; spd-say is NOT
|
||||
// called (the null sink must never produce sound).
|
||||
func TestSayDirectFallbackWithoutEspeak(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
seen := filepath.Join(dir, "seen.txt")
|
||||
fakeTTSBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
|
||||
// PATH NUR auf das fake-spd-say-Verzeichnis: kein echtes espeak, kein
|
||||
// echtes spd-say dazwischen.
|
||||
// PATH ONLY the fake spd-say directory: no real espeak, no real spd-say.
|
||||
t.Setenv("PATH", dir)
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"Hallo Welt"}, &out, &errBuf)
|
||||
code := runCLI([]string{"Hello world"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("talk (Linux ohne espeak, sink=null): Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
t.Fatalf("talk (Linux without espeak, sink=null): exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
}
|
||||
if _, err := os.Stat(seen); err == nil {
|
||||
t.Error("spd-say wurde gerufen — null-Sink hätte die Ansage verworfen müssen")
|
||||
t.Error("spd-say was called; the null sink should have discarded the announcement")
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "verworfen") {
|
||||
t.Errorf("stderr = %q, will Verwurf-Hinweis", errBuf.String())
|
||||
if !strings.Contains(errBuf.String(), "discarded") {
|
||||
t.Errorf("stderr = %q, want a discard note", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayDirektesFallbeckenMitOtoSink: ohne espeak, mit (fake) spd-say und
|
||||
// dem Default-Sink oto muss die Direktansage über spd-say laufen.
|
||||
func TestSayDirektesFallbeckenMitOtoSink(t *testing.T) {
|
||||
// TestSayDirectFallbackWithOtoSink: without espeak, with (fake) spd-say and
|
||||
// the default sink oto, direct speech must go through spd-say.
|
||||
func TestSayDirectFallbackWithOtoSink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
seen := filepath.Join(dir, "seen.txt")
|
||||
fakeTTSBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
|
||||
t.Setenv("PATH", dir)
|
||||
setGetenv(t, nil) // Default-Sink oto, keine Env-Overrides
|
||||
setGetenv(t, nil) // default sink oto, no env overrides
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"Hallo Welt"}, &out, &errBuf)
|
||||
code := runCLI([]string{"Der Build ist fertig"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("talk (Direktansage): Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
t.Fatalf("talk (direct speech): exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
}
|
||||
args, err := os.ReadFile(seen)
|
||||
if err != nil {
|
||||
t.Fatalf("fake spd-say nicht gerufen: %v", err)
|
||||
t.Fatalf("fake spd-say not called: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(string(args)); got != "-w -l de Hallo Welt" {
|
||||
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt")
|
||||
if got := strings.TrimSpace(string(args)); got != "-w -l de Der Build ist fertig" {
|
||||
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Der Build ist fertig")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeCloudServer startet einen httptest-HTTP-Fake, der status mit body
|
||||
// antwortet und den letzten Request aufzeichnet (Struktur wie im
|
||||
// provider-Paket; die CLI-Tests beobachten nur extern: Exit-Code, stderr,
|
||||
// ob der Fake gerufen wurde und mit welchem Pfad).
|
||||
// fakeCloudReq records what the fake cloud endpoint saw.
|
||||
type fakeCloudReq struct {
|
||||
path string
|
||||
apiKey string
|
||||
}
|
||||
|
||||
// fakeCloudServer starts an httptest fake that answers status with body and
|
||||
// records each request (structure as in the provider package; the CLI tests
|
||||
// only observe externally: exit code, stderr, whether the fake was called
|
||||
// and with which path).
|
||||
func fakeCloudServer(t *testing.T, status int, body []byte) (url string, reqs chan fakeCloudReq) {
|
||||
t.Helper()
|
||||
reqs = make(chan fakeCloudReq, 8)
|
||||
@@ -291,11 +296,11 @@ func fakeCloudServer(t *testing.T, status int, body []byte) (url string, reqs ch
|
||||
return ts.URL, reqs
|
||||
}
|
||||
|
||||
// TestSayCloudProviderMitFake: `talk "Text" --provider=elevenlabs` mit Key
|
||||
// + Fake-Endpunkt und sink=null läuft mit Exit 0 durch — Synthese lief
|
||||
// (Fake gerufen, korrekter Pfad inkl. neutraler Default-Stimme, Key im
|
||||
// Header), Wiedergabe verworfen.
|
||||
func TestSayCloudProviderMitFake(t *testing.T) {
|
||||
// TestSayCloudProviderWithFake: `talk "text" --provider=elevenlabs` with a
|
||||
// key, a fake endpoint and sink=null exits 0: synthesis ran (fake called,
|
||||
// correct path with the neutral default voice, key in the header), playback
|
||||
// discarded.
|
||||
func TestSayCloudProviderWithFake(t *testing.T) {
|
||||
url, reqs := fakeCloudServer(t, 200, []byte("\xff\xfb\x90\x00mp3"))
|
||||
setGetenv(t, map[string]string{
|
||||
"TALK_AUDIO_SINK": "null",
|
||||
@@ -306,26 +311,26 @@ func TestSayCloudProviderMitFake(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"--provider=elevenlabs", "Test"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("talk --provider=elevenlabs (Fake): Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
t.Fatalf("talk --provider=elevenlabs (fake): exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
}
|
||||
select {
|
||||
case req := <-reqs:
|
||||
if want := "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM"; req.path != want {
|
||||
t.Errorf("Pfad = %q, want %q (neutrales Default)", req.path, want)
|
||||
t.Errorf("path = %q, want %q (neutral default)", req.path, want)
|
||||
}
|
||||
if req.apiKey != "cli-testkey" {
|
||||
t.Errorf("xi-api-key = %q", req.apiKey)
|
||||
}
|
||||
default:
|
||||
t.Error("Fake-Endpunkt wurde nicht gerufen")
|
||||
t.Error("fake endpoint was not called")
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "verworfen (null sink)") {
|
||||
t.Errorf("stderr = %q, will Verwurf-Zeile", errBuf.String())
|
||||
if !strings.Contains(errBuf.String(), "discarded (null sink)") {
|
||||
t.Errorf("stderr = %q, want the discard line", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayVoiceFlagAmCloudProvider: --voice landet im Request-Pfad.
|
||||
func TestSayVoiceFlagAmCloudProvider(t *testing.T) {
|
||||
// TestSayVoiceFlagAtCloudProvider: --voice lands in the request path.
|
||||
func TestSayVoiceFlagAtCloudProvider(t *testing.T) {
|
||||
url, reqs := fakeCloudServer(t, 200, []byte("\xff\xfb\x90\x00mp3"))
|
||||
setGetenv(t, map[string]string{
|
||||
"TALK_AUDIO_SINK": "null",
|
||||
@@ -334,43 +339,42 @@ func TestSayVoiceFlagAmCloudProvider(t *testing.T) {
|
||||
})
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runCLI([]string{"--provider=elevenlabs", "--voice=MeineStimme", "Test"}, &out, &errBuf); code != exitOK {
|
||||
t.Fatalf("Exit-Code = %d, stderr = %q", code, errBuf.String())
|
||||
if code := runCLI([]string{"--provider=elevenlabs", "--voice=MyVoice", "Test"}, &out, &errBuf); code != exitOK {
|
||||
t.Fatalf("exit code = %d, stderr = %q", code, errBuf.String())
|
||||
}
|
||||
select {
|
||||
case req := <-reqs:
|
||||
if want := "/v1/text-to-speech/MeineStimme"; req.path != want {
|
||||
t.Errorf("Pfad = %q, want %q", req.path, want)
|
||||
if want := "/v1/text-to-speech/MyVoice"; req.path != want {
|
||||
t.Errorf("path = %q, want %q", req.path, want)
|
||||
}
|
||||
default:
|
||||
t.Error("Fake-Endpunkt wurde nicht gerufen")
|
||||
t.Error("fake endpoint was not called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayCloudOhneKey: gepinnter Cloud-Provider ohne Key → klarer Fehler
|
||||
// (Stufe nicht verfügbar, Exit 1).
|
||||
func TestSayCloudOhneKey(t *testing.T) {
|
||||
// TestSayCloudWithoutKey: a pinned cloud provider without a key is a clear
|
||||
// error (tier not available, exit 1).
|
||||
func TestSayCloudWithoutKey(t *testing.T) {
|
||||
url, _ := fakeCloudServer(t, 200, []byte("x"))
|
||||
setGetenv(t, map[string]string{
|
||||
"TALK_AUDIO_SINK": "null",
|
||||
"TALK_ELEVENLABS_ENDPOINT": url, // Endpunkt, aber kein Key
|
||||
"TALK_ELEVENLABS_ENDPOINT": url, // endpoint, but no key
|
||||
})
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"--provider=elevenlabs", "Test"}, &out, &errBuf)
|
||||
if code != exitFailure {
|
||||
t.Fatalf("Exit-Code = %d, want %d", code, exitFailure)
|
||||
t.Fatalf("exit code = %d, want %d", code, exitFailure)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "nicht verfügbar") {
|
||||
t.Errorf("stderr = %q, will Verfügbarkeits-Hinweis", errBuf.String())
|
||||
if !strings.Contains(errBuf.String(), "not available") {
|
||||
t.Errorf("stderr = %q, want an availability note", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayCloud500FaelltAufOsbuiltin: gepinnt wäre kurzfristig — hier ohne
|
||||
// Pin: elevenlabs (Fake, 500) → openai (kein Key) → edge (tot) → osbuiltin
|
||||
// (fake espeak) liefert die Bytes. Der CLI-Ketten-Fallback ist end-to-end
|
||||
// über die Env-Seam beobachtbar.
|
||||
func TestSayCloud500FaelltAufOsbuiltin(t *testing.T) {
|
||||
// TestSayCloud500FallsBackToOsbuiltin: without a pin: elevenlabs (fake, 500)
|
||||
// → openai (no key) → edge (dead) → osbuiltin (fake espeak) yields the
|
||||
// bytes. The CLI chain fallback is observable end to end via the env seam.
|
||||
func TestSayCloud500FallsBackToOsbuiltin(t *testing.T) {
|
||||
url, _ := fakeCloudServer(t, 500, []byte("boom"))
|
||||
seen := withFakeEspeak(t)
|
||||
setGetenv(t, map[string]string{
|
||||
@@ -380,22 +384,21 @@ func TestSayCloud500FaelltAufOsbuiltin(t *testing.T) {
|
||||
})
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"Ketten Test"}, &out, &errBuf)
|
||||
code := runCLI([]string{"Chain test"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("talk mit 500-Cloud-Stufe: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
t.Fatalf("talk with a 500 cloud tier: exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
}
|
||||
if _, err := os.Stat(seen); err != nil {
|
||||
t.Errorf("fake espeak nicht gerufen (Kette nicht bis osbuiltin gefallen): %v", err)
|
||||
t.Errorf("fake espeak not called (chain did not fall through to osbuiltin): %v", err)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "500") || !strings.Contains(errBuf.String(), "Fallback") {
|
||||
t.Errorf("stderr = %q, will sichtbaren 500-Fallback", errBuf.String())
|
||||
if !strings.Contains(errBuf.String(), "500") || !strings.Contains(errBuf.String(), "falling back") {
|
||||
t.Errorf("stderr = %q, want a visible 500 fallback", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSayFlagNachDemText: `talk "Text" --provider=…` (Flag NACH dem Text,
|
||||
// Abnahme-Aufrufform) pinnt den Provider — nur die gepinnte Stufe wird
|
||||
// angefragt.
|
||||
func TestSayFlagNachDemText(t *testing.T) {
|
||||
// TestSayFlagAfterText: `talk "text" --provider=…` (flag AFTER the text)
|
||||
// pins the provider: only the pinned tier is asked.
|
||||
func TestSayFlagAfterText(t *testing.T) {
|
||||
url, reqs := fakeCloudServer(t, 200, []byte("\xff\xfb\x90\x00mp3"))
|
||||
setGetenv(t, map[string]string{
|
||||
"TALK_AUDIO_SINK": "null",
|
||||
@@ -404,30 +407,30 @@ func TestSayFlagNachDemText(t *testing.T) {
|
||||
})
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"Test ansage", "--provider=elevenlabs"}, &out, &errBuf)
|
||||
code := runCLI([]string{"Test announcement", "--provider=elevenlabs"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("talk \"Text\" --provider=elevenlabs: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
t.Fatalf("talk \"text\" --provider=elevenlabs: exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
}
|
||||
select {
|
||||
case req := <-reqs:
|
||||
if !strings.Contains(req.path, "/v1/text-to-speech/") {
|
||||
t.Errorf("Pfad = %q, want TTS-Pfad", req.path)
|
||||
t.Errorf("path = %q, want a TTS path", req.path)
|
||||
}
|
||||
default:
|
||||
t.Error("Fake-Endpunkt wurde nicht gerufen")
|
||||
t.Error("fake endpoint was not called")
|
||||
}
|
||||
// Zwei Argumente mit verteilten Flags: Text bleibt vollständig.
|
||||
// Two arguments with interspersed flags: the text stays complete.
|
||||
seen := withFakeEspeak(t)
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
code = runCLI([]string{"Erster", "--lang=en", "zweiter Teil"}, &out, &errBuf)
|
||||
code = runCLI([]string{"First", "--lang=en", "second part"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("talk Erster --lang=en zweiter Teil: Exit-Code = %d, stderr = %q", code, errBuf.String())
|
||||
t.Fatalf("talk First --lang=en second part: exit code = %d, stderr = %q", code, errBuf.String())
|
||||
}
|
||||
args, err := os.ReadFile(seen)
|
||||
if err != nil {
|
||||
t.Fatalf("fake espeak nicht gerufen: %v", err)
|
||||
t.Fatalf("fake espeak not called: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(string(args)); got != "-v en --stdout Erster zweiter Teil" {
|
||||
t.Errorf("espeak ARGS = %q, want %q", got, "-v en --stdout Erster zweiter Teil")
|
||||
if got := strings.TrimSpace(string(args)); got != "-v en --stdout First second part" {
|
||||
t.Errorf("espeak ARGS = %q, want %q", got, "-v en --stdout First second part")
|
||||
}
|
||||
}
|
||||
+147
-106
@@ -21,64 +21,65 @@ import (
|
||||
"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.
|
||||
// setupInteractive decides whether the wizard may ask questions (stdin is a
|
||||
// terminal). Tests override the seam and feed answers via stdin.
|
||||
var setupInteractive = isCharDevice
|
||||
|
||||
// setupSample ist der Probehör-Satz je Sprache.
|
||||
// setupSample is the preview sentence per language.
|
||||
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.
|
||||
// runSetup implements `talk setup`: a few questions, then 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.
|
||||
// 1. Daemon, audio.
|
||||
// 2. Keys: found → premium; none → explanation that the free Edge voice
|
||||
// (internet) or the OS voice work regardless.
|
||||
// 3. Language: which language talk assumes when it cannot tell (and speaks
|
||||
// its own announcements in).
|
||||
// 4. Voice per language (de, en) for the active tier: pick a catalog
|
||||
// number, preview, accept.
|
||||
// 5. Quiet hours (optional).
|
||||
// 6. Write config.toml (merge an existing one, .bak), test sentence.
|
||||
//
|
||||
// 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).
|
||||
// Without a terminal (pipe, agent) setup asks nothing: it shows the
|
||||
// effective configuration and exits 0; nothing breaks, nothing waits.
|
||||
// Abort with Ctrl-C or "q": nothing half-written (the file is written only
|
||||
// at the very end).
|
||||
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")
|
||||
fmt.Fprintln(stderr, "talk setup: no arguments expected")
|
||||
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)
|
||||
fmt.Fprintf(stderr, "talk setup: %v\nPlease fix or rename the config file first.\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)
|
||||
fmt.Fprintf(stderr, "talk setup: %v; without a config directory setup cannot save anything\n", perr)
|
||||
return exitFailure
|
||||
}
|
||||
|
||||
if !setupInteractive(stdin) {
|
||||
fmt.Fprintln(stdout, "talk setup braucht ein Terminal, um Fragen zu stellen. Wirksame Konfiguration:")
|
||||
fmt.Fprintln(stdout, "talk setup needs a terminal to ask questions. Effective configuration:")
|
||||
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, "Setting up talk: a few questions. Enter takes the suggestion in [brackets], q aborts.")
|
||||
fmt.Fprintln(stdout)
|
||||
|
||||
// 1. Umgebung
|
||||
fmt.Fprintf(stdout, "Audio-Ausgabe: %s\n", audioSummary(cfg))
|
||||
// 1. Environment
|
||||
fmt.Fprintf(stdout, "Audio output: %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)
|
||||
fmt.Fprintf(stdout, "Daemon: running at %s\n", client.BaseURL)
|
||||
} else {
|
||||
fmt.Fprintln(stdout, "Daemon: startet beim ersten Sprechbefehl von selbst")
|
||||
fmt.Fprintln(stdout, "Daemon: starts by itself on the first speak command")
|
||||
}
|
||||
|
||||
// 2. Keys
|
||||
@@ -91,83 +92,98 @@ func runSetup(args []string, _ *sayOptions, stdin io.Reader, stdout, stderr io.W
|
||||
fmt.Fprintln(stdout)
|
||||
switch {
|
||||
case getenv(provider.EnvElevenLabsKey) != "":
|
||||
fmt.Fprintln(stdout, "Stimmen: ElevenLabs-Key gefunden (TALK_ELEVENLABS_KEY) — Premium-Stimme.")
|
||||
fmt.Fprintln(stdout, "Voices: ElevenLabs key found (TALK_ELEVENLABS_KEY); premium voice.")
|
||||
case getenv(provider.EnvOpenAIKey) != "":
|
||||
fmt.Fprintln(stdout, "Stimmen: OpenAI-Key gefunden (TALK_OPENAI_KEY).")
|
||||
fmt.Fprintln(stdout, "Voices: OpenAI key found (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).")
|
||||
fmt.Fprintln(stdout, "Voices: no key found; that is fine. talk uses Microsoft's free Edge voice")
|
||||
fmt.Fprintln(stdout, " (needs internet) and otherwise your operating system's voice.")
|
||||
fmt.Fprintln(stdout, " For premium voices, set TALK_ELEVENLABS_KEY or TALK_OPENAI_KEY as an")
|
||||
fmt.Fprintln(stdout, " environment variable later (never in 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.")
|
||||
fmt.Fprintln(stderr, "talk setup: no voice is available right now; `talk doctor` shows what is missing. Settings are saved anyway.")
|
||||
} else {
|
||||
fmt.Fprintf(stdout, "Aktive Stufe: %s\n", active)
|
||||
fmt.Fprintf(stdout, "Active tier: %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
|
||||
// 3. Language
|
||||
fmt.Fprintln(stdout)
|
||||
quiet, ok := w.askQuietHours(cfg.QuietHours)
|
||||
lang, ok := w.askLang(cfg.Lang)
|
||||
if !ok {
|
||||
fmt.Fprintln(stdout, "Abgebrochen — nichts geschrieben.")
|
||||
fmt.Fprintln(stdout, "Aborted; nothing written.")
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// 5. Schreiben
|
||||
changes := map[string]any{}
|
||||
// 4. Voices
|
||||
voices := map[string]string{}
|
||||
if active != speak.NoProvider && active != "osbuiltin" {
|
||||
for _, l := range []string{lang, otherLang(lang)} {
|
||||
id, ok := w.chooseVoice(engine, active, l)
|
||||
if !ok {
|
||||
fmt.Fprintln(stdout, "Aborted; nothing written.")
|
||||
return exitOK
|
||||
}
|
||||
if id != "" {
|
||||
voices[l] = id
|
||||
}
|
||||
}
|
||||
} else if active == "osbuiltin" {
|
||||
fmt.Fprintln(stdout, "\nThe OS voice is chosen by your operating system; nothing to set here.")
|
||||
}
|
||||
|
||||
// 5. Quiet hours
|
||||
fmt.Fprintln(stdout)
|
||||
quiet, ok := w.askQuietHours(cfg.QuietHours)
|
||||
if !ok {
|
||||
fmt.Fprintln(stdout, "Aborted; nothing written.")
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// 6. Write
|
||||
changes := map[string]any{"lang": lang, "quiet_hours": quiet}
|
||||
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)
|
||||
fmt.Fprintf(stderr, "talk setup: writing config: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
fmt.Fprintln(stdout)
|
||||
fmt.Fprintf(stdout, "Gespeichert: %s\n", path)
|
||||
fmt.Fprintf(stdout, "Saved: %s\n", path)
|
||||
if backup != "" {
|
||||
fmt.Fprintf(stdout, "Sicherung: %s\n", backup)
|
||||
fmt.Fprintf(stdout, "Backup: %s\n", backup)
|
||||
}
|
||||
|
||||
// Abschluss: Testsatz über die neue Config.
|
||||
// Finish: a test sentence through the new config.
|
||||
if active != speak.NoProvider {
|
||||
fmt.Fprintln(stdout, "\nTestsatz …")
|
||||
fmt.Fprintln(stdout, "\nTest sentence …")
|
||||
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)
|
||||
if out, err := eng2.Speak(ctx, speak.Request{Text: testPhrase[lang], Lang: lang}); err != nil {
|
||||
fmt.Fprintf(stderr, "talk setup: test sentence failed: %v (see talk doctor)\n", err)
|
||||
} else {
|
||||
fmt.Fprintf(stdout, "Gesprochen über %s.\n", out.Provider)
|
||||
fmt.Fprintf(stdout, "Spoken via %s.\n", out.Provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(stdout, "\nFertig. Probiere: talk \"Hallo Welt\" — Status: talk status — Stumm: talk mute")
|
||||
fmt.Fprintln(stdout, "\nDone. Try: talk \"Hello world\" / status: talk status / silence: talk mute")
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// wizard hält Ein-/Ausgabe des Setups.
|
||||
// otherLang returns the other supported language.
|
||||
func otherLang(lang string) string {
|
||||
if lang == "de" {
|
||||
return "en"
|
||||
}
|
||||
return "de"
|
||||
}
|
||||
|
||||
// wizard holds the setup's input and output.
|
||||
type wizard struct {
|
||||
in *bufio.Reader
|
||||
out io.Writer
|
||||
@@ -175,8 +191,8 @@ type wizard struct {
|
||||
cfg config.Config
|
||||
}
|
||||
|
||||
// ask stellt eine Frage mit Vorschlag; Enter liefert den Vorschlag, „q"
|
||||
// bricht ab (ok=false). EOF zählt als Abbruch.
|
||||
// ask asks a question with a suggestion; Enter returns the suggestion, "q"
|
||||
// aborts (ok=false). EOF counts as abort.
|
||||
func (w *wizard) ask(prompt, def string) (string, bool) {
|
||||
if def != "" {
|
||||
fmt.Fprintf(w.out, "%s [%s]: ", prompt, def)
|
||||
@@ -197,12 +213,27 @@ func (w *wizard) ask(prompt, def string) (string, bool) {
|
||||
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.
|
||||
// askLang asks for the fallback language (de | en).
|
||||
func (w *wizard) askLang(current string) (string, bool) {
|
||||
fmt.Fprintln(w.out, "Language: talk detects German and English automatically. Which one should it assume when it cannot tell?")
|
||||
for {
|
||||
answer, ok := w.ask("Language (de/en)", current)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
answer = strings.ToLower(answer)
|
||||
if answer == "de" || answer == "en" {
|
||||
return answer, true
|
||||
}
|
||||
fmt.Fprintln(w.out, "Please answer de or en.")
|
||||
}
|
||||
}
|
||||
|
||||
// chooseVoice lets the user pick a voice for provider/lang: numbered catalog
|
||||
// list with the current default marked; preview; accept or choose again.
|
||||
// Returns the chosen ID ("" = keep the default) and ok=false on abort.
|
||||
func (w *wizard) chooseVoice(engine *speak.Engine, prov, lang string) (string, bool) {
|
||||
langName := map[string]string{"de": "Deutsch", "en": "Englisch"}[lang]
|
||||
langName := map[string]string{"de": "German", "en": "English"}[lang]
|
||||
current := provider.DefaultVoice(prov, lang, w.cfg.Voice)
|
||||
var list []provider.Voice
|
||||
for _, v := range provider.Catalog() {
|
||||
@@ -210,7 +241,7 @@ func (w *wizard) chooseVoice(engine *speak.Engine, prov, lang string) (string, b
|
||||
list = append(list, v)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w.out, "\nStimme für %s (%s):\n", langName, prov)
|
||||
fmt.Fprintf(w.out, "\nVoice for %s (%s):\n", langName, prov)
|
||||
defIdx := ""
|
||||
for i, v := range list {
|
||||
mark := " "
|
||||
@@ -221,17 +252,17 @@ func (w *wizard) chooseVoice(engine *speak.Engine, prov, lang string) (string, b
|
||||
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)
|
||||
fmt.Fprintf(w.out, " * current: %s (from config.toml)\n", current)
|
||||
}
|
||||
for {
|
||||
answer, ok := w.ask("Nummer wählen, oder eine Voice-ID eintippen", defIdx)
|
||||
answer, ok := w.ask("Pick a number, or type a voice ID", 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))
|
||||
fmt.Fprintf(w.out, "Please pick 1 to %d.\n", len(list))
|
||||
continue
|
||||
}
|
||||
id = list[n-1].ID
|
||||
@@ -239,63 +270,72 @@ func (w *wizard) chooseVoice(engine *speak.Engine, prov, lang string) (string, b
|
||||
if id == "" {
|
||||
return "", true
|
||||
}
|
||||
hear, ok := w.ask("Probehören?", "J")
|
||||
hear, ok := w.ask("Preview?", "Y")
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if strings.EqualFold(hear, "j") || strings.EqualFold(hear, "ja") || strings.EqualFold(hear, "y") {
|
||||
if isYes(hear) {
|
||||
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)
|
||||
fmt.Fprintf(w.err, "preview failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
take, ok := w.ask("Übernehmen?", "J")
|
||||
take, ok := w.ask("Use this voice?", "Y")
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if strings.EqualFold(take, "j") || strings.EqualFold(take, "ja") || strings.EqualFold(take, "y") {
|
||||
if isYes(take) {
|
||||
if id == current {
|
||||
return "", true // Default behalten — nichts in die Datei schreiben
|
||||
return "", true // keep the default: nothing to write
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// askQuietHours fragt das Ruhezeit-Fenster ab (leer = keine).
|
||||
// isYes accepts y/yes/j/ja in any case.
|
||||
func isYes(s string) bool {
|
||||
switch strings.ToLower(s) {
|
||||
case "y", "yes", "j", "ja":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// askQuietHours asks for the quiet window (empty = none).
|
||||
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.")
|
||||
fmt.Fprintln(w.out, "Quiet hours: talk stays silent in this window (for example 22:00-07:00). Leave empty for none.")
|
||||
def := current
|
||||
if def == "" {
|
||||
def = "keine"
|
||||
def = "none"
|
||||
}
|
||||
for {
|
||||
answer, ok := w.ask("Ruhezeit", def)
|
||||
answer, ok := w.ask("Quiet hours", def)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if answer == "keine" || answer == "-" || answer == "aus" {
|
||||
if answer == "none" || answer == "-" || answer == "off" {
|
||||
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)
|
||||
fmt.Fprintf(w.out, "Format HH:MM-HH:MM, for example 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 "".
|
||||
// writeConfig merges changes into the existing config.toml (missing file:
|
||||
// new) and writes it atomically; an existing file is backed up as .bak
|
||||
// first. Nested tables (voice.<provider>) are merged, not replaced. Returns
|
||||
// the backup path or "".
|
||||
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)
|
||||
return "", fmt.Errorf("existing %s unreadable: %w", path, derr)
|
||||
}
|
||||
backup = path + ".bak"
|
||||
if werr := os.WriteFile(backup, raw, 0o644); werr != nil {
|
||||
@@ -305,12 +345,12 @@ func writeConfig(path string, changes map[string]any) (backup string, err error)
|
||||
return "", rerr
|
||||
}
|
||||
mergeInto(existing, changes)
|
||||
// Leere Ruhezeit heißt: Schlüssel raus, statt "" zu schreiben.
|
||||
// Empty quiet hours means: drop the key rather than writing "".
|
||||
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")
|
||||
buf.WriteString("# talk: written by `talk setup`. Keys do NOT belong here (TALK_* environment variables).\n\n")
|
||||
if err := toml.NewEncoder(&buf).Encode(existing); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -328,7 +368,7 @@ func writeConfig(path string, changes map[string]any) (backup string, err error)
|
||||
return backup, nil
|
||||
}
|
||||
|
||||
// mergeInto schreibt src rekursiv in dst (Tabellen zusammenführen).
|
||||
// mergeInto writes src recursively into dst (merging tables).
|
||||
func mergeInto(dst, src map[string]any) {
|
||||
for k, v := range src {
|
||||
if sv, ok := v.(map[string]any); ok {
|
||||
@@ -349,27 +389,28 @@ func toAny(m map[string]string) map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
// printEffective zeigt die wirksame Konfiguration (Nicht-Terminal-Pfad).
|
||||
// printEffective shows the effective configuration (non-terminal path).
|
||||
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, " → ")))
|
||||
fmt.Fprintf(w, " config: %s\n", path)
|
||||
fmt.Fprintf(w, " audio: %s\n", audioSummary(cfg))
|
||||
fmt.Fprintf(w, " language: %s (fallback)\n", cfg.Lang)
|
||||
fmt.Fprintf(w, " provider: %s\n", orDefault(cfg.Provider, "chain "+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, " 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)
|
||||
fmt.Fprintf(w, " quiet hours: %s\n", orDefault(cfg.QuietHours, "none"))
|
||||
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 "null (no sound; CI/servers)"
|
||||
}
|
||||
return "eingebaute Wiedergabe (oto)"
|
||||
return "built-in playback (oto)"
|
||||
}
|
||||
|
||||
func orDefault(v, def string) string {
|
||||
|
||||
+47
-43
@@ -16,9 +16,9 @@ import (
|
||||
|
||||
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.
|
||||
// setupEnv makes the wizard "interactive" and pins the chain to a fake
|
||||
// ElevenLabs endpoint (sink null): preview and test sentence hit the fake.
|
||||
// Returns the counter of synthesis calls.
|
||||
func setupEnv(t *testing.T, root string, extra map[string]string) *atomic.Int32 {
|
||||
t.Helper()
|
||||
var calls atomic.Int32
|
||||
@@ -44,52 +44,56 @@ func setupEnv(t *testing.T, root string, extra map[string]string) *atomic.Int32
|
||||
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) {
|
||||
// TestSetupWritesConfig: answers via stdin (language en; voice 2 for
|
||||
// English, preview, accept; German keeps the default; quiet hours) yield a
|
||||
// config.toml with exactly those values; preview and test sentence hit the
|
||||
// fake.
|
||||
func TestSetupWritesConfig(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")
|
||||
stdin := strings.NewReader("en\n2\ny\ny\n\nn\ny\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())
|
||||
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 {
|
||||
Lang string `toml:"lang"`
|
||||
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)
|
||||
t.Fatalf("reading config: %v", err)
|
||||
}
|
||||
if got.Lang != "en" {
|
||||
t.Errorf("lang = %q, want en", got.Lang)
|
||||
}
|
||||
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 got.Voice["elevenlabs"]["en"] != "pNInz6obpgDQGcFmaJgB" { // no. 2 = Adam
|
||||
t.Errorf("voice.elevenlabs.en = %q, want Adam", got.Voice["elevenlabs"]["en"])
|
||||
}
|
||||
if _, ok := got.Voice["elevenlabs"]["en"]; ok {
|
||||
t.Errorf("voice.elevenlabs.en gesetzt, obwohl Default behalten wurde: %v", got.Voice)
|
||||
if _, ok := got.Voice["elevenlabs"]["de"]; ok {
|
||||
t.Errorf("voice.elevenlabs.de set although the default was kept: %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 n := calls.Load(); n != 2 { // 1× preview en + 1× test sentence
|
||||
t.Errorf("fake calls = %d, want 2 (preview + test sentence)", n)
|
||||
}
|
||||
if _, err := os.Stat(path + ".bak"); !os.IsNotExist(err) {
|
||||
t.Errorf("Sicherung ohne Vorgänger-Datei angelegt")
|
||||
t.Errorf("backup created without a previous file")
|
||||
}
|
||||
if !strings.Contains(out.String(), "Gespeichert") || !strings.Contains(out.String(), "Gesprochen über elevenlabs") {
|
||||
if !strings.Contains(out.String(), "Saved") || !strings.Contains(out.String(), "Spoken via 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) {
|
||||
// TestSetupMergesExistingConfig: existing values (other port, another
|
||||
// provider's voice) survive, there is a .bak, and "none" removes an existing
|
||||
// quiet window.
|
||||
func TestSetupMergesExistingConfig(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
setupEnv(t, root, nil)
|
||||
dir := filepath.Join(root, "talk")
|
||||
@@ -101,63 +105,63 @@ func TestSetupMergtBestehendeConfig(t *testing.T) {
|
||||
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")
|
||||
// The setup itself must not touch port 7900 from the file: TALK_PORT=1
|
||||
// from setGetenv beats the file.
|
||||
stdin := strings.NewReader("\n1\nn\ny\n1\nn\ny\nnone\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())
|
||||
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)
|
||||
t.Errorf(".bak = %q (%v), want the 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)
|
||||
t.Errorf("quiet_hours not removed: %v", got)
|
||||
}
|
||||
if srv, _ := got["server"].(map[string]any); srv["port"] != int64(7900) {
|
||||
t.Errorf("server.port verloren: %v", got["server"])
|
||||
t.Errorf("server.port lost: %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)
|
||||
t.Errorf("voice.edge.de lost: %v", voice)
|
||||
}
|
||||
// Default behalten (Nr. 1 = Rachel = Repo-Default) → kein elevenlabs-Eintrag.
|
||||
// Default kept (no. 1 = Rachel = built-in default) → no elevenlabs entry.
|
||||
if _, ok := voice["elevenlabs"]; ok {
|
||||
t.Errorf("voice.elevenlabs geschrieben, obwohl Default behalten: %v", voice)
|
||||
t.Errorf("voice.elevenlabs written although the default was kept: %v", voice)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupAbbruchSchreibtNichts: „q“ bricht ab, keine Datei entsteht.
|
||||
func TestSetupAbbruchSchreibtNichts(t *testing.T) {
|
||||
// TestSetupAbortWritesNothing: "q" aborts, no file is created.
|
||||
func TestSetupAbortWritesNothing(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())
|
||||
t.Fatalf("exit %d, stderr %q", code, errBuf.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "Abgebrochen") {
|
||||
if !strings.Contains(out.String(), "Aborted") {
|
||||
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")
|
||||
t.Error("config.toml created despite abort")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupOhneTerminal: Pipe → keine Fragen, wirksame Konfiguration, Exit 0.
|
||||
func TestSetupOhneTerminal(t *testing.T) {
|
||||
// TestSetupWithoutTerminal: a pipe → no questions, effective configuration,
|
||||
// exit 0.
|
||||
func TestSetupWithoutTerminal(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())
|
||||
t.Fatalf("exit %d, stderr %q", code, errBuf.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "Wirksame Konfiguration") || !strings.Contains(out.String(), "ruhezeit: keine") {
|
||||
if !strings.Contains(out.String(), "Effective configuration") || !strings.Contains(out.String(), "quiet hours: none") {
|
||||
t.Errorf("stdout = %q", out.String())
|
||||
}
|
||||
}
|
||||
+20
-20
@@ -12,41 +12,41 @@ import (
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/speak"
|
||||
)
|
||||
|
||||
// testPhrase ist die feste Testphrase je Sprache.
|
||||
// testPhrase is the fixed test sentence per language.
|
||||
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.
|
||||
// runTest implements `talk test`: speaks the test sentence DIRECTLY in this
|
||||
// process (not via the daemon), so the command sees which tier really spoke
|
||||
// and how long synthesis and playback took. Mute and quiet hours
|
||||
// deliberately do not apply (the user is testing right now); an active mute
|
||||
// is mentioned.
|
||||
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)")
|
||||
fmt.Fprintln(stderr, "talk test: no arguments expected (language: --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
|
||||
}
|
||||
lang := opts.lang
|
||||
if lang == "" {
|
||||
lang = cfg.Lang
|
||||
}
|
||||
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)")
|
||||
fmt.Fprintln(stderr, "talk test: mute is on; the test plays anyway, normal announcements stay silent (talk unmute)")
|
||||
}
|
||||
}
|
||||
if cfg.Audio.Sink == config.SinkNull {
|
||||
fmt.Fprintln(stderr, "talk test: audio.sink = null — es wird kein Ton ausgegeben")
|
||||
fmt.Fprintln(stderr, "talk test: audio.sink = null; no sound will be produced")
|
||||
}
|
||||
|
||||
engine, err := speak.New(cfg, getenv, stderr)
|
||||
@@ -59,19 +59,19 @@ func runTest(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Wri
|
||||
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.")
|
||||
fmt.Fprintln(stderr, "hint: `talk doctor` shows which tier is missing and why.")
|
||||
return exitFailure
|
||||
}
|
||||
fmt.Fprintf(stdout, "provider: %s\n", out.Provider)
|
||||
fmt.Fprintf(stdout, "sprache: %s\n", lang)
|
||||
fmt.Fprintf(stdout, "provider: %s\n", out.Provider)
|
||||
fmt.Fprintf(stdout, "language: %s\n", lang)
|
||||
switch {
|
||||
case out.Discarded:
|
||||
fmt.Fprintln(stdout, "wiedergabe: verworfen (audio.sink = null)")
|
||||
fmt.Fprintln(stdout, "playback: discarded (audio.sink = null)")
|
||||
case out.Direct:
|
||||
fmt.Fprintf(stdout, "dauer: %s (Direktansage über das OS)\n", out.Synthesis.Round(time.Millisecond))
|
||||
fmt.Fprintf(stdout, "duration: %s (direct speech via the 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))
|
||||
fmt.Fprintf(stdout, "synthesis: %s (%d bytes)\n", out.Synthesis.Round(time.Millisecond), out.Bytes)
|
||||
fmt.Fprintf(stdout, "playback: %s\n", out.Playback.Round(time.Millisecond))
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
+11
-11
@@ -6,32 +6,32 @@ import (
|
||||
"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) {
|
||||
// TestTestCommand: `talk test` speaks directly through the chain (here: fake
|
||||
// espeak → osbuiltin) and names the provider; sink null is announced.
|
||||
func TestTestCommand(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())
|
||||
t.Fatalf("talk test: exit %d, stderr %q", code, errBuf.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "provider: osbuiltin") || !strings.Contains(out.String(), "sprache: en") {
|
||||
if !strings.Contains(out.String(), "provider: osbuiltin") || !strings.Contains(out.String(), "language: 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 !strings.Contains(errBuf.String(), "no sound") {
|
||||
t.Errorf("stderr = %q, want a note about sink null", errBuf.String())
|
||||
}
|
||||
if _, err := readFile(seen); err != nil {
|
||||
t.Errorf("fake espeak nicht gerufen: %v", err)
|
||||
t.Errorf("fake espeak not called: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTestBefehlUngueltigeSprache: --lang prüft wie say.
|
||||
func TestTestBefehlUngueltigeSprache(t *testing.T) {
|
||||
// TestTestCommandInvalidLanguage: --lang is validated like say.
|
||||
func TestTestCommandInvalidLanguage(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())
|
||||
t.Errorf("exit %d, stderr %q", code, errBuf.String())
|
||||
}
|
||||
}
|
||||
+13
-13
@@ -11,7 +11,7 @@ import (
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/provider"
|
||||
)
|
||||
|
||||
// voiceRow ist eine Zeile von `talk voices` (auch die JSON-Form).
|
||||
// voiceRow is one row of `talk voices` (also the JSON form).
|
||||
type voiceRow struct {
|
||||
Provider string `json:"provider"`
|
||||
Lang string `json:"lang"`
|
||||
@@ -20,21 +20,21 @@ type voiceRow struct {
|
||||
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.
|
||||
// runVoices implements `talk voices`: the catalog of known voices plus the
|
||||
// ones set in config.toml (even when not in the catalog), filtered by
|
||||
// --provider/--lang; "*" marks the effective default voice per provider and
|
||||
// language. osbuiltin appears as one row without an ID: the operating system
|
||||
// picks the voice.
|
||||
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)")
|
||||
fmt.Fprintln(stderr, "talk voices: no arguments expected (filters: --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())
|
||||
fmt.Fprintf(stderr, "talk voices: unknown provider %q (available: %v)\n", opts.provider, provider.Names())
|
||||
return exitFailure
|
||||
}
|
||||
cfg, err := config.Load(config.Options{Getenv: getenv})
|
||||
@@ -60,11 +60,11 @@ func runVoices(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.W
|
||||
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>\".")
|
||||
fmt.Fprintln(stderr, "Pick a voice: --voice <id> once, or permanently in config.toml under [voice.<provider>] de = \"<id>\".")
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// voiceRows baut die Tabelle: Katalog + Config-Einträge, gefiltert.
|
||||
// voiceRows builds the table: catalog + config entries, filtered.
|
||||
func voiceRows(cfg config.Config, onlyProvider, onlyLang string) []voiceRow {
|
||||
var rows []voiceRow
|
||||
seen := map[[3]string]bool{}
|
||||
@@ -84,11 +84,11 @@ func voiceRows(cfg config.Config, onlyProvider, onlyLang string) []voiceRow {
|
||||
}
|
||||
for _, name := range provider.DefaultOrder {
|
||||
for _, lang := range []string{"de", "en"} {
|
||||
// Config-Eintrag zuerst, damit er oben steht (auch ohne Katalog).
|
||||
// Config entry first so it appears on top (also without catalog).
|
||||
if id := cfg.Voice[name][lang]; id != "" {
|
||||
label := provider.VoiceName(name, id)
|
||||
if label == "" {
|
||||
label = "(aus config.toml)"
|
||||
label = "(from config.toml)"
|
||||
}
|
||||
add(name, lang, id, label)
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func voiceRows(cfg config.Config, onlyProvider, onlyLang string) []voiceRow {
|
||||
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)")
|
||||
add("osbuiltin", lang, "", "operating system voice (the OS picks)")
|
||||
}
|
||||
return rows
|
||||
}
|
||||
+22
-22
@@ -9,25 +9,25 @@ import (
|
||||
"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) {
|
||||
// TestVoiceRowsDefaultAndConfig: without config "*" marks the built-in
|
||||
// default; a config mapping moves the mark and appears as its own row on
|
||||
// top, also for IDs outside the catalog. Filters apply.
|
||||
func TestVoiceRowsDefaultAndConfig(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)
|
||||
t.Fatalf("edge/de without config: first row = %+v, want Katja as default", rows)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.Provider != "edge" || r.Lang != "de" {
|
||||
t.Errorf("Filter verletzt: %+v", r)
|
||||
t.Errorf("filter violated: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
cfg.Voice = map[string]map[string]string{"edge": {"de": "de-DE-EigeneNeural"}}
|
||||
cfg.Voice = map[string]map[string]string{"edge": {"de": "de-DE-CustomNeural"}}
|
||||
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])
|
||||
if rows[0].ID != "de-DE-CustomNeural" || !rows[0].Default || rows[0].Name != "(from config.toml)" {
|
||||
t.Errorf("config ID outside the catalog: first row = %+v", rows[0])
|
||||
}
|
||||
defaults := 0
|
||||
for _, r := range rows {
|
||||
@@ -35,45 +35,45 @@ func TestVoiceRowsDefaultUndConfig(t *testing.T) {
|
||||
defaults++
|
||||
}
|
||||
if r.ID == "de-DE-KatjaNeural" && r.Default {
|
||||
t.Error("Katja weiter als Default markiert, obwohl Config eine andere Stimme setzt")
|
||||
t.Error("Katja still marked as default although config sets another voice")
|
||||
}
|
||||
}
|
||||
if defaults != 1 {
|
||||
t.Errorf("%d Default-Markierungen, want genau 1", defaults)
|
||||
t.Errorf("%d default marks, want exactly 1", defaults)
|
||||
}
|
||||
|
||||
// osbuiltin erscheint ohne ID, nie als Default.
|
||||
// osbuiltin appears without an ID, never as 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)
|
||||
t.Errorf("osbuiltin row = %+v, want empty ID without default", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("osbuiltin fehlt in der Gesamtliste")
|
||||
t.Error("osbuiltin missing from the full list")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoicesCLI: Tabelle mit Kopfzeile, --json als Liste, unbekannter
|
||||
// Provider ist ein Fehler.
|
||||
// TestVoicesCLI: table with header, --json as a list, unknown provider is an
|
||||
// error.
|
||||
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())
|
||||
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())
|
||||
t.Errorf("table = %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())
|
||||
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 {
|
||||
@@ -81,13 +81,13 @@ func TestVoicesCLI(t *testing.T) {
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.Provider != "openai" {
|
||||
t.Errorf("--provider openai liefert %+v", r)
|
||||
t.Errorf("--provider openai yields %+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())
|
||||
if code := runCLI([]string{"voices", "--provider", "siri"}, &out, &errBuf); code == exitOK || !strings.Contains(errBuf.String(), "unknown provider") {
|
||||
t.Errorf("unknown provider: exit %d, stderr %q", code, errBuf.String())
|
||||
}
|
||||
}
|
||||
+69
-73
@@ -13,33 +13,33 @@ import (
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/langdetect"
|
||||
)
|
||||
|
||||
// Kind nicht gefunden — Shell-Konvention 127.
|
||||
// exitNotFound is the shell convention for "command not found".
|
||||
const exitNotFound = 127
|
||||
|
||||
// runWrap ist der wrap-Eingangspfad (Task 07): `talk wrap -- <Befehl>
|
||||
// [Argumente …]` führt den Befehl aus — stdin/stdout/stderr werden
|
||||
// durchgereicht, stdout gehört allein dem Befehl —, misst die Wandzeit und
|
||||
// sagt danach das Ergebnis an: „<label> erfolgreich. Dauer: X." bzw.
|
||||
// „<label> fehlgeschlagen, Code N." (Label = config `wrap.label` oder der
|
||||
// Basisname des Befehls; lokalisiert de/en nach langdetect des Labels).
|
||||
// Die Ansage läuft über denselben Weg wie say: Daemon (enqueue, non-
|
||||
// blocking; --wait respektiert; Autostart), ohne Daemon direkt. wrap erbt
|
||||
// die say-Flags und reicht sie an die Ansage durch.
|
||||
// runWrap is the wrap entry point: `talk wrap -- <command> [args …]` runs
|
||||
// the command (stdin/stdout/stderr passed through; stdout belongs to the
|
||||
// command alone), measures wall time and announces the result afterwards:
|
||||
// "<label> succeeded. Duration: X." or "<label> failed, code N." (label =
|
||||
// config `wrap.label` or the command's base name; localized de/en by
|
||||
// language detection on the label, falling back to the configured lang).
|
||||
// The announcement takes the same path as say: daemon (enqueue, non-
|
||||
// blocking; --wait honoured; autostart), without a daemon directly. wrap
|
||||
// inherits the say flags and passes them on.
|
||||
//
|
||||
// Exit-Code-Politik: wrap exitet IMMER mit dem Code des Befehls (talk-
|
||||
// Fehler ≠ Befehls-Fehler). Scheitert die Ansage, gibt es eine Warning auf
|
||||
// stderr — der Befehls-Code bleibt. Benutzungsfehler von talk selbst
|
||||
// (kein Befehl, unbekannte Flags, ungültiges --lang) fallen dagegen VOR
|
||||
// dem Start des Befehls auf: Er läuft dann gar nicht erst. Nur
|
||||
// Konfigurations-Fehler (kaputte config.toml/Env) treten nach dem Lauf
|
||||
// auf — der Befehl bleibt gelaufen, die Ansage wird mit Warning ausgesetzt.
|
||||
// Exit code policy: wrap ALWAYS exits with the command's code (talk errors
|
||||
// are not command errors). If the announcement fails, there is a warning on
|
||||
// stderr and the command's code stands. Usage errors of talk itself (no
|
||||
// command, unknown flags, invalid --lang) fail BEFORE the command starts,
|
||||
// so it does not run at all. Only configuration errors (broken config.toml
|
||||
// or env) surface after the run: the command has run, the announcement is
|
||||
// skipped with a warning.
|
||||
//
|
||||
// Die Positionsargumente sind die Befehlszeile (Befehl + Argumente); das
|
||||
// „--" selbst hat parseInterspersed bereits konsumiert — alles dahinter
|
||||
// kam unverändert durch den Flag-Schutz hindurch.
|
||||
// The positional arguments are the command line (command + args); the "--"
|
||||
// itself was consumed by parseInterspersed, everything after it came
|
||||
// through untouched.
|
||||
func runWrap(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(stderr, "talk wrap: kein Befehl — Verwendung: talk wrap [Flags] -- <Befehl> [Argumente …]")
|
||||
fmt.Fprintln(stderr, "talk wrap: no command given; usage: talk wrap [flags] -- <command> [args …]")
|
||||
return exitUsage
|
||||
}
|
||||
if code, bad := validateSayOptions(opts, stderr); bad {
|
||||
@@ -51,46 +51,44 @@ func runWrap(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io
|
||||
duration := time.Since(start)
|
||||
code := childExitCode(runErr)
|
||||
if runErr != nil {
|
||||
// Z. B. Binary nicht gefunden oder Signal-Tod: Der Hinweis gehört
|
||||
// auf stderr; die Ansage nennt denselben Code, mit dem wrap exitet.
|
||||
fmt.Fprintf(stderr, "talk wrap: Befehl nicht sauber beendet: %v\n", runErr)
|
||||
// For example: binary not found or killed by a signal. The note goes
|
||||
// to stderr; the announcement names the same code wrap exits with.
|
||||
fmt.Fprintf(stderr, "talk wrap: command did not exit cleanly: %v\n", runErr)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(config.Options{Getenv: getenv})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk wrap: Ansage ausgesetzt — Config: %v (Exit-Code %d des Befehls bleibt)\n", err, code)
|
||||
fmt.Fprintf(stderr, "talk wrap: announcement skipped; config: %v (the command's exit code %d stands)\n", err, code)
|
||||
return code
|
||||
}
|
||||
|
||||
label := wrapLabel(cfg.Wrap.Label, args[0])
|
||||
lang := opts.lang
|
||||
if lang == "" {
|
||||
lang = langdetect.Detect(label)
|
||||
lang = langdetect.Detect(label, cfg.Lang)
|
||||
}
|
||||
announceWrap(cfg, announcementText(label, code, duration, lang), opts, code, stderr)
|
||||
return code
|
||||
}
|
||||
|
||||
// announceWrap bringt die wrap-Ansage unter — über denselben Weg wie say
|
||||
// (announce: Daemon, ggf. Autostart, sonst direkt). Der Exit-Code der
|
||||
// Ansage-Maschinerie verwirft NIE den Code des Befehls: Scheitert sie,
|
||||
// gibt es eine Warning auf stderr (talk-Fehler ≠ Befehls-Fehler).
|
||||
// Informationszeilen der Ansage (Item-ID, muted, discarded) gehen bei wrap
|
||||
// auf stderr — stdout gehört allein dem (bereits beendeten) Befehl, damit
|
||||
// Pipes und Redirects des Befehls nicht durch talk-Ausgaben verunreinigt
|
||||
// werden.
|
||||
// announceWrap delivers the wrap announcement the same way say does
|
||||
// (announce: daemon, autostart if needed, otherwise direct). The
|
||||
// announcement machinery NEVER overrides the command's exit code: if it
|
||||
// fails, there is a warning on stderr. Information lines of the
|
||||
// announcement (item ID, muted, discarded) go to stderr for wrap: stdout
|
||||
// belongs to the (already finished) command, so its pipes and redirects
|
||||
// stay clean.
|
||||
func announceWrap(cfg config.Config, text string, opts *sayOptions, commandCode int, stderr io.Writer) {
|
||||
if announce(cfg, text, opts, stderr, stderr) != exitOK {
|
||||
fmt.Fprintf(stderr, "talk wrap: Ansage fehlgeschlagen — Exit-Code %d des Befehls bleibt\n", commandCode)
|
||||
fmt.Fprintf(stderr, "talk wrap: announcement failed; the command's exit code %d stands\n", commandCode)
|
||||
}
|
||||
}
|
||||
|
||||
// execChild startet argv als Kindprozess und wartet auf sein Ende.
|
||||
// stdin/stdout/stderr werden unverändert durchgereicht — der Befehl
|
||||
// besitzt die Konsole, talk mischt sich nicht ein (die Ansage selbst geht
|
||||
// erst NACH dem Ende des Befehls raus). Reines os/exec ohne
|
||||
// plattformspezifische Aufrufe: läuft unverändert auf Windows, macOS und
|
||||
// Linux (Kindprozesse startet das jeweilige Betriebssystem-Exec selbst).
|
||||
// execChild starts argv as a child process and waits for it. stdin/stdout/
|
||||
// stderr are passed through unchanged: the command owns the console, talk
|
||||
// does not interfere (the announcement goes out only AFTER the command has
|
||||
// ended). Plain os/exec without platform-specific calls: runs unchanged on
|
||||
// Windows, macOS and Linux.
|
||||
func execChild(argv []string, stdin io.Reader, stdout, stderr io.Writer) error {
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
cmd.Stdin = stdin
|
||||
@@ -99,17 +97,16 @@ func execChild(argv []string, stdin io.Reader, stdout, stderr io.Writer) error {
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// childExitCode übersetzt das Run-Ergebnis des Kindprozesses in den
|
||||
// Exit-Code, mit dem wrap selbst exitet (Durchreich-Vertrag):
|
||||
// childExitCode translates the child's Run result into the exit code wrap
|
||||
// itself exits with (pass-through contract):
|
||||
//
|
||||
// - normales Ende: der Exit-Code des Befehls (0 = Erfolg).
|
||||
// - Ende durch Signal: portabel auf 1 normiert. Die Unix-Konvention
|
||||
// 128+Signalnummer bräuchte syscall.WaitStatus (Signaltode meldet Go
|
||||
// als ExitCode -1) und wäre nicht mehr frei cross-compilierbar; die
|
||||
// Ansage nennt bewusst denselben normierten Code, mit dem wrap exitet.
|
||||
// - Start fehlgeschlagen (Binary nicht gefunden/nicht ausführbar):
|
||||
// 127, die Shell-Konvention — der Befehl ist gescheitert, ohne je
|
||||
// gelaufen zu sein.
|
||||
// - normal exit: the command's exit code (0 = success).
|
||||
// - killed by a signal: normalized to 1, portably. The Unix convention
|
||||
// 128+signal would need syscall.WaitStatus (Go reports signal deaths as
|
||||
// ExitCode -1) and would no longer cross-compile freely; the
|
||||
// announcement deliberately names the same normalized code.
|
||||
// - start failed (binary not found/not executable): 127, the shell
|
||||
// convention; the command failed without ever running.
|
||||
func childExitCode(runErr error) int {
|
||||
if runErr == nil {
|
||||
return 0
|
||||
@@ -124,9 +121,9 @@ func childExitCode(runErr error) int {
|
||||
return exitNotFound
|
||||
}
|
||||
|
||||
// wrapLabel löst das Ansage-Label: config `wrap.label` (Env-Override
|
||||
// TALK_WRAP_LABEL) schlägt den Basisnamen des Befehls
|
||||
// (`/usr/bin/make` → „make").
|
||||
// wrapLabel resolves the announcement label: config `wrap.label` (env
|
||||
// override TALK_WRAP_LABEL) beats the command's base name
|
||||
// (`/usr/bin/make` → "make").
|
||||
func wrapLabel(cfgLabel, command string) string {
|
||||
if cfgLabel != "" {
|
||||
return cfgLabel
|
||||
@@ -134,16 +131,16 @@ func wrapLabel(cfgLabel, command string) string {
|
||||
return filepath.Base(command)
|
||||
}
|
||||
|
||||
// formatDuration macht eine Wandzeit menschenlesbar, lokalisiert de/en:
|
||||
// „3 Minuten 42 Sekunden" / „3 minutes 42 seconds". Gerundet auf ganze
|
||||
// Sekunden (halb aufwärts); Nullteile fallen weg („1 Minute"), nur bei
|
||||
// insgesamt null bleibt „0 Sekunden" stehen. Die Einzahl wird dekliniert
|
||||
// („1 Sekunde", „1 minute"). Alles über einer Stunde nennt auch Stunden.
|
||||
// formatDuration renders wall time for humans, localized de/en: "3 Minuten
|
||||
// 42 Sekunden" / "3 minutes 42 seconds". Rounded to whole seconds (half
|
||||
// up); zero parts are dropped ("1 Minute"), only an overall zero keeps
|
||||
// "0 Sekunden". Singulars are declined ("1 Sekunde", "1 minute").
|
||||
// Anything over an hour names hours too.
|
||||
func formatDuration(d time.Duration, lang string) string {
|
||||
type unit struct{ singular, plural string }
|
||||
units := []unit{{"Stunde", "Stunden"}, {"Minute", "Minuten"}, {"Sekunde", "Sekunden"}}
|
||||
if lang == "en" {
|
||||
units = []unit{{"hour", "hours"}, {"minute", "minutes"}, {"second", "seconds"}}
|
||||
units := []unit{{"hour", "hours"}, {"minute", "minutes"}, {"second", "seconds"}}
|
||||
if lang == "de" {
|
||||
units = []unit{{"Stunde", "Stunden"}, {"Minute", "Minuten"}, {"Sekunde", "Sekunden"}}
|
||||
}
|
||||
secs := int64(d.Round(time.Second) / time.Second)
|
||||
n := []int64{secs / 3600, secs / 60 % 60, secs % 60}
|
||||
@@ -164,21 +161,20 @@ func formatDuration(d time.Duration, lang string) string {
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// announcementText baut die wrap-Ansage nach Brief: Erfolg
|
||||
// „<label> erfolgreich. Dauer: X." bzw. Misserfolg
|
||||
// „<label> fehlgeschlagen, Code N." — lokalisiert (englisch:
|
||||
// „<label> succeeded. Duration: X." / „<label> failed, code N."). Die
|
||||
// Sprache bestimmt die Spracherkennung des Labels (oder --lang, falls
|
||||
// gesetzt); alles außer "en" spricht deutsch (langdetect-Default).
|
||||
// announcementText builds the wrap announcement: success "<label>
|
||||
// succeeded. Duration: X." / failure "<label> failed, code N.", in German
|
||||
// "<label> erfolgreich. Dauer: X." / "<label> fehlgeschlagen, Code N.". The
|
||||
// language comes from detection on the label (or --lang); anything but
|
||||
// "de" speaks English.
|
||||
func announcementText(label string, code int, d time.Duration, lang string) string {
|
||||
if lang == "en" {
|
||||
if lang == "de" {
|
||||
if code == 0 {
|
||||
return fmt.Sprintf("%s succeeded. Duration: %s.", label, formatDuration(d, lang))
|
||||
return fmt.Sprintf("%s erfolgreich. Dauer: %s.", label, formatDuration(d, lang))
|
||||
}
|
||||
return fmt.Sprintf("%s failed, code %d.", label, code)
|
||||
return fmt.Sprintf("%s fehlgeschlagen, Code %d.", label, code)
|
||||
}
|
||||
if code == 0 {
|
||||
return fmt.Sprintf("%s erfolgreich. Dauer: %s.", label, formatDuration(d, lang))
|
||||
return fmt.Sprintf("%s succeeded. Duration: %s.", label, formatDuration(d, lang))
|
||||
}
|
||||
return fmt.Sprintf("%s fehlgeschlagen, Code %d.", label, code)
|
||||
return fmt.Sprintf("%s failed, code %d.", label, code)
|
||||
}
|
||||
+101
-87
@@ -12,9 +12,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestFormatDuration ist tischgesteuert für die menschliche Dauer-Format
|
||||
// (de/en): Gerundung auf ganze Sekunden, Nullteile fallen weg, Einzahl
|
||||
// wird dekliniert, Stunden ab einer Stunde, „0 Sekunden" als Fallback.
|
||||
// TestFormatDuration is table-driven for the human duration format (de/en):
|
||||
// rounding to whole seconds, zero parts dropped, singulars declined, hours
|
||||
// from one hour on, "0 seconds" as the fallback.
|
||||
func TestFormatDuration(t *testing.T) {
|
||||
cases := []struct {
|
||||
d time.Duration
|
||||
@@ -23,7 +23,7 @@ func TestFormatDuration(t *testing.T) {
|
||||
}{
|
||||
{0, "0 Sekunden", "0 seconds"},
|
||||
{300 * time.Millisecond, "0 Sekunden", "0 seconds"},
|
||||
{500 * time.Millisecond, "1 Sekunde", "1 second"}, // halb aufwärts
|
||||
{500 * time.Millisecond, "1 Sekunde", "1 second"}, // half up
|
||||
{1500 * time.Millisecond, "2 Sekunden", "2 seconds"},
|
||||
{1 * time.Second, "1 Sekunde", "1 second"},
|
||||
{42 * time.Second, "42 Sekunden", "42 seconds"},
|
||||
@@ -45,8 +45,8 @@ func TestFormatDuration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnnouncementText: die Ansage-Texte nach Brief, lokalisiert de/en —
|
||||
// Erfolg mit Dauer, Misserfolg mit Code (ohne Dauer).
|
||||
// TestAnnouncementText: the announcement texts, localized de/en: success
|
||||
// with duration, failure with code (without duration).
|
||||
func TestAnnouncementText(t *testing.T) {
|
||||
d := 222 * time.Second
|
||||
cases := []struct {
|
||||
@@ -66,7 +66,7 @@ func TestAnnouncementText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapLabel: config-Label schlägt den Basisnamen des Befehls.
|
||||
// TestWrapLabel: the config label beats the command's base name.
|
||||
func TestWrapLabel(t *testing.T) {
|
||||
if got := wrapLabel("", "/usr/bin/make"); got != "make" {
|
||||
t.Errorf("wrapLabel(\"\", /usr/bin/make) = %q, want %q", got, "make")
|
||||
@@ -79,37 +79,37 @@ func TestWrapLabel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapOhneBefehl: `talk wrap` ohne Befehl ist ein klarer
|
||||
// Benutzungsfehler — der Befehl (nicht vorhanden) läuft natürlich nicht.
|
||||
func TestWrapOhneBefehl(t *testing.T) {
|
||||
// TestWrapWithoutCommand: `talk wrap` without a command is a clear usage
|
||||
// error; the (absent) command naturally does not run.
|
||||
func TestWrapWithoutCommand(t *testing.T) {
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"wrap"}, &out, &errBuf)
|
||||
if code != exitUsage {
|
||||
t.Fatalf("talk wrap ohne Befehl: Exit-Code = %d, want %d", code, exitUsage)
|
||||
t.Fatalf("talk wrap without command: exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "kein Befehl") {
|
||||
t.Errorf("stderr = %q, will Hinweis auf fehlenden Befehl", errBuf.String())
|
||||
if !strings.Contains(errBuf.String(), "no command") {
|
||||
t.Errorf("stderr = %q, want a note about the missing command", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapValidiertSayFlagsVorDemStart: ungültige geerbte say-Flags
|
||||
// scheitern VOR dem Befehlsstart (Benutzungsfehler laufen nie den Befehl).
|
||||
func TestWrapValidiertSayFlagsVorDemStart(t *testing.T) {
|
||||
// TestWrapValidatesSayFlagsBeforeStart: invalid inherited say flags fail
|
||||
// BEFORE the command starts (usage errors never run the command).
|
||||
func TestWrapValidatesSayFlagsBeforeStart(t *testing.T) {
|
||||
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"wrap", "--lang=fr", "--", "true"}, &out, &errBuf)
|
||||
if code != exitFailure {
|
||||
t.Fatalf("talk wrap --lang=fr: Exit-Code = %d, want %d", code, exitFailure)
|
||||
t.Fatalf("talk wrap --lang=fr: exit code = %d, want %d", code, exitFailure)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "--lang") {
|
||||
t.Errorf("stderr = %q, will --lang-Fehler", errBuf.String())
|
||||
t.Errorf("stderr = %q, want a --lang error", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// wrapFakeTTS ist ein ElevenLabs-Fake, der den ANSAGE-TEXT aufzeichnet
|
||||
// (der CLI-Fake in say_test.go beobachtet nur Pfad/Key — wrap braucht den
|
||||
// gesprochenen Text als Beobachtung).
|
||||
// wrapFakeTTS is an ElevenLabs fake recording the ANNOUNCEMENT TEXT (the
|
||||
// CLI fake in say_test.go only observes path/key; wrap needs the spoken text
|
||||
// as its observation).
|
||||
func wrapFakeTTS(t *testing.T, status int) (url string, texts chan string) {
|
||||
t.Helper()
|
||||
texts = make(chan string, 8)
|
||||
@@ -126,10 +126,10 @@ func wrapFakeTTS(t *testing.T, status int) (url string, texts chan string) {
|
||||
return ts.URL, texts
|
||||
}
|
||||
|
||||
// wrapEnv ist die Degraded-Grundausstattung der In-Prozess-wrap-Tests:
|
||||
// Daemon garantiert tot (TALK_PORT=1), Kette auf den Fake gepinnt — die
|
||||
// Ansage läuft deterministisch lokal gegen den Fake (sink null). extra
|
||||
// ergänzt weitere Env-Werte (ein Aufruf, kein Verdrängen der Basis).
|
||||
// wrapEnv is the base kit of the in-process wrap tests: daemon guaranteed
|
||||
// dead (TALK_PORT=1), chain pinned to the fake; the announcement runs
|
||||
// deterministically on the direct path against the fake (sink null). extra
|
||||
// adds env values (one call, no displacing of the base).
|
||||
func wrapEnv(t *testing.T, endpoint string, extra map[string]string) {
|
||||
t.Helper()
|
||||
kv := map[string]string{
|
||||
@@ -144,19 +144,19 @@ func wrapEnv(t *testing.T, endpoint string, extra map[string]string) {
|
||||
setGetenv(t, kv)
|
||||
}
|
||||
|
||||
// needUnixBin überspringt den Test, wenn das Binary fehlt (die
|
||||
// In-Prozess-Tests starten echte Kinder: true/sh — auf dem Linux-Dev-Host
|
||||
// und in CI immer vorhanden).
|
||||
// needUnixBin skips the test when the binary is missing (the in-process
|
||||
// tests start real children: true/sh; always present on Linux and in CI).
|
||||
func needUnixBin(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath(name); err != nil {
|
||||
t.Skipf("%s fehlt: %v", name, err)
|
||||
t.Skipf("%s missing: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapDurchreichungErfolg: `talk wrap -- true` (Daemon tot → Degraded,
|
||||
// Ansage gegen den Fake) exitet 0 und sagt den Erfolg mit Dauer.
|
||||
func TestWrapDurchreichungErfolg(t *testing.T) {
|
||||
// TestWrapPassThroughSuccess: `talk wrap -- true` (daemon dead → direct,
|
||||
// announcement against the fake) exits 0 and announces success with the
|
||||
// duration, in English by default.
|
||||
func TestWrapPassThroughSuccess(t *testing.T) {
|
||||
needUnixBin(t, "true")
|
||||
url, texts := wrapFakeTTS(t, 200)
|
||||
wrapEnv(t, url, nil)
|
||||
@@ -164,103 +164,117 @@ func TestWrapDurchreichungErfolg(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"wrap", "--", "true"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("talk wrap -- true: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
t.Fatalf("talk wrap -- true: exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
}
|
||||
select {
|
||||
case text := <-texts:
|
||||
// true läuft Millisekunden — Dauer rund 0; unter Last auch gern
|
||||
// 1 Sekunde (Einzahl). Nur Struktur verifiziert.
|
||||
if !regexp.MustCompile(`^true erfolgreich\. Dauer: \d+ Sekunden?\.$`).MatchString(text) {
|
||||
t.Errorf("Ansage = %q, will „true erfolgreich. Dauer: N Sekunde(n).“", text)
|
||||
// true runs for milliseconds: duration about 0; under load also
|
||||
// 1 second (singular). Only the structure is verified.
|
||||
if !regexp.MustCompile(`^true succeeded\. Duration: \d+ seconds?\.$`).MatchString(text) {
|
||||
t.Errorf("announcement = %q, want \"true succeeded. Duration: N second(s).\"", text)
|
||||
}
|
||||
default:
|
||||
t.Error("Ansage lief nicht (Fake nicht gerufen)")
|
||||
t.Error("announcement did not run (fake not called)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapDurchreichungFehlschlag: `talk wrap -- sh -c "exit 3"` exitet 3
|
||||
// und sagt den Misserfolg mit genau diesem Code.
|
||||
func TestWrapDurchreichungFehlschlag(t *testing.T) {
|
||||
// TestWrapPassThroughFailure: `talk wrap -- sh -c "exit 3"` exits 3 and
|
||||
// announces the failure with exactly that code; with TALK_LANG=de in German.
|
||||
func TestWrapPassThroughFailure(t *testing.T) {
|
||||
needUnixBin(t, "sh")
|
||||
url, texts := wrapFakeTTS(t, 200)
|
||||
wrapEnv(t, url, nil)
|
||||
for _, tc := range []struct {
|
||||
lang string
|
||||
want string
|
||||
}{
|
||||
{"", "sh failed, code 3."},
|
||||
{"de", "sh fehlgeschlagen, Code 3."},
|
||||
} {
|
||||
t.Run("lang="+tc.lang, func(t *testing.T) {
|
||||
url, texts := wrapFakeTTS(t, 200)
|
||||
extra := map[string]string{}
|
||||
if tc.lang != "" {
|
||||
extra["TALK_LANG"] = tc.lang
|
||||
}
|
||||
wrapEnv(t, url, extra)
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"wrap", "--", "sh", "-c", "exit 3"}, &out, &errBuf)
|
||||
if code != 3 {
|
||||
t.Fatalf("talk wrap -- sh -c 'exit 3': Exit-Code = %d, want 3", code)
|
||||
}
|
||||
select {
|
||||
case text := <-texts:
|
||||
if text != "sh fehlgeschlagen, Code 3." {
|
||||
t.Errorf("Ansage = %q, want %q", text, "sh fehlgeschlagen, Code 3.")
|
||||
}
|
||||
default:
|
||||
t.Error("Ansage lief nicht (Fake nicht gerufen)")
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"wrap", "--", "sh", "-c", "exit 3"}, &out, &errBuf)
|
||||
if code != 3 {
|
||||
t.Fatalf("talk wrap -- sh -c 'exit 3': exit code = %d, want 3", code)
|
||||
}
|
||||
select {
|
||||
case text := <-texts:
|
||||
if text != tc.want {
|
||||
t.Errorf("announcement = %q, want %q", text, tc.want)
|
||||
}
|
||||
default:
|
||||
t.Error("announcement did not run (fake not called)")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapStdoutGehoertBefehl: stdout der wrap-CLI führt NUR die Ausgabe
|
||||
// des Befehls — die Informationszeilen der Ansage (hier: Degraded-Protokoll
|
||||
// und Ketten-Logs) gehen auf stderr. Pipes des Befehls bleiben sauber.
|
||||
func TestWrapStdoutGehoertBefehl(t *testing.T) {
|
||||
// TestWrapStdoutBelongsToCommand: the wrap CLI's stdout carries ONLY the
|
||||
// command's output; the announcement's information lines (direct-path note
|
||||
// and chain logs) go to stderr. The command's pipes stay clean.
|
||||
func TestWrapStdoutBelongsToCommand(t *testing.T) {
|
||||
needUnixBin(t, "sh")
|
||||
url, _ := wrapFakeTTS(t, 200)
|
||||
wrapEnv(t, url, nil)
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"wrap", "--", "sh", "-c", "echo kinderausgabe"}, &out, &errBuf)
|
||||
code := runCLI([]string{"wrap", "--", "sh", "-c", "echo child-output"}, &out, &errBuf)
|
||||
if code != exitOK {
|
||||
t.Fatalf("talk wrap -- sh -c echo: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
t.Fatalf("talk wrap -- sh -c echo: exit code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
|
||||
}
|
||||
if out.String() != "kinderausgabe\n" {
|
||||
t.Errorf("stdout = %q, will ausschließlich die Befehlsausgabe %q", out.String(), "kinderausgabe\n")
|
||||
if out.String() != "child-output\n" {
|
||||
t.Errorf("stdout = %q, want only the command's output %q", out.String(), "child-output\n")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapAnsageScheitertCodeBleibt: Stirbt die Ansage (Endpunkt tot,
|
||||
// Kette gepinnt → kein Fallback), bleibt der Exit-Code des Befehls
|
||||
// trotzdem — nur eine Warning auf stderr (talk-Fehler ≠ Befehls-Fehler).
|
||||
func TestWrapAnsageScheitertCodeBleibt(t *testing.T) {
|
||||
// TestWrapAnnouncementFailsCodeStands: if the announcement dies (endpoint
|
||||
// dead, chain pinned → no fallback), the command's exit code still stands;
|
||||
// only a warning on stderr (talk errors are not command errors).
|
||||
func TestWrapAnnouncementFailsCodeStands(t *testing.T) {
|
||||
needUnixBin(t, "sh")
|
||||
wrapEnv(t, "http://127.0.0.1:1/", nil) // Endpunkt garantiert tot
|
||||
wrapEnv(t, "http://127.0.0.1:1/", nil) // endpoint guaranteed dead
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"wrap", "--", "sh", "-c", "exit 4"}, &out, &errBuf)
|
||||
if code != 4 {
|
||||
t.Fatalf("talk wrap (Ansage scheitert): Exit-Code = %d, want 4 (Befehl gewinnt)", code)
|
||||
t.Fatalf("talk wrap (announcement fails): exit code = %d, want 4 (command wins)", code)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "Ansage fehlgeschlagen") {
|
||||
t.Errorf("stderr = %q, will Warning, dass die Ansage scheiterte", errBuf.String())
|
||||
if !strings.Contains(errBuf.String(), "announcement failed") {
|
||||
t.Errorf("stderr = %q, want a warning that the announcement failed", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapBefehlNichtGefunden: unbekannter Befehl → Shell-Konvention 127,
|
||||
// Ansage nennt denselben Code.
|
||||
func TestWrapBefehlNichtGefunden(t *testing.T) {
|
||||
// TestWrapCommandNotFound: unknown command → shell convention 127, the
|
||||
// announcement names the same code.
|
||||
func TestWrapCommandNotFound(t *testing.T) {
|
||||
url, texts := wrapFakeTTS(t, 200)
|
||||
wrapEnv(t, url, nil)
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"wrap", "--", "diesen-befehl-gibt-es-nicht"}, &out, &errBuf)
|
||||
code := runCLI([]string{"wrap", "--", "this-command-does-not-exist"}, &out, &errBuf)
|
||||
if code != exitNotFound {
|
||||
t.Fatalf("talk wrap -- <unbekannt>: Exit-Code = %d, want %d", code, exitNotFound)
|
||||
t.Fatalf("talk wrap -- <unknown>: exit code = %d, want %d", code, exitNotFound)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "nicht sauber beendet") {
|
||||
t.Errorf("stderr = %q, will Hinweis auf den Startfehler", errBuf.String())
|
||||
if !strings.Contains(errBuf.String(), "did not exit cleanly") {
|
||||
t.Errorf("stderr = %q, want a note about the start failure", errBuf.String())
|
||||
}
|
||||
select {
|
||||
case text := <-texts:
|
||||
if text != "diesen-befehl-gibt-es-nicht fehlgeschlagen, Code 127." {
|
||||
t.Errorf("Ansage = %q, want Code-127-Ansage", text)
|
||||
if text != "this-command-does-not-exist failed, code 127." {
|
||||
t.Errorf("announcement = %q, want the code 127 announcement", text)
|
||||
}
|
||||
default:
|
||||
t.Error("Ansage lief nicht (Fake nicht gerufen)")
|
||||
t.Error("announcement did not run (fake not called)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapLabelAusEnv: TALK_WRAP_LABEL ersetzt den Basisnamen.
|
||||
func TestWrapLabelAusEnv(t *testing.T) {
|
||||
// TestWrapLabelFromEnv: TALK_WRAP_LABEL replaces the base name.
|
||||
func TestWrapLabelFromEnv(t *testing.T) {
|
||||
needUnixBin(t, "false")
|
||||
url, texts := wrapFakeTTS(t, 200)
|
||||
wrapEnv(t, url, map[string]string{"TALK_WRAP_LABEL": "Deployment"})
|
||||
@@ -268,14 +282,14 @@ func TestWrapLabelAusEnv(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runCLI([]string{"wrap", "--", "false"}, &out, &errBuf)
|
||||
if code != 1 {
|
||||
t.Fatalf("talk wrap -- false: Exit-Code = %d, want 1", code)
|
||||
t.Fatalf("talk wrap -- false: exit code = %d, want 1", code)
|
||||
}
|
||||
select {
|
||||
case text := <-texts:
|
||||
if text != "Deployment fehlgeschlagen, Code 1." {
|
||||
t.Errorf("Ansage = %q, want %q", text, "Deployment fehlgeschlagen, Code 1.")
|
||||
if text != "Deployment failed, code 1." {
|
||||
t.Errorf("announcement = %q, want %q", text, "Deployment failed, code 1.")
|
||||
}
|
||||
default:
|
||||
t.Error("Ansage lief nicht (Fake nicht gerufen)")
|
||||
t.Error("announcement did not run (fake not called)")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user