Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,230 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user