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