181 lines
6.2 KiB
Go
181 lines
6.2 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"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/review"
|
|
)
|
|
|
|
func TestProcessEventsCoalescesWebButtonNames(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
speech := &fakeSpeechQueue{spoken: make(chan string, 32)}
|
|
frames := make(chan braille.Frame, 32)
|
|
adapter := &fakeAdapter{events: make(chan atspi.Event, 32)}
|
|
button := atspi.Snapshot{BusName: ":1.42", Path: "/counter", Name: "Counter value 0", Role: "button", Focused: true, Focusable: true}
|
|
browser := newBrowserModeController(nil, &modeKeys{}, nil)
|
|
browser.focus, browser.document = button.ID(), atspi.NewObjectID(button.BusName, "/document")
|
|
browser.ready, browser.mode = true, review.BrowseMode
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), outputQueue{speech: speech, braille: frameRecorder{frames}}, adapter, nil, nil, 0, nil, browser, nil, nil)
|
|
}()
|
|
defer func() { cancel(); <-done }()
|
|
event := atspi.NewEvent(atspi.FocusEvent, button)
|
|
event.Window = atspi.NewObjectID(button.BusName, "/window")
|
|
adapter.events <- event
|
|
expect := func(want string) {
|
|
t.Helper()
|
|
select {
|
|
case got := <-speech.spoken:
|
|
if got != want {
|
|
t.Fatalf("speech = %q, want %q", got, want)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("missing speech %q", want)
|
|
}
|
|
}
|
|
expect("Counter value 0, button")
|
|
select {
|
|
case <-frames:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("missing focus Braille")
|
|
}
|
|
// Structural navigation also owns this button. Updates must still reach
|
|
// Braille and the coalescer while delayed caret/focus echoes are suppressed.
|
|
browser.mutex.Lock()
|
|
browser.navigation, browser.navigationDocument = button.ID(), browser.document
|
|
browser.mutex.Unlock()
|
|
// Match the reported page's six updates per second for longer than the
|
|
// quiet window. Frame delivery acknowledges each event's processing.
|
|
ticker := time.NewTicker(150 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
for i := 1; i <= 6; i++ {
|
|
<-ticker.C
|
|
event.Type = atspi.AccessibleNameEvent
|
|
event.Source.Name = fmt.Sprintf("Counter value %d", i)
|
|
adapter.events <- event
|
|
select {
|
|
case frame := <-frames:
|
|
if frame.Text != event.Source.Name+" button" {
|
|
t.Fatalf("current Braille = %q", frame.Text)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("name change did not refresh Braille")
|
|
}
|
|
select {
|
|
case got := <-speech.spoken:
|
|
t.Fatalf("changing counter interrupted focus: %q", got)
|
|
default:
|
|
}
|
|
}
|
|
// Native input releases echo ownership before the next focus event.
|
|
browser.pointer_input()
|
|
// A redundant native focus notification must not discard the final name.
|
|
event.Type = atspi.FocusEvent
|
|
adapter.events <- event
|
|
<-frames
|
|
expect("Counter value 6, button")
|
|
|
|
// A later action label still speaks once after settling.
|
|
event.Type = atspi.AccessibleNameEvent
|
|
event.Source.Name = "Update complete"
|
|
adapter.events <- event
|
|
<-frames
|
|
expect("Update complete, button")
|
|
|
|
// Moving away cancels a pending name, and does not delay new focus.
|
|
event.Source.Name = "Pending counter"
|
|
adapter.events <- event
|
|
<-frames
|
|
event.Type, event.Source.Path, event.Source.Name = atspi.FocusEvent, "/next", "Next control"
|
|
adapter.events <- event
|
|
expect("Next control, button")
|
|
select {
|
|
case got := <-speech.spoken:
|
|
t.Fatalf("departed button spoke again: %q", got)
|
|
case <-time.After(2 * browserButtonNameDelay):
|
|
}
|
|
}
|
|
|
|
func TestWebButtonNameScopeAndCancellation(t *testing.T) {
|
|
for _, kind := range []string{"settled", "live", "desktop", "entry", "other focus", "input", "document", "generation", "lock", "suspended", "window", "focus", "clear"} {
|
|
t.Run(kind, func(t *testing.T) {
|
|
button := atspi.Snapshot{BusName: ":1.42", Path: "/button", Name: "Play", Role: "button"}
|
|
event := atspi.NewEvent(atspi.AccessibleNameEvent, button)
|
|
event.Window = atspi.NewObjectID(button.BusName, "/window")
|
|
state := &eventLoopState{}
|
|
state.current, state.focusedID = button, button.ID()
|
|
state.owner.window = event.Window
|
|
source := fixedInputActivity{input.Activity{TitleInterruptionSequence: 1}}
|
|
browser := newBrowserModeController(source, &modeKeys{}, nil)
|
|
browser.ready, browser.focus, browser.document = true, button.ID(), atspi.NewObjectID(button.BusName, "/document")
|
|
spoken := 0
|
|
handler := &accessibilityEventHandler{state: state, browser: browser, logger: slog.New(slog.DiscardHandler), focusGate: &focusGate{}, speak: func(atspi.Event, ...input.Intent) { spoken++ }}
|
|
defer state.pending.clear()
|
|
switch kind {
|
|
case "live":
|
|
event.Source.LiveRegion = true
|
|
case "desktop":
|
|
browser.ready = false
|
|
case "entry":
|
|
event.Source.Role = "entry"
|
|
case "other focus":
|
|
state.focusedID.Path = "/other"
|
|
}
|
|
deferred := handler.defer_browser_button_name(event, source.activity)
|
|
if kind == "live" || kind == "desktop" || kind == "entry" || kind == "other focus" {
|
|
if deferred {
|
|
t.Fatal("unrelated event was delayed")
|
|
}
|
|
return
|
|
}
|
|
if !deferred {
|
|
t.Fatal("web button name was not delayed")
|
|
}
|
|
timer := state.pending.browserButtonName.timer
|
|
handler.defer_browser_button_name(event, source.activity)
|
|
if state.pending.browserButtonName.timer != timer {
|
|
t.Fatal("duplicate name postponed presentation")
|
|
}
|
|
switch kind {
|
|
case "input":
|
|
browser.source = fixedInputActivity{input.Activity{TitleInterruptionSequence: 2}}
|
|
case "document":
|
|
browser.document.Path = "/newDocument"
|
|
case "generation":
|
|
browser.generation++
|
|
case "lock":
|
|
handler.focusGate.secure.Store(true)
|
|
case "suspended":
|
|
handler.focusGate.suspended.Store(true)
|
|
case "window":
|
|
state.owner.window.Path = "/otherWindow"
|
|
case "focus":
|
|
state.focusedID.Path = "/other"
|
|
case "clear":
|
|
state.pending.clear()
|
|
}
|
|
handler.flush_browser_button_name()
|
|
want := 0
|
|
if kind == "settled" {
|
|
want = 1
|
|
}
|
|
if spoken != want {
|
|
t.Fatalf("presentations = %d, want %d", spoken, want)
|
|
}
|
|
})
|
|
}
|
|
}
|