// 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") } }) } }