214 lines
6.9 KiB
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):
|
|
}
|
|
}
|