639 lines
23 KiB
Go
639 lines
23 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"
|
|
"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")
|
|
}
|
|
}
|
|
}
|