Files
cthulhu/internal/app/wine_window_titles_native_test.go

214 lines
6.9 KiB
Go

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