// 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 presentation import ( "testing" "git.stormux.org/storm/cthulhu/internal/atspi" ) func TestFocusTextPresentsControlStates(t *testing.T) { tests := []struct { name string snapshot atspi.Snapshot want string }{ {name: "unchecked checkbox", snapshot: atspi.Snapshot{Name: "Updates", Role: "check box"}, want: "Updates, check box, not checked"}, {name: "checked checkbox", snapshot: atspi.Snapshot{Name: "Updates", Role: "check box", Checked: true}, want: "Updates, check box, checked"}, {name: "mixed checkbox", snapshot: atspi.Snapshot{Name: "Updates", Role: "check box", Indeterminate: true}, want: "Updates, check box, partially checked"}, {name: "selected radio", snapshot: atspi.Snapshot{Name: "Automatic", Role: "radio button", Checked: true}, want: "Automatic, radio button, selected"}, {name: "radio group", snapshot: atspi.Snapshot{Name: "Some", Role: "radio button", RadioGroupName: "Punctuation Level", Checked: true}, want: "Punctuation Level, Some, radio button, selected"}, {name: "switch off", snapshot: atspi.Snapshot{Name: "Network", Role: "switch"}, want: "Network, switch, off"}, {name: "toggle pressed", snapshot: atspi.Snapshot{Name: "Bold", Role: "toggle button", Checked: true}, want: "Bold, toggle button, pressed"}, {name: "collapsed item", snapshot: atspi.Snapshot{Name: "Documents", Role: "tree item", Expandable: true}, want: "Documents, tree item, collapsed"}, {name: "expanded selected item", snapshot: atspi.Snapshot{Name: "Documents", Role: "tree item", Expandable: true, Expanded: true, Selected: true}, want: "Documents, tree item, expanded, selected"}, {name: "selected menu item", snapshot: atspi.Snapshot{Name: "Open", Role: "menu item", Selected: true}, want: "Open, menu item"}, {name: "unchecked generic menu", snapshot: atspi.Snapshot{Name: "Updates", Role: "menu item", Checkable: true}, want: "Updates, check menu item, not checked"}, {name: "checked generic menu without marker", snapshot: atspi.Snapshot{Name: "Updates", Role: "menu item", Checked: true}, want: "Updates, check menu item, checked"}, {name: "mixed generic menu", snapshot: atspi.Snapshot{Name: "Updates", Role: "menu item", Indeterminate: true}, want: "Updates, check menu item, partially checked"}, {name: "ordinary menu", snapshot: atspi.Snapshot{Name: "Open", Role: "menu item"}, want: "Open, menu item"}, {name: "selected table cell", snapshot: atspi.Snapshot{Name: "First post", Role: "table cell", Selected: true}, want: "First post"}, {name: "field requirements", snapshot: atspi.Snapshot{Name: "Account", Role: "text", ReadOnly: true, Required: true, Invalid: true}, want: "Account, text, read only, required, invalid entry"}, {name: "disabled control", snapshot: atspi.Snapshot{Name: "Apply", Role: "push button", SensitiveKnown: true}, want: "Apply, push button, grayed"}, {name: "unknown sensitivity", snapshot: atspi.Snapshot{Name: "Apply", Role: "push button"}, want: "Apply, push button"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if got := FocusText(test.snapshot); got != test.want { t.Fatalf("FocusText() = %q, want %q", got, test.want) } }) } } func TestEventTextPresentsOnlyChangedState(t *testing.T) { tests := []struct { name string event atspi.Event want string }{ {name: "checked", event: atspi.NewEvent("object:state-changed:checked", atspi.Snapshot{Name: "Updates", Role: "check box", Checked: true}), want: "checked"}, {name: "generic menu checked", event: atspi.NewEvent("object:state-changed:checked", atspi.Snapshot{Name: "Updates", Role: "menu item", Checkable: true, Checked: true}), want: "checked"}, {name: "generic menu unchecked without marker", event: atspi.NewEvent("object:state-changed:checked", atspi.Snapshot{Name: "Updates", Role: "menu item"}), want: "not checked"}, {name: "expanded", event: atspi.NewEvent("object:state-changed:expanded", atspi.Snapshot{Name: "Files", Role: "tree item", Expanded: true}), want: "expanded"}, {name: "menu selection omitted", event: atspi.NewEvent("object:state-changed:selected", atspi.Snapshot{Name: "Open", Role: "menu item", Selected: true}), want: ""}, {name: "tree selection", event: atspi.NewEvent("object:state-changed:selected", atspi.Snapshot{Name: "Files", Role: "tree item", Selected: true}), want: "selected"}, {name: "table cell selected", event: atspi.NewEvent("object:state-changed:selected", atspi.Snapshot{Name: "First post", Role: "table cell", Selected: true}), want: "selected"}, {name: "table cell unselected", event: atspi.NewEvent("object:state-changed:selected", atspi.Snapshot{Name: "First post", Role: "table cell"}), want: ""}, {name: "available omitted", event: atspi.NewEvent("object:state-changed:sensitive", atspi.Snapshot{Name: "Save", Role: "push button", SensitiveKnown: true, Sensitive: true}), want: ""}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if got := EventText(test.event); got != test.want { t.Fatalf("EventText() = %q, want %q", got, test.want) } }) } } func TestEventTextPresentsValueWithoutRepeatingControl(t *testing.T) { snapshot := atspi.Snapshot{Name: "Volume", Role: "slider", ValueKnown: true, CurrentValue: 42, MinimumValue: 0, MaximumValue: 100} event := atspi.NewEvent(atspi.ValueChangedEvent, snapshot) if got := EventText(event); got != "42" { t.Fatalf("EventText() = %q, want %q", got, "42") } } func TestFocusTextPresentsControlValues(t *testing.T) { tests := []struct { name string snapshot atspi.Snapshot want string }{ { name: "application value text", snapshot: atspi.Snapshot{Name: "Volume", Role: "slider", ValueKnown: true, CurrentValue: 50, MinimumValue: 0, MaximumValue: 100, ValueText: "medium"}, want: "Volume, slider, medium", }, { name: "combo selected item", snapshot: atspi.Snapshot{Name: "Time format", Role: "combo box", ValueText: "24-hour"}, want: "Time format, combo box, 24-hour", }, { name: "spin button actual value from nonzero range", snapshot: atspi.Snapshot{Name: "Zoom", Role: "spin button", ValueKnown: true, CurrentValue: 150, MinimumValue: 100, MaximumValue: 200}, want: "Zoom, spin button, 150", }, { name: "rounded percentage", snapshot: atspi.Snapshot{Name: "Download", Role: "progress bar", ValueKnown: true, CurrentValue: 1, MinimumValue: 0, MaximumValue: 3}, want: "Download, progress bar, 33 percent", }, { name: "unknown value", snapshot: atspi.Snapshot{Name: "Volume", Role: "slider", CurrentValue: 50, MinimumValue: 0, MaximumValue: 100}, want: "Volume, slider", }, { name: "actual value remains available for a fixed range", snapshot: atspi.Snapshot{Name: "Volume", Role: "slider", ValueKnown: true, CurrentValue: 50, MinimumValue: 100, MaximumValue: 100}, want: "Volume, slider, 50", }, { name: "value excluded from unrelated role", snapshot: atspi.Snapshot{Name: "Save", Role: "push button", ValueKnown: true, CurrentValue: 50, MinimumValue: 0, MaximumValue: 100, ValueText: "medium"}, want: "Save, push button", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if got := FocusText(test.snapshot); got != test.want { t.Fatalf("FocusText() = %q, want %q", got, test.want) } }) } } func TestNumericValueUsesProviderPrecision(t *testing.T) { for _, test := range []struct { name string value, increment float64 want string }{ {"integer", 150, 1, "150"}, {"hundredths", 1.25, 0.01, "1.25"}, {"quarter step", 1.25, 0.25, "1.2"}, {"zero increment fractional", 0.5, 0, "0.5"}, {"negative fractional", -0.5, 0, "-0.5"}, {"invalid increment", 1, -1, ""}, } { t.Run(test.name, func(t *testing.T) { for _, role := range []string{"slider", "spin button", "dial", "scroll bar", "split pane"} { snapshot := atspi.Snapshot{Role: role, ValueKnown: true, CurrentValue: test.value, MinimumIncrement: test.increment, MinimumValue: -100, MaximumValue: 200} if got := EventText(atspi.NewEvent(atspi.ValueChangedEvent, snapshot)); got != test.want { t.Fatalf("%s: value = %q, want %q", role, got, test.want) } } }) } } func TestSliderFocusUsesCurrentValueInsteadOfStaleText(t *testing.T) { for _, tc := range []struct { name, text, valueText, description, want string valueKnown bool }{ {"previous value", "4.0", "", "", "4.5", true}, {"previous tenth", "4.4", "", "", "4.5", true}, {"same value different formatting", "4.50", "", "", "4.5", true}, {"provider value text", "4.0", "medium", "", "medium", true}, {"description", "4.0", "", "Voice pitch", "4.5, Voice pitch", true}, {"text without value", "4.0", "", "", "4.0", false}, } { t.Run(tc.name, func(t *testing.T) { snapshot := atspi.Snapshot{Name: "Pitch:", Role: "slider", Text: tc.text, ValueKnown: tc.valueKnown, CurrentValue: 4.5, MinimumIncrement: 0.1, ValueText: tc.valueText, Description: tc.description} if got := FocusText(snapshot); got != "Pitch:, slider, "+tc.want { t.Fatalf("focus speech = %q", got) } options := DefaultOptions() reading := reading_options(options) reading.OnlySpeakDisplayedText = true options.Reading = &reading want := tc.want if tc.description != "" { want = "4.5" } if got := FocusTextWithOptions(snapshot, options); got != "Pitch: "+want { t.Fatalf("displayed-only speech = %q", got) } }) } }