Files
cthulhu/internal/app/browser_mode_test.go
2026-10-28 22:06:04 -04:00

809 lines
31 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"errors"
"log/slog"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/presentation"
"git.stormux.org/storm/cthulhu/internal/review"
"git.stormux.org/storm/cthulhu/internal/sound"
)
type modeKeys struct {
enabled bool
nativeButtonFocus bool
}
func (keys *modeKeys) SetBrowseNavigation(enabled bool) { keys.enabled = enabled }
func (keys *modeKeys) SetNativeButtonFocus(focused bool) {
keys.nativeButtonFocus = focused
}
func TestBrowserModeClearsNativeButtonFocus(t *testing.T) {
keys := &modeKeys{}
browser := newBrowserModeController(nil, keys, nil)
browser.nativeButtonFocus(true)
if !keys.nativeButtonFocus {
t.Fatal("focused button did not request native Return")
}
browser.clear()
if keys.nativeButtonFocus {
t.Fatal("departed button retained native Return policy")
}
}
type modeSoundQueue struct {
speech *fakeSpeechQueue
sounds chan sound.Cue
}
func (queue *modeSoundQueue) Enqueue(request speechRequest) {
queue.speech.Enqueue(request)
for _, cue := range request.Sound {
queue.sounds <- cue
}
}
func (queue *modeSoundQueue) Interrupt() {
queue.speech.Interrupt()
}
func modeContext(t *testing.T, role string, editableAncestor bool) atspi.DocumentContext {
t.Helper()
return modeContextInDocument(t, role, editableAncestor, "/document")
}
func modeContextInDocument(t *testing.T, role string, editableAncestor bool, documentPath string) atspi.DocumentContext {
t.Helper()
snapshot := atspi.Snapshot{BusName: ":1.42", Path: "/target", Role: role, StatesKnown: true}
doc := atspi.Snapshot{BusName: ":1.42", Path: documentPath, Role: "document web", StatesKnown: true, Editable: editableAncestor}
tree, err := atspi.NewAccessibleTree(doc.ID(), []atspi.TreeNode{{Snapshot: doc, Children: []atspi.ObjectID{snapshot.ID()}}, {Snapshot: snapshot}})
if err != nil {
t.Fatal(err)
}
ref, _ := tree.Reference(snapshot.ID())
owner, err := tree.DocumentContext(ref)
if err != nil {
t.Fatal(err)
}
return owner
}
func TestBrowserModeRejectsDepartedFocusAndPreservesTyping(t *testing.T) {
keys := &modeKeys{}
browser := newBrowserModeController(nil, keys, nil)
queue := &fakeSpeechQueue{spoken: make(chan string, 16)}
heading := modeContext(t, "heading", false)
focus := heading.Ancestors[0].ID()
browser.follow(focus)
browser.accept(browserModeResult{focus: focus, generation: 1, owner: heading}, queue)
if !keys.enabled {
t.Fatal("heading did not enable browse keys")
}
browser.toggle(queue)
if keys.enabled {
t.Fatal("manual focus mode retained letters")
}
browser.toggle(queue)
if !keys.enabled {
t.Fatal("manual browse mode did not restore letters")
}
chrome := atspi.NewObjectID(":1.42", "/chrome")
browser.follow(chrome)
if keys.enabled {
t.Fatal("leaving content did not immediately release letters")
}
browser.accept(browserModeResult{focus: focus, generation: 1, owner: heading}, queue)
if keys.enabled {
t.Fatal("late result re-enabled letters in chrome")
}
browser.accept(browserModeResult{focus: chrome, generation: 2, err: atspi.ErrNotInDocument}, queue)
browser.toggle(queue)
if keys.enabled {
t.Fatal("toggle in chrome enabled letters")
}
entry := modeContext(t, "entry", false)
entryFocus := atspi.NewObjectID(":1.42", "/entry")
browser.follow(entryFocus)
browser.accept(browserModeResult{focus: entryFocus, generation: 3, owner: entry}, queue)
if keys.enabled || browser.mode != review.FocusMode {
t.Fatal("entry did not preserve typing")
}
browser.follow(focus)
browser.accept(browserModeResult{focus: focus, generation: 4, owner: heading}, queue)
if !keys.enabled {
t.Fatal("return to content did not restore navigation")
}
}
func TestBrowserModeDuplicateFocusKeepsBrowseRouting(t *testing.T) {
keys := &modeKeys{}
browser := newBrowserModeController(nil, keys, nil)
heading := modeContext(t, "heading", false)
focus := heading.Ancestors[0].ID()
browser.follow(focus)
browser.accept(browserModeResult{focus: focus, generation: 1, owner: heading}, &fakeSpeechQueue{spoken: make(chan string, 1)})
if !browser.ready || !keys.enabled {
t.Fatal("initial browse routing was not ready")
}
browser.follow(focus)
if !browser.ready || !keys.enabled || browser.generation != 1 {
t.Fatal("duplicate focus restarted browser capture or released browse routing")
}
}
func TestBrowserCaretLeavesFocusModeForDocumentText(t *testing.T) {
keys := &modeKeys{}
browser := newBrowserModeController(nil, keys, nil)
queue := &fakeSpeechQueue{spoken: make(chan string, 4)}
entry := modeContext(t, "entry", false)
entryFocus := atspi.NewObjectID(":1.42", "/entry")
browser.follow(entryFocus)
browser.accept(browserModeResult{focus: entryFocus, generation: 1, owner: entry}, queue)
if browser.mode != review.FocusMode || keys.enabled {
t.Fatal("entry did not enter focus mode")
}
paragraph := modeContext(t, "paragraph", false)
caret := paragraph.Ancestors[0].ID()
browser.followCaret(caret)
if browser.focus != caret || browser.origin != review.CaretNavigation {
t.Fatal("document caret was not tracked")
}
browser.accept(browserModeResult{focus: caret, generation: 2, owner: paragraph, origin: review.CaretNavigation}, queue)
if browser.mode != review.BrowseMode || !keys.enabled {
t.Fatal("caret navigation did not restore browse keys")
}
browser.followCaret(caret)
if browser.generation != 2 || !keys.enabled {
t.Fatal("repeated caret movement restarted browse routing")
}
}
func TestBrowserModePlaysModeChangeSounds(t *testing.T) {
keys := &modeKeys{}
browser := newBrowserModeController(nil, keys, nil)
speech := &fakeSpeechQueue{spoken: make(chan string, 16)}
queue := &modeSoundQueue{speech: speech, sounds: make(chan sound.Cue, 16)}
heading := modeContext(t, "heading", false)
entry := modeContext(t, "entry", false)
focus := heading.Ancestors[0].ID()
browser.follow(focus)
browser.accept(browserModeResult{focus: focus, generation: 1, owner: heading}, queue)
if len(queue.sounds) != 0 || len(speech.spoken) != 0 {
t.Fatal("initial browse mode was announced")
}
browser.toggle(queue)
assertModeSound(t, queue, speech, "focus_mode", "Focus mode")
browser.follow(focus)
browser.accept(browserModeResult{focus: focus, generation: 2, owner: entry}, queue)
if len(queue.sounds) != 0 {
t.Fatalf("unchanged mode queued cue %#v", <-queue.sounds)
}
browser.toggle(queue)
assertModeSound(t, queue, speech, "browse_mode", "Browse mode")
}
func assertModeSound(t *testing.T, queue *modeSoundQueue, speech *fakeSpeechQueue, wantCue, wantText string) {
t.Helper()
select {
case cue := <-queue.sounds:
if cue.Name != wantCue || cue.Pan != 0 {
t.Fatalf("got mode cue %#v want centered %q", cue, wantCue)
}
case <-time.After(time.Second):
t.Fatalf("mode cue %q was not queued", wantCue)
}
select {
case text := <-speech.spoken:
if text != wantText {
t.Fatalf("got mode text %q want %q", text, wantText)
}
case <-time.After(time.Second):
t.Fatalf("mode text %q was not spoken", wantText)
}
}
func TestBrowserModeAcceptsSameDocumentNavigationDuringRecapture(t *testing.T) {
browser := newBrowserModeController(nil, &modeKeys{}, nil)
heading := modeContext(t, "heading", false)
focus := heading.Ancestors[0].ID()
browser.follow(focus)
queue := &fakeSpeechQueue{spoken: make(chan string, 1)}
browser.accept(browserModeResult{focus: focus, generation: 1, owner: heading}, queue)
position := review.DocumentPosition{
Document: heading.TopLevel,
Snapshot: heading.Ancestors[0],
}
if !browser.acceptsDocumentResult(review.Result{DocumentPosition: position}) {
t.Fatal("same-document navigation result rejected")
}
browser.follow(atspi.NewObjectID(":1.42", "/second"))
if browser.ready {
t.Fatal("new focus did not start recapture")
}
if browser.acceptsDocumentResult(review.Result{DocumentPosition: position}) {
t.Fatal("old result accepted while the new focus document is unknown")
}
browser.follow(position.Snapshot.ID())
if !browser.acceptsDocumentResult(review.Result{DocumentPosition: position}) {
t.Fatal("native focus echo on the structural target rejected")
}
other := modeContextInDocument(t, "heading", false, "/other_document").TopLevel
if browser.acceptsDocumentResult(review.Result{DocumentPosition: review.DocumentPosition{Document: other}}) {
t.Fatal("other-document result accepted")
}
if browser.acceptsDocumentResult(review.Result{Err: errors.New("capture failed"), DocumentPosition: position}) {
t.Fatal("failed result accepted")
}
if !browser.acceptsDocumentResult(review.Result{Err: errors.New("capture failed"), DocumentFocus: position.Snapshot.ID()}) {
t.Fatal("current navigation failure would be silently discarded")
}
}
func TestBrowserRuntimeModeProtectsAmbiguousAndEditableContent(t *testing.T) {
for _, tc := range []struct {
role string
editable bool
want review.DocumentMode
}{
{"heading", false, review.BrowseMode}, {"entry", false, review.FocusMode},
{"paragraph", true, review.FocusMode}, {"table cell", false, review.FocusMode},
{"embedded", false, review.FocusMode}, {"menu item", false, review.FocusMode},
} {
t.Run(tc.role, func(t *testing.T) {
var state review.DocumentModeState
got := state.Update(runtimeModeInput(modeContext(t, tc.role, tc.editable)), review.DefaultModeSettings())
if got != tc.want {
t.Fatalf("got %v want %v", got, tc.want)
}
})
}
}
type canceledModeCapture struct{ started chan struct{} }
func (capture canceledModeCapture) CaptureDocumentContext(ctx context.Context, _ atspi.ObjectID) (atspi.DocumentContext, error) {
close(capture.started)
<-ctx.Done()
return atspi.DocumentContext{}, ctx.Err()
}
func TestBrowserModeShutdownCancelsNativeWait(t *testing.T) {
capture := canceledModeCapture{make(chan struct{})}
browser := newBrowserModeController(nil, &modeKeys{}, capture)
if err := browser.Start(context.Background()); err != nil {
t.Fatal(err)
}
browser.follow(atspi.NewObjectID(":1.42", "/target"))
select {
case <-capture.started:
case <-time.After(time.Second):
t.Fatal("capture did not start")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := browser.Stop(ctx); err != nil && !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
}
type timeoutThenModeCapture struct {
calls chan struct{}
owner atspi.DocumentContext
}
func (capture *timeoutThenModeCapture) CaptureDocumentContext(ctx context.Context, _ atspi.ObjectID) (atspi.DocumentContext, error) {
capture.calls <- struct{}{}
if len(capture.calls) == 1 {
<-ctx.Done()
return atspi.DocumentContext{}, ctx.Err()
}
return capture.owner, nil
}
func TestBrowserModeRetriesTimedOutCurrentFocus(t *testing.T) {
owner := modeContext(t, "heading", false)
focus := owner.Ancestors[0].ID()
capture := &timeoutThenModeCapture{calls: make(chan struct{}, 2), owner: owner}
keys := &modeKeys{}
browser := newBrowserModeController(nil, keys, capture)
browser.captureTimeout = 20 * time.Millisecond
if err := browser.Start(context.Background()); err != nil {
t.Fatal(err)
}
defer browser.Stop(context.Background())
browser.follow(focus)
select {
case result := <-browser.results:
browser.accept(result, &fakeSpeechQueue{spoken: make(chan string, 1)})
if result.err != nil || result.focus != focus || !keys.enabled || len(capture.calls) != 2 {
t.Fatalf("timed-out capture did not recover: %+v, calls %d", result, len(capture.calls))
}
case <-time.After(time.Second):
t.Fatal("timed-out capture was not retried")
}
}
type cancelableModeCapture struct {
started chan atspi.ObjectID
canceled chan atspi.ObjectID
}
func (capture *cancelableModeCapture) CaptureDocumentContext(ctx context.Context, focus atspi.ObjectID) (atspi.DocumentContext, error) {
started := false
for !started {
select {
case capture.started <- focus:
started = true
case <-ctx.Done():
return atspi.DocumentContext{}, ctx.Err()
}
}
<-ctx.Done()
select {
case capture.canceled <- focus:
case <-context.Background().Done():
}
return atspi.DocumentContext{}, ctx.Err()
}
func TestBrowserModeCancelsCaptureOnLoadLifecycle(t *testing.T) {
first := atspi.NewObjectID(":1.42", "/first")
second := atspi.NewObjectID(":1.42", "/second")
capture := &cancelableModeCapture{
started: make(chan atspi.ObjectID, 1),
canceled: make(chan atspi.ObjectID, 1),
}
browser := newBrowserModeController(nil, &modeKeys{}, capture)
if err := browser.Start(context.Background()); err != nil {
t.Fatal(err)
}
defer browser.Stop(context.Background())
browser.follow(first)
select {
case got := <-capture.started:
if got != first {
t.Fatalf("first capture focus = %v", got)
}
case <-time.After(time.Second):
t.Fatal("first capture did not start")
}
browser.loading()
select {
case got := <-capture.canceled:
if got != first {
t.Fatalf("canceled capture focus = %v", got)
}
case <-time.After(time.Second):
t.Fatal("page loading did not cancel the old capture")
}
if len(capture.started) != 0 {
t.Fatal("page loading started a new capture before load completion")
}
browser.loaded(second)
select {
case got := <-capture.started:
if got != second {
t.Fatalf("second capture focus = %v", got)
}
case <-time.After(time.Second):
t.Fatal("load completion did not start the latest capture")
}
}
func TestBrowserLoadingRejectsOldCaptureAndRecoversOnStop(t *testing.T) {
owner := modeContext(t, "heading", false)
focus := owner.Ancestors[0].ID()
keys := &modeKeys{}
browser := newBrowserModeController(nil, keys, nil)
speech := &fakeSpeechQueue{spoken: make(chan string, 1)}
browser.follow(focus)
browser.accept(browserModeResult{focus: focus, generation: 1, owner: owner}, speech)
if !keys.enabled {
t.Fatal("initial page did not enable browse mode")
}
browser.loading()
if browser.ready || keys.enabled {
t.Fatal("reloading page retained old browse keys")
}
browser.accept(browserModeResult{focus: focus, generation: 1, owner: owner}, speech)
if browser.ready || keys.enabled {
t.Fatal("late pre-reload capture restored the old page")
}
browser.loaded(focus)
browser.accept(browserModeResult{focus: focus, generation: browser.generation, owner: owner}, speech)
if !browser.ready || !keys.enabled {
t.Fatal("stopped page load did not restore browse mode")
}
}
func TestBrowserModeSuppressesStructuralNavigationEcho(t *testing.T) {
keys := &modeKeys{}
browser := newBrowserModeController(nil, keys, nil)
target := atspi.NewSnapshot("/target", "Target", "heading", "", "")
target.BusName = ":1.42"
owner := modeContext(t, "heading", false)
browser.navigated(review.DocumentPosition{
Document: owner.TopLevel,
Snapshot: target,
}, target.ID())
for _, eventType := range []string{atspi.CaretEvent, atspi.FocusEvent, atspi.FocusedStateEvent} {
if !browser.suppressNavigationEcho(atspi.NewEvent(eventType, target)) {
t.Fatalf("%s echo was not suppressed", eventType)
}
}
child := atspi.NewSnapshot("/new-child", "Child", "static", "", "")
child.BusName = ":1.42"
for _, eventType := range []string{atspi.CaretEvent, atspi.FocusEvent, atspi.FocusedStateEvent} {
if !browser.suppressNavigationEcho(atspi.NewEvent(eventType, child)) {
t.Fatalf("%s descendant echo was not suppressed", eventType)
}
}
other := atspi.NewSnapshot("/other", "Other", "heading", "", "")
other.BusName = ":1.99"
if browser.suppressNavigationEcho(atspi.NewEvent(atspi.FocusEvent, other)) {
t.Fatal("unrelated focus echo was suppressed")
}
browser.observeCommand(input.ToggleDocumentMode)
if browser.suppressNavigationEcho(atspi.NewEvent(atspi.FocusEvent, target)) {
t.Fatal("non-structural command retained navigation suppression")
}
}
func TestBrowserNavigationEchoEndsOnNativeKey(t *testing.T) {
tracker := &input.IntentTracker{}
tracker.Observe(input.NewKeyEvent(0, true, "h"))
browser := newBrowserModeController(tracker, &modeKeys{}, nil)
target := atspi.Snapshot{BusName: ":1.42", Path: "/heading", Role: "heading"}
browser.navigated(review.DocumentPosition{Snapshot: target}, target.ID())
if !browser.suppressNavigationEcho(atspi.NewEvent(atspi.CaretEvent, target)) {
t.Fatal("structural echo was not suppressed")
}
tracker.Observe(input.NewKeyEvent(0, true, "Up"))
if browser.suppressNavigationEcho(atspi.NewEvent(atspi.CaretEvent, target)) {
t.Fatal("native movement back into heading was suppressed")
}
}
func TestBrowserTracksRecentlyConsumedCommand(t *testing.T) {
browser := newBrowserModeController(nil, &modeKeys{}, nil)
if browser.recentConsumedCommand() {
t.Fatal("new browser controller reported a consumed command")
}
browser.observeCommand(input.ReviewNextLine)
if !browser.recentConsumedCommand() {
t.Fatal("fresh command was not reported as consumed")
}
browser.mutex.Lock()
browser.commandAt = time.Now().Add(-time.Second)
browser.mutex.Unlock()
if browser.recentConsumedCommand() {
t.Fatal("stale command was reported as recently consumed")
}
}
type activeCommandKeys struct {
modeKeys
active bool
}
func (keys *activeCommandKeys) CommandGrabActive() bool { return keys.active }
func TestBrowserRecognizesCommandGrabBeforeDispatch(t *testing.T) {
keys := &activeCommandKeys{active: true}
browser := newBrowserModeController(nil, keys, nil)
if !browser.recentConsumedCommand() {
t.Fatal("active command grab was not recognized before dispatch")
}
keys.active = false
if browser.recentConsumedCommand() {
t.Fatal("inactive command grab was reported without a dispatched command")
}
}
func TestBrowserNavigationEchoSurvivesFastStructuralRepeat(t *testing.T) {
tracker := &input.IntentTracker{}
tracker.Observe(input.NewKeyEvent(0, true, "h"))
browser := newBrowserModeController(tracker, &modeKeys{}, nil)
owner := modeContext(t, "heading", false)
target := atspi.Snapshot{BusName: ":1.42", Path: "/heading", Role: "heading"}
browser.navigated(review.DocumentPosition{Document: owner.TopLevel, Snapshot: target}, target.ID())
tracker.Observe(input.NewKeyEvent(0, true, "h"))
browser.observeCommand(input.DocumentNextHeading)
child := atspi.Snapshot{BusName: ":1.42", Path: "/replacement", Role: "static"}
if !browser.suppressNavigationEcho(atspi.NewEvent(atspi.CaretEvent, child)) {
t.Fatal("fast repeated structural command exposed prior caret echo")
}
}
func TestBrowserNavigationOwnershipSurvivesQueuedReaderCommands(t *testing.T) {
for _, test := range []struct {
name, key string
command input.Command
}{
{"heading", "h", input.DocumentNextHeading},
{"reverse heading", "h", input.DocumentPreviousHeading},
{"line", "Down", input.DocumentCaretNextLine},
{"character", "Right", input.DocumentCaretNextCharacter},
} {
t.Run(test.name, func(t *testing.T) {
tracker := &input.IntentTracker{}
tracker.Observe(input.NewKeyEvent(0, true, "h"))
browser := newBrowserModeController(tracker, &modeKeys{}, nil)
owner := modeContext(t, "heading", false)
target := owner.Ancestors[0]
browser.navigated(review.DocumentPosition{Document: owner.TopLevel, Snapshot: target}, owner.TopLevel.ID())
tracker.Observe(input.NewKeyEvent(0, true, test.key))
browser.observeCommand(test.command)
child := atspi.Snapshot{BusName: target.BusName, Path: "/old-heading-child", Role: "static"}
if !browser.suppressNavigationEcho(atspi.NewEvent(atspi.CaretEvent, child)) {
t.Fatal("delayed caret reset the cursor while a reader command was pending")
}
tracker.Observe(input.NewKeyEvent(0, true, "Tab"))
if browser.suppressNavigationEcho(atspi.NewEvent(atspi.FocusEvent, child)) {
t.Fatal("native Tab did not release navigation ownership")
}
})
}
}
func TestBrowserNavigationOwnershipSurvivesIdleAndEndsOnPointer(t *testing.T) {
source := &fixedInputActivity{activity: input.Activity{InputSequence: 1, Sequence: 1, Intent: input.TypingIntent}}
browser := newBrowserModeController(source, &modeKeys{}, nil)
owner := modeContext(t, "heading", false)
target := owner.Ancestors[0]
browser.navigated(review.DocumentPosition{Document: owner.TopLevel, Snapshot: target}, owner.TopLevel.ID())
source.activity = input.Activity{InputSequence: 1}
if !browser.suppressNavigationEcho(atspi.NewEvent(atspi.CaretEvent, target)) {
t.Fatal("expired speech intent released the virtual caret")
}
for _, snapshot := range []atspi.Snapshot{
{BusName: target.BusName, Path: "/entry", Role: "entry", Editable: true},
{BusName: target.BusName, Path: "/menu", Role: "menu"},
{BusName: target.BusName, Path: "/other-document", Role: "document web"},
} {
if browser.suppressNavigationEcho(atspi.NewEvent(atspi.FocusEvent, snapshot)) {
t.Fatalf("suppressed focus on %s", snapshot.Role)
}
}
chrome := atspi.NewEvent(atspi.FocusEvent, target)
chrome.FocusOutsideDocument = atspi.NewObjectID(target.BusName, "/chrome")
if browser.suppressNavigationEcho(chrome) {
t.Fatal("suppressed chrome focus")
}
handler := &accessibilityEventHandler{state: &eventLoopState{}, browser: browser, logger: slog.New(slog.DiscardHandler)}
handler.handle(atspi.Event{Type: atspi.MouseButtonEvent})
if browser.suppressNavigationEcho(atspi.NewEvent(atspi.CaretEvent, target)) {
t.Fatal("pointer click did not release the virtual caret")
}
}
func TestQueuedHeadingOwnsCaretAfterActivation(t *testing.T) {
tracker := &input.IntentTracker{}
browser := newBrowserModeController(tracker, &modeKeys{}, nil)
owner := modeContext(t, "link", false)
target := owner.Ancestors[0]
browser.navigated(review.DocumentPosition{Document: owner.TopLevel, Snapshot: target}, owner.TopLevel.ID())
tracker.Observe(input.NewKeyEvent(0, true, "Return"))
browser.observeCommand(input.DocumentActivate)
tracker.Observe(input.NewKeyEvent(0, true, "h"))
browser.observeCommand(input.DocumentNextHeading)
// A fragment activation can emit multiple paragraph caret events. The
// second can arrive after the heading request but before its result.
paragraph := atspi.Snapshot{BusName: target.BusName, Path: "/fragment", Role: "paragraph"}
if !browser.suppressNavigationEcho(atspi.NewEvent(atspi.CaretEvent, paragraph)) {
t.Fatal("activation caret was allowed to queue behind the heading move")
}
}
func TestBrowserVirtualPositionSurvivesNativePlacementFailure(t *testing.T) {
worker, err := review.NewWorker(&documentLineCapturer{}, time.Second)
if err != nil {
t.Fatal(err)
}
owner := modeContext(t, "heading", false)
anchor := owner.TopLevel.ID()
worker.SyncFocus(anchor)
browser := newBrowserModeController(nil, &modeKeys{}, nil)
initial := review.DocumentPosition{Document: owner.TopLevel, Snapshot: owner.Ancestors[0]}
browser.navigated(initial, anchor)
state := &eventLoopState{}
state.current, state.presentationOptions = initial.Snapshot, presentation.DefaultOptions()
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
handler := &eventReviewHandler{state: state, browser: browser, objectReview: worker, speech: queue,
logger: slog.New(slog.DiscardHandler), syncForeground: func() {}}
target := atspi.Snapshot{BusName: anchor.BusName, Path: "/replacement-text", Role: "static", Name: "Current total"}
result := review.Result{DocumentEpoch: worker.DocumentEpoch(), DocumentFocus: anchor,
DocumentCaretCommand: review.CaretNextLine, DocumentCaretText: target.Name, Moved: true,
DocumentPosition: review.DocumentPosition{Document: owner.TopLevel, Snapshot: target, Offset: 4},
CaretErr: atspi.ErrStaleSnapshot}
handler.handle(result)
if state.current.ID() != target.ID() || browser.navigation != target.ID() || worker.Focus() != anchor {
t.Fatal("native placement failure displaced the virtual position or its native anchor")
}
// A late result still cannot overwrite a newly focused browser control.
browser.follow(atspi.NewObjectID(anchor.BusName, "/address"))
state.current = atspi.Snapshot{BusName: anchor.BusName, Path: "/address", Role: "entry", Editable: true}
handler.handle(result)
if state.current.Path != "/address" {
t.Fatal("late virtual move displaced browser chrome")
}
}
func TestBrowserLoadRetiresPositionEvenWhenPathIsReused(t *testing.T) {
browser := newBrowserModeController(nil, &modeKeys{}, nil)
owner := modeContext(t, "heading", false)
position := review.DocumentPosition{Document: owner.TopLevel, Snapshot: owner.Ancestors[0]}
browser.navigated(position, position.Snapshot.ID())
browser.loaded(position.Snapshot.ID())
if browser.ready || browser.mode != review.InactiveMode || browser.acceptsDocumentResult(review.Result{DocumentPosition: position}) {
t.Fatal("load retained the old document position")
}
if browser.suppressNavigationEcho(atspi.NewEvent(atspi.FocusEvent, position.Snapshot)) {
t.Fatal("load retained old navigation echo suppression")
}
select {
case <-browser.wake:
default:
t.Fatal("same-path load did not schedule fresh context capture")
}
}
func TestBrowserLoadAcceptsCurrentFragmentTarget(t *testing.T) {
browser := newBrowserModeController(nil, &modeKeys{}, nil)
document := atspi.NewObjectID(":1.42", "/document")
browser.loaded(document)
result := review.Result{DocumentFragment: true, DocumentFocus: document, Moved: true}
if !browser.acceptsDocumentResult(result) {
t.Fatal("current document fragment was rejected before context resolution")
}
result.DocumentFocus = atspi.NewObjectID(":1.42", "/old_document")
if browser.acceptsDocumentResult(result) {
t.Fatal("old document fragment crossed a page load")
}
}
func TestBrowserModeRetriesSameFocusAfterCaptureFailure(t *testing.T) {
browser := newBrowserModeController(nil, &modeKeys{}, nil)
owner := modeContext(t, "heading", false)
focus := owner.Ancestors[0].ID()
browser.follow(focus)
browser.accept(browserModeResult{focus: focus, generation: 1, err: atspi.ErrStaleObject}, &fakeSpeechQueue{})
browser.follow(focus)
if browser.generation != 2 {
t.Fatal("transient context failure permanently disabled same-object navigation")
}
browser.accept(browserModeResult{focus: focus, generation: 2, owner: owner}, &fakeSpeechQueue{})
if !browser.ready || browser.mode != review.BrowseMode {
t.Fatal("same-object retry did not restore browse mode")
}
}
func TestBrowserReportsNavigationFailureAfterCaretMovesFromNativeFocus(t *testing.T) {
browser := newBrowserModeController(nil, &modeKeys{}, nil)
owner := modeContext(t, "heading", false)
anchor := owner.TopLevel.ID()
position := review.DocumentPosition{Document: owner.TopLevel, Snapshot: owner.Ancestors[0], Origin: review.CaretNavigation}
browser.navigated(position, anchor)
for _, result := range []review.Result{
{DocumentCaretCommand: review.CaretNextLine, DocumentFocus: anchor, Err: context.DeadlineExceeded},
{DocumentTarget: review.DocumentHeading, DocumentFocus: anchor, Err: context.DeadlineExceeded},
{DocumentActivate: true, DocumentFocus: anchor, Err: context.DeadlineExceeded},
} {
if !browser.acceptsDocumentResult(result) {
t.Fatalf("current navigation error was silently discarded: %+v", result)
}
result.DocumentFocus = atspi.NewObjectID(anchor.BusName, "/another-document")
if browser.acceptsDocumentResult(result) {
t.Fatal("accepted an unrelated navigation error")
}
}
browser.follow(atspi.NewObjectID(anchor.BusName, "/address-bar"))
if browser.acceptsDocumentResult(review.Result{DocumentCaretCommand: review.CaretNextLine, DocumentFocus: anchor, Err: context.DeadlineExceeded}) {
t.Fatal("old document error interrupted chrome focus")
}
}
func TestStickyBrowserModeSurvivesNavigationChromeAndWindowSuspension(t *testing.T) {
for _, mode := range []review.DocumentMode{review.BrowseMode, review.FocusMode} {
keys := &modeKeys{}
browser := newBrowserModeController(nil, keys, nil)
queue := &fakeSpeechQueue{spoken: make(chan string, 20)}
owner := modeContext(t, "paragraph", false)
focus := owner.Ancestors[0].ID()
accept := func(owner atspi.DocumentContext) {
browser.follow(focus)
browser.accept(browserModeResult{focus: focus, generation: browser.generation, owner: owner}, queue)
}
accept(owner)
browser.sticky(mode, queue)
browser.navigated(review.DocumentPosition{Document: owner.TopLevel, Snapshot: owner.Ancestors[0]}, focus)
if browser.mode != mode {
t.Fatal("navigation discarded sticky mode")
}
browser.clear()
accept(modeContext(t, "entry", false))
if browser.mode != mode || keys.enabled != (mode == review.BrowseMode) {
t.Fatal("window return lost sticky mode")
}
browser.loaded(focus)
accept(modeContextInDocument(t, "entry", false, "/new_document"))
if browser.mode != mode {
t.Fatal("new document lost sticky mode")
}
browser.toggle(queue)
if browser.state.Sticky() != review.InactiveMode || len(browser.stickyModes) != 0 {
t.Fatal("single press failed to clear sticky mode")
}
}
}
func TestStructuralNavigationAutomaticFocus(t *testing.T) {
for _, tc := range []struct {
name, role string
enabled bool
origin review.NavigationOrigin
sticky, want review.DocumentMode
}{
{name: "default entry", role: "entry", want: review.BrowseMode},
{name: "entry opt in", role: "entry", enabled: true, want: review.FocusMode},
{name: "combo opt in", role: "combo box", enabled: true, want: review.FocusMode},
{name: "heading stays browse", role: "heading", enabled: true, want: review.BrowseMode},
{name: "link stays browse", role: "link", enabled: true, want: review.BrowseMode},
{name: "sticky browse", role: "entry", enabled: true, sticky: review.BrowseMode, want: review.BrowseMode},
{name: "sticky focus", role: "heading", enabled: true, sticky: review.FocusMode, want: review.FocusMode},
{name: "caret unchanged", role: "entry", enabled: true, origin: review.CaretNavigation, want: review.BrowseMode},
} {
t.Run(tc.name, func(t *testing.T) {
keys := &modeKeys{}
browser := newBrowserModeController(nil, keys, nil)
browser.settings = func() review.ModeSettings {
settings := review.DefaultModeSettings()
settings.StructuralTriggersFocus = tc.enabled
return settings
}
owner := modeContext(t, tc.role, false)
browser.stickyModes[owner.TopLevel.ID().BusName] = tc.sticky
origin := tc.origin
if origin == review.NativeNavigation {
origin = review.StructuralNavigation
}
result := review.Result{DocumentPosition: review.DocumentPosition{Document: owner.TopLevel, Snapshot: owner.Ancestors[0], Origin: origin}, DocumentCaretContext: owner.Ancestors}
queue := &fakeSpeechQueue{spoken: make(chan string, 4)}
browser.navigated(result.DocumentPosition, owner.TopLevel.ID())
browser.navigationMode(result, queue)
if browser.mode != tc.want || keys.enabled != (tc.want == review.BrowseMode) {
t.Fatalf("mode=%v browse keys=%t, want %v", browser.mode, keys.enabled, tc.want)
}
if tc.want == review.FocusMode && tc.sticky == review.InactiveMode {
select {
case message := <-queue.spoken:
if message != "Focus mode" {
t.Fatalf("announcement=%q", message)
}
default:
t.Fatal("missing focus mode announcement")
}
} else if len(queue.spoken) != 0 {
t.Fatal("unnecessary mode announcement")
}
})
}
}