234 lines
8.9 KiB
Go
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")
|
|
}
|
|
}
|