Files

234 lines
8.9 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 atspi
import (
"strings"
"testing"
)
func TestShouldSpeakEvent(t *testing.T) {
tests := []struct {
name string
eventType string
detail1 int
want bool
}{
{name: "focus event", eventType: FocusEvent, detail1: 0, want: true},
{name: "focused state", eventType: FocusedStateEvent, detail1: 1, want: true},
{name: "unfocused state", eventType: FocusedStateEvent, detail1: 0, want: false},
{name: "window activation", eventType: WindowActivatedEvent, detail1: 0, want: true},
{name: "value changed", eventType: ValueChangedEvent, detail1: 0, want: true},
{name: "other state", eventType: "object:state-changed:checked", detail1: 1, want: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := shouldSpeakEvent(test.eventType, test.detail1); got != test.want {
t.Fatalf("shouldSpeakEvent(%q, %d) = %v, want %v", test.eventType, test.detail1, got, test.want)
}
})
}
}
func TestTextChangeOriginIsCanonicalized(t *testing.T) {
for _, test := range []struct{ incoming, want string }{
{TextInsertedEvent + ":system", TextInsertedEvent},
{TextDeletedEvent + ":system", TextDeletedEvent},
{TextInsertedEvent, TextInsertedEvent},
{TextDeletedEvent, TextDeletedEvent},
{"object:text-caret-moved", "object:text-caret-moved"},
{"object:text-changed:insertion", "object:text-changed:insertion"},
} {
if got := canonicalTextChangeEventType(test.incoming); got != test.want {
t.Errorf("canonicalTextChangeEventType(%q) = %q, want %q", test.incoming, got, test.want)
}
if got := NewEvent(test.incoming, Snapshot{}).Type; got != test.want {
t.Errorf("NewEvent(%q).Type = %q, want %q", test.incoming, got, test.want)
}
if test.incoming != test.want && NewEvent(test.incoming, Snapshot{}).NativeType != test.incoming {
t.Errorf("NewEvent(%q) lost its native type", test.incoming)
}
}
}
func TestFirefoxChromeAutocompleteBoundary(t *testing.T) {
address := Snapshot{ApplicationProcess: "firefox", Role: "combo box", Editable: true, HasPopup: true}
if !isFirefoxChromeAutocomplete(address, false) {
t.Fatal("Firefox chrome address bar was not recognized")
}
for _, test := range []struct {
name string
snapshot Snapshot
inDocument bool
}{
{"page form", address, true},
{"Thunderbird", Snapshot{ApplicationProcess: "thunderbird", Role: "combo box", Editable: true, HasPopup: true}, false},
{"Chromium", Snapshot{ApplicationProcess: "chromium", Role: "combo box", Editable: true, HasPopup: true}, false},
{"read only", Snapshot{ApplicationProcess: "firefox", Role: "combo box", ReadOnly: true, Editable: true, HasPopup: true}, false},
{"no popup", Snapshot{ApplicationProcess: "firefox", Role: "combo box", Editable: true}, false},
} {
if isFirefoxChromeAutocomplete(test.snapshot, test.inDocument) {
t.Errorf("%s was classified as Firefox chrome autocomplete", test.name)
}
}
}
func TestAdapterRegistersWindowLifecycle(t *testing.T) {
for _, wanted := range []string{WindowActivatedEvent, WindowDeactivatedEvent, WindowDestroyedEvent} {
found := false
for _, eventType := range eventTypes {
if eventType == wanted {
found = true
break
}
}
if !found {
t.Fatalf("window lifecycle event %q is not registered", wanted)
}
}
}
func TestNewSnapshotSanitizesFields(t *testing.T) {
snapshot := NewSnapshot("/org/a11y/atspi/accessible/1", " Generic\r\nname ", "text frame", "Invalid: \xff", "Current\r\nline ")
if snapshot.Path != "/org/a11y/atspi/accessible/1" {
t.Fatalf("path = %q", snapshot.Path)
}
if snapshot.Name != "Generic name" {
t.Fatalf("name = %q, want %q", snapshot.Name, "Generic name")
}
if snapshot.Description != "Invalid: �" {
t.Fatalf("description = %q, want %q", snapshot.Description, "Invalid: �")
}
}
func TestObjectIDIncludesBusNameAndPath(t *testing.T) {
snapshot := NewSnapshot("/org/a11y/atspi/accessible/1", "Example", "push button", "", "")
snapshot.BusName = ":1.42"
if got := snapshot.ID(); got != (ObjectID{BusName: ":1.42", Path: "/org/a11y/atspi/accessible/1"}) {
t.Fatalf("object ID = %#v", got)
}
if NewObjectID(" :1.42\n", " /org/a11y/atspi/accessible/1 ") != snapshot.ID() {
t.Fatal("new object ID was not sanitized")
}
if !(ObjectID{BusName: "org.a11y.atspi.Registry", Path: "/org/a11y/atspi/accessible/root"}).Valid() {
t.Fatal("well-known registry identity is invalid")
}
for _, invalid := range []ObjectID{
{BusName: "missing-dot", Path: "/object"},
{BusName: "7org.example", Path: "/object"},
{BusName: "org.example", Path: "relative"},
{BusName: "org.example", Path: "/invalid path"},
{BusName: "org.example", Path: "/double//separator"},
} {
if invalid.Valid() {
t.Fatalf("invalid identity accepted: %#v", invalid)
}
}
}
func TestNewSnapshotLimitsFieldLength(t *testing.T) {
for _, character := range []string{"é", "€", "😀"} {
t.Run(character, func(t *testing.T) {
snapshot := NewSnapshot("", strings.Repeat(character, 5000), "", "", "")
if len(snapshot.Name) > maxSnapshotFieldLength {
t.Fatalf("name length = %d, want <= %d", len(snapshot.Name), maxSnapshotFieldLength)
}
if !strings.HasSuffix(snapshot.Name, character) {
t.Fatal("field truncation split a UTF-8 rune")
}
})
}
}
func TestNewSnapshotWithCaretCharacter(t *testing.T) {
snapshot := NewSnapshotWithCaretCharacter(
"/accessible/terminal", "", "terminal", "", "prompt ls", "l",
)
if snapshot.CaretCharacter != "l" {
t.Fatalf("caret character = %q, want %q", snapshot.CaretCharacter, "l")
}
}
func TestNewSnapshotPreservesCaretSpace(t *testing.T) {
snapshot := NewSnapshotWithCaretCharacter("", "", "", "", "", " space ")
if snapshot.CaretCharacter != " " {
t.Fatalf("caret character = %q, want %q", snapshot.CaretCharacter, " ")
}
}
func TestNewEventWithPayloadSanitizesPayload(t *testing.T) {
event := NewEventWithPayload(TextInsertedEvent, NewSnapshot("/accessible/terminal", "", "terminal", "", ""), "l\r\n")
if event.Type != TextInsertedEvent {
t.Fatalf("type = %q, want %q", event.Type, TextInsertedEvent)
}
if event.Payload != "l" {
t.Fatalf("payload = %q, want %q", event.Payload, "l")
}
}
func TestNewEventWithPayloadPreservesInsertedSpace(t *testing.T) {
event := NewEventWithPayload(TextInsertedEvent, NewSnapshot("/accessible/editor", "", "text", "", ""), " ")
if event.Payload != " " {
t.Fatalf("payload = %q, want one space", event.Payload)
}
}
func TestNewEventWithPayloadPreservesTextDelimiters(t *testing.T) {
snapshot := NewSnapshot("/accessible/editor", "", "text", "", "")
for input, want := range map[string]string{"\t": "\t", "\n": "\n", "\r": "\n", "\r\n": "\n"} {
event := NewEventWithPayload(TextInsertedEvent, snapshot, input)
if event.Payload != want {
t.Fatalf("payload for %q = %q, want %q", input, event.Payload, want)
}
}
}
func TestSanitizeTextContextPreservesBoundariesAndLimitsLength(t *testing.T) {
context := sanitizeTextContext("First. Second. \r\n")
if context != "First. Second. \n\n" {
t.Fatalf("context = %q", context)
}
context = sanitizeTextContext(strings.Repeat("é", maxSnapshotFieldLength))
if len(context) > maxSnapshotFieldLength || !strings.HasPrefix(context, "é") {
t.Fatalf("bounded context has %d bytes and invalid rune boundary", len(context))
}
}
func TestNewManagedDescendantEventPreservesOrigin(t *testing.T) {
source := NewSnapshot("/accessible/result", "Result", "table cell", "", "")
event := NewManagedDescendantEvent(SelectionChangedEvent, " /accessible/list\n", source)
if event.Source.Path != "/accessible/result" {
t.Fatalf("source path = %q", event.Source.Path)
}
if event.OriginPath != "/accessible/list" {
t.Fatalf("origin path = %q", event.OriginPath)
}
}
func TestBrowserChromeTabExcludesDocumentWidgets(t *testing.T) {
for _, toolkit := range []string{"Chromium", "Gecko", "WebKitGTK"} {
tab := Snapshot{Role: "page tab", ToolkitName: toolkit}
if !isBrowserChromeTab(tab, false) || isBrowserChromeTab(tab, true) {
t.Fatalf("chrome/document classification for %s", toolkit)
}
}
if isBrowserChromeTab(Snapshot{Role: "page tab", ToolkitName: "gtk"}, false) ||
isBrowserChromeTab(Snapshot{Role: "button", ToolkitName: "Chromium"}, false) {
t.Fatal("ordinary application control classified as browser tab")
}
}