328 lines
13 KiB
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)
|
|
}
|
|
}
|