Files
cthulhu/internal/app/control_events_test.go

328 lines
13 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
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"
"git.stormux.org/storm/cthulhu/internal/presentation"
"git.stormux.org/storm/cthulhu/internal/review"
)
func TestRepeatedControlFocusAfterStateChange(t *testing.T) {
for _, role := range []string{"radio button", "check box", "toggle button", "check menu item", "menu item"} {
for _, mode := range []string{"speech_only", "sound_and_speech", "sound_only"} {
t.Run(role+"/"+mode, func(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 16), requests: make(chan speechRequest, 16)}
frames := make(chan braille.Frame, 16)
state := &eventLoopState{}
state.presentationOptions = presentation.DefaultOptions()
state.presentationOptions.EnableSound = true
state.presentationOptions.PlaySoundForState = true
state.presentationOptions.RoleSoundPresentation = mode
logger := slog.New(slog.DiscardHandler)
handler := accessibilityEventHandler{state: state, logger: logger, brailleOutput: frameRecorder{frames: frames}}
presenter := eventPresenter{state: state, logger: logger, speech: queue}
present := func(event atspi.Event) bool {
if handler.acceptFocusedEvent(event, input.Activity{}) {
return false
}
presenter.speak(event)
return true
}
control := atspi.Snapshot{BusName: ":1.7", Path: "/option", Role: role, Name: "Some", RadioGroupName: "Punctuation Level"}
control.Checkable = role == "menu item"
focus := atspi.NewEvent(atspi.FocusedStateEvent, control)
focus.Detail1 = 1
present(focus)
<-queue.requests
<-frames
control.Checked = true
checked := atspi.NewEvent("object:state-changed:checked", control)
checked.Detail1 = 1
present(checked)
changed := <-queue.requests
if mode == "sound_only" {
if changed.Text != "" || len(changed.Sound) != 1 {
t.Fatalf("state sound = %+v", changed)
}
} else {
want := map[string]string{"radio button": "selected", "check box": "checked", "toggle button": "pressed", "check menu item": "checked", "menu item": "checked"}[role]
if changed.Text != want {
t.Fatalf("state speech = %q, want %q", changed.Text, want)
}
}
checkedFrame := <-frames
focus.Source = control
if present(focus) || len(queue.requests) != 0 {
t.Fatal("repeated focus after state change restarted the control announcement")
}
if frame := <-frames; frame.Text != checkedFrame.Text || !state.current.Checked || !state.brailleSnapshot.Checked {
t.Fatalf("repeated focus lost the checked state: %+v", frame)
}
control.Checked = false
present(atspi.NewEvent("object:state-changed:checked", control))
changed = <-queue.requests
if mode == "sound_only" {
if changed.Text != "" || len(changed.Sound) != 1 {
t.Fatalf("unchecked state sound = %+v", changed)
}
} else {
want := map[string]string{"radio button": "not selected", "check box": "not checked", "toggle button": "not pressed", "check menu item": "not checked", "menu item": "not checked"}[role]
if changed.Text != want {
t.Fatalf("unchecked state speech = %q, want %q", changed.Text, want)
}
}
<-frames
focus.Source = control
// New semantics at the same identity still need an announcement.
focus.Source.Name = "Most"
if !present(focus) || len(queue.requests) != 1 {
t.Fatal("changed control name was suppressed")
}
<-queue.requests
other := atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.7", Path: "/apply", Role: "button", Name: "Apply"})
present(other)
<-queue.requests
if !present(focus) || len(queue.requests) != 1 {
t.Fatal("returning to the control was suppressed")
}
})
}
}
}
func TestRepeatedTerminalFocusRetainsPendingPresentation(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 10*time.Millisecond, nil, nil, nil, nil)
}()
defer func() { cancel(); <-done }()
terminal := atspi.Snapshot{BusName: ":1.7", Path: "/terminal", ApplicationName: "Fixture", Role: "terminal", Text: "Ready"}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, terminal)
focused := atspi.NewEvent(atspi.FocusedStateEvent, terminal)
focused.Detail1 = 1
adapter.events <- focused
select {
case got := <-queue.spoken:
if got != "terminal, Ready" {
t.Fatalf("terminal focus speech = %q", got)
}
case <-time.After(time.Second):
t.Fatal("repeated focus discarded pending terminal speech")
}
}
func TestSettingsTreeSelectionSpeaksOnceAndIgnoresUnfocusedEdits(t *testing.T) {
for _, toolkit := range []string{"Gecko", "Chromium", "gtk"} {
t.Run(toolkit, func(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 32)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 32)}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil)
}()
defer func() { cancel(); <-done }()
item := atspi.Snapshot{BusName: ":1.7", Path: "/server", Name: "Server Settings", Role: "tree item", ToolkitName: toolkit, Selected: true}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, item)
selected := atspi.NewEvent(atspi.SelectedStateEvent, item)
selected.Detail1 = 1
adapter.events <- selected
selection := atspi.NewEvent(atspi.SelectionChangedEvent, item)
selection.OriginPath = "/tree"
adapter.events <- selection
port := atspi.Snapshot{BusName: ":1.7", Path: "/port", Name: "Port", Role: "spin button", Editable: true, Text: "993"}
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, port, "993")
adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, port, "993")
// An active-page live region must still announce its update.
live := atspi.Snapshot{BusName: ":1.7", Path: "/live", Role: "entry", Editable: true, LiveRegion: true}
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, live, "Connection status updated")
// Actual focus and subsequent edits to the port must still speak.
adapter.events <- atspi.NewEvent(atspi.FocusEvent, port)
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, port, "4")
// Selection changes within the focused tree must remain usable.
adapter.events <- atspi.NewEvent(atspi.FocusEvent, item)
adapter.events <- selection
next := item
next.Path, next.Name = "/folders", "Copies & Folders"
selection.Source = next
adapter.events <- selection
adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.7", Path: "/end", Role: "button", Name: "End"})
for _, want := range []string{
"Server Settings, tree item, selected", "Connection status updated", "Port, spin button, 993", "4",
"Server Settings, tree item, selected", "Copies & Folders, tree item, selected", "End, button",
} {
select {
case got := <-queue.spoken:
if got != want {
t.Fatalf("speech = %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatalf("missing %q", want)
}
}
})
}
}
func TestControlFocusCaretDoesNotInterruptButArrowCaretSpeaks(t *testing.T) {
for _, role := range []string{"spin button", "button", "link"} {
t.Run(role, func(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
tracker := &input.IntentTracker{}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, tracker, nil, nil)
}()
defer func() { cancel(); <-done }()
control := atspi.Snapshot{BusName: ":1.7", Path: "/control", Name: "Port", Role: role, Editable: role == "spin button", Text: "993"}
tracker.Observe(input.NewKeyEvent(0, true, "Tab"))
adapter.events <- atspi.NewEvent(atspi.FocusEvent, control)
select {
case <-queue.spoken:
case <-time.After(time.Second):
t.Fatal("missing control focus speech")
}
adapter.events <- atspi.NewEvent(atspi.CaretEvent, control)
select {
case got := <-queue.spoken:
t.Fatalf("automatic caret interrupted control speech with %q", got)
case <-time.After(50 * time.Millisecond):
}
tracker.Observe(input.NewKeyEvent(0, true, "Right"))
control.CaretCharacter = "9"
adapter.events <- atspi.NewEvent(atspi.CaretEvent, control)
select {
case got := <-queue.spoken:
if got != "9" {
t.Fatalf("arrow caret speech = %q, want 9", got)
}
case <-time.After(time.Second):
t.Fatal("missing arrow caret speech")
}
})
}
}
func TestEmbeddedObjectCaretDoesNotReplaceControlFocus(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
tracker := &input.IntentTracker{}
browser := newBrowserModeController(tracker, &modeKeys{}, nil)
browser.ready, browser.mode = true, review.BrowseMode
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, browser, nil, nil)
}()
defer func() { cancel(); <-done }()
tracker.Observe(input.NewKeyEvent(0, true, "Tab"))
checkbox := atspi.Snapshot{BusName: ":1.7", Path: "/checkbox", Role: "check box", Name: "Use HTML", StatesKnown: true, Sensitive: true, Focusable: true}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, checkbox)
select {
case got := <-queue.spoken:
if got != "Use HTML, check box, not checked" {
t.Fatalf("checkbox focus speech = %q", got)
}
case <-time.After(time.Second):
t.Fatal("missing checkbox focus speech")
}
container := atspi.Snapshot{BusName: ":1.7", Path: "/panel", Role: "panel", Text: "\ufffc"}
adapter.events <- atspi.NewEvent(atspi.CaretEvent, container)
select {
case got := <-queue.spoken:
t.Fatalf("embedded-object caret interrupted checkbox with %q", got)
case <-time.After(50 * time.Millisecond):
}
checkbox.Checked = true
adapter.events <- atspi.NewEvent("object:state-changed:checked", checkbox)
select {
case got := <-queue.spoken:
if got != "checked" {
t.Fatalf("checkbox toggle speech = %q, want checked", got)
}
case <-time.After(time.Second):
t.Fatal("checkbox focus was lost after container caret")
}
cancel()
<-done
if browser.focus != checkbox.ID() {
t.Fatalf("container caret replaced browser focus: %v", browser.focus)
}
}
func TestFocusedTreeSelectionStateChangesRemainAudible(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil)
}()
defer func() { cancel(); <-done }()
item := atspi.Snapshot{BusName: ":1.7", Path: "/item", Role: "list item", Name: "Important", Selected: true}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, item)
item.Selected = false
adapter.events <- atspi.NewEvent(atspi.SelectedStateEvent, item)
item.Selected = true
selected := atspi.NewEvent(atspi.SelectedStateEvent, item)
selected.Detail1 = 1
adapter.events <- selected
for _, want := range []string{"Important, list item, selected", "not selected", "selected"} {
select {
case got := <-queue.spoken:
if got != want {
t.Fatalf("selection state speech = %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatalf("missing selection state %q", want)
}
}
}
// Drive focus and checked-state events through the real event loop and speech
// worker. The backend call stays in flight while the selection arrives, so any
// automatic CANCEL (including one after SPEAK returns) fails the expected order.
func TestSpeechWorkerRadioSelectionDoesNotCancelLabel(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
speaker := &controlledSpeaker{calls: make(chan speechCall, 8)}
worker := newSpeechWorker(logger, speaker)
if err := worker.Start(t.Context()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { worker.Stop(context.Background()) })
adapter := &fakeAdapter{events: make(chan atspi.Event, 2)}
option := atspi.Snapshot{BusName: ":1.7", Path: "/option", Name: "Laptop", Role: "radio button"}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, option)
option.Checked = true
selected := atspi.NewEvent("object:state-changed:checked", option)
selected.Detail1 = 1
adapter.events <- selected
close(adapter.events)
if err := processEvents(t.Context(), logger, worker, adapter, 0); err != nil {
t.Fatal(err)
}
for _, want := range []string{"Laptop", "radio button", "not selected", "selected"} {
close(expectSpeechCall(t, speaker, want).done)
}
}