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,414 @@
|
||||
// Cthulhu screen reader.
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify it
|
||||
// under the terms of the GNU General Public License as published by the Free
|
||||
// Software Foundation, either version 3 of the License, or (at your option)
|
||||
// any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
// more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/braille"
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
)
|
||||
|
||||
func TestProcessEventsSuppressesDuplicateFocusObjects(t *testing.T) {
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 2)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 3)}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
completed := make(chan struct{})
|
||||
go func() {
|
||||
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
|
||||
close(completed)
|
||||
}()
|
||||
|
||||
adapter.events <- atspi.NewEvent("focus:", atspi.NewSnapshot("/accessible/save", "Save", "push button", "", ""))
|
||||
saveFocused := atspi.NewEvent("object:state-changed:focused", atspi.NewSnapshot("/accessible/save", "Save", "push button", "", ""))
|
||||
saveFocused.Detail1 = 1
|
||||
adapter.events <- saveFocused
|
||||
cancelFocused := atspi.NewEvent("object:state-changed:focused", atspi.NewSnapshot("/accessible/cancel", "Cancel", "push button", "", ""))
|
||||
cancelFocused.Detail1 = 1
|
||||
adapter.events <- cancelFocused
|
||||
|
||||
for _, expected := range []string{"Save, push button", "Cancel, push button"} {
|
||||
select {
|
||||
case text := <-speech.spoken:
|
||||
if text != expected {
|
||||
t.Fatalf("spoken text = %q, want %q", text, expected)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for speech %q", expected)
|
||||
}
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case <-completed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for event loop to stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessEventsSuppressesUnfocusedNameChangesDuringFileNavigation(t *testing.T) {
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 8)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
completed := make(chan struct{})
|
||||
go func() {
|
||||
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, nil, 0, nil,
|
||||
fixedInputActivity{input.Activity{Intent: input.LineIntent}}, nil, nil)
|
||||
close(completed)
|
||||
}()
|
||||
defer func() { cancel(); <-completed }()
|
||||
|
||||
frame := atspi.NewSnapshot("/thunar/window", "Thunar", "frame", "", "")
|
||||
frame.BusName, frame.ApplicationName = ":1.45", "thunar"
|
||||
activated := atspi.NewEvent(atspi.WindowActivatedEvent, frame)
|
||||
activated.Window = frame.ID()
|
||||
adapter.events <- activated
|
||||
<-speech.spoken
|
||||
|
||||
file := atspi.NewSnapshot("/thunar/file", "Audio", "table cell", "", "")
|
||||
file.BusName, file.ApplicationName = frame.BusName, "thunar"
|
||||
focused := atspi.NewEvent(atspi.FocusedStateEvent, file)
|
||||
focused.Detail1, focused.Window = 1, frame.ID()
|
||||
adapter.events <- focused
|
||||
if got := <-speech.spoken; got != "Audio" {
|
||||
t.Fatalf("file speech = %q", got)
|
||||
}
|
||||
|
||||
for _, role := range []string{"directory pane", "status bar"} {
|
||||
changed := atspi.NewSnapshot("/thunar/"+role, "Updated "+role, role, "", "")
|
||||
changed.BusName, changed.ApplicationName = frame.BusName, "thunar"
|
||||
event := atspi.NewEvent(atspi.AccessibleNameEvent, changed)
|
||||
event.Window = frame.ID()
|
||||
adapter.events <- event
|
||||
}
|
||||
select {
|
||||
case got := <-speech.spoken:
|
||||
t.Fatalf("unfocused name change interrupted file: %q", got)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
|
||||
status := atspi.NewSnapshot("/thunar/status bar", "Selected file", "status bar", "", "")
|
||||
status.BusName, status.ApplicationName = frame.BusName, "thunar"
|
||||
statusFocus := atspi.NewEvent(atspi.FocusedStateEvent, status)
|
||||
statusFocus.Detail1, statusFocus.Window = 1, frame.ID()
|
||||
adapter.events <- statusFocus
|
||||
if got := <-speech.spoken; got != "Selected file, status bar" {
|
||||
t.Fatalf("focused status bar speech = %q", got)
|
||||
}
|
||||
status.Name = "Updated status"
|
||||
statusChanged := atspi.NewEvent(atspi.AccessibleNameEvent, status)
|
||||
statusChanged.Window = frame.ID()
|
||||
adapter.events <- statusChanged
|
||||
if got := <-speech.spoken; got != "Updated status, status bar" {
|
||||
t.Fatalf("focused status bar update = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessEventsFiltersUnfocusedStateAndValueChanges(t *testing.T) {
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 4)}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
completed := make(chan struct{})
|
||||
go func() {
|
||||
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
|
||||
close(completed)
|
||||
}()
|
||||
|
||||
focused := atspi.Snapshot{Path: "/volume", Name: "Volume", Role: "slider", ValueKnown: true,
|
||||
CurrentValue: 20, MinimumValue: 0, MaximumValue: 100}
|
||||
adapter.events <- atspi.NewEvent(atspi.FocusEvent, focused)
|
||||
if got := <-speech.spoken; got != "Volume, slider, 20" {
|
||||
t.Fatalf("focus speech = %q", got)
|
||||
}
|
||||
|
||||
unfocused := atspi.Snapshot{Path: "/other", Name: "Other", Role: "slider", ValueKnown: true,
|
||||
CurrentValue: 90, MinimumValue: 0, MaximumValue: 100}
|
||||
adapter.events <- atspi.NewEvent(atspi.ValueChangedEvent, unfocused)
|
||||
adapter.events <- atspi.NewEvent("object:state-changed:sensitive", atspi.Snapshot{
|
||||
Path: "/other", Name: "Other", Role: "push button", SensitiveKnown: true,
|
||||
})
|
||||
focused.CurrentValue = 30
|
||||
adapter.events <- atspi.NewEvent(atspi.ValueChangedEvent, focused)
|
||||
|
||||
select {
|
||||
case got := <-speech.spoken:
|
||||
if got != "30" {
|
||||
t.Fatalf("value-change speech = %q", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for focused value change")
|
||||
}
|
||||
select {
|
||||
case got := <-speech.spoken:
|
||||
t.Fatalf("unexpected unfocused event speech: %q", got)
|
||||
default:
|
||||
}
|
||||
|
||||
cancel()
|
||||
<-completed
|
||||
}
|
||||
|
||||
func TestProcessEventsPresentsFocusedManagedDescendant(t *testing.T) {
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
completed := make(chan struct{})
|
||||
go func() {
|
||||
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
|
||||
close(completed)
|
||||
}()
|
||||
|
||||
list := atspi.NewSnapshot("/run-dialog/results", "Run dialog suggestions", "list", "", "")
|
||||
adapter.events <- atspi.NewEvent(atspi.FocusEvent, list)
|
||||
if got := <-speech.spoken; got != "Run dialog suggestions, list" {
|
||||
t.Fatalf("list focus speech = %q", got)
|
||||
}
|
||||
|
||||
result := atspi.NewSnapshot("/run-dialog/results/1", "LibreOffice Writer", "table cell", "", "")
|
||||
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, result)
|
||||
if got := <-speech.spoken; got != "LibreOffice Writer" {
|
||||
t.Fatalf("selected result speech = %q", got)
|
||||
}
|
||||
|
||||
// GTK can emit both selection and active-descendant events for the same row.
|
||||
adapter.events <- atspi.NewManagedDescendantEvent(atspi.ActiveDescendantEvent, list.Path, result)
|
||||
select {
|
||||
case got := <-speech.spoken:
|
||||
t.Fatalf("duplicate managed-descendant speech = %q", got)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
// A feed refresh can briefly expose defunct descendants and repeatedly
|
||||
// select the same row while updating its relative timestamp. None of
|
||||
// these events represents navigation or a new post.
|
||||
invalid := atspi.NewSnapshot("/run-dialog/results/defunct", "", "invalid", "", "")
|
||||
adapter.events <- atspi.NewManagedDescendantEvent(atspi.ActiveDescendantEvent, list.Path, invalid)
|
||||
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, result)
|
||||
updated := result
|
||||
updated.Name = "LibreOffice Writer, 2 minutes ago"
|
||||
adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, updated)
|
||||
|
||||
// GTK can move explicit focus to the current child before reporting a
|
||||
// sibling as the container's new selected descendant. The focused child
|
||||
// remains owned by the same managed container, so the sibling change is
|
||||
// still relevant.
|
||||
focusEvent := atspi.NewEvent(atspi.FocusedStateEvent, result)
|
||||
focusEvent.Detail1 = 1
|
||||
adapter.events <- focusEvent
|
||||
sibling := atspi.NewSnapshot("/run-dialog/results/2", "Calculator", "table cell", "", "")
|
||||
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, sibling)
|
||||
if got := <-speech.spoken; got != "Calculator" {
|
||||
t.Fatalf("sibling result speech = %q", got)
|
||||
}
|
||||
|
||||
other := atspi.NewSnapshot("/other/results/1", "Unrelated", "table cell", "", "")
|
||||
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, "/other/results", other)
|
||||
select {
|
||||
case got := <-speech.spoken:
|
||||
t.Fatalf("unfocused selection speech = %q", got)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
|
||||
cancel()
|
||||
<-completed
|
||||
}
|
||||
|
||||
func TestGeckoCurrentButtonNameArrivesAfterFocus(t *testing.T) {
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 3)}
|
||||
frames := make(chan braille.Frame, 3)
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 3)}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
processEvents(ctx, slog.New(slog.DiscardHandler), outputQueue{speech: speech, braille: frameRecorder{frames}}, adapter, 0)
|
||||
close(done)
|
||||
}()
|
||||
defer func() { cancel(); <-done }()
|
||||
|
||||
button := atspi.NewSnapshot("/mail/toolbar/button", "", "button", "", "")
|
||||
button.ToolkitName = "Gecko"
|
||||
adapter.events <- atspi.NewEvent(atspi.FocusEvent, button)
|
||||
if got := <-speech.spoken; got != "button" {
|
||||
t.Fatalf("initial unnamed button = %q", got)
|
||||
}
|
||||
<-frames
|
||||
button.Name = "Open calendar"
|
||||
adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, button)
|
||||
select {
|
||||
case got := <-speech.spoken:
|
||||
if got != "Open calendar, button" {
|
||||
t.Fatalf("updated button name = %q", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("updated Gecko button name was not spoken")
|
||||
}
|
||||
select {
|
||||
case frame := <-frames:
|
||||
if frame.Text != "Open calendar button" {
|
||||
t.Fatalf("updated button braille = %q", frame.Text)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("updated Gecko button name did not refresh braille")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReusedTableRowSelectionReadsChangedMessage(t *testing.T) {
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 4)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 4)}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
|
||||
close(done)
|
||||
}()
|
||||
defer func() { cancel(); <-done }()
|
||||
|
||||
row := atspi.NewSnapshot("/mail/list/row", "First message", "table row", "", "")
|
||||
row.ToolkitName = "Gecko"
|
||||
adapter.events <- atspi.NewEvent(atspi.FocusEvent, row)
|
||||
if got := <-speech.spoken; got != "First message, table row" {
|
||||
t.Fatalf("first message = %q", got)
|
||||
}
|
||||
selected := atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, "/mail/list", row)
|
||||
adapter.events <- selected
|
||||
|
||||
replacement := row
|
||||
replacement.Name = "Next message"
|
||||
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, "/mail/list", replacement)
|
||||
select {
|
||||
case got := <-speech.spoken:
|
||||
if got != "Next message, table row" {
|
||||
t.Fatalf("replacement message = %q", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("replacement table row was not spoken")
|
||||
}
|
||||
|
||||
third := replacement
|
||||
third.Name = "Third message"
|
||||
adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, third)
|
||||
select {
|
||||
case got := <-speech.spoken:
|
||||
if got != "Third message, table row" {
|
||||
t.Fatalf("renamed message = %q", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("renamed table row was not spoken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileManagerStatusFallbackRequiresOwnedUnnamedNavigationFocus(t *testing.T) {
|
||||
window := atspi.NewObjectID(":1.45", "/thunar/window")
|
||||
owner := windowOwner{applicationBus: window.BusName, window: window}
|
||||
status := atspi.NewSnapshot("/thunar/status", `"Beta" | 0 bytes`, "status bar", "", "")
|
||||
status.BusName = window.BusName
|
||||
status.ApplicationName = "thunar"
|
||||
event := atspi.NewEvent(atspi.AccessibleNameEvent, status)
|
||||
event.Window = window
|
||||
panel := atspi.NewSnapshot("/thunar/file-view", "", "panel", "", "")
|
||||
if !shouldPresentFileManagerStatus(event, panel, owner, input.LineIntent) {
|
||||
t.Fatal("selected-file status was lost while focus remained on the file-view panel")
|
||||
}
|
||||
event.Source.ApplicationName = "pcmanfm"
|
||||
if !shouldPresentFileManagerStatus(event, panel, owner, input.LineIntent) {
|
||||
t.Fatal("PCManFM icon selection status was lost while focus remained on its panel")
|
||||
}
|
||||
event.Source.ApplicationName = "thunar"
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
modify func(*atspi.Event, *atspi.Snapshot, *windowOwner)
|
||||
intent input.Intent
|
||||
}{
|
||||
{"no navigation key", nil, input.NoIntent},
|
||||
{"named file focus", func(_ *atspi.Event, focus *atspi.Snapshot, _ *windowOwner) {
|
||||
focus.Role, focus.Name = "table cell", "Beta"
|
||||
}, input.LineIntent},
|
||||
{"other window", func(event *atspi.Event, _ *atspi.Snapshot, _ *windowOwner) {
|
||||
event.Window = atspi.NewObjectID(":1.45", "/other/window")
|
||||
}, input.LineIntent},
|
||||
{"other application", func(event *atspi.Event, _ *atspi.Snapshot, _ *windowOwner) { event.Source.ApplicationName = "editor" }, input.LineIntent},
|
||||
{"empty status", func(event *atspi.Event, _ *atspi.Snapshot, _ *windowOwner) { event.Source.Name = "" }, input.LineIntent},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate, focus, active := event, panel, owner
|
||||
if test.modify != nil {
|
||||
test.modify(&candidate, &focus, &active)
|
||||
}
|
||||
if shouldPresentFileManagerStatus(candidate, focus, active, test.intent) {
|
||||
t.Fatal("unrelated status change took over file navigation")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessEventsSpeaksFileManagerSelectionStatusAfterPanelFocus(t *testing.T) {
|
||||
for _, applicationName := range []string{"thunar", "pcmanfm"} {
|
||||
t.Run(applicationName, func(t *testing.T) {
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 8)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
completed := make(chan struct{})
|
||||
go func() {
|
||||
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, nil, 0, nil,
|
||||
fixedInputActivity{input.Activity{Intent: input.LineIntent}}, nil, nil)
|
||||
close(completed)
|
||||
}()
|
||||
defer func() { cancel(); <-completed }()
|
||||
|
||||
frame := atspi.NewSnapshot("/thunar/window", "Thunar", "frame", "", "")
|
||||
frame.BusName, frame.ApplicationName = ":1.45", applicationName
|
||||
activated := atspi.NewEvent(atspi.WindowActivatedEvent, frame)
|
||||
activated.Window = frame.ID()
|
||||
adapter.events <- activated
|
||||
if got := <-speech.spoken; got != "Thunar, frame" {
|
||||
t.Fatalf("window speech = %q", got)
|
||||
}
|
||||
panel := atspi.NewSnapshot("/thunar/file-view", "", "panel", "", "")
|
||||
panel.BusName, panel.ApplicationName = frame.BusName, applicationName
|
||||
focused := atspi.NewEvent(atspi.FocusedStateEvent, panel)
|
||||
focused.Detail1, focused.Window = 1, frame.ID()
|
||||
adapter.events <- focused
|
||||
if got := <-speech.spoken; got != "panel" {
|
||||
t.Fatalf("panel speech = %q", got)
|
||||
}
|
||||
status := atspi.NewSnapshot("/thunar/status", `"Beta" | 0 bytes`, "status bar", "", "")
|
||||
status.BusName, status.ApplicationName = frame.BusName, applicationName
|
||||
changed := atspi.NewEvent(atspi.AccessibleNameEvent, status)
|
||||
changed.Window = frame.ID()
|
||||
adapter.events <- changed
|
||||
if got := <-speech.spoken; got != status.Name {
|
||||
t.Fatalf("selected file speech = %q, want %q", got, status.Name)
|
||||
}
|
||||
adapter.events <- changed
|
||||
select {
|
||||
case got := <-speech.spoken:
|
||||
t.Fatalf("duplicate status speech = %q", got)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user