295 lines
11 KiB
Go
295 lines
11 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 presentation
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
)
|
|
|
|
func TestBriefSpeechReducesOptionalRoleDetail(t *testing.T) {
|
|
options := DefaultOptions()
|
|
table := atspi.Snapshot{Role: "table", TableRowCount: 2, TableColumnCount: 3}
|
|
if got := FocusTextWithOptions(table, options); got != "table with 2 rows 3 columns" {
|
|
t.Fatalf("verbose table = %q", got)
|
|
}
|
|
options.SpeechVerbosityLevel = 0
|
|
if got := FocusTextWithOptions(table, options); got != "table" {
|
|
t.Fatalf("brief table = %q", got)
|
|
}
|
|
icon := atspi.Snapshot{Name: "Weather", Role: "icon"}
|
|
if got := FocusTextWithOptions(icon, options); got != "Weather" {
|
|
t.Fatalf("brief icon = %q", got)
|
|
}
|
|
list := atspi.Snapshot{Name: "Tasks", Role: "list", ChildCount: 3, ChildCountKnown: true}
|
|
if got := FocusTextWithOptions(list, options); got != "Tasks, list" {
|
|
t.Fatalf("brief list = %q", got)
|
|
}
|
|
options.SpeechVerbosityLevel = 1
|
|
if got := FocusTextWithOptions(list, options); got != "Tasks, 3 items" {
|
|
t.Fatalf("verbose list = %q", got)
|
|
}
|
|
list.ChildCount = 1
|
|
if got := FocusTextWithOptions(list, options); got != "Tasks, 1 item" {
|
|
t.Fatalf("singular list = %q", got)
|
|
}
|
|
list.ChildCountKnown = false
|
|
if got := FocusTextWithOptions(list, options); got != "Tasks, list" {
|
|
t.Fatalf("unknown list count = %q", got)
|
|
}
|
|
cell := atspi.Snapshot{Name: "42", Role: "table cell", TableRowHeaders: "Monday", TableColumnHeaders: "Total"}
|
|
if got := FocusTextWithOptions(cell, options); got != "Monday row header, Total column header, 42" {
|
|
t.Fatalf("verbose cell headers = %q", got)
|
|
}
|
|
options.SpeechVerbosityLevel = 0
|
|
if got := FocusTextWithOptions(cell, options); got != "Monday, Total, 42" {
|
|
t.Fatalf("brief cell headers = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestFocusText(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
snapshot atspi.Snapshot
|
|
want string
|
|
}{
|
|
{name: "all fields", snapshot: atspi.Snapshot{Name: "Save", Role: "push button"}, want: "Save, push button"},
|
|
{name: "role only", snapshot: atspi.Snapshot{Role: "text frame"}, want: "text frame"},
|
|
{name: "empty", snapshot: atspi.Snapshot{}, want: ""},
|
|
{name: "description", snapshot: atspi.Snapshot{Name: "Example", Role: "dialog", Description: "Generic description"}, want: "Example, dialog, Generic description"},
|
|
{name: "text", snapshot: atspi.Snapshot{Role: "terminal", Text: "Current terminal line"}, want: "terminal, Current terminal line"},
|
|
{name: "selected autocomplete", snapshot: atspi.Snapshot{Name: "Address and search bar", Role: "entry", Autocomplete: true, Text: "https://example.test", SelectionEnd: 20}, want: "Address and search bar, autocomplete, https://example.test, selected"},
|
|
{name: "table cell omits redundant role", snapshot: atspi.Snapshot{Name: "Budget", Role: "table cell"}, want: "Budget"},
|
|
}
|
|
|
|
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 TestFocusTextCanOmitDescription(t *testing.T) {
|
|
snapshot := atspi.Snapshot{Name: "Example", Role: "dialog", Description: "Generic description"}
|
|
options := Options{SpeakDescription: false}
|
|
if got := FocusTextWithOptions(snapshot, options); got != "Example, dialog" {
|
|
t.Fatalf("FocusTextWithOptions() = %q, want %q", got, "Example, dialog")
|
|
}
|
|
combo := atspi.Snapshot{Name: "Time format", Role: "combo box", ValueText: "24-hour"}
|
|
if got := FocusTextWithOptions(combo, options); got != "Time format, combo box, 24-hour" {
|
|
t.Fatalf("FocusTextWithOptions() combo = %q", got)
|
|
}
|
|
comboFromDescription := atspi.Snapshot{Name: "Time format", Role: "combo box", Description: "24-hour"}
|
|
if got := FocusTextWithOptions(comboFromDescription, options); got != "Time format, combo box, 24-hour" {
|
|
t.Fatalf("FocusTextWithOptions() combo description = %q", got)
|
|
}
|
|
spinFromText := atspi.Snapshot{Name: "Progress bar update frequency in seconds", Role: "spin button", Text: "10"}
|
|
if got := FocusTextWithOptions(spinFromText, options); got != "Progress bar update frequency in seconds, spin button, 10" {
|
|
t.Fatalf("FocusTextWithOptions() spin text = %q", got)
|
|
}
|
|
spinValueText := atspi.Snapshot{Name: "Zoom", Role: "spin button", Text: "150", ValueText: "150 percent"}
|
|
if got := FocusTextWithOptions(spinValueText, options); got != "Zoom, spin button, 150" {
|
|
t.Fatalf("FocusTextWithOptions() spin value text = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestEventTextUsesCaretLine(t *testing.T) {
|
|
snapshot := atspi.NewSnapshotWithCaretCharacter(
|
|
"/accessible/terminal", "", "terminal", "", "Current terminal line", "",
|
|
)
|
|
focus := atspi.NewEvent(atspi.CaretEvent, snapshot)
|
|
if got := EventText(focus); got != "Current terminal line" {
|
|
t.Fatalf("EventText() = %q, want caret line", got)
|
|
}
|
|
}
|
|
|
|
func TestEventTextPrefersCaretCharacter(t *testing.T) {
|
|
snapshot := atspi.NewSnapshotWithCaretCharacter(
|
|
"/accessible/terminal", "", "terminal", "", "prompt ls", "l",
|
|
)
|
|
event := atspi.NewEvent(atspi.CaretEvent, snapshot)
|
|
if got := EventText(event); got != "l" {
|
|
t.Fatalf("EventText() = %q, want %q", got, "l")
|
|
}
|
|
}
|
|
|
|
func TestEventTextNamesWhitespaceCaretCharacters(t *testing.T) {
|
|
tests := []struct {
|
|
character string
|
|
want string
|
|
}{
|
|
{character: " ", want: "space"},
|
|
{character: "\t", want: "tab"},
|
|
{character: "\u00a0", want: "no break space"},
|
|
{character: "\u2003", want: "blank"},
|
|
}
|
|
for _, test := range tests {
|
|
snapshot := atspi.NewSnapshotWithCaretCharacter(
|
|
"/accessible/terminal", "", "terminal", "", "prompt", test.character,
|
|
)
|
|
event := atspi.NewEvent(atspi.CaretEvent, snapshot)
|
|
if got := EventText(event); got != test.want {
|
|
t.Fatalf("EventText(%q) = %q, want %q", test.character, got, test.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEventTextUsesChangedText(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
event atspi.Event
|
|
want string
|
|
fallback string
|
|
}{
|
|
{
|
|
name: "insert",
|
|
event: atspi.NewEventWithPayload(
|
|
atspi.TextInsertedEvent,
|
|
atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "prompt l"),
|
|
"l",
|
|
),
|
|
want: "l",
|
|
},
|
|
{
|
|
name: "delete",
|
|
event: atspi.NewEventWithPayload(
|
|
atspi.TextDeletedEvent,
|
|
atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "prompt"),
|
|
"s",
|
|
),
|
|
want: "s",
|
|
},
|
|
{
|
|
name: "delete space",
|
|
event: atspi.NewEventWithPayload(
|
|
atspi.TextDeletedEvent,
|
|
atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "prompt"),
|
|
" ",
|
|
),
|
|
want: "space",
|
|
},
|
|
{
|
|
name: "empty insert payload",
|
|
event: atspi.NewEventWithPayload(
|
|
atspi.TextInsertedEvent,
|
|
atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "prompt"),
|
|
"",
|
|
),
|
|
want: "",
|
|
fallback: "terminal, prompt",
|
|
},
|
|
}
|
|
|
|
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)
|
|
}
|
|
if test.fallback == "" {
|
|
return
|
|
}
|
|
if got := FocusText(test.event.Source); got != test.fallback {
|
|
t.Fatalf("FocusText() = %q, want %q", got, test.fallback)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWebSemantics(t *testing.T) {
|
|
cases := []struct {
|
|
snapshot atspi.Snapshot
|
|
want string
|
|
}{
|
|
{atspi.Snapshot{Name: "Section", Role: "heading", HeadingLevel: 2}, "Section, heading level 2"},
|
|
{atspi.Snapshot{Name: "Section", Role: "heading"}, "Section, heading"},
|
|
{atspi.Snapshot{Name: "Section", Role: "heading", HeadingLevel: -1}, "Section, heading"},
|
|
{atspi.Snapshot{Name: "Custom", Role: "heading", HeadingLevel: 9}, "Custom, heading level 9"},
|
|
{atspi.Snapshot{Name: "Example", Role: "link", Visited: true}, "Example, visited link"},
|
|
{atspi.Snapshot{Name: "Example", Role: "link"}, "Example, link"},
|
|
{atspi.Snapshot{Name: "Save", Role: "button", HeadingLevel: 2, Visited: true}, "Save, button"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := FocusText(c.snapshot); got != c.want {
|
|
t.Errorf("got %q; want %q", got, c.want)
|
|
}
|
|
if got := EventText(atspi.NewEvent("object:state-changed:focused", c.snapshot)); got != c.want {
|
|
t.Errorf("event got %q; want %q", got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWindowActivationIncludesTopLevelTitle(t *testing.T) {
|
|
tests := []struct {
|
|
title, source, role, want string
|
|
}{
|
|
{"Editor - Notes", "Save", "button", "Editor - Notes, Save, button"},
|
|
{"Page title", "Page title", "document web", "Page title, document web"},
|
|
{"", "Save", "button", "Save, button"},
|
|
}
|
|
for _, test := range tests {
|
|
event := atspi.NewEvent(atspi.WindowActivatedEvent, atspi.Snapshot{Name: test.source, Role: test.role})
|
|
event.WindowTitle = test.title
|
|
if got := EventText(event); got != test.want {
|
|
t.Errorf("title %q: got %q, want %q", test.title, got, test.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGUITablePresentation(t *testing.T) {
|
|
table := atspi.Snapshot{Name: "Desktop items", Role: "table", TableRowCount: 11, TableColumnCount: 2}
|
|
if got := FocusText(table); got != "Desktop items, table with 11 rows 2 columns" {
|
|
t.Fatalf("table focus = %q", got)
|
|
}
|
|
cell := atspi.Snapshot{Name: "Steam", Text: "Steam", Role: "table cell", TableName: "Desktop items",
|
|
TableRowCount: 11, TableColumnCount: 2, TableRowText: "Steam, Shortcut", GUITable: true}
|
|
if got := EventText(atspi.NewEvent(atspi.SelectionChangedEvent, cell)); got != "Steam, Shortcut" {
|
|
t.Fatalf("selected row = %q", got)
|
|
}
|
|
if got := EventTextWithOptions(atspi.NewEvent(atspi.SelectionChangedEvent, cell),
|
|
Options{ReadFullRowInGUITable: false}); got != "Steam" {
|
|
t.Fatalf("single cell setting = %q", got)
|
|
}
|
|
activation := atspi.NewEvent(atspi.WindowActivatedEvent, cell)
|
|
activation.WindowTitle = "I38 Desktop"
|
|
activation.WindowDescription = "Select a desktop item and press Enter to open it."
|
|
if got := EventText(activation); got != "I38 Desktop, Desktop items, table with 11 rows 2 columns, Steam, Shortcut, Select a desktop item and press Enter to open it." {
|
|
t.Fatalf("window activation = %q", got)
|
|
}
|
|
if got := EventTextWithOptions(activation, Options{SpeakDescription: false}); strings.Contains(got, "Select a desktop item") {
|
|
t.Fatalf("disabled description = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestDocumentLoadAnnouncesOnlyTitle(t *testing.T) {
|
|
for _, tc := range []struct{ role, name, want string }{
|
|
{"document web", "Page title", "Page title"},
|
|
{"document frame", "Page title", "Page title"},
|
|
{"document web", "", ""},
|
|
{"entry", "Address bar", ""},
|
|
} {
|
|
snapshot := atspi.NewSnapshot("/doc", tc.name, tc.role, "Description", "Page contents")
|
|
if got := EventText(atspi.NewEvent(atspi.DocumentLoadCompleteEvent, snapshot)); got != tc.want {
|
|
t.Fatalf("%s: got %q, want %q", tc.role, got, tc.want)
|
|
}
|
|
}
|
|
}
|