Files
cthulhu/internal/app/browser_button_names_test.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)
}
})
}
}