// 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 . 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): } }) } }