148 lines
5.7 KiB
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)
|
|
}
|
|
}
|