Files

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