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