Files
cthulhu/internal/app/speech_history_test.go

231 lines
8.1 KiB
Go

// 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")
}
}