// 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" "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" "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() // A title restored within the repeat window is a repeat, not news; // animated cycles pass through a blank frame every cycle and must // not revive announcements. It announces again after the window. if len(queue.requests) != 1 { t.Fatal("restored title repeated within the repeat window") } } func TestAutomaticTitleSuppressesAnimatedSpinnerCycle(t *testing.T) { titles, queue, changed := titleControllerFixture(t) // Terminal TUIs animate a spinner glyph in the title several times a // second while the meaningful title text stays the same. The cycle // includes a blank frame; it must not reset suppression. for _, text := range []string{"⠋ Terminal", "⠙ Terminal", "⠹ Terminal", "", "⠋ Terminal", "Terminal", "", "⠙ Terminal"} { changed.Source.Name = text titles.accessible(changed) titles.flush() } if len(queue.requests) != 1 || queue.requests[0].Text != "⠋ Terminal" { t.Fatalf("spinner title requests = %+v, want only the first frame", queue.requests) } // A different title is not part of the animation and still announces. changed.Source.Name = "Build finished" titles.accessible(changed) titles.flush() if len(queue.requests) != 2 || queue.requests[1].Text != "Build finished" { t.Fatalf("title after spinner = %+v", queue.requests) } // A leading count is meaningful: each value announces, a rapid repeat // of an earlier value in the same cycle does not. for _, text := range []string{"(2) Terminal", "(3) Terminal", "(2) Terminal"} { changed.Source.Name = text titles.accessible(changed) titles.flush() } if len(queue.requests) != 4 { t.Fatalf("counted title requests = %+v, want the cycle repeat suppressed", queue.requests) } } func TestWindowReactivationDoesNotReviveSpinnerCycle(t *testing.T) { titles, queue, changed := titleControllerFixture(t) for _, text := range []string{"9 Terminal", "8 Terminal"} { changed.Source.Name = text titles.accessible(changed) titles.flush() } if len(queue.requests) != 2 { t.Fatalf("initial spinner titles = %+v", queue.requests) } // Leaving for another workspace deactivates (reset) and returning // reactivates the same window. Neither may restart the cycle. titles.reset() titles.activated(changed) for _, text := range []string{"9 Terminal", "8 Terminal"} { changed.Source.Name = text titles.accessible(changed) titles.flush() } if len(queue.requests) != 2 { t.Fatalf("spinner revived by workspace round-trip = %+v", queue.requests) } } func TestNativeTitleSwitchSamplesDoNotReviveSpinnerCycle(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 = "9 Terminal" titles.accessible(changed) titles.flush() changed.Source.Name = "8 Terminal" titles.accessible(changed) titles.flush() if len(queue.requests) != 2 { t.Fatalf("initial spinner titles = %+v", queue.requests) } // A native sampler may re-report the same client as a switch on every // sample. That must not wipe the animated-title repeat history, so the // cycle returning to an earlier frame stays suppressed. for _, text := range []string{"9 Terminal", "8 Terminal"} { source.title.Epoch++ source.title.Title.Title = text titles.nativeChanged() titles.flush() } if len(queue.requests) != 2 { t.Fatalf("spinner revived by native switch samples = %+v", queue.requests) } } 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 TestAutomaticTitleDoesNotRepeatFocusedEdit(t *testing.T) { for _, native := range []bool{false, true} { t.Run(fmt.Sprintf("native=%v", native), func(t *testing.T) { titles, queue, changed := titleControllerFixture(t) tracker := &input.IntentTracker{} titles.inputSource = tracker entry := atspi.Snapshot{BusName: changed.Source.BusName, Path: "/entry", Role: "entry", Editable: true, Focused: true} titles.state.current = entry titles.state.focusedID, titles.state.owner.focus = entry.ID(), entry.ID() titles.state.owner.title, titles.lastTitle = "Compose: (empty) - Mail", "Compose: (empty) - Mail" source := &titleProbeSource{title: atspi.ForegroundTitle{Epoch: 1, Title: xorg.WindowTitle{Window: 20, TitleWindow: 20, Title: titles.lastTitle}}} if native { titles.source, titles.foreground, titles.lastNative = source, source, source.title } change := func(text string) { if native { source.title.Title.Title = text titles.nativeChanged() } else { changed.Source.Name = text titles.accessible(changed) } titles.flush() } for _, step := range []struct{ key, text string }{{"D", "D"}, {"r", "Dr"}, {"BackSpace", "D"}} { tracker.Observe(input.NewKeyEvent(0, true, step.key)) titles.state.current.Text = step.text change("Compose: " + step.text + " - Mail") } if len(queue.requests) != 0 { t.Fatalf("titles repeated the edited text: %+v", queue.requests) } // The entry still owns focus and input is still recent. A changed // status outside the edited value must nevertheless be announced. change("Compose: D - Mail (saved)") if len(queue.requests) != 1 || queue.requests[0].Text != "Compose: D - Mail (saved)" { t.Fatalf("unrelated status title = %+v", queue.requests) } }) } } func TestAutomaticTitleEditFilterRequiresOwnedEdit(t *testing.T) { for _, name := range []string{"no-input", "navigation", "read-only", "unfocused", "other-focus", "other-window", "empty", "ambiguous", "introduction"} { t.Run(name, func(t *testing.T) { titles, queue, changed := titleControllerFixture(t) tracker := &input.IntentTracker{} titles.inputSource = tracker tracker.Observe(input.NewKeyEvent(0, true, "r")) titles.state.current = atspi.Snapshot{BusName: changed.Source.BusName, Path: "/entry", Role: "entry", Text: "Draft", Editable: true, Focused: true} titles.state.focusedID, titles.state.owner.focus = titles.state.current.ID(), titles.state.current.ID() titles.state.owner.title, titles.lastTitle = "Compose: Draf - Mail", "Compose: Draf - Mail" changed.Source.Name = "Compose: Draft - Mail" switch name { case "no-input": titles.inputSource = nil case "navigation": tracker.Observe(input.NewKeyEvent(0, true, "Left")) case "read-only": titles.state.current.ReadOnly = true case "unfocused": titles.state.current.Focused = false case "other-focus": titles.state.owner.focus.Path = "/other" case "other-window": titles.state.current.BusName = ":1.99" titles.state.focusedID, titles.state.owner.focus = titles.state.current.ID(), titles.state.current.ID() case "empty": titles.state.current.Text = "" case "ambiguous": changed.Source.Name = "Compose: Draft - Draft" case "introduction": titles.state.owner.title, titles.lastTitle = "", "" } titles.accessible(changed) titles.flush() if len(queue.requests) != 1 || queue.requests[0].Text != changed.Source.Name { t.Fatalf("title without matching edit = %+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") } } func TestTitleRepeatHistoryIsScopedToWindow(t *testing.T) { titles, queue, changed := titleControllerFixture(t) changed.Source.Name = "Build finished" titles.accessible(changed) titles.flush() other := ownerEvent(atspi.WindowActivatedEvent, ":1.51", "/window", "/window") other.Source.Name, other.Source.Role = "Original", "frame" titles.state.owner.activate(other) titles.activated(other) other.Type, other.Source.Name = atspi.AccessibleNameEvent, "Build finished" titles.accessible(other) titles.flush() if len(queue.requests) != 2 { t.Fatalf("another window's title was suppressed: %+v", queue.requests) } } func TestTitleStatusSymbolsRemainMeaningful(t *testing.T) { titles, queue, changed := titleControllerFixture(t) for _, text := range []string{"✓ Build", "✗ Build", "! Build", "? Build"} { changed.Source.Name = text titles.accessible(changed) titles.flush() if len(queue.requests) == 0 || queue.requests[len(queue.requests)-1].Text != text { t.Fatalf("meaningful title status %q was suppressed: %+v", text, queue.requests) } } if len(queue.requests) != 4 { t.Fatalf("status titles = %+v", queue.requests) } } func TestTitleRepeatHistoryRefreshesAndExpires(t *testing.T) { titles, _, changed := titleControllerFixture(t) pending := pendingWindowTitle{window: changed.Source.ID(), text: "⠋ Terminal"} start := time.Now() if titles.repeatedWithinWindow(pending, start) { t.Fatal("first title suppressed") } pending.text = "⠙ Terminal" if !titles.repeatedWithinWindow(pending, start.Add(2*time.Second)) || !titles.repeatedWithinWindow(pending, start.Add(4*time.Second)) { t.Fatal("active animation history expired") } if titles.repeatedWithinWindow(pending, start.Add(8*time.Second)) { t.Fatal("inactive animation history did not expire") } pending.native = xorg.WindowTitle{Window: 10, TitleWindow: 11} if titles.repeatedWithinWindow(pending, start.Add(8*time.Second)) { t.Fatal("native window inherited AT-SPI-only history") } pending.native.TitleWindow = 12 if titles.repeatedWithinWindow(pending, start.Add(8*time.Second)) { t.Fatal("another native child inherited title history") } }