Files
cthulhu/internal/review/document_mode_test.go

220 lines
9.7 KiB
Go

// Cthulhu screen reader.
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
package review
import (
"testing"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func modeInput(t *testing.T, role string, ancestors ...string) ModeInput {
t.Helper()
tree, _ := webCursor(t)
ref, err := tree.Reference(webID("/first"))
if err != nil {
t.Fatal(err)
}
context, err := tree.DocumentContext(ref)
if err != nil {
t.Fatal(err)
}
context.Ancestors = []atspi.Snapshot{{Role: role, StatesKnown: true}}
for _, role := range ancestors {
context.Ancestors = append(context.Ancestors, atspi.Snapshot{Role: role})
}
context.Ancestors = append(context.Ancestors, atspi.Snapshot{Role: "document web"})
return ModeInput{Context: context}
}
func TestDocumentModeWidgetRoles(t *testing.T) {
for _, role := range []string{"combo box", "entry", "list box", "menu", "menu item", "check menu item", "radio menu item", "page tab", "password text", "progress bar", "slider", "spin button", "tool bar", "tree item", "tree table", "tree", "table", "table cell", "embedded"} {
t.Run(role, func(t *testing.T) {
var state DocumentModeState
if got := state.Update(modeInput(t, role), DefaultModeSettings()); got != FocusMode {
t.Fatalf("got %v", got)
}
})
}
for _, role := range []string{"heading", "paragraph", "link", "push button", "check box", "radio button", "list item", "document web"} {
t.Run(role, func(t *testing.T) {
var state DocumentModeState
if got := state.Update(modeInput(t, role), DefaultModeSettings()); got != BrowseMode {
t.Fatalf("got %v", got)
}
})
}
}
func TestDocumentModeWidgetSemantics(t *testing.T) {
for _, tc := range []struct {
name string
role string
ancestors []string
traits ModeTraits
editable, expandable bool
focusable, hasPopup bool
want DocumentMode
}{
{name: "editable paragraph", role: "paragraph", editable: true, want: FocusMode},
{name: "expandable button", role: "push button", expandable: true, focusable: true, want: FocusMode},
{name: "unfocusable expandable", role: "push button", expandable: true, want: BrowseMode},
{name: "expandable link", role: "link", expandable: true, focusable: true, want: BrowseMode},
{name: "native popup button", role: "button", hasPopup: true, want: FocusMode},
{name: "popup toggle", role: "toggle button", hasPopup: true, want: FocusMode},
{name: "popup link", role: "link", hasPopup: true, want: BrowseMode},
{name: "popup button", role: "push button", hasPopup: true, want: FocusMode},
{name: "listbox item", role: "list item", ancestors: []string{"list box"}, want: FocusMode},
{name: "ordinary list", role: "list item", ancestors: []string{"list"}, want: BrowseMode},
{name: "menu descendant", role: "check box", ancestors: []string{"menu"}, want: FocusMode},
{name: "toolbar descendant", role: "push button", ancestors: []string{"tool bar"}, want: FocusMode},
{name: "grid descendant", role: "check box", traits: ModeTraits{GridDescendant: true}, want: FocusMode},
{name: "layout table", role: "table", traits: ModeTraits{LayoutOnlyTable: true, GridDescendant: true}, want: BrowseMode},
{name: "layout cell", role: "table cell", traits: ModeTraits{LayoutOnlyTable: true}, want: BrowseMode},
{name: "editable layout cell", role: "table cell", editable: true, traits: ModeTraits{LayoutOnlyTable: true}, want: FocusMode},
{name: "PDF table", role: "table", traits: ModeTraits{PDFViewer: true}, want: BrowseMode},
{name: "text block", role: "embedded", traits: ModeTraits{TextBlock: true}, want: BrowseMode},
{name: "named action", role: "embedded", traits: ModeTraits{NamedActionWithoutUsefulChildren: true}, want: BrowseMode},
{name: "embedded editor", role: "section", traits: ModeTraits{ContentEditableWithEmbeddedObjects: true}, want: FocusMode},
} {
t.Run(tc.name, func(t *testing.T) {
input := modeInput(t, tc.role, tc.ancestors...)
input.Traits = tc.traits
input.Context.Ancestors[0].Editable = tc.editable
input.Context.Ancestors[0].Expandable = tc.expandable
input.Context.Ancestors[0].Focusable = tc.focusable
input.Context.Ancestors[0].HasPopup = tc.hasPopup
var state DocumentModeState
if got := state.Update(input, DefaultModeSettings()); got != tc.want {
t.Fatalf("got %v want %v", got, tc.want)
}
})
}
}
func TestDocumentModePrecedence(t *testing.T) {
for _, tc := range []struct {
name string
origin NavigationOrigin
settings ModeSettings
sticky DocumentMode
sayAll, tooltip bool
want DocumentMode
}{
{name: "native default", settings: DefaultModeSettings(), want: FocusMode},
{name: "structural default", origin: StructuralNavigation, settings: DefaultModeSettings(), want: BrowseMode},
{name: "caret default", origin: CaretNavigation, settings: DefaultModeSettings(), want: BrowseMode},
{name: "structural opt in", origin: StructuralNavigation, settings: ModeSettings{StructuralTriggersFocus: true}, want: FocusMode},
{name: "caret opt in", origin: CaretNavigation, settings: ModeSettings{CaretTriggersFocus: true}, want: FocusMode},
{name: "tooltip escape", origin: CaretNavigation, tooltip: true, want: FocusMode},
{name: "say all", settings: DefaultModeSettings(), sayAll: true, want: BrowseMode},
{name: "sticky focus beats say all", sticky: FocusMode, sayAll: true, origin: StructuralNavigation, want: FocusMode},
{name: "sticky browse beats editable", sticky: BrowseMode, settings: DefaultModeSettings(), want: BrowseMode},
{name: "native disabled retains browse", want: BrowseMode},
} {
t.Run(tc.name, func(t *testing.T) {
var state DocumentModeState
state.SetSticky(tc.sticky)
input := modeInput(t, "entry")
input.Origin, input.SayAll, input.PreviousNavigableTooltip = tc.origin, tc.sayAll, tc.tooltip
if got := state.Update(input, tc.settings); got != tc.want {
t.Fatalf("got %v want %v", got, tc.want)
}
})
}
state := DocumentModeState{}
state.Toggle()
if got := state.Update(modeInput(t, "paragraph"), ModeSettings{}); got != FocusMode {
t.Fatal("disabled native policy did not preserve focus mode")
}
}
func TestDocumentModeFocusRetention(t *testing.T) {
for _, tc := range []struct {
name, role string
ancestors []string
arrow, focused bool
want DocumentMode
}{
{name: "arrow link", role: "link", arrow: true, want: FocusMode},
{name: "tab link", role: "link", want: BrowseMode},
{name: "arrow radio", role: "radio button", arrow: true, want: FocusMode},
{name: "arrow button", role: "push button", arrow: true, want: BrowseMode},
{name: "web app button", role: "push button", ancestors: []string{"embedded"}, want: FocusMode},
{name: "web app document", role: "document web", ancestors: []string{"embedded"}, want: BrowseMode},
{name: "focused tooltip", role: "tool tip", ancestors: []string{"embedded"}, focused: true, want: BrowseMode},
{name: "unfocused tooltip", role: "tool tip", ancestors: []string{"embedded"}, want: FocusMode},
} {
t.Run(tc.name, func(t *testing.T) {
input := modeInput(t, tc.role, tc.ancestors...)
input.UnmodifiedArrow, input.Context.Ancestors[0].Focused = tc.arrow, tc.focused
var state DocumentModeState
state.Toggle()
if got := state.Update(input, DefaultModeSettings()); got != tc.want {
t.Fatalf("got %v want %v", got, tc.want)
}
// Retention conditions must not themselves enter focus mode.
state = DocumentModeState{}
if got := state.Update(input, DefaultModeSettings()); got != BrowseMode {
t.Fatalf("entered focus mode: %v", got)
}
})
}
}
func TestDocumentModeStickySuspensionAndToggle(t *testing.T) {
for _, sticky := range []DocumentMode{FocusMode, BrowseMode} {
var state DocumentModeState
state.SetSticky(sticky)
if got := state.Update(ModeInput{}, DefaultModeSettings()); got != InactiveMode {
t.Fatal("web policy active outside document")
}
if state.Sticky() != sticky || state.Current() != sticky {
t.Fatal("suspension lost sticky mode")
}
input := modeInput(t, "entry")
if got := state.Update(input, DefaultModeSettings()); got != sticky {
t.Fatal("return lost sticky mode")
}
input.Context.Ancestors[0].Defunct = true
if got := state.Update(input, DefaultModeSettings()); got != InactiveMode {
t.Fatal("defunct object activated policy")
}
if got := state.Toggle(); got == sticky || state.Sticky() != InactiveMode {
t.Fatal("manual toggle failed to reverse and clear sticky mode")
}
}
}
func TestDocumentModeUnknownStates(t *testing.T) {
input := modeInput(t, "entry")
input.Context.Ancestors[0].StatesKnown = false
var state DocumentModeState
state.SetSticky(FocusMode)
if got := state.Update(input, DefaultModeSettings()); got != InactiveMode {
t.Fatalf("missing native states enabled mode: %v", got)
}
if state.Current() != FocusMode || state.Sticky() != FocusMode {
t.Fatal("unavailable states discarded remembered mode")
}
input.Context.Ancestors[0].StatesKnown = true
if got := state.Update(input, DefaultModeSettings()); got != FocusMode {
t.Fatalf("recaptured states did not restore mode: %v", got)
}
}