Files
cthulhu/internal/app/radio_sound_test.go

148 lines
5.7 KiB
Go

// 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: "<Alt>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)
}
}