// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // 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/notification" "git.stormux.org/storm/cthulhu/internal/platform/xorg" "git.stormux.org/storm/cthulhu/internal/webscripts" ) func steamToastEvent(text string) atspi.Event { source := atspi.NewSnapshot("/fragment", "", "static", "", "") source.BusName = ":1.42" source.ApplicationName = "Chromium" source.ApplicationProcess = "steamwebhelper" event := atspi.NewEventWithPayload(atspi.TextInsertedEvent, source, text) event.WebNotification = &webscripts.Notification{BusName: source.BusName, RootPath: "/toast", WindowPath: "/steam", Text: text} event.ForegroundMismatch = true return event } func TestSteamBackgroundNotificationReachesSpeechBrailleAndHistory(t *testing.T) { adapter := &foregroundAdapter{fakeAdapter: fakeAdapter{events: make(chan atspi.Event, 8)}, changes: make(chan struct{}, 1)} state := atspi.ForegroundState{Client: xorg.Foreground{Known: true, Window: 101}, Epoch: 1} adapter.state.Store(&state) queue := &fakeSpeechQueue{spoken: make(chan string, 8)} gate := &focusGate{} gate.SetSuspended(true) display := &flashRecorder{} history := notification.NewPresenter() output := outputQueue{speech: gatedSpeech{speech: queue, gate: gate}, braille: display} ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) processEventsWithInput(ctx, slog.New(slog.DiscardHandler), output, adapter, nil, nil, 0, nil, nil, gate, history) }() defer func() { cancel(); <-done }() adapter.events <- steamToastEvent("Username") adapter.events <- steamToastEvent("Playing: Example Game") select { case message := <-queue.spoken: if message != "Steam: Username Playing: Example Game" { t.Fatalf("fragmented message: %q", message) } case <-time.After(2 * time.Second): t.Fatal("background Steam notification was filtered or timer did not fire") } if !gate.Suspended() { t.Fatal("background toast took foreground ownership") } if entries := history.Entries(); len(entries) != 1 || entries[0].Message != "Steam: Username Playing: Example Game" { t.Fatalf("notification history: %+v", entries) } if len(display.messages) != 1 || display.messages[0] != "Steam: Username Playing: Example Game" { t.Fatalf("braille messages: %q", display.messages) } // A Chromium page with identical metadata cannot borrow the Steam match. other := steamToastEvent("Unrelated browser content") other.Source.ApplicationProcess = "chromium" other.Source.LiveRegion = true adapter.events <- other // A normal accepted focus event is a barrier for the rejected event above. focus := ownerEvent(atspi.FocusedStateEvent, ":1.43", "/window", "/button") focus.Source.Name, focus.Source.Role = "Current control", "button" focus.Source.Focused, focus.Detail1, focus.Foreground = true, 1, state adapter.events <- focus expectActivationSpeech(t, queue.spoken, "Current control, button") select { case message := <-queue.spoken: t.Fatalf("background Chromium spoke: %q", message) default: } if len(history.Entries()) != 1 { t.Fatal("background Chromium entered notification history") } } func TestSteamNotificationTimerCancelledOnLockAndDestruction(t *testing.T) { for _, retire := range []string{"lock", "root", "window"} { t.Run(retire, func(t *testing.T) { queue := &fakeSpeechQueue{spoken: make(chan string, 1)} history := notification.NewPresenter() gate := &focusGate{} controller := &webScriptController{speech: queue, notifications: history, gate: gate} defer controller.reset() if !controller.handle(steamToastEvent("Pending content")) || controller.due == nil { t.Fatal("toast did not arm a timer") } switch retire { case "lock": gate.secure.Store(true) controller.flush() case "root": event := atspi.NewEvent(atspi.DefunctStateEvent, atspi.Snapshot{BusName: ":1.42", Path: "/toast"}) event.Detail1 = 1 controller.handle(event) case "window": controller.handle(atspi.NewEvent(atspi.WindowDestroyedEvent, atspi.Snapshot{BusName: ":1.42", Path: "/steam"})) } if controller.due != nil || !controller.registry.Deadline().IsZero() || len(history.Entries()) != 0 { t.Fatal("retired toast remained queued") } }) } }