// 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" "git.stormux.org/storm/cthulhu/internal/braille" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/presentation" "git.stormux.org/storm/cthulhu/internal/review" ) func TestRepeatedControlFocusAfterStateChange(t *testing.T) { for _, role := range []string{"radio button", "check box", "toggle button", "check menu item", "menu item"} { for _, mode := range []string{"speech_only", "sound_and_speech", "sound_only"} { t.Run(role+"/"+mode, func(t *testing.T) { queue := &fakeSpeechQueue{spoken: make(chan string, 16), requests: make(chan speechRequest, 16)} frames := make(chan braille.Frame, 16) state := &eventLoopState{} state.presentationOptions = presentation.DefaultOptions() state.presentationOptions.EnableSound = true state.presentationOptions.PlaySoundForState = true state.presentationOptions.RoleSoundPresentation = mode logger := slog.New(slog.DiscardHandler) handler := accessibilityEventHandler{state: state, logger: logger, brailleOutput: frameRecorder{frames: frames}} presenter := eventPresenter{state: state, logger: logger, speech: queue} present := func(event atspi.Event) bool { if handler.acceptFocusedEvent(event, input.Activity{}) { return false } presenter.speak(event) return true } control := atspi.Snapshot{BusName: ":1.7", Path: "/option", Role: role, Name: "Some", RadioGroupName: "Punctuation Level"} control.Checkable = role == "menu item" focus := atspi.NewEvent(atspi.FocusedStateEvent, control) focus.Detail1 = 1 present(focus) <-queue.requests <-frames control.Checked = true checked := atspi.NewEvent("object:state-changed:checked", control) checked.Detail1 = 1 present(checked) changed := <-queue.requests if mode == "sound_only" { if changed.Text != "" || len(changed.Sound) != 1 { t.Fatalf("state sound = %+v", changed) } } else { want := map[string]string{"radio button": "selected", "check box": "checked", "toggle button": "pressed", "check menu item": "checked", "menu item": "checked"}[role] if changed.Text != want { t.Fatalf("state speech = %q, want %q", changed.Text, want) } } checkedFrame := <-frames focus.Source = control if present(focus) || len(queue.requests) != 0 { t.Fatal("repeated focus after state change restarted the control announcement") } if frame := <-frames; frame.Text != checkedFrame.Text || !state.current.Checked || !state.brailleSnapshot.Checked { t.Fatalf("repeated focus lost the checked state: %+v", frame) } control.Checked = false present(atspi.NewEvent("object:state-changed:checked", control)) changed = <-queue.requests if mode == "sound_only" { if changed.Text != "" || len(changed.Sound) != 1 { t.Fatalf("unchecked state sound = %+v", changed) } } else { want := map[string]string{"radio button": "not selected", "check box": "not checked", "toggle button": "not pressed", "check menu item": "not checked", "menu item": "not checked"}[role] if changed.Text != want { t.Fatalf("unchecked state speech = %q, want %q", changed.Text, want) } } <-frames focus.Source = control // New semantics at the same identity still need an announcement. focus.Source.Name = "Most" if !present(focus) || len(queue.requests) != 1 { t.Fatal("changed control name was suppressed") } <-queue.requests other := atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.7", Path: "/apply", Role: "button", Name: "Apply"}) present(other) <-queue.requests if !present(focus) || len(queue.requests) != 1 { t.Fatal("returning to the control was suppressed") } }) } } } func TestRepeatedTerminalFocusRetainsPendingPresentation(t *testing.T) { queue := &fakeSpeechQueue{spoken: make(chan string, 8)} adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 10*time.Millisecond, nil, nil, nil, nil) }() defer func() { cancel(); <-done }() terminal := atspi.Snapshot{BusName: ":1.7", Path: "/terminal", ApplicationName: "Fixture", Role: "terminal", Text: "Ready"} adapter.events <- atspi.NewEvent(atspi.FocusEvent, terminal) focused := atspi.NewEvent(atspi.FocusedStateEvent, terminal) focused.Detail1 = 1 adapter.events <- focused select { case got := <-queue.spoken: if got != "terminal, Ready" { t.Fatalf("terminal focus speech = %q", got) } case <-time.After(time.Second): t.Fatal("repeated focus discarded pending terminal speech") } } func TestSettingsTreeSelectionSpeaksOnceAndIgnoresUnfocusedEdits(t *testing.T) { for _, toolkit := range []string{"Gecko", "Chromium", "gtk"} { t.Run(toolkit, func(t *testing.T) { queue := &fakeSpeechQueue{spoken: make(chan string, 32)} adapter := &fakeAdapter{events: make(chan atspi.Event, 32)} ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil) }() defer func() { cancel(); <-done }() item := atspi.Snapshot{BusName: ":1.7", Path: "/server", Name: "Server Settings", Role: "tree item", ToolkitName: toolkit, Selected: true} adapter.events <- atspi.NewEvent(atspi.FocusEvent, item) selected := atspi.NewEvent(atspi.SelectedStateEvent, item) selected.Detail1 = 1 adapter.events <- selected selection := atspi.NewEvent(atspi.SelectionChangedEvent, item) selection.OriginPath = "/tree" adapter.events <- selection port := atspi.Snapshot{BusName: ":1.7", Path: "/port", Name: "Port", Role: "spin button", Editable: true, Text: "993"} adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, port, "993") adapter.events <- atspi.NewEventWithPayload(atspi.TextDeletedEvent, port, "993") // An active-page live region must still announce its update. live := atspi.Snapshot{BusName: ":1.7", Path: "/live", Role: "entry", Editable: true, LiveRegion: true} adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, live, "Connection status updated") // Actual focus and subsequent edits to the port must still speak. adapter.events <- atspi.NewEvent(atspi.FocusEvent, port) adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, port, "4") // Selection changes within the focused tree must remain usable. adapter.events <- atspi.NewEvent(atspi.FocusEvent, item) adapter.events <- selection next := item next.Path, next.Name = "/folders", "Copies & Folders" selection.Source = next adapter.events <- selection adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.7", Path: "/end", Role: "button", Name: "End"}) for _, want := range []string{ "Server Settings, tree item, selected", "Connection status updated", "Port, spin button, 993", "4", "Server Settings, tree item, selected", "Copies & Folders, tree item, selected", "End, button", } { select { case got := <-queue.spoken: if got != want { t.Fatalf("speech = %q, want %q", got, want) } case <-time.After(time.Second): t.Fatalf("missing %q", want) } } }) } } func TestControlFocusCaretDoesNotInterruptButArrowCaretSpeaks(t *testing.T) { for _, role := range []string{"spin button", "button", "link"} { t.Run(role, func(t *testing.T) { queue := &fakeSpeechQueue{spoken: make(chan string, 8)} adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} tracker := &input.IntentTracker{} ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, tracker, nil, nil) }() defer func() { cancel(); <-done }() control := atspi.Snapshot{BusName: ":1.7", Path: "/control", Name: "Port", Role: role, Editable: role == "spin button", Text: "993"} tracker.Observe(input.NewKeyEvent(0, true, "Tab")) adapter.events <- atspi.NewEvent(atspi.FocusEvent, control) select { case <-queue.spoken: case <-time.After(time.Second): t.Fatal("missing control focus speech") } adapter.events <- atspi.NewEvent(atspi.CaretEvent, control) select { case got := <-queue.spoken: t.Fatalf("automatic caret interrupted control speech with %q", got) case <-time.After(50 * time.Millisecond): } tracker.Observe(input.NewKeyEvent(0, true, "Right")) control.CaretCharacter = "9" adapter.events <- atspi.NewEvent(atspi.CaretEvent, control) select { case got := <-queue.spoken: if got != "9" { t.Fatalf("arrow caret speech = %q, want 9", got) } case <-time.After(time.Second): t.Fatal("missing arrow caret speech") } }) } } func TestEmbeddedObjectCaretDoesNotReplaceControlFocus(t *testing.T) { queue := &fakeSpeechQueue{spoken: make(chan string, 8)} adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} tracker := &input.IntentTracker{} browser := newBrowserModeController(tracker, &modeKeys{}, nil) browser.ready, browser.mode = true, review.BrowseMode ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, browser, nil, nil) }() defer func() { cancel(); <-done }() tracker.Observe(input.NewKeyEvent(0, true, "Tab")) checkbox := atspi.Snapshot{BusName: ":1.7", Path: "/checkbox", Role: "check box", Name: "Use HTML", StatesKnown: true, Sensitive: true, Focusable: true} adapter.events <- atspi.NewEvent(atspi.FocusEvent, checkbox) select { case got := <-queue.spoken: if got != "Use HTML, check box, not checked" { t.Fatalf("checkbox focus speech = %q", got) } case <-time.After(time.Second): t.Fatal("missing checkbox focus speech") } container := atspi.Snapshot{BusName: ":1.7", Path: "/panel", Role: "panel", Text: "\ufffc"} adapter.events <- atspi.NewEvent(atspi.CaretEvent, container) select { case got := <-queue.spoken: t.Fatalf("embedded-object caret interrupted checkbox with %q", got) case <-time.After(50 * time.Millisecond): } checkbox.Checked = true adapter.events <- atspi.NewEvent("object:state-changed:checked", checkbox) select { case got := <-queue.spoken: if got != "checked" { t.Fatalf("checkbox toggle speech = %q, want checked", got) } case <-time.After(time.Second): t.Fatal("checkbox focus was lost after container caret") } cancel() <-done if browser.focus != checkbox.ID() { t.Fatalf("container caret replaced browser focus: %v", browser.focus) } } func TestFocusedTreeSelectionStateChangesRemainAudible(t *testing.T) { queue := &fakeSpeechQueue{spoken: make(chan string, 8)} adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil) }() defer func() { cancel(); <-done }() item := atspi.Snapshot{BusName: ":1.7", Path: "/item", Role: "list item", Name: "Important", Selected: true} adapter.events <- atspi.NewEvent(atspi.FocusEvent, item) item.Selected = false adapter.events <- atspi.NewEvent(atspi.SelectedStateEvent, item) item.Selected = true selected := atspi.NewEvent(atspi.SelectedStateEvent, item) selected.Detail1 = 1 adapter.events <- selected for _, want := range []string{"Important, list item, selected", "not selected", "selected"} { select { case got := <-queue.spoken: if got != want { t.Fatalf("selection state speech = %q, want %q", got, want) } case <-time.After(time.Second): t.Fatalf("missing selection state %q", want) } } } // Drive focus and checked-state events through the real event loop and speech // worker. The backend call stays in flight while the selection arrives, so any // automatic CANCEL (including one after SPEAK returns) fails the expected order. func TestSpeechWorkerRadioSelectionDoesNotCancelLabel(t *testing.T) { logger := slog.New(slog.DiscardHandler) speaker := &controlledSpeaker{calls: make(chan speechCall, 8)} worker := newSpeechWorker(logger, speaker) if err := worker.Start(t.Context()); err != nil { t.Fatal(err) } t.Cleanup(func() { worker.Stop(context.Background()) }) adapter := &fakeAdapter{events: make(chan atspi.Event, 2)} option := atspi.Snapshot{BusName: ":1.7", Path: "/option", Name: "Laptop", Role: "radio button"} adapter.events <- atspi.NewEvent(atspi.FocusEvent, option) option.Checked = true selected := atspi.NewEvent("object:state-changed:checked", option) selected.Detail1 = 1 adapter.events <- selected close(adapter.events) if err := processEvents(t.Context(), logger, worker, adapter, 0); err != nil { t.Fatal(err) } for _, want := range []string{"Laptop", "radio button", "not selected", "selected"} { close(expectSpeechCall(t, speaker, want).done) } }