// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "errors" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/input" "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")) 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 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") } } }