// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "fmt" "log/slog" "path/filepath" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/preferences" ) func TestSpeechHistoryRetentionPauseAndRemoval(t *testing.T) { h := newSpeechHistory(config.DefaultSpeechHistoryLimit) h.add(" first ", "system") h.add("first", "system") h.add("secret", "password text") for i := 0; i < 50; i++ { h.add(fmt.Sprintf("item %d", i), "label") } items := h.snapshot() if len(items) != 50 || items[0] != "item 49" || items[49] != "item 0" { t.Fatalf("unexpected retained history: %v", items) } h.setPaused(true) h.add("window feedback", "system") if len(h.snapshot()) != 50 { t.Fatal("paused speech changed history") } h.setPaused(false) if !h.remove("item 20") || h.remove("item 20") { t.Fatal("removal result is wrong") } h.add("item 20", "label") if got := h.snapshot()[0]; got != "item 20" { t.Fatalf("re-added item = %q", got) } } func TestSpeechHistoryLimitTrimsOldestAndCanGrow(t *testing.T) { h := newSpeechHistory(1000) for i := range 1001 { h.add(fmt.Sprintf("item %d", i), "label") } if items := h.snapshot(); len(items) != 1000 || items[0] != "item 1000" || items[999] != "item 1" { t.Fatalf("maximum history retention = %d items", len(items)) } h.setLimit(10) items := h.snapshot() if len(items) != 10 || items[0] != "item 1000" || items[9] != "item 991" { t.Fatalf("trimmed history = %v", items) } h.setLimit(1000) h.add("new item", "label") if items = h.snapshot(); len(items) != 11 || items[0] != "new item" { t.Fatalf("grown history = %v", items) } } func TestPreferencesApplyHistoryLimitLiveAndPersisted(t *testing.T) { settings := config.DefaultSettings() settings.Path = filepath.Join(t.TempDir(), "settings.toml") history := newSpeechHistory(settings.SpeechHistoryLimit) for i := range 20 { history.add(fmt.Sprintf("item %d", i), "label") } worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil) worker.history = history refreshes := 0 worker.refreshHistory = func() { refreshes++ } values := preferences.FromSettings(settings) values.SpeechHistoryLimit = 10 response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true}) if !response.Accepted || len(history.snapshot()) != 10 || refreshes != 1 { t.Fatalf("limit was not applied immediately: response=%+v, count=%d, refreshes=%d", response, len(history.snapshot()), refreshes) } loaded, err := config.Load(settings.Path) if err != nil || loaded.SpeechHistoryLimit != 10 { t.Fatalf("saved history limit = %d, error %v", loaded.SpeechHistoryLimit, err) } for _, invalid := range []int{0, 1001} { values.SpeechHistoryLimit = invalid response = worker.apply(context.Background(), preferences.Request{Values: values}) if response.Accepted || len(history.snapshot()) != 10 || refreshes != 1 { t.Fatalf("invalid limit %d changed history: response=%+v", invalid, response) } } } func TestSpeechHistoryMonitorIncludesDuplicatesWhileCapturePaused(t *testing.T) { h := newSpeechHistory(config.DefaultSpeechHistoryLimit) stream := make(chan string, 3) h.setMonitor(stream) h.setPaused(true) h.add(" repeated ", "system") h.add("repeated", "system") h.add("private", "password text") for i := 0; i < 2; i++ { if got := <-stream; got != "repeated" { t.Fatalf("monitor line = %q", got) } } if len(stream) != 0 || len(h.snapshot()) != 0 { t.Fatal("monitor leaked a password or captured paused speech") } } func TestSpeechDeliveryRecordsHistory(t *testing.T) { speaker := &fakeSpeaker{spoken: make(chan string, 4)} worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) worker.history = newSpeechHistory(config.DefaultSpeechHistoryLimit) for _, request := range []speechRequest{ {Text: "Delivered text", Role: "label"}, {Text: "private text", Role: "password text"}, {Text: "History row spoken after the window closes", Role: "table cell", ExcludeFromHistory: true}, {Text: "Copied to clipboard.", Role: "system", Cause: "speech-history"}, } { if err := worker.deliver(context.Background(), request); err != nil { t.Fatal(err) } } items := worker.history.snapshot() if len(items) != 1 || items[0] != "Delivered text" { t.Fatalf("delivery history = %v", items) } } func TestSpeechHistoryWindowEventsDoNotEnterHistory(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 3), requests: make(chan speechRequest, 3)} adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} ctx, cancel := context.WithCancel(context.Background()) defer cancel() go processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) historyWindow := atspi.NewObjectID(":1.73", "/history") history := atspi.NewSnapshot(historyWindow.Path, speechHistoryWindowTitle, "frame", "", "") history.BusName = historyWindow.BusName activated := atspi.NewEvent(atspi.WindowActivatedEvent, history) activated.Window = historyWindow activated.WindowTitle = speechHistoryWindowTitle adapter.events <- activated row := atspi.NewSnapshot("/history/row", "Previously spoken text", "table cell", "", "") row.BusName = historyWindow.BusName row.Focused = true focused := atspi.NewEvent(atspi.FocusedStateEvent, row) focused.Window = historyWindow focused.Detail1 = 1 adapter.events <- focused otherWindow := atspi.NewObjectID(":1.80", "/other") other := atspi.NewSnapshot(otherWindow.Path, "Other window", "frame", "", "") other.BusName = otherWindow.BusName otherActivated := atspi.NewEvent(atspi.WindowActivatedEvent, other) otherActivated.Window = otherWindow otherActivated.WindowTitle = "Other window" adapter.events <- otherActivated for index, excluded := range []bool{true, true, false} { select { case request := <-speech.requests: if request.ExcludeFromHistory != excluded { t.Fatalf("request %d (%q) excludes history = %t, want %t", index, request.Text, request.ExcludeFromHistory, excluded) } if index == 1 { speaker := &spellingTestSpeaker{} worker := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) worker.textTransform = func(text string) string { return pronunciationSpeechText(text, []config.PronunciationEntry{{Word: "Previously", Replacement: "Changed"}}) } if err := worker.deliver(ctx, request); err != nil { t.Fatal(err) } if got := strings.Join(speaker.parts, " "); !strings.Contains(got, "Previously spoken text") { t.Fatalf("history row changed during delivery: %q", got) } } case <-time.After(time.Second): t.Fatalf("timed out waiting for request %d", index) } } } func TestRunRoutesSpeechHistoryCommands(t *testing.T) { settings := config.DefaultSettings() settings.EnableSpeech = false settings.EnableSound = false commands := &fakeCommands{commands: make(chan input.CommandEvent, 3)} called := make(chan input.Command, 2) done := make(chan error, 1) go func() { done <- Run(context.Background(), Options{ Logger: slog.New(slog.DiscardHandler), Settings: &settings, newCommands: func() commandListener { return commands }, newAdapter: func() Adapter { return &fakeAdapter{events: make(chan atspi.Event)} }, newInput: func() InputListener { return &fakeInputListener{events: make(chan input.Event)} }, onSpeechHistory: func() { called <- input.ShowSpeechHistory }, onSpeechMonitor: func() { called <- input.ToggleSpeechMonitor }, }) }() for _, command := range []input.Command{input.ShowSpeechHistory, input.ToggleSpeechMonitor} { commands.commands <- input.CommandEvent{Command: command} select { case got := <-called: if got != command { t.Fatalf("routed %s, want %s", got, command) } case <-time.After(time.Second): t.Fatalf("command %s was not routed", command) } } commands.commands <- input.CommandEvent{Command: input.Quit} select { case err := <-done: if err != nil { t.Fatal(err) } case <-time.After(time.Second): t.Fatal("quit was not handled") } }