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
+126
-125
@@ -1,10 +1,10 @@
|
||||
package test
|
||||
|
||||
// Prozessgrenzen-Tests für das Anti-Spam-Gesetz (Task 05): Rate-Limit
|
||||
// (low verworfen, normal geparkt, high-burst, now-Ausbruch) und Ruhezeiten
|
||||
// (discard/toast) — beobachtet als Black Box über HTTP/CLI, Fake-Provider
|
||||
// und Daemon-Protokoll. Der Harness stellt dafür TALK_RATE_LIMIT=0s als
|
||||
// Basis; diese Tests stellen das Gesetz bewusst per extra an.
|
||||
// Process-boundary tests for the anti-spam law (Task 05): rate limit (low
|
||||
// discarded, normal parked, high burst, now breakout) and quiet hours
|
||||
// (discard/toast) — observed as a black box via HTTP/CLI, fake provider and
|
||||
// daemon log. The harness provides TALK_RATE_LIMIT=0s as the base; these
|
||||
// tests deliberately turn the law on via extra.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -14,17 +14,17 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// fensterUm legt ein Ruhezeit-Fenster um die aktuelle lokale Zeit — von
|
||||
// vor drei Minuten bis in vier Minuten — so ist das Fenster unabhängig von
|
||||
// der Uhrzeit des Testlaufs sicher aktiv, auch wenn der Test (Daemon-Start,
|
||||
// Race-Detector, langsame CI) über eine Minutengrenze läuft. (±1 Minute
|
||||
// war zu knapp: ein Lauf um hh:mm:59 kippte in die Inaktivität.)
|
||||
func fensterUm() string {
|
||||
// windowAround places a quiet-hours window around the current local time —
|
||||
// from three minutes ago until four minutes from now — so the window is
|
||||
// safely active regardless of the time of the test run, even when the test
|
||||
// (daemon start, race detector, slow CI) crosses a minute boundary. (±1
|
||||
// minute was too tight: a run at hh:mm:59 tipped into inactivity.)
|
||||
func windowAround() string {
|
||||
now := time.Now()
|
||||
return now.Add(-3*time.Minute).Format("15:04") + "-" + now.Add(4*time.Minute).Format("15:04")
|
||||
}
|
||||
|
||||
// lawStatus ist die Gesetz-Sicht aus GET /status.
|
||||
// lawStatus is the law view from GET /status.
|
||||
type lawStatus struct {
|
||||
RateLimit struct {
|
||||
Interval string `json:"interval"`
|
||||
@@ -39,7 +39,7 @@ type lawStatus struct {
|
||||
} `json:"quiet_hours"`
|
||||
}
|
||||
|
||||
// getLawStatus ruft GET /status und dekodiert nur die Gesetz-Felder.
|
||||
// getLawStatus calls GET /status and decodes only the law fields.
|
||||
func getLawStatus(t *testing.T, d *daemonProc) lawStatus {
|
||||
t.Helper()
|
||||
resp, err := http.Get(d.baseURL + "/status")
|
||||
@@ -49,13 +49,13 @@ func getLawStatus(t *testing.T, d *daemonProc) lawStatus {
|
||||
defer resp.Body.Close()
|
||||
var st lawStatus
|
||||
if err := json.NewDecoder(resp.Body).Decode(&st); err != nil {
|
||||
t.Fatalf("GET /status dekodieren: %v", err)
|
||||
t.Fatalf("decoding GET /status: %v", err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// discardedOf liest das discarded-Feld einer /speak-Antwort-Map — fehlt der
|
||||
// Schlüssel (JSON-Decode in map[string]any liefert nil), ist er leer.
|
||||
// discardedOf reads the discarded field of a /speak response map — when the
|
||||
// key is missing (JSON decode into map[string]any yields nil) it is empty.
|
||||
func discardedOf(m map[string]any) string {
|
||||
if v, ok := m["discarded"].(string); ok {
|
||||
return v
|
||||
@@ -63,104 +63,104 @@ func discardedOf(m map[string]any) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestRateLimitGesetz (Abnahmekriterium): kurzes Rate-Limit (1/s) — von
|
||||
// fünf Ansagen spielt genau die erste sofort; low verfällt noch beim
|
||||
// Eintritt (202 nennt den Grund), normal parkt und spielt nach dem Fenster.
|
||||
func TestRateLimitGesetz(t *testing.T) {
|
||||
// TestRateLimitLaw (acceptance criterion): short rate limit (1/s) — of five
|
||||
// announcements exactly the first plays immediately; low expires right on
|
||||
// admission (202 names the reason), normal parks and plays after the window.
|
||||
func TestRateLimitLaw(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fake := newFakeProvider(t, 100*time.Millisecond)
|
||||
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "1s"})
|
||||
env := daemonEnv(root, d.port, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "1s"})
|
||||
|
||||
code, erste := speak(t, d, `{"text":"erste ansage"}`)
|
||||
if code != 202 || erste["id"] == "" {
|
||||
t.Fatalf("erste: Status=%d Body=%v, want 202 mit id", code, erste)
|
||||
code, first := speak(t, d, `{"text":"first announcement"}`)
|
||||
if code != 202 || first["id"] == "" {
|
||||
t.Fatalf("first: status=%d body=%v, want 202 with id", code, first)
|
||||
}
|
||||
fake.waitArrived(t, 1) // erste läuft — das Fenster (1 s) ist gebucht.
|
||||
fake.waitArrived(t, 1) // first is running — the window (1 s) is booked.
|
||||
|
||||
// CLI-Beobachtung: talk say --priority low meldet den Verwurf plain.
|
||||
res := runTalk(t, env, "say", "--priority", "low", "spam eins")
|
||||
// CLI observation: talk say --priority low reports the discard plainly.
|
||||
res := runTalk(t, env, "say", "--priority", "low", "spam one")
|
||||
if res.code != 0 {
|
||||
t.Fatalf("talk say --priority low: Exit %d, stderr: %s", res.code, res.stderr)
|
||||
t.Fatalf("talk say --priority low: exit %d, stderr: %s", res.code, res.stderr)
|
||||
}
|
||||
if res.stdout != "discarded: rate_limit\n" {
|
||||
t.Errorf("stdout = %q, want %q", res.stdout, "discarded: rate_limit\n")
|
||||
}
|
||||
|
||||
// Zwei weitere low-Ansagen verfallen ebenso beim Eintritt.
|
||||
for _, text := range []string{"spam zwei", "spam drei"} {
|
||||
// Two more low announcements expire on admission as well.
|
||||
for _, text := range []string{"spam two", "spam three"} {
|
||||
code, m := speak(t, d, `{"text":"`+text+`","priority":"low"}`)
|
||||
if code != 202 || m["id"] != "" || m["discarded"] != "rate_limit" {
|
||||
t.Fatalf("low %s: Status=%d Body=%v, want 202 mit leerer id und discarded=rate_limit", text, code, m)
|
||||
t.Fatalf("low %s: status=%d body=%v, want 202 with empty id and discarded=rate_limit", text, code, m)
|
||||
}
|
||||
}
|
||||
|
||||
// Normal wird aufgenommen …
|
||||
code, geparkte := speak(t, d, `{"text":"geparkte ansage"}`)
|
||||
if code != 202 || geparkte["id"] == "" || discardedOf(geparkte) != "" {
|
||||
t.Fatalf("normal: Status=%d Body=%v, want 202 mit id", code, geparkte)
|
||||
// Normal is admitted …
|
||||
code, parked := speak(t, d, `{"text":"parked announcement"}`)
|
||||
if code != 202 || parked["id"] == "" || discardedOf(parked) != "" {
|
||||
t.Fatalf("normal: status=%d body=%v, want 202 with id", code, parked)
|
||||
}
|
||||
|
||||
// … und sichtbar geparkt: Queue zeigt sie, der nächste Slot liegt in
|
||||
// der Zukunft.
|
||||
// … and visibly parked: the queue shows it, the next slot lies in the
|
||||
// future.
|
||||
st := getStatus(t, d)
|
||||
if st.Queue != 1 || len(st.QueueIDs) != 1 || st.QueueIDs[0] != geparkte["id"] {
|
||||
t.Fatalf("queue = %v (%d), will die geparkte Ansage", st.QueueIDs, st.Queue)
|
||||
if st.Queue != 1 || len(st.QueueIDs) != 1 || st.QueueIDs[0] != parked["id"] {
|
||||
t.Fatalf("queue = %v (%d), want the parked announcement", st.QueueIDs, st.Queue)
|
||||
}
|
||||
law := getLawStatus(t, d)
|
||||
if law.RateLimit.Interval != "1s" || !law.RateLimit.AllowHighBurst {
|
||||
t.Errorf("rate_limit = %+v, want Interval 1s mit Burst-Recht", law.RateLimit)
|
||||
t.Errorf("rate_limit = %+v, want interval 1s with burst rights", law.RateLimit)
|
||||
}
|
||||
if law.RateLimit.NextSlot == nil {
|
||||
t.Errorf("next_slot = null während geparkter Ansage, want Termin")
|
||||
t.Errorf("next_slot = null while an announcement is parked, want a time")
|
||||
}
|
||||
|
||||
// Genau eine ist bislang angekommen: die geparkte spielt NICHT vor
|
||||
// dem Fenster-Ablauf (Abstand zum Fenster-Rand reichlich).
|
||||
// Exactly one has arrived so far: the parked one does NOT play before
|
||||
// the window expires (ample distance to the window edge).
|
||||
fake.assertNoFurtherArrivals(t, 400*time.Millisecond)
|
||||
|
||||
// Nach dem Fenster spielt die geparkte — und zwar frühestens zum Slot.
|
||||
// After the window the parked one plays — at the slot at the earliest.
|
||||
hits := fake.waitCompleted(t, 2)
|
||||
if hits[1].Text != "geparkte ansage" {
|
||||
t.Errorf("zweiter Treffer = %q, will die geparkte Ansage", hits[1].Text)
|
||||
if hits[1].Text != "parked announcement" {
|
||||
t.Errorf("second hit = %q, want the parked announcement", hits[1].Text)
|
||||
}
|
||||
if gap := hits[1].Start.Sub(hits[0].Start); gap < 850*time.Millisecond {
|
||||
t.Errorf("zweiter Start nach %v, want >= Fenster (1 s, Toleranz 150 ms)", gap)
|
||||
t.Errorf("second start after %v, want >= window (1 s, tolerance 150 ms)", gap)
|
||||
}
|
||||
waitStatusIdle(t, d, 3*time.Second)
|
||||
}
|
||||
|
||||
// TestHighBurstProzess: mit Burst-Recht (Default) startet high trotz
|
||||
// vollem Fensters sofort nach der laufenden Ansage; ohne Recht parkt high
|
||||
// wie normal. (Das spätere Abspielen nach langem Fenster deckt der
|
||||
// Park-Test oben mit kurzem Fenster.)
|
||||
func TestHighBurstProzess(t *testing.T) {
|
||||
t.Run("burst an (Default)", func(t *testing.T) {
|
||||
// TestHighBurstProcess: with burst rights (default) high starts immediately
|
||||
// after the running announcement despite a full window; without rights high
|
||||
// parks like normal. (The later playback after a long window is covered by
|
||||
// the parking test above with a short window.)
|
||||
func TestHighBurstProcess(t *testing.T) {
|
||||
t.Run("burst on (default)", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fake := newFakeProvider(t, 50*time.Millisecond)
|
||||
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "8s"})
|
||||
|
||||
speak(t, d, `{"text":"erste"}`)
|
||||
fake.waitArrived(t, 1) // Fenster für 8 s gebucht.
|
||||
speak(t, d, `{"text":"first"}`)
|
||||
fake.waitArrived(t, 1) // window booked for 8 s.
|
||||
|
||||
code, m := speak(t, d, `{"text":"dringend","priority":"high"}`)
|
||||
code, m := speak(t, d, `{"text":"urgent","priority":"high"}`)
|
||||
if code != 202 || m["id"] == "" {
|
||||
t.Fatalf("high: Status=%d Body=%v, want 202 mit id", code, m)
|
||||
t.Fatalf("high: status=%d body=%v, want 202 with id", code, m)
|
||||
}
|
||||
|
||||
// high startet, sobald die erste fertig ist — bewusst innerhalb
|
||||
// des Fensters (Verstoß mit Recht).
|
||||
// high starts as soon as the first is done — deliberately inside
|
||||
// the window (violation with rights).
|
||||
hits := fake.waitCompleted(t, 2)
|
||||
if hits[1].Text != "dringend" {
|
||||
t.Errorf("zweiter Treffer = %q, will dringend", hits[1].Text)
|
||||
if hits[1].Text != "urgent" {
|
||||
t.Errorf("second hit = %q, want urgent", hits[1].Text)
|
||||
}
|
||||
if gap := hits[1].Start.Sub(hits[0].Start); gap >= 4*time.Second {
|
||||
t.Errorf("high startete nach %v — Burst greift nicht (Fenster 8 s)", gap)
|
||||
t.Errorf("high started after %v — burst does not take effect (window 8 s)", gap)
|
||||
}
|
||||
waitStatusIdle(t, d, 3*time.Second)
|
||||
})
|
||||
|
||||
t.Run("burst aus", func(t *testing.T) {
|
||||
t.Run("burst off", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fake := newFakeProvider(t, 0)
|
||||
d := startDaemon(t, root, fake.srv.URL, map[string]string{
|
||||
@@ -168,130 +168,131 @@ func TestHighBurstProzess(t *testing.T) {
|
||||
"TALK_ALLOW_HIGH_BURST": "false",
|
||||
})
|
||||
|
||||
speak(t, d, `{"text":"erste"}`)
|
||||
speak(t, d, `{"text":"first"}`)
|
||||
fake.waitArrived(t, 1)
|
||||
|
||||
code, m := speak(t, d, `{"text":"dringend","priority":"high"}`)
|
||||
code, m := speak(t, d, `{"text":"urgent","priority":"high"}`)
|
||||
if code != 202 || m["id"] == "" {
|
||||
t.Fatalf("high: Status=%d Body=%v, want 202 mit id (parkt)", code, m)
|
||||
t.Fatalf("high: status=%d body=%v, want 202 with id (parks)", code, m)
|
||||
}
|
||||
|
||||
// high bleibt geparkt: keine zweite Ankunft, Queue zeigt es.
|
||||
// high stays parked: no second arrival, the queue shows it.
|
||||
fake.assertNoFurtherArrivals(t, 700*time.Millisecond)
|
||||
st := getStatus(t, d)
|
||||
if st.Queue != 1 || len(st.QueueIDs) != 1 || st.QueueIDs[0] != m["id"] {
|
||||
t.Fatalf("queue = %v (%d), will geparktes high", st.QueueIDs, st.Queue)
|
||||
t.Fatalf("queue = %v (%d), want the parked high", st.QueueIDs, st.Queue)
|
||||
}
|
||||
if law := getLawStatus(t, d); law.RateLimit.AllowHighBurst {
|
||||
t.Error("allow_high_burst = true, want false")
|
||||
}
|
||||
|
||||
// Aufräumen über cancel — und der Daemon bleibt gesund (now
|
||||
// umgeht das weiter laufende 8-s-Fenster).
|
||||
// Clean up via cancel — and the daemon stays healthy (now bypasses
|
||||
// the still running 8 s window).
|
||||
code, cm := postPath(t, d, "/cancel")
|
||||
if code != 200 || cm["cancelled"] != float64(1) {
|
||||
t.Fatalf("cancel: %d %v, want 1 geparkte Ansage", code, cm)
|
||||
t.Fatalf("cancel: %d %v, want 1 parked announcement", code, cm)
|
||||
}
|
||||
speak(t, d, `{"text":"danach","now":true}`)
|
||||
speak(t, d, `{"text":"afterwards","now":true}`)
|
||||
hits := fake.waitCompleted(t, 2)
|
||||
if hits[1].Text != "danach" {
|
||||
t.Errorf("Treffer nach cancel = %q, want danach", hits[1].Text)
|
||||
if hits[1].Text != "afterwards" {
|
||||
t.Errorf("hit after cancel = %q, want afterwards", hits[1].Text)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestNowBypassesRateUndRuhe: now ist der dokumentierte Ausbruch — es
|
||||
// spielt trotz vollem Rate-Fensters UND aktiver Ruhezeit.
|
||||
func TestNowBypassesRateUndRuhe(t *testing.T) {
|
||||
// TestNowBypassesRateAndQuiet: now is the documented breakout — it plays
|
||||
// despite a full rate window AND active quiet hours.
|
||||
func TestNowBypassesRateAndQuiet(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fake := newFakeProvider(t, 0)
|
||||
d := startDaemon(t, root, fake.srv.URL, map[string]string{
|
||||
"TALK_RATE_LIMIT": "30s",
|
||||
"TALK_QUIET_HOURS": fensterUm(),
|
||||
"TALK_QUIET_HOURS": windowAround(),
|
||||
})
|
||||
|
||||
// Normal verfällt in der Ruhezeit (hier: still).
|
||||
code, m := speak(t, d, `{"text":"ruhig bitte"}`)
|
||||
// Normal expires during quiet hours (here: silently).
|
||||
code, m := speak(t, d, `{"text":"quiet please"}`)
|
||||
if code != 202 || m["id"] != "" || m["discarded"] != "quiet_hours" {
|
||||
t.Fatalf("normal in Ruhezeit: Status=%d Body=%v, want 202 mit leerer id und discarded=quiet_hours", code, m)
|
||||
t.Fatalf("normal during quiet hours: status=%d body=%v, want 202 with empty id and discarded=quiet_hours", code, m)
|
||||
}
|
||||
|
||||
// now bricht durch: Aufnahme mit ID, Synthese trotz beider Gesetze.
|
||||
code, m = speak(t, d, `{"text":"notfall","now":true}`)
|
||||
// now breaks through: admission with ID, synthesis despite both laws.
|
||||
code, m = speak(t, d, `{"text":"emergency","now":true}`)
|
||||
if code != 202 || m["id"] == "" || discardedOf(m) != "" {
|
||||
t.Fatalf("now: Status=%d Body=%v, want 202 mit id", code, m)
|
||||
t.Fatalf("now: status=%d body=%v, want 202 with id", code, m)
|
||||
}
|
||||
hits := fake.waitCompleted(t, 1)
|
||||
if hits[0].Text != "notfall" {
|
||||
t.Errorf("Treffer = %q, will notfall", hits[0].Text)
|
||||
if hits[0].Text != "emergency" {
|
||||
t.Errorf("hit = %q, want emergency", hits[0].Text)
|
||||
}
|
||||
fake.assertNoFurtherArrivals(t, 300*time.Millisecond)
|
||||
|
||||
if law := getLawStatus(t, d); !law.QuietHours.Active {
|
||||
t.Errorf("quiet_hours.active = false, want true (Fenster um jetzt): %+v", law.QuietHours)
|
||||
t.Errorf("quiet_hours.active = false, want true (window around now): %+v", law.QuietHours)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuhezeitDiscard: aktives Fenster verwirft normal UND high still
|
||||
// (Burst ist ein Rate-Recht, kein Ruhe-Recht) — ohne Provider-Kontakt,
|
||||
// mit Verwurf-Zeile im Daemon-Protokoll und Fenster-Ende in /status.
|
||||
func TestRuhezeitDiscard(t *testing.T) {
|
||||
// TestQuietHoursDiscard: an active window discards normal AND high silently
|
||||
// (burst is a rate right, not a quiet-hours right) — without provider
|
||||
// contact, with a discard line in the daemon log and the window end in
|
||||
// /status.
|
||||
func TestQuietHoursDiscard(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fake := newFakeProvider(t, 0)
|
||||
fenster := fensterUm()
|
||||
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_QUIET_HOURS": fenster})
|
||||
window := windowAround()
|
||||
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_QUIET_HOURS": window})
|
||||
|
||||
for _, body := range []string{
|
||||
`{"text":"abends eins"}`,
|
||||
`{"text":"abends dringend","priority":"high"}`,
|
||||
`{"text":"evening one"}`,
|
||||
`{"text":"evening urgent","priority":"high"}`,
|
||||
} {
|
||||
code, m := speak(t, d, body)
|
||||
if code != 202 || m["id"] != "" || m["discarded"] != "quiet_hours" || m["toast"] != nil {
|
||||
t.Fatalf("%s: Status=%d Body=%v, want 202 mit leerer id und discarded=quiet_hours", body, code, m)
|
||||
t.Fatalf("%s: status=%d body=%v, want 202 with empty id and discarded=quiet_hours", body, code, m)
|
||||
}
|
||||
}
|
||||
fake.assertNoHits(t, 400*time.Millisecond)
|
||||
|
||||
log := d.stderr.String()
|
||||
if !strings.Contains(log, "ruhezeit") || !strings.Contains(log, "verworfen") {
|
||||
t.Errorf("Daemon-Log will Verwurf-Zeile mit ruhezeit, hat:\n%s", log)
|
||||
if !strings.Contains(log, "quiet hours") || !strings.Contains(log, "discarded") {
|
||||
t.Errorf("daemon log should have a discard line mentioning quiet hours, has:\n%s", log)
|
||||
}
|
||||
|
||||
law := getLawStatus(t, d)
|
||||
if law.QuietHours.Window != fenster || !law.QuietHours.Active || law.QuietHours.Mode != "discard" {
|
||||
t.Errorf("quiet_hours = %+v, want Fenster %q aktiv im Modus discard", law.QuietHours, fenster)
|
||||
if law.QuietHours.Window != window || !law.QuietHours.Active || law.QuietHours.Mode != "discard" {
|
||||
t.Errorf("quiet_hours = %+v, want window %q active in mode discard", law.QuietHours, window)
|
||||
}
|
||||
if law.QuietHours.Until == nil {
|
||||
t.Error("quiet_hours.until = null im aktiven Fenster, want Termin")
|
||||
t.Error("quiet_hours.until = null in an active window, want a time")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuhezeitToast: quiet_mode=toast ersetzt Audio durch eine
|
||||
// Benachrichtigung — derzeit eine Daemon-Protokoll-Zeile über den
|
||||
// Notification-Pfad (dokumentierte Einschränkung bis zur OS-Anbindung).
|
||||
func TestRuhezeitToast(t *testing.T) {
|
||||
// TestQuietHoursToast: quiet_mode=toast replaces audio with a notification —
|
||||
// currently a daemon log line via the notification path (documented
|
||||
// limitation until the OS integration).
|
||||
func TestQuietHoursToast(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fake := newFakeProvider(t, 0)
|
||||
d := startDaemon(t, root, fake.srv.URL, map[string]string{
|
||||
"TALK_QUIET_HOURS": fensterUm(),
|
||||
"TALK_QUIET_HOURS": windowAround(),
|
||||
"TALK_QUIET_MODE": "toast",
|
||||
})
|
||||
|
||||
code, m := speak(t, d, `{"text":"abend kurz"}`)
|
||||
code, m := speak(t, d, `{"text":"evening short"}`)
|
||||
if code != 202 || m["id"] != "" || m["discarded"] != "quiet_hours" || m["toast"] != true {
|
||||
t.Fatalf("toast: Status=%d Body=%v, want 202 mit leerer id, discarded=quiet_hours und toast=true", code, m)
|
||||
t.Fatalf("toast: status=%d body=%v, want 202 with empty id, discarded=quiet_hours and toast=true", code, m)
|
||||
}
|
||||
fake.assertNoHits(t, 300*time.Millisecond)
|
||||
|
||||
if log := d.stderr.String(); !strings.Contains(log, "toast") {
|
||||
t.Errorf("Daemon-Log will Toast-Zeile (Notification-Pfad), hat:\n%s", log)
|
||||
t.Errorf("daemon log should have a toast line (notification path), has:\n%s", log)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusGesetzeFelder (Abnahmekriterium): /status und `talk status`
|
||||
// zeigen Rate-Limit- und Ruhezeit-State — Defaults im Leerlauf, gebuchter
|
||||
// Slot nach einer Ansage.
|
||||
func TestStatusGesetzeFelder(t *testing.T) {
|
||||
// TestStatusLawFields (acceptance criterion): /status and `talk status` show
|
||||
// rate-limit and quiet-hours state — defaults when idle, a booked slot after
|
||||
// an announcement.
|
||||
func TestStatusLawFields(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fake := newFakeProvider(t, 0)
|
||||
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "30s"})
|
||||
@@ -299,35 +300,35 @@ func TestStatusGesetzeFelder(t *testing.T) {
|
||||
|
||||
law := getLawStatus(t, d)
|
||||
if law.RateLimit.Interval != "30s" || !law.RateLimit.AllowHighBurst || law.RateLimit.NextSlot != nil {
|
||||
t.Errorf("rate_limit = %+v, want Default 30s, Burst an, Slot frei", law.RateLimit)
|
||||
t.Errorf("rate_limit = %+v, want default 30s, burst on, slot free", law.RateLimit)
|
||||
}
|
||||
if law.QuietHours.Window != "" || law.QuietHours.Active || law.QuietHours.Mode != "discard" || law.QuietHours.Until != nil {
|
||||
t.Errorf("quiet_hours = %+v, want deaktiviert im Modus discard", law.QuietHours)
|
||||
t.Errorf("quiet_hours = %+v, want disabled in mode discard", law.QuietHours)
|
||||
}
|
||||
|
||||
res := runTalk(t, env, "status")
|
||||
if res.code != 0 {
|
||||
t.Fatalf("talk status: Exit %d — stderr: %s", res.code, res.stderr)
|
||||
t.Fatalf("talk status: exit %d — stderr: %s", res.code, res.stderr)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"rate-limit: 30s",
|
||||
"high-burst erlaubt",
|
||||
"ruhezeit: aus",
|
||||
"modus discard",
|
||||
"rate-limit: 30s",
|
||||
"high burst allowed",
|
||||
"quiet-hours: off",
|
||||
"mode discard",
|
||||
} {
|
||||
if !strings.Contains(res.stdout, want) {
|
||||
t.Errorf("talk status stdout = %q, will %q", res.stdout, want)
|
||||
t.Errorf("talk status stdout = %q, want %q", res.stdout, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Nach einer Ansage steht der nächste Slot: HTTP-Feld + CLI-Zeile.
|
||||
speak(t, d, `{"text":"einmal reicht"}`)
|
||||
// After an announcement the next slot is set: HTTP field + CLI line.
|
||||
speak(t, d, `{"text":"once is enough"}`)
|
||||
fake.waitArrived(t, 1)
|
||||
if law := getLawStatus(t, d); law.RateLimit.NextSlot == nil {
|
||||
t.Error("next_slot = null nach abgespielter Ansage, want Termin")
|
||||
t.Error("next_slot = null after a played announcement, want a time")
|
||||
}
|
||||
res = runTalk(t, env, "status")
|
||||
if res.code != 0 || !strings.Contains(res.stdout, "nächster slot") {
|
||||
t.Errorf("talk status nach Ansage = %q (Exit %d), will Slot-Zeile", res.stdout, res.code)
|
||||
if res.code != 0 || !strings.Contains(res.stdout, "next slot") {
|
||||
t.Errorf("talk status after announcement = %q (exit %d), want the slot line", res.stdout, res.code)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user