// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "log/slog" "os" "sync" "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/wine" ) type wineTitleLoopAdapter struct { fakeAdapter *reviewFocusMonitor } type changingWineTitleAdapter struct { wineTitleLoopAdapter mu sync.Mutex changes chan struct{} } func (adapter *changingWineTitleAdapter) Foreground() atspi.ForegroundState { adapter.mu.Lock() defer adapter.mu.Unlock() return adapter.state } func (adapter *changingWineTitleAdapter) ForegroundTitle() atspi.ForegroundTitle { adapter.mu.Lock() defer adapter.mu.Unlock() return adapter.title } func (adapter *changingWineTitleAdapter) TitleChanges() <-chan struct{} { return adapter.changes } func TestPrivateWineReviewUpdatesPreserveQueuedTitleSpeech(t *testing.T) { if os.Getenv("CTHULHU_WINE_TITLE_DBUS") != "1" { t.Skip("requires a private D-Bus session") } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() logger := slog.New(slog.DiscardHandler) registry := wine.NewReviewRegistry(logger) if err := registry.Start(ctx); err != nil { t.Fatal(err) } defer registry.Stop(context.Background()) _, fixture, monitor, reviewWorker := reviewFocusSetup(t) notice := fixture.notices[0] if !registry.RegisterProvider(notice.Sender, wine.ReviewObjectPath, wine.ReviewProtocolVersion) || !registry.PresentFocus(notice.Sender, notice) { t.Fatal("could not register private Wine focus") } adapter := &changingWineTitleAdapter{wineTitleLoopAdapter: wineTitleLoopAdapter{fakeAdapter: fakeAdapter{events: make(chan atspi.Event)}, reviewFocusMonitor: monitor}, changes: make(chan struct{}, 1)} live := &liveSettings{wineReview: registry} live.Store(config.DefaultSettings()) speech := newSpeechWorker(logger, &fakeSpeaker{spoken: make(chan string, 4)}) speech.running = true done := make(chan error, 1) go func() { done <- processEventsWithInput(ctx, logger, speech, adapter, nil, reviewWorker, 0, live, nil, nil, nil) }() defer func() { cancel() if err := <-done; err != nil { t.Error(err) } }() wait := func(check func() bool) { t.Helper() for !check() { select { case <-ctx.Done(): t.Fatal("Wine title replay timed out") case <-time.After(time.Millisecond): } } } wait(func() bool { return reviewWorker.Focus() == notice.ID() }) speech.mutex.Lock() initiallyInterrupted := speech.generation != 0 speech.mutex.Unlock() if initiallyInterrupted { t.Fatal("Wine review focus reset requested speech cancellation") } var initial speechRequest wait(func() bool { var ok bool; initial, ok = speech.next(); return ok }) if err := speech.deliver(ctx, initial); err != nil { t.Fatal(err) } for _, title := range []string{"East", "In front of the safe zone", "Leaving in front of the safe zone"} { // The native caption changes before the helper's next metadata notice. // The old notice loses eligibility, then a new revision reclaims focus. adapter.mu.Lock() adapter.title.Title.Title = title adapter.mu.Unlock() adapter.changes <- struct{}{} wait(func() bool { return !reviewWorker.Focus().Valid() }) speech.mutex.Lock() interrupted := speech.generation != 0 speech.mutex.Unlock() if interrupted { t.Fatal("Wine metadata retirement canceled title speech") } notice.Title = title notice.Revision++ if !registry.PresentFocus(notice.Sender, notice) { t.Fatal("could not update Wine metadata") } wait(func() bool { return reviewWorker.Focus() == notice.ID() }) wait(func() bool { speech.mutex.Lock() defer speech.mutex.Unlock() return len(speech.pending) > 0 && speech.pending[len(speech.pending)-1].Text == title }) if speech.InterruptForInput(input.Event{Name: "Up", Pressed: true, Repeat: true}) { t.Fatal("metadata update removed held-key protection") } } speech.mutex.Lock() generation := speech.generation count := len(speech.pending) speech.mutex.Unlock() if generation != 0 || count != 3 { t.Fatalf("metadata interrupted titles: generation=%d pending=%d", generation, count) } for _, want := range []string{"East", "In front of the safe zone", "Leaving in front of the safe zone"} { request, ok := speech.next() if !ok || request.Text != want || !request.Append { t.Fatalf("queued title = %+v, want %q", request, want) } if err := speech.deliver(ctx, request); err != nil { t.Fatal(err) } } if !speech.InterruptForInput(input.Event{Name: "Down", Pressed: true}) { t.Fatal("fresh press failed to stop speech") } } func TestPrivateWineTitleSurvivesReviewFocusBurst(t *testing.T) { if os.Getenv("CTHULHU_WINE_TITLE_DBUS") != "1" { t.Skip("requires a private D-Bus session") } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() logger := slog.New(slog.DiscardHandler) registry := wine.NewReviewRegistry(logger) if err := registry.Start(ctx); err != nil { t.Fatal(err) } defer registry.Stop(context.Background()) _, fixture, monitor, worker := reviewFocusSetup(t) notice := fixture.notices[0] if !registry.RegisterProvider(notice.Sender, wine.ReviewObjectPath, wine.ReviewProtocolVersion) || !registry.PresentFocus(notice.Sender, notice) { t.Fatal("could not register private Wine focus") } adapter := &wineTitleLoopAdapter{fakeAdapter: fakeAdapter{events: make(chan atspi.Event)}, reviewFocusMonitor: monitor} live := &liveSettings{wineReview: registry} settings := config.DefaultSettings() settings.Reading.AutomaticallyReadTitleChanges = false live.Store(settings) queue := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)} done := make(chan error, 1) go func() { done <- processEventsWithInput(ctx, logger, queue, adapter, nil, worker, 0, live, nil, nil, nil) }() defer func() { cancel() if err := <-done; err != nil { t.Error(err) } }() for worker.Focus() != notice.ID() { select { case <-ctx.Done(): t.Fatal("Wine review did not establish focus") case <-time.After(time.Millisecond): } } // Wine publishes the frame, then its focused edit control while the // native title introduction is waiting. Both belong to the same window. notice.Focus = "edit" notice.Revision++ if !registry.PresentFocus(notice.Sender, notice) { t.Fatal("could not publish focused edit control") } select { case request := <-queue.requests: if request.Text != "Same title" || request.Cause != "window-title" || !request.Append { t.Fatalf("Wine window introduction = %+v", request) } case <-time.After(time.Second): t.Fatal("Wine review focus burst cancelled the title introduction") } notice.Revision++ registry.PresentFocus(notice.Sender, notice) select { case request := <-queue.requests: t.Fatalf("Wine control revision repeated the title: %+v", request) case <-time.After(300 * time.Millisecond): } }