// 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 app import ( "context" "log/slog" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" ) func TestTableWindowDescriptionSurvivesImmediateTableFocus(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 3), requests: make(chan speechRequest, 3)} adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} ctx, cancel := context.WithCancel(context.Background()) defer cancel() completed := make(chan struct{}) go func() { processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) close(completed) }() window := atspi.NewObjectID(":1.20", "/desktop") cell := atspi.NewSnapshot("/desktop/table/item", "Alpha", "table cell", "", "") cell.BusName = window.BusName cell.TableName = "Desktop items" cell.TableRowCount, cell.TableColumnCount = 2, 2 cell.TableRowText, cell.GUITable = "Alpha, Document", true activated := atspi.NewEvent(atspi.WindowActivatedEvent, cell) activated.Window = window activated.WindowTitle = "Desktop" activated.WindowDescription = "Select an item and press Enter to open it." adapter.events <- activated table := atspi.NewSnapshot("/desktop/table", "Desktop items", "table", "", "") table.BusName = window.BusName table.TableRowCount, table.TableColumnCount = 2, 2 focused := atspi.NewEvent(atspi.FocusedStateEvent, table) focused.Window = window focused.Detail1 = 1 adapter.events <- focused for index, expected := range []struct { text string append bool }{ {"Desktop, Desktop items, table with 2 rows 2 columns, Alpha, Document, Select an item and press Enter to open it.", false}, {"Desktop items, table with 2 rows 2 columns", true}, } { select { case request := <-speech.requests: if request.Text != expected.text || request.Append != expected.append { t.Fatalf("request %d = %q append %t, want %q append %t", index, request.Text, request.Append, expected.text, expected.append) } case <-time.After(time.Second): t.Fatalf("timed out waiting for request %d", index) } } cancel() <-completed } func TestTableDescriptionArrivingAfterActivation(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 3), requests: make(chan speechRequest, 3)} adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} ctx, cancel := context.WithCancel(context.Background()) defer cancel() completed := make(chan struct{}) go func() { processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) close(completed) }() window := atspi.NewObjectID(":1.21", "/desktop") table := atspi.NewSnapshot("/desktop/table", "Desktop items", "table", "", "") table.BusName = window.BusName activated := atspi.NewEvent(atspi.WindowActivatedEvent, table) activated.Window = window activated.WindowTitle = "Desktop" adapter.events <- activated first := <-speech.requests if first.Text != "Desktop, Desktop items, table" { t.Fatalf("activation = %q", first.Text) } table.TableRowCount, table.TableColumnCount = 2, 2 focused := atspi.NewEvent(atspi.FocusedStateEvent, table) focused.Window = window focused.WindowDescription = "Select an item and press Enter to open it." focused.Detail1 = 1 adapter.events <- focused second := <-speech.requests if second.Text != "Desktop items, table with 2 rows 2 columns, Select an item and press Enter to open it." { t.Fatalf("focused table = %q", second.Text) } cell := atspi.NewSnapshot("/desktop/table/first", "Alpha", "table cell", "", "") cell.BusName = window.BusName cell.TablePath = table.Path cell.TableRowText, cell.GUITable = "Alpha, Document", true cell.TablePositionKnown = true third := atspi.NewEvent(atspi.FocusedStateEvent, cell) third.Window = window third.Detail1 = 1 adapter.events <- third request := <-speech.requests if request.Text != "Alpha, Document" || !request.Append { t.Fatalf("following cell = %q append %t", request.Text, request.Append) } cancel() <-completed } func TestGUITableHorizontalMovementReadsOneCell(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)} adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} inputs := &sequencedInputActivity{} ctx, cancel := context.WithCancel(context.Background()) defer cancel() completed := make(chan struct{}) go func() { processEventsWithInput(ctx, slog.New(slog.DiscardHandler), speech, adapter, nil, nil, 0, nil, inputs, nil, nil) close(completed) }() window := atspi.NewObjectID(":1.22", "/desktop") table := atspi.NewSnapshot("/desktop/table", "Desktop items", "table", "", "") table.BusName = window.BusName activated := atspi.NewEvent(atspi.WindowActivatedEvent, table) activated.Window = window adapter.events <- activated <-speech.requests cellEvent := func(path, name, rowText string, row, column int, eventType string) atspi.Event { cell := atspi.NewSnapshot(path, name, "table cell", "", "") cell.BusName = window.BusName cell.TablePath = table.Path cell.TableRowIndex, cell.TableColumnIndex = row, column cell.TablePositionKnown, cell.GUITable = true, true cell.TableRowText = rowText event := atspi.NewManagedDescendantEvent(eventType, table.Path, cell) event.Window = window return event } for _, step := range []struct { sequence uint64 event atspi.Event want string }{ {1, cellEvent("/desktop/table/alpha", "Alpha", "Alpha, Document", 0, 0, atspi.SelectionChangedEvent), "Alpha, Document"}, {2, cellEvent("/desktop/table/document", "Document", "Alpha, Document", 0, 1, atspi.ActiveDescendantEvent), "Document"}, {3, cellEvent("/desktop/table/alpha", "Alpha", "Alpha, Document", 0, 0, atspi.ActiveDescendantEvent), "Alpha"}, {4, cellEvent("/desktop/table/beta", "Beta", "Beta, Shortcut", 1, 0, atspi.SelectionChangedEvent), "Beta, Shortcut"}, {4, cellEvent("/desktop/table/replacement", "Renamed", "Renamed, Shortcut", 1, 0, atspi.ActiveDescendantEvent), "Renamed"}, } { inputs.set(step.sequence) adapter.events <- step.event select { case request := <-speech.requests: if request.Text != step.want { t.Fatalf("sequence %d = %q, want %q", step.sequence, request.Text, step.want) } case <-time.After(time.Second): t.Fatalf("sequence %d produced no speech", step.sequence) } } cancel() <-completed } func TestGUITableReusedCurrentCellReadsReplacementRow(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 4), requests: make(chan speechRequest, 4)} adapter := &fakeAdapter{events: make(chan atspi.Event, 4)} ctx, cancel := context.WithCancel(context.Background()) completed := make(chan struct{}) go func() { processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) close(completed) }() defer func() { cancel(); <-completed }() cell := atspi.NewSnapshot("/mail/messages/row", "First subject", "table cell", "", "") cell.BusName = ":1.23" cell.TablePath = "/mail/messages" cell.TableRowIndex, cell.TableColumnIndex = 0, 0 cell.TablePositionKnown, cell.GUITable = true, true cell.TableRowText = "First subject, First sender" focus := atspi.NewEvent(atspi.FocusedStateEvent, cell) focus.Detail1 = 1 adapter.events <- focus if got := (<-speech.requests).Text; got != cell.TableRowText { t.Fatalf("initial row = %q", got) } replacement := cell replacement.Name = "Next subject" replacement.TableRowText = "Next subject, Next sender" selected := atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, cell.TablePath, replacement) adapter.events <- selected select { case request := <-speech.requests: if request.Text != replacement.TableRowText { t.Fatalf("replacement row = %q, want %q", request.Text, replacement.TableRowText) } case <-time.After(time.Second): t.Fatal("replacement row was not spoken") } // A name event is another way a provider can expose a reused row. third := replacement third.Name = "Third subject" third.TableRowText = "Third subject, Third sender" nameChanged := atspi.NewEvent(atspi.AccessibleNameEvent, third) adapter.events <- nameChanged select { case request := <-speech.requests: if request.Text != third.TableRowText { t.Fatalf("renamed row = %q, want %q", request.Text, third.TableRowText) } case <-time.After(time.Second): t.Fatal("renamed current row was not spoken") } }