// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "log/slog" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/config" ) // Fixture recorded from Niri 26.04 on 2026-10-31: a workspace switch emits // object:property-change:accessible-name and then object:announcement // (detail1=1, polite) with the same text on a label in the niri application, // about 10ms before the newly focused window's activation events. func announcementEvent(text string, politeness int) atspi.Event { source := atspi.Snapshot{BusName: ":1.20", Path: "/org/a11y/atspi/accessible/2", Role: "label", Name: text, ApplicationName: "niri"} event := atspi.NewEventWithPayload(atspi.AnnouncementEvent, source, text) event.Detail1 = politeness return event } func announcementFixture(t *testing.T) (*fakeSpeechQueue, *fakeAdapter, func()) { t.Helper() speech := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)} adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) }() return speech, adapter, func() { cancel(); <-done } } func TestAnnouncementSpeaksPoliteWorkspaceSwitch(t *testing.T) { speech, adapter, done := announcementFixture(t) defer done() adapter.events <- announcementEvent("Workspace 2 Example", 1) select { case request := <-speech.requests: if request.Text != "Workspace 2 Example" || request.Role != "system" || request.Cause != atspi.AnnouncementEvent || !request.Append || !request.BypassFocusGate { t.Fatalf("announcement request = %+v", request) } case <-time.After(time.Second): t.Fatal("announcement was not spoken") } } func TestAnnouncementAssertiveQueues(t *testing.T) { speech, adapter, done := announcementFixture(t) defer done() adapter.events <- announcementEvent("Config parse error", 2) select { case request := <-speech.requests: if request.Text != "Config parse error" || !request.Append { t.Fatalf("assertive request = %+v", request) } case <-time.After(time.Second): t.Fatal("assertive announcement was not spoken") } } func TestAnnouncementDedupesTrimmedRepeatButNotNewText(t *testing.T) { speech, adapter, done := announcementFixture(t) defer done() adapter.events <- announcementEvent("Workspace 2 Example", 1) // Niri can append a space to force re-emission of identical text. adapter.events <- announcementEvent("Workspace 2 Example ", 1) adapter.events <- announcementEvent("Workspace 1 Example", 1) var got []string for range 2 { select { case request := <-speech.requests: got = append(got, request.Text) case <-time.After(time.Second): t.Fatalf("announcements spoken = %v", got) } } select { case request := <-speech.requests: t.Fatalf("duplicate announcement spoken: %+v (all: %v)", request, got) case <-time.After(30 * time.Millisecond): } if got[0] != "Workspace 2 Example" || got[1] != "Workspace 1 Example" { t.Fatalf("announcements = %v", got) } } func TestEmptyAnnouncementDoesNotSpeak(t *testing.T) { speech, adapter, done := announcementFixture(t) defer done() adapter.events <- announcementEvent(" ", 1) select { case request := <-speech.requests: t.Fatalf("empty announcement spoken: %+v", request) case <-time.After(30 * time.Millisecond): } } func TestAnnouncementBrailleAndLockGate(t *testing.T) { live := &liveSettings{} live.Store(config.DefaultSettings()) speech := &remoteSpeechQueue{} display := &flashRecorder{} gate := newFocusGate(nil) handler := &accessibilityEventHandler{ state: &eventLoopState{}, focusGate: gate, speech: gatedSpeech{gate: gate, speech: outputQueue{speech: speech, braille: display, live: live}}, } event := announcementEvent("Workspace 2 Example", 1) handler.presentAnnouncement(event) if len(display.messages) != 1 || display.messages[0] != event.Payload { t.Fatalf("Braille flashes = %v", display.messages) } gate.secure.Store(true) handler.handle(announcementEvent("Locked workspace", 1)) if len(display.messages) != 1 { t.Fatal("locked announcement flashed") } } func TestIdenticalAnnouncementFromAnotherProviderIsPreserved(t *testing.T) { speech := &remoteSpeechQueue{} handler := &accessibilityEventHandler{state: &eventLoopState{}, speech: speech} event := announcementEvent("Example message", 1) handler.presentAnnouncement(event) event.Source.BusName = ":1.21" handler.presentAnnouncement(event) if len(speech.requests) != 2 { t.Fatalf("requests = %d, want 2", len(speech.requests)) } }