220 lines
9.7 KiB
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)
|
|
}
|
|
}
|