// 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" ) func TestWindowTitleChangesSuppressRepeatedNames(t *testing.T) { queue := &fakeSpeechQueue{spoken: make(chan string, 16)} adapter := &fakeAdapter{events: make(chan atspi.Event, 16)} ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil) }() defer func() { cancel(); <-done }() activation := ownerEvent(atspi.WindowActivatedEvent, ":1.70", "/window", "/window") activation.Source.Name, activation.Source.Role = "Original title", "frame" activation.WindowTitle = "Original title" adapter.events <- activation expectActivationSpeech(t, queue.spoken, "Original title, frame") changed := activation changed.Type = atspi.AccessibleNameEvent for _, title := range []string{"Original title", "Intermediate title", "Updated title", "Updated title", "", "Updated title"} { changed.Source.Name = title adapter.events <- changed } expectActivationSpeech(t, queue.spoken, "Updated title") select { case got := <-queue.spoken: t.Fatalf("burst queued extra title %q", got) default: } } func TestWindowActivationEstablishesFocusedObjectOwnership(t *testing.T) { queue := &fakeSpeechQueue{spoken: make(chan string, 4)} adapter := &fakeAdapter{events: make(chan atspi.Event, 4)} ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil) }() defer func() { cancel(); <-done }() focused := atspi.Snapshot{ BusName: ":1.70", Path: "/focused", ApplicationName: "steamwebhelper", Name: "Library", Role: "check box", Focused: true, } activation := atspi.NewEvent(atspi.WindowActivatedEvent, focused) activation.OriginPath = "/window" adapter.events <- activation expectActivationSpeech(t, queue.spoken, "Library, check box, not checked") duplicate := activation duplicate.Source.Name = "Duplicate activation" adapter.events <- duplicate select { case got := <-queue.spoken: t.Fatalf("duplicate activation spoke %q", got) case <-time.After(30 * time.Millisecond): } // Accessible paths are process-local. A background process using the same // path must not be mistaken for the active Steam object. background := focused background.BusName = ":1.71" background.ApplicationName = "Background Application" background.Name = "Background" background.Checked = true changed := atspi.NewEvent("object:state-changed:checked", background) changed.Detail1 = 1 adapter.events <- changed select { case got := <-queue.spoken: t.Fatalf("background path collision spoke %q", got) case <-time.After(30 * time.Millisecond): } focused.Checked = true changed = atspi.NewEvent("object:state-changed:checked", focused) changed.Detail1 = 1 adapter.events <- changed expectActivationSpeech(t, queue.spoken, "checked") } func TestFocusBeforeActivationAnnouncesWindowTitleOnce(t *testing.T) { queue := &fakeSpeechQueue{spoken: make(chan string, 4)} adapter := &fakeAdapter{events: make(chan atspi.Event, 4)} ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil) }() defer func() { cancel(); <-done }() focus := ownerEvent(atspi.FocusedStateEvent, ":1.70", "/window", "/entry") focus.Source.Name = "Search" focus.Source.Role = "entry" focus.Source.Focused = true focus.Detail1 = 1 adapter.events <- focus expectActivationSpeech(t, queue.spoken, "Search, entry") activation := ownerEvent(atspi.WindowActivatedEvent, ":1.70", "/window", "/window") activation.Source.Name = "Example Window" activation.Source.Role = "frame" activation.WindowTitle = "Example Window" adapter.events <- activation expectActivationSpeech(t, queue.spoken, "Example Window") adapter.events <- activation select { case got := <-queue.spoken: t.Fatalf("repeated activation spoke %q", got) case <-time.After(30 * time.Millisecond): } } func TestActiveWindowNameChangeAnnouncesOnlyCurrentTitle(t *testing.T) { queue := &fakeSpeechQueue{spoken: make(chan string, 4)} adapter := &fakeAdapter{events: make(chan atspi.Event, 4)} ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil) }() defer func() { cancel(); <-done }() activation := ownerEvent(atspi.WindowActivatedEvent, ":1.70", "/window", "/window") activation.Source.Name = "Original title" activation.Source.Role = "frame" adapter.events <- activation expectActivationSpeech(t, queue.spoken, "Original title, frame") background := ownerEvent(atspi.AccessibleNameEvent, ":1.70", "/other", "/other") background.Source.Name = "Background title" background.Source.Role = "frame" adapter.events <- background changed := ownerEvent(atspi.AccessibleNameEvent, ":1.70", "/window", "/window") changed.Source.Name = "Updated title" changed.Source.Role = "frame" adapter.events <- changed expectActivationSpeech(t, queue.spoken, "Updated title") select { case got := <-queue.spoken: t.Fatalf("background title spoke %q", got) case <-time.After(30 * time.Millisecond): } } func TestRepeatedWindowActivationPreservesFocusedDescendant(t *testing.T) { queue := &fakeSpeechQueue{spoken: make(chan string, 4)} adapter := &fakeAdapter{events: make(chan atspi.Event, 4)} ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil) }() defer func() { cancel(); <-done }() window := atspi.Snapshot{ BusName: ":1.70", Path: "/window", ApplicationName: "Example Application", Name: "Example Window", Role: "frame", } activation := atspi.NewEvent(atspi.WindowActivatedEvent, window) activation.OriginPath = window.Path adapter.events <- activation expectActivationSpeech(t, queue.spoken, "Example Window, frame") focused := atspi.Snapshot{ BusName: window.BusName, Path: "/focused", ApplicationName: window.ApplicationName, Name: "Review target", Role: "check box", Focused: true, } focusEvent := atspi.NewEvent(atspi.FocusedStateEvent, focused) focusEvent.Detail1 = 1 adapter.events <- focusEvent expectActivationSpeech(t, queue.spoken, "Review target, check box, not checked") // X11 grabs can cause the same top-level activation to be emitted again // with the frame as its source. It must neither speak nor replace the // focused descendant used by review and state-change filtering. repeated := activation repeated.Source.Name = "Repeated window activation" adapter.events <- repeated focused.Checked = true changed := atspi.NewEvent("object:state-changed:checked", focused) changed.Detail1 = 1 adapter.events <- changed expectActivationSpeech(t, queue.spoken, "checked") } func TestWindowActivationIsFocusGain(t *testing.T) { event := atspi.NewEvent(atspi.WindowActivatedEvent, atspi.Snapshot{}) if !isFocusEvent(event.Type) || !isFocusGainEvent(event) { t.Fatal("window activation was not treated as a focus gain") } } func TestWindowLifecycleClearsFocusedObjectOwnership(t *testing.T) { queue := &fakeSpeechQueue{spoken: make(chan string, 4)} adapter := &fakeAdapter{events: make(chan atspi.Event, 4)} ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil) }() defer func() { cancel(); <-done }() focused := atspi.Snapshot{ BusName: ":1.70", Path: "/focused", ApplicationName: "Example Application", Name: "Active control", Role: "check box", Focused: true, } activation := atspi.NewEvent(atspi.WindowActivatedEvent, focused) activation.Window = atspi.NewObjectID(focused.BusName, "/window") adapter.events <- activation expectActivationSpeech(t, queue.spoken, "Active control, check box, not checked") deactivation := atspi.NewEvent(atspi.WindowDeactivatedEvent, atspi.Snapshot{ BusName: focused.BusName, Path: "/window", Role: "frame", }) deactivation.Window = activation.Window adapter.events <- deactivation changed := atspi.NewEvent("object:state-changed:checked", focused) changed.Detail1 = 1 adapter.events <- changed select { case got := <-queue.spoken: t.Fatalf("event from deactivated window spoke %q", got) case <-time.After(30 * time.Millisecond): } } func expectActivationSpeech(t *testing.T, spoken <-chan string, want string) { t.Helper() select { case got := <-spoken: if got != want { t.Fatalf("speech = %q, want %q", got, want) } case <-time.After(time.Second): t.Fatalf("missing speech %q", want) } }