// 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" "os" "os/exec" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/presentation" ) func TestNativeGUIListTableSpeech(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_GUI_LIST_TABLE") != "1" { t.Skip("requires isolated GTK table with list role") } ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) defer cancel() adapter := atspi.New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer func() { stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) defer stopCancel() if err := adapter.Stop(stopCtx); err != nil { t.Errorf("stop adapter: %v", err) } }() tree, err := adapter.CaptureDesktop(ctx) if err != nil { t.Fatal(err) } var entry atspi.Snapshot var window atspi.ObjectID root, err := tree.Root() if err != nil { t.Fatal(err) } pending := []atspi.ObjectReference{root} for len(pending) > 0 { reference := pending[len(pending)-1] pending = pending[:len(pending)-1] snapshot, err := tree.Resolve(reference) if err != nil { t.Fatal(err) } if snapshot.Name == "Command" { entry = snapshot } if snapshot.Name == "Table presentation probe" { window = snapshot.ID() } for child, ok, err := tree.FirstChild(reference); ; child, ok, err = tree.NextSibling(child) { if err != nil { t.Fatal(err) } if !ok { break } pending = append(pending, child) } } if !entry.ID().Valid() || !window.Valid() { t.Fatal("launcher entry or window missing") } queue := &fakeSpeechQueue{spoken: make(chan string, 32), requests: make(chan speechRequest, 32)} logger := slog.New(slog.DiscardHandler) state := &eventLoopState{} state.presentationOptions = presentation.DefaultOptions() state.focusedID, state.current = entry.ID(), entry presenter := eventPresenter{state: state, logger: logger, speech: queue} handler := accessibilityEventHandler{state: state, logger: logger} speaker := &fakeSpeaker{spoken: make(chan string, 64)} worker := newSpeechWorker(logger, speaker) worker.reading = func() config.ReadingSettings { return config.DefaultSettings().Reading } worker.verbosity = func() int { return 1 } activation := atspi.NewEvent(atspi.WindowActivatedEvent, entry) activation.Window, activation.WindowTitle = window, "Table presentation probe" activation.WindowDescription = "Select an item and press Enter to open it." presenter.speak(activation) deliver := func() { t.Helper() for len(queue.requests) > 0 { if err := worker.deliver(ctx, <-queue.requests); err != nil { t.Fatal(err) } } } deliver() for index, step := range []struct{ key, name string }{{"Down", "Alpha"}, {"Down", "Beta"}, {"shift+Tab", "Command"}} { if err := exec.Command("xdotool", "key", step.key).Run(); err != nil { t.Fatal(err) } matched: for { select { case <-ctx.Done(): t.Fatalf("no focus/selection for %s", step.name) case event := <-adapter.Events(): if !isFocusGainEvent(event) && !isManagedDescendantEvent(event.Type) { continue } if !handler.acceptFocusedEvent(event, input.Activity{Sequence: uint64(index + 1)}) { presenter.speak(event) } deliver() if event.Source.Name == step.name { break matched } } } } var parts []string for len(speaker.spoken) > 0 { parts = append(parts, <-speaker.spoken) } text := strings.Join(parts, " | ") t.Log(text) if strings.Count(text, activation.WindowDescription) != 1 || strings.Contains(text, "Leaving ") || strings.Count(text, "Alpha") != 1 || strings.Count(text, "Beta") != 1 || strings.Count(text, "Probe items") != 1 { t.Fatalf("unexpected launcher speech: %s", text) } } func TestGUIListTableNameWithoutBrowseContext(t *testing.T) { for _, contextEnabled := range []bool{true, false} { state := &eventLoopState{} state.presentationOptions = presentation.DefaultOptions() reading := config.DefaultSettings().Reading reading.SpeakContextList = contextEnabled state.presentationOptions.Reading = &reading list := atspi.Snapshot{BusName: ":1.24", Path: "/results", Role: "list", Name: "Results"} cell := atspi.Snapshot{BusName: list.BusName, Path: "/results/alpha", Name: "Alpha", Role: "table cell", GUITable: true, TablePath: list.Path, TableName: list.Name, SpeechContext: []atspi.Snapshot{list}} event := atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, cell) result := state.prepareGUITable(event, 1) spoken, _ := state.decorateGUITable(event, presentation.Speech{Text: "Alpha"}, result) want := "Results, Alpha" if spoken.Text != want { t.Fatalf("list context enabled=%t: got %q, want %q", contextEnabled, spoken.Text, want) } } } func TestTableDescriptionAlreadyReadWithEntryActivation(t *testing.T) { queue := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)} state := &eventLoopState{} state.presentationOptions = presentation.DefaultOptions() presenter := eventPresenter{state: state, logger: slog.New(slog.DiscardHandler), speech: queue} window := atspi.NewObjectID(":1.24", "/launcher") entry := atspi.NewSnapshot("/launcher/entry", "Command", "text", "", "") entry.BusName = window.BusName event := atspi.NewEvent(atspi.WindowActivatedEvent, entry) event.Window, event.WindowTitle = window, "Launcher" event.WindowDescription = "Choose a result and press Enter." presenter.speak(event) if got := (<-queue.requests).Text; got != "Launcher, Command, text, Choose a result and press Enter." { t.Fatalf("entry activation = %q", got) } cell := atspi.NewSnapshot("/launcher/results/alpha", "Alpha", "table cell", "", "") cell.BusName = window.BusName event.Type, event.Source = atspi.SelectionChangedEvent, cell presenter.speak(event) if got := (<-queue.requests).Text; got != "Alpha" { t.Fatalf("first result repeated window description: %q", got) } // Changed help and help in another window must still be presented. event.WindowDescription = "Updated result help." presenter.speak(event) if got := (<-queue.requests).Text; got != "Alpha, Updated result help." { t.Fatalf("changed description = %q", got) } event.Window = atspi.NewObjectID(window.BusName, "/other") event.Source.Path = "/other/results/alpha" presenter.speak(event) if got := (<-queue.requests).Text; got != "Alpha, Updated result help." { t.Fatalf("other window description = %q", got) } // Opening with descriptions disabled must not consume help which becomes // enabled before the user enters the results. state.presentationOptions.SpeakDescription = false event.Type, event.Source, event.Window = atspi.WindowActivatedEvent, entry, window presenter.speak(event) <-queue.requests state.presentationOptions.SpeakDescription = true event.Type, event.Source = atspi.SelectionChangedEvent, cell presenter.speak(event) if got := (<-queue.requests).Text; got != "Alpha, Updated result help." { t.Fatalf("newly enabled description = %q", got) } } 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") } }