// 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) } }) } }