// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "log/slog" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/presentation" "git.stormux.org/storm/cthulhu/internal/sound" ) func radio_sound_fixture(t *testing.T) (*eventPresenter, *fakeSpeechQueue, func(atspi.Event)) { t.Helper() logger := slog.New(slog.DiscardHandler) state := &eventLoopState{} state.presentationOptions = presentation.DefaultOptions() state.presentationOptions.EnableSound = true state.presentationOptions.PlaySoundForRole = true state.presentationOptions.PlaySoundForState = true state.presentationOptions.RoleSoundPresentation = "sound_only" queue := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)} presenter := &eventPresenter{state: state, logger: logger, speech: queue} handler := &accessibilityEventHandler{state: state, logger: logger} t.Cleanup(state.pending.clear) return presenter, queue, func(event atspi.Event) { if !handler.acceptFocusedEvent(event, input.Activity{}) { presenter.speak(event) } } } func TestRadioFocusSoundCoalescesSelectionWithoutDelayingLabel(t *testing.T) { presenter, queue, present := radio_sound_fixture(t) option := atspi.Snapshot{BusName: ":1.7", Path: "/radio", Role: "radio button", Name: "Laptop", Mnemonic: "l"} present(atspi.NewEvent(atspi.FocusEvent, option)) request := <-queue.requests if !strings.Contains(request.Text, "Laptop") || strings.Contains(request.Text, "radio button") || len(request.Sound) != 0 || presenter.state.pending.radioSound.ready == nil { t.Fatalf("focus label/sound = %+v", request) } // The old member loses selection before GTK checks the focused member. old := option old.Path = "/previous" present(atspi.NewEvent("object:state-changed:checked", old)) if len(queue.requests) != 0 { t.Fatal("unfocused member emitted a state sound") } option.Checked = true checked := atspi.NewEvent("object:state-changed:checked", option) checked.Source.Mnemonic = "" // State-only native captures skip mnemonic lookup. checked.Detail1 = 1 present(checked) request = <-queue.requests if len(request.Sound) != 1 || request.Sound[0].Name != "radiobutton_checked" || presenter.state.pending.radioSound.ready != nil { t.Fatalf("selected sound = %+v", request) } if presenter.state.current.Mnemonic != option.Mnemonic { t.Fatal("state update discarded focus metadata") } present(atspi.NewEvent(atspi.FocusEvent, option)) if len(queue.requests) != 0 { t.Fatal("duplicate focus replayed selected sound") } } func TestRadioFocusSoundRetiresStalePendingState(t *testing.T) { for _, change := range []string{"none", "focus", "input", "window", "lock", "handoff", "defunct", "disabled"} { t.Run(change, func(t *testing.T) { presenter, queue, present := radio_sound_fixture(t) option := atspi.Snapshot{BusName: ":1.7", Path: "/radio", Role: "radio button", Name: "Option"} present(atspi.NewEvent(atspi.FocusEvent, option)) <-queue.requests gate := &focusGate{} switch change { case "focus": present(atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.7", Path: "/next", Role: "button", Name: "Next"})) <-queue.requests case "input": presenter.inputSource = fixedInputActivity{activity: input.Activity{InputSequence: 1}} case "window": presenter.state.owner.window = atspi.NewObjectID(":1.7", "/other") case "lock": gate.secure.Store(true) case "handoff": gate.suspended.Store(true) case "defunct": presenter.state.current.Defunct = true case "disabled": presenter.state.presentationOptions.EnableSound = false } presenter.flush_radio_sound(gate) if change != "none" { if len(queue.requests) != 0 { t.Fatal("retired focus emitted a delayed sound") } return } request := <-queue.requests if request.Text != "" || request.FallbackText != "not selected" || len(request.Sound) != 1 || request.Sound[0].Name != "radiobutton_unchecked" { t.Fatalf("settled unchecked sound/fallback = %+v", request) } }) } } func TestProcessEventsFlushesUncheckedRadioSound(t *testing.T) { presenter, queue, _ := radio_sound_fixture(t) adapter := &fakeAdapter{events: make(chan atspi.Event, 1)} ctx, cancel := context.WithCancel(t.Context()) done := make(chan error, 1) go func() { done <- processEvents(ctx, presenter.logger, queue, adapter, 0, presenter.state.presentationOptions) }() t.Cleanup(func() { cancel(); <-done }) adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.7", Path: "/radio", Role: "radio button", Name: "Option"}) for index := range 2 { select { case request := <-queue.requests: if index == 0 && (request.Text == "" || len(request.Sound) != 0) { t.Fatalf("label was deferred or played unchecked sound: %+v", request) } if index == 1 && (len(request.Sound) != 1 || request.Sound[0] != (sound.Cue{Name: "radiobutton_unchecked"})) { t.Fatalf("unchecked focus sound = %+v", request) } case <-time.After(time.Second): t.Fatal("radio focus presentation did not finish") } } } func TestDeferredRadioSoundPreservesUnavailableCueFallback(t *testing.T) { presenter, queue, present := radio_sound_fixture(t) present(atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.7", Path: "/radio", Role: "radio button", Name: "Option"})) <-queue.requests presenter.state.presentationOptions.SoundCueAvailable = func(string) bool { return false } presenter.speech = outputQueue{speech: queue, sounds: rejectingSound{}} presenter.flush_radio_sound(nil) if request := <-queue.requests; request.Text != "not selected" { t.Fatalf("missing state-sound fallback: %+v", request) } }