Files
cthulhu/internal/app/window_titles_test.go

578 lines
21 KiB
Go

// 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")
}
}