// 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" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/platform/xorg" ) func TestPresentWindowTitleRepeatsDespiteAutomaticPolicy(t *testing.T) { titles, queue, changed := titleControllerFixture(t) settings := titles.live.Load() settings.Reading.AutomaticallyReadTitleChanges = false titles.live.Store(settings) changed.Source.Name = "Current page - Browser" titles.accessible(changed) titles.lastTitle = changed.Source.Name for range 2 { titles.present() } if len(queue.requests) != 2 || queue.interruptions != 0 || titles.pending != nil { t.Fatalf("explicit titles = %+v; interruptions %d; pending %+v", queue.requests, queue.interruptions, titles.pending) } for _, request := range queue.requests { if request.Text != changed.Source.Name || request.Cause != "present-window-title" || request.Role != "system" || request.Append || request.BypassFocusGate || request.Window != titles.state.owner.window { t.Fatalf("explicit request = %+v", request) } } titles.flush() if len(queue.requests) != 2 { t.Fatal("a pending automatic title repeated the explicit request") } } func TestPresentWindowTitleForegroundSources(t *testing.T) { for _, name := range []string{"native", "wine-child", "accessible", "stale-epoch", "wrong-window", "missing"} { t.Run(name, func(t *testing.T) { titles, queue, _ := titleControllerFixture(t) titles.state.owner.title = "Accessible title" titles.state.foregroundState = atspi.ForegroundState{Epoch: 2, Client: xorg.Foreground{Known: true, Window: 10}} source := &titleProbeSource{title: atspi.ForegroundTitle{Epoch: 2, Title: xorg.WindowTitle{Window: 10, TitleWindow: 10, Title: "Native title"}}} titles.source = source want := "Native title" switch name { case "wine-child": source.title.Title.TitleWindow = 11 titles.state.owner.clear() case "accessible": titles.source = nil want = "Accessible title" case "stale-epoch": source.title.Epoch-- titles.state.owner.clear() want = "Could not find current window title." case "wrong-window": source.title.Title.Window = 20 titles.state.owner.clear() want = "Could not find current window title." case "missing": source.title.Title.Title = " " titles.state.owner.clear() want = "Could not find current window title." } titles.present() if len(queue.requests) != 1 || queue.requests[0].Text != want { t.Fatalf("title = %+v, want %q", queue.requests, want) } }) } } func TestPresentWindowTitleFlashesOnBraille(t *testing.T) { titles, speech, _ := titleControllerFixture(t) display := &flashRecorder{} titles.speech = outputQueue{speech: speech, braille: display, live: titles.live} titles.present() if len(display.messages) != 1 || display.messages[0] != "Original" { t.Fatalf("title Braille = %v", display.messages) } } func TestPresentWindowTitleRespectsLockHandoffAndForegroundChange(t *testing.T) { for _, name := range []string{"locked", "suspended", "foreground-changed"} { t.Run(name, func(t *testing.T) { titles, queue, _ := titleControllerFixture(t) titles.gate = &focusGate{} switch name { case "locked": titles.gate.secure.Store(true) case "suspended": titles.gate.suspended.Store(true) case "foreground-changed": titles.foreground = &titleProbeSource{title: atspi.ForegroundTitle{Epoch: 1}} } titles.present() if len(queue.requests) != 0 || queue.interruptions != 0 { t.Fatalf("unexpected title during %s: %+v", name, queue.requests) } }) } } func TestProcessEventsPresentWindowTitlePreservesFocus(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) speech := &fakeSpeechQueue{spoken: make(chan string, 8)} adapter := &fakeAdapter{events: make(chan atspi.Event)} requests := make(chan input.Command) live := &liveSettings{} settings := config.DefaultSettings() settings.Reading.AutomaticallyReadTitleChanges = false live.Store(settings) done := make(chan error, 1) go func() { done <- processEventsWithSettings(ctx, slog.New(slog.DiscardHandler), speech, adapter, requests, nil, 0, live) }() t.Cleanup(func() { cancel() select { case err := <-done: if err != nil { t.Error(err) } case <-time.After(time.Second): t.Error("event loop did not stop") } }) wait := 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) } } activation := ownerEvent(atspi.WindowActivatedEvent, ":1.50", "/window", "/window") activation.Source.Name, activation.Source.Role = "Page title", "frame" adapter.events <- activation wait("Page title, frame") focus := ownerEvent(atspi.FocusEvent, ":1.50", "/window", "/button") focus.Source.Name, focus.Source.Role, focus.Source.Focused = "Next post", "button", true adapter.events <- focus wait("Next post, button") changed := activation changed.Type, changed.Source.Name = atspi.AccessibleNameEvent, "Updated page title" adapter.events <- changed for range 2 { requests <- input.PresentWindowTitle wait("Updated page title") } requests <- input.WhereAmI wait("Next post, button") }