// 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" "path/filepath" "testing" "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" "git.stormux.org/storm/cthulhu/internal/preferences" ) type titleProbeSource struct{ title atspi.ForegroundTitle } func (source *titleProbeSource) ForegroundTitle() atspi.ForegroundTitle { return source.title } func (source *titleProbeSource) TitleChanges() <-chan struct{} { return nil } func (source *titleProbeSource) Foreground() atspi.ForegroundState { return atspi.ForegroundState{Epoch: source.title.Epoch, Client: xorg.Foreground{Known: true, Window: source.title.Title.Window}} } func (source *titleProbeSource) ForegroundChanges() <-chan struct{} { return nil } type titleProbeQueue struct { requests []speechRequest interruptions int } func (queue *titleProbeQueue) Enqueue(request speechRequest) { queue.requests = append(queue.requests, request) } func (queue *titleProbeQueue) Interrupt() { queue.interruptions++ } func titleControllerFixture(t *testing.T) (*windowTitleController, *titleProbeQueue, atspi.Event) { t.Helper() queue := &titleProbeQueue{} live := &liveSettings{} live.Store(config.DefaultSettings()) titles := &windowTitleController{state: &eventLoopState{}, live: live, speech: queue} activation := ownerEvent(atspi.WindowActivatedEvent, ":1.50", "/window", "/window") activation.Source.Name, activation.Source.Role = "Original", "frame" titles.state.owner.activate(activation) titles.activated(activation) t.Cleanup(titles.reset) changed := activation changed.Type = atspi.AccessibleNameEvent return titles, queue, changed } func TestAutomaticTitlePolicyCoalescesAndPreservesSpeech(t *testing.T) { titles, queue, changed := titleControllerFixture(t) for _, text := range []string{"Loading", "Loading 50%", "Loading 100%"} { changed.Source.Name = text if !titles.accessible(changed) { t.Fatal("title event not consumed") } } titles.flush() if len(queue.requests) != 1 || queue.requests[0].Text != "Loading 100%" || !queue.requests[0].Append || queue.interruptions != 0 { t.Fatalf("title burst = %+v, interruptions %d", queue.requests, queue.interruptions) } titles.accessible(changed) titles.flush() if len(queue.requests) != 1 { t.Fatal("duplicate title was announced") } changed.Source.Name = "" titles.accessible(changed) changed.Source.Name = "Loading 100%" titles.accessible(changed) titles.flush() if len(queue.requests) != 2 { t.Fatal("restored title was not announced") } } func TestScheduledTitleInterruptedBeforeSpeechQueue(t *testing.T) { for _, name := range []string{"c", "Tab", "Control_L", "Escape"} { t.Run(name, func(t *testing.T) { titles, queue, changed := titleControllerFixture(t) tracker := &input.IntentTracker{} titles.inputSource = tracker changed.Source.Name = "Scheduled status" titles.accessible(changed) tracker.Observe(input.NewKeyEvent(0, true, name)) // A newer sample in the same burst must not undo cancellation. changed.Source.Name = "Latest status" titles.accessible(changed) titles.flush() if len(queue.requests) != 0 || titles.pending != nil { t.Fatal("interrupted title reached the speech queue") } // A genuinely later change still announces normally. changed.Source.Name = "New status" titles.accessible(changed) titles.flush() if len(queue.requests) != 1 || queue.requests[0].Text != "New status" { t.Fatalf("later title = %+v", queue.requests) } }) } } func TestScheduledNativeIntroductionPreservesSwitchAndRepeatInput(t *testing.T) { titles, queue, _ := titleControllerFixture(t) titles.state.owner.clear() tracker := &input.IntentTracker{} titles.inputSource = tracker source := &titleProbeSource{title: atspi.ForegroundTitle{Epoch: 1, Title: xorg.WindowTitle{Window: 20, TitleWindow: 20, Title: "Other window"}}} titles.source, titles.foreground = source, source // Input initiating a switch precedes discovery of its destination title. tracker.Observe(input.NewKeyEvent(0, true, "Tab")) titles.nativeChanged() repeat := input.NewKeyEvent(0, true, "Tab") repeat.Repeat = true tracker.Observe(repeat) titles.flush() if len(queue.requests) != 1 || queue.requests[0].Text != "Other window" { t.Fatalf("destination title = %+v", queue.requests) } // Fresh typing after a subsequent switch cancels its delayed introduction. source.title.Epoch++ source.title.Title.Window, source.title.Title.TitleWindow = 21, 21 source.title.Title.Title = "Next window" titles.nativeChanged() tracker.Observe(input.NewKeyEvent(0, true, "c")) repeat = input.NewKeyEvent(0, true, "c") repeat.Repeat = true tracker.Observe(repeat) titles.flush() if len(queue.requests) != 1 { t.Fatal("typing interruption was hidden by its later repeat") } // Returning to that window must introduce it again despite cancellation. source.title.Epoch++ titles.nativeChanged() titles.flush() if len(queue.requests) != 2 || queue.requests[1].Text != "Next window" { t.Fatalf("return title = %+v", queue.requests) } } func TestScheduledTitleCannotReviveWhileWaitingForSettings(t *testing.T) { titles, queue, changed := titleControllerFixture(t) tracker := &input.IntentTracker{} titles.inputSource = tracker titles.live.onApplication = func(string) {} titles.application = "Example Application" changed.Source.Name = "Pending application title" titles.accessible(changed) titles.flush() // Requeued until the application override is committed. tracker.Observe(input.NewKeyEvent(0, true, "c")) application := titles.application titles.live.application.Store(&application) titles.flush() if len(queue.requests) != 0 || titles.pending != nil { t.Fatal("settings retry revived an interrupted title") } } func TestAutomaticTitleUsesBrailleMessagePathAndKeepsFocus(t *testing.T) { titles, queue, changed := titleControllerFixture(t) display := &flashRecorder{} titles.speech = outputQueue{speech: queue, braille: display, live: titles.live} focused := titles.state.owner.focus changed.Source.Name = "Updated title" titles.accessible(changed) titles.flush() if len(display.messages) != 1 || display.messages[0] != "Updated title" || titles.state.owner.focus != focused { t.Fatalf("title braille = %q, focus = %+v", display.messages, titles.state.owner.focus) } settings := titles.live.Load() settings.Reading.AutomaticallyReadTitleChanges = false titles.live.Store(settings) changed.Source.Name = "Silent title" titles.accessible(changed) titles.flush() if len(display.messages) != 1 { t.Fatal("disabled title still flashed on braille") } } func TestAutomaticTitlesRejectBackgroundDisabledAndStaleEvents(t *testing.T) { titles, queue, changed := titleControllerFixture(t) background := changed background.Source.Path = "/background" background.Source.Name = "Background" titles.accessible(background) titles.flush() settings := titles.live.Load() settings.Reading.AutomaticallyReadTitleChanges = false titles.live.Store(settings) changed.Source.Name = "Disabled" titles.accessible(changed) titles.flush() settings.Reading.AutomaticallyReadTitleChanges = true titles.live.Store(settings) changed.Source.Name = "Stale" titles.accessible(changed) titles.state.owner.clear() titles.flush() if len(queue.requests) != 0 { t.Fatalf("unwanted titles = %+v", queue.requests) } } func TestAutomaticTitlesCancelEmptyAndLockedUpdates(t *testing.T) { titles, queue, changed := titleControllerFixture(t) changed.Source.Name = "Transient" titles.accessible(changed) changed.Source.Name = "" titles.accessible(changed) titles.flush() titles.gate = newFocusGate(nil) changed.Source.Name = "Locked title" titles.accessible(changed) titles.gate.secure.Store(true) titles.flush() titles.gate.secure.Store(false) titles.gate.suspended.Store(true) changed.Source.Name = "XTerm handoff" titles.accessible(changed) titles.flush() if len(queue.requests) != 0 { t.Fatalf("unwanted titles = %+v", queue.requests) } } func TestNativeXTermIntroductionDuringHandoff(t *testing.T) { for _, accessible := range []bool{false, true} { t.Run(map[bool]string{false: "native only", true: "accessible activation"}[accessible], func(t *testing.T) { titles, queue, activation := titleControllerFixture(t) titles.state.owner.clear() titles.gate = newFocusGate(nil) titles.gate.SetSuspended(true) titles.speech = gatedSpeech{speech: queue, gate: titles.gate} source := &titleProbeSource{title: atspi.ForegroundTitle{Epoch: 1, Title: xorg.WindowTitle{Window: 20, TitleWindow: 20, Title: "Shell", Application: "xterm"}}} titles.source, titles.foreground = source, source settings := titles.live.Load() settings.Reading.AutomaticallyReadTitleChanges = false titles.live.Store(settings) if accessible { activation.Source.Name, activation.Source.ApplicationName = "Shell", "xterm" activation.Foreground.Epoch = 1 titles.state.owner.activate(activation) titles.activated(activation) } titles.nativeChanged() titles.flush() if len(queue.requests) != 1 || queue.requests[0].Text != "Shell" || !titles.gate.Suspended() { t.Fatalf("handoff title = %+v; suspended = %t", queue.requests, titles.gate.Suspended()) } titles.nativeChanged() titles.flush() source.title.Title.Title = "Shell updated" titles.nativeChanged() titles.flush() if len(queue.requests) != 1 { t.Fatal("handoff announced duplicate or terminal title change") } // Separate xterms can have the same title; each switch introduces it. source.title.Epoch++ source.title.Title.Window, source.title.Title.TitleWindow = 21, 21 source.title.Title.Title = "Shell" titles.nativeChanged() titles.flush() if len(queue.requests) != 2 || queue.requests[1].Text != "Shell" { t.Fatalf("second terminal introduction = %+v", queue.requests) } source.title.Epoch++ source.title.Title.Window, source.title.Title.TitleWindow = 22, 22 source.title.Title.Title = "Second shell" titles.nativeChanged() titles.gate.secure.Store(true) titles.flush() if len(queue.requests) != 2 { t.Fatal("locked screen announced terminal title") } }) } } func TestNativeTitlesSharePolicyAndActivationDeduplication(t *testing.T) { titles, queue, changed := titleControllerFixture(t) source := &titleProbeSource{title: atspi.ForegroundTitle{Title: xorg.WindowTitle{Window: 10, TitleWindow: 10, Title: "Original"}}} titles.source, titles.foreground = source, source titles.nativeChanged() titles.flush() changed.Source.Name = "Changed" titles.accessible(changed) source.title.Title.Title = "Changed" titles.nativeChanged() titles.flush() if len(queue.requests) != 1 || queue.requests[0].Text != "Changed" { t.Fatalf("duplicate source output = %+v", queue.requests) } source.title.Title.Title = "Changed again" titles.nativeChanged() settings := titles.live.Load() settings.Reading.AutomaticallyReadTitleChanges = false titles.live.Store(settings) titles.flush() if len(queue.requests) != 1 { t.Fatal("native change ignored disabled preference") } titles.state.owner.clear() source.title.Epoch++ source.title.Title.Window, source.title.Title.TitleWindow, source.title.Title.Title = 20, 20, "Other window" titles.nativeChanged() source.title.Title.Title = "Other window ready" titles.nativeChanged() titles.flush() if len(queue.requests) != 2 || queue.requests[1].Text != "Other window ready" { t.Fatal("disabled title changes suppressed initial window introduction") } } func TestNativeTitleDropsOldForegroundAndTracksWineChildSwitch(t *testing.T) { titles, queue, _ := titleControllerFixture(t) titles.state.owner.clear() source := &titleProbeSource{title: atspi.ForegroundTitle{Epoch: 1, Title: xorg.WindowTitle{Window: 10, TitleWindow: 11, Title: "Wine game"}}} titles.source, titles.foreground = source, source titles.nativeChanged() source.title.Epoch++ titles.flush() if len(queue.requests) != 0 { t.Fatal("old foreground title survived") } titles.nativeChanged() titles.flush() source.title.Title.TitleWindow, source.title.Title.Title = 12, "Wine dialog" settings := titles.live.Load() settings.Reading.AutomaticallyReadTitleChanges = false titles.live.Store(settings) titles.nativeChanged() titles.flush() if len(queue.requests) != 2 || queue.requests[1].Text != "Wine dialog" { t.Fatalf("Wine child switch = %+v", queue.requests) } } func TestAutomaticTitlesWaitForApplicationOverride(t *testing.T) { titles, queue, changed := titleControllerFixture(t) titles.application = "Example Application" titles.live.onApplication = func(string) {} changed.Source.Name = "Changing title" titles.accessible(changed) titles.flush() if len(queue.requests) != 0 || titles.pending == nil { t.Fatal("title raced application settings") } application := titles.application settings := titles.live.Load() settings.Reading.AutomaticallyReadTitleChanges = false titles.live.Store(settings) titles.live.application.Store(&application) titles.flush() if len(queue.requests) != 0 || titles.pending != nil { t.Fatal("application override was not honored") } } func TestLateNativeSampleHonorsDisabledTitleChanges(t *testing.T) { titles, queue, _ := titleControllerFixture(t) settings := titles.live.Load() settings.Reading.AutomaticallyReadTitleChanges = false titles.live.Store(settings) source := &titleProbeSource{title: atspi.ForegroundTitle{Title: xorg.WindowTitle{Window: 10, TitleWindow: 10, Title: "Changed after activation"}}} titles.source, titles.foreground = source, source titles.nativeChanged() titles.flush() if len(queue.requests) != 0 { t.Fatal("first native sample bypassed disabled preference") } } func TestTitlePreferenceAppliesPersistsAndRestoresAcrossApplications(t *testing.T) { settings := config.DefaultSettings() settings.Path = filepath.Join(t.TempDir(), "settings.toml") store, err := config.NewStore(settings.Path) if err != nil { t.Fatal(err) } if err = store.Save(settings); err != nil { t.Fatal(err) } worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil) worker.live = &liveSettings{} worker.live.Store(settings) ctx := context.Background() worker.switchApplication(ctx, "Noisy Application") worker.beginEdit(ctx, "\x00") values := preferences.FromSettings(worker.editing) if !values.Reading.AutomaticallyReadTitleChanges { t.Fatal("checkbox defaults off") } values.Reading.AutomaticallyReadTitleChanges = false if response := worker.apply(ctx, preferences.Request{Values: values, Save: true}); !response.Accepted { t.Fatal(response.Message) } worker.editingActive = false worker.switchApplication(ctx, "Other Application") if !worker.live.Load().Reading.AutomaticallyReadTitleChanges { t.Fatal("override leaked") } worker.switchApplication(ctx, "Noisy Application") if worker.live.Load().Reading.AutomaticallyReadTitleChanges { t.Fatal("override lost on return") } reopened, err := config.NewStore(settings.Path) if err != nil { t.Fatal(err) } loaded, found, err := reopened.LoadForApplication("Noisy Application") if err != nil || !found || loaded.Reading.AutomaticallyReadTitleChanges { t.Fatalf("saved override = %t, %t, %v", loaded.Reading.AutomaticallyReadTitleChanges, found, err) } global, err := reopened.Load() if err != nil || !global.Reading.AutomaticallyReadTitleChanges { t.Fatalf("global default changed: %v", err) } } func TestWineTitlesUseSharedControllerWhileControlsStayReadable(t *testing.T) { titles, queue, _ := titleControllerFixture(t) titles.state.owner.clear() titles.lastTitle = "" control := make(chan string, 2) owner := newWineEventPresenter(func(text string) bool { control <- text; return true }) defer owner.Close() connectWineWindowTitles(owner, titles.live, &fakeAdapter{}) owner.pending = &winePresentation{source: "control", window: "Wine game", message: "Play, button", parts: []string{"Play", "button"}} owner.flush() if got := <-control; got != "Play, button" { t.Fatalf("control included title: %q", got) } titles.wineChanged(<-titles.live.wineTitles) titles.flush() if len(queue.requests) != 1 || queue.requests[0].Text != "Wine game" { t.Fatal("Wine initial title missing") } settings := titles.live.Load() settings.Reading.AutomaticallyReadTitleChanges = false titles.live.Store(settings) owner.pending = &winePresentation{source: "control", window: "Wine game loading", message: "Play, button", parts: []string{"Play", "button"}} owner.flush() <-control titles.wineChanged(<-titles.live.wineTitles) titles.flush() if len(queue.requests) != 1 { t.Fatal("Wine helper bypassed preference") } owner.pending = &winePresentation{source: "window", window: "Wine game ready", message: "Wine game ready", parts: []string{"Wine game ready"}} owner.flush() titles.wineChanged(<-titles.live.wineTitles) titles.flush() select { case text := <-control: t.Fatalf("title-only helper focus bypassed preference: %q", text) default: } if len(queue.requests) != 1 { t.Fatal("title-only focus bypassed preference") } } func TestWineTitleReportsCannotClaimUnrelatedForeground(t *testing.T) { titles, queue, _ := titleControllerFixture(t) source := &titleProbeSource{title: atspi.ForegroundTitle{Epoch: 1, Title: xorg.WindowTitle{Window: 10, TitleWindow: 10, Title: "Terminal"}}} titles.source, titles.foreground = source, source titles.wineChanged(wineWindowTitle{text: "Background game", foreground: source.title}) titles.flush() if len(queue.requests) != 0 { t.Fatal("background helper title was announced") } } func TestWineReviewFocusDoesNotSuppressTitleIntroduction(t *testing.T) { for _, desktop := range []bool{false, true} { for _, reviewFirst := range []bool{false, true} { t.Run(fmt.Sprintf("desktop=%t/reviewFirst=%t", desktop, reviewFirst), func(t *testing.T) { c, registry, source, worker := reviewFocusSetup(t) titles, queue, _ := titleControllerFixture(t) titles.state = c.state titles.source, titles.foreground = source, source settings := titles.live.Load() settings.Reading.AutomaticallyReadTitleChanges = false titles.live.Store(settings) if desktop { source.title.Title.TitleWindow = 21 source.title.Title.Client.Window = 21 } introduce := func() { if reviewFirst { c.changed() } titles.nativeChanged() c.changed() // The event loop syncs review focus again before flushing. titles.wineChanged(wineWindowTitle{text: "Same title", foreground: source.title}) titles.flush() } introduce() if len(queue.requests) != 1 || queue.requests[0].Text != "Same title" { t.Fatalf("Wine introduction = %+v", queue.requests) } if worker.Focus() != registry.notices[0].ID() || c.state.owner.title != "Same title" { t.Fatal("title presentation changed Wine review ownership") } c.changed() titles.nativeChanged() titles.wineChanged(wineWindowTitle{text: "Same title", foreground: source.title}) titles.flush() if len(queue.requests) != 1 { t.Fatal("duplicate Wine title was spoken") } registry.notices[0].Title = "Status changed" source.title.Title.Title = "Status changed" c.changed() titles.nativeChanged() titles.flush() if len(queue.requests) != 1 { t.Fatal("Wine title change bypassed disabled preference") } // Returning to the same Wine window must introduce it again. c.retire() source.state.Epoch++ source.title.Epoch = source.state.Epoch registry.notices[0].Title = "Same title" source.title.Title.Title = "Same title" introduce() if len(queue.requests) != 2 || queue.requests[1].Text != "Same title" { t.Fatalf("return to Wine window = %+v", queue.requests) } }) } } } func TestWineReviewTitlesUseNativeApplicationPreferences(t *testing.T) { c, registry, source, _ := reviewFocusSetup(t) titles, queue, _ := titleControllerFixture(t) titles.state = c.state titles.source, titles.foreground = source, source var application string titles.live.onApplication = func(name string) { application = name } previousApplication := "Other Application" titles.live.application.Store(&previousApplication) c.changed() titles.nativeChanged() titles.flush() if application != "fixture.exe" || len(queue.requests) != 0 || titles.pending == nil { t.Fatalf("Wine title raced application settings: application=%q requests=%+v", application, queue.requests) } settings := titles.live.Load() settings.Reading.AutomaticallyReadTitleChanges = false titles.live.Store(settings) titles.live.application.Store(&application) titles.flush() if len(queue.requests) != 1 || queue.requests[0].Text != "Same title" { t.Fatalf("Wine introduction = %+v", queue.requests) } registry.notices[0].Title = "Changed status" source.title.Title.Title = "Changed status" c.changed() titles.nativeChanged() titles.flush() if len(queue.requests) != 1 { t.Fatal("Wine title ignored its application preference") } }