// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "log/slog" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" ) func TestGUITableNormalizedFocusRetainsOwnerAndTitle(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 16), requests: make(chan speechRequest, 16)} adapter := &fakeAdapter{events: make(chan atspi.Event, 16)} 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", "/preferences") cell_event := 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, cell.TableName = "/preferences/reading", "Reading settings" cell.TableRowIndex, cell.TableColumnIndex = row, column cell.TablePositionKnown, cell.GUITable = true, true cell.TableRowText = rowText event := atspi.NewManagedDescendantEvent(eventType, cell.TablePath, cell) event.Window = window if eventType == atspi.FocusedStateEvent { event.Detail1 = 1 } return event } expect_speech := func(event atspi.Event, sequence uint64, want string) { t.Helper() inputs.set(sequence) adapter.events <- event select { case request := <-speech.requests: if request.Text != want { t.Fatalf("%s: got %q, want %q", event.Type, request.Text, want) } case <-time.After(time.Second): t.Fatalf("%s produced no speech", event.Type) } } // The adapter resolves the table's initial focus to a selected cell. GTK // need not send another focused-state event as Up/Down changes rows. first := cell_event("/reading/0/state", "unchecked", "unchecked, Only speak displayed text", 0, 0, atspi.FocusedStateEvent) expect_speech(first, 1, "Reading settings, unchecked, Only speak displayed text") second := cell_event("/reading/1/state", "checked", "checked, Break speech into chunks between pauses", 1, 0, atspi.SelectionChangedEvent) expect_speech(second, 2, "checked, Break speech into chunks between pauses") duplicate := second duplicate.Type = atspi.ActiveDescendantEvent adapter.events <- duplicate right := cell_event("/reading/1/label", "Break speech into chunks between pauses", second.Source.TableRowText, 1, 1, atspi.ActiveDescendantEvent) expect_speech(right, 3, "Break speech into chunks between pauses") left := second left.Type = atspi.ActiveDescendantEvent expect_speech(left, 4, "checked") up := first up.Type = atspi.SelectionChangedEvent expect_speech(up, 5, "unchecked, Only speak displayed text") // Background tables and another bus cannot inherit the focused table. other := second other.OriginPath, other.Source.TablePath = "/unrelated", "/unrelated" other.Source.Path = "/unrelated/cell" adapter.events <- other other.OriginPath, other.Source.TablePath = first.OriginPath, first.Source.TablePath other.Source.BusName = ":1.99" adapter.events <- other expect_speech(second, 6, "checked, Break speech into chunks between pauses") button := atspi.NewSnapshot("/preferences/apply", "Apply", "button", "", "") button.BusName = window.BusName focus := atspi.NewEvent(atspi.FocusEvent, button) focus.Window = window expect_speech(focus, 7, "Apply, button") adapter.events <- up // A table selection after leaving the table is noise. // Returning through a cell focus event establishes ownership and speaks // the table name again, without changing the user's row-reading option. first.OriginPath = first.Source.Path expect_speech(first, 8, "Reading settings, unchecked, Only speak displayed text") expect_speech(second, 9, "checked, Break speech into chunks between pauses") cancel() <-completed }