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