214 lines
8.1 KiB
Go
214 lines
8.1 KiB
Go
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/braille"
|
|
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
|
"git.stormux.org/storm/cthulhu/internal/review"
|
|
"git.stormux.org/storm/cthulhu/internal/wine"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type reviewFocusRegistry struct{ notices []wine.ReviewFocusNotice }
|
|
|
|
func (r *reviewFocusRegistry) Focuses() []wine.ReviewFocusNotice {
|
|
return append([]wine.ReviewFocusNotice(nil), r.notices...)
|
|
}
|
|
|
|
func TestWineReviewSpeechBrailleAndSpelling(t *testing.T) {
|
|
c, _, _, worker := reviewFocusSetup(t)
|
|
c.changed()
|
|
queue := &fakeSpeechQueue{spoken: make(chan string, 10), requests: make(chan speechRequest, 10)}
|
|
frames := make(chan braille.Frame, 10)
|
|
handler := &eventReviewHandler{state: c.state, logger: slog.New(slog.DiscardHandler), speech: queue, objectReview: worker, wineReview: c,
|
|
brailleOutput: frameRecorder{frames: frames}, syncForeground: c.foregroundChanged}
|
|
for _, test := range []struct {
|
|
unit review.FlatUnit
|
|
style review.FlatSpeech
|
|
text string
|
|
}{
|
|
{review.Line, review.FlatSpeak, "alpha beta"}, {review.Word, review.FlatSpell, "cat"}, {review.Character, review.FlatPhonetic, "c"},
|
|
} {
|
|
handler.handle(review.Result{FlatUnit: test.unit, FlatSpeech: test.style, FlatFocus: worker.Focus(), DocumentEpoch: worker.DocumentEpoch(), FlatPosition: review.FlatPosition{Text: test.text}})
|
|
request := <-queue.requests
|
|
if request.Cause != "flat-review" || (test.style != review.FlatSpeak && len(request.Spelling) == 0) {
|
|
t.Fatalf("shared presentation lost: %+v", request)
|
|
}
|
|
select {
|
|
case frame := <-frames:
|
|
if frame.Text != test.text || len(frame.Routes) != 0 || frame.Document != nil {
|
|
t.Fatalf("braille: %+v", frame)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Wine review braille frame missing")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWineReviewLockAndXTermHandoff(t *testing.T) {
|
|
c, _, _, worker := reviewFocusSetup(t)
|
|
c.gate = newFocusGate(nil)
|
|
c.gate.SetSuspended(true)
|
|
c.changed()
|
|
if c.gate.Suspended() || !worker.Focus().Valid() {
|
|
t.Fatal("Wine focus failed to resume review grabs")
|
|
}
|
|
c.gate.secure.Store(true)
|
|
c.changed()
|
|
if worker.Focus().Valid() {
|
|
t.Fatal("locked session kept Wine review focus")
|
|
}
|
|
}
|
|
func (r *reviewFocusRegistry) Current(id atspi.ObjectID) bool {
|
|
for _, n := range r.notices {
|
|
if n.ID() == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type reviewFocusMonitor struct {
|
|
state atspi.ForegroundState
|
|
title atspi.ForegroundTitle
|
|
}
|
|
|
|
func (m *reviewFocusMonitor) Foreground() atspi.ForegroundState { return m.state }
|
|
func (m *reviewFocusMonitor) ForegroundChanges() <-chan struct{} { return nil }
|
|
func (m *reviewFocusMonitor) ForegroundTitle() atspi.ForegroundTitle { return m.title }
|
|
func (m *reviewFocusMonitor) TitleChanges() <-chan struct{} { return nil }
|
|
func reviewFocusSetup(t *testing.T) (*wineReviewFocusController, *reviewFocusRegistry, *reviewFocusMonitor, *review.Worker) {
|
|
t.Helper()
|
|
n := wine.ReviewFocusNotice{Sender: ":1.20", Path: wine.ReviewObjectPath, Instance: "instance", Window: "window", Focus: "empty", Revision: 1, Title: "Same title", Role: "window", Bounds: atspi.Rectangle{X: -20, Y: 10, Width: 400, Height: 300}}
|
|
registry := &reviewFocusRegistry{notices: []wine.ReviewFocusNotice{n}}
|
|
monitor := &reviewFocusMonitor{state: atspi.ForegroundState{Epoch: 1, Client: xorg.Foreground{Known: true, Window: 20, X: -20, Y: 10, Width: 400, Height: 300}}}
|
|
monitor.title = atspi.ForegroundTitle{Epoch: 1, Title: xorg.WindowTitle{Window: 20, TitleWindow: 20, Title: n.Title, Application: "fixture.exe", Wine: true, Focused: true, Client: monitor.state.Client}}
|
|
worker, err := review.NewWorker(&fakeAdapter{}, time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
state := &eventLoopState{}
|
|
controller := &wineReviewFocusController{registry: registry, state: state, foreground: monitor, titles: monitor, worker: worker, clear: func() { state.current = atspi.Snapshot{}; state.focusedID = atspi.ObjectID{}; worker.ClearFocus() }}
|
|
return controller, registry, monitor, worker
|
|
}
|
|
func TestWineReviewIdenticalTitlesDoNotClaimForeground(t *testing.T) {
|
|
c, r, m, w := reviewFocusSetup(t)
|
|
r.notices[0].Bounds.X = 500
|
|
c.changed()
|
|
if w.Focus().Valid() {
|
|
t.Fatal("title alone claimed focus")
|
|
}
|
|
r.notices[0].Bounds.X = -20
|
|
duplicate := r.notices[0]
|
|
duplicate.Sender = ":1.21"
|
|
r.notices = append(r.notices, duplicate)
|
|
c.changed()
|
|
if w.Focus().Valid() {
|
|
t.Fatal("ambiguous providers claimed focus")
|
|
}
|
|
r.notices = r.notices[:1]
|
|
m.title.Title.Wine = false
|
|
c.changed()
|
|
if w.Focus().Valid() {
|
|
t.Fatal("Linux client claimed by stale Wine provider")
|
|
}
|
|
m.title.Title.Wine = true
|
|
c.changed()
|
|
if w.Focus() != r.notices[0].ID() {
|
|
t.Fatal("unique geometry did not establish focus")
|
|
}
|
|
}
|
|
func TestWineReviewForegroundAndVirtualDesktop(t *testing.T) {
|
|
c, r, m, w := reviewFocusSetup(t)
|
|
c.changed()
|
|
if w.Focus() != r.notices[0].ID() {
|
|
t.Fatal("negative coordinate focus missing")
|
|
}
|
|
m.state.Epoch++
|
|
c.foregroundChanged()
|
|
if w.Focus().Valid() {
|
|
t.Fatal("old metadata epoch reused")
|
|
}
|
|
m.title.Epoch = m.state.Epoch
|
|
m.state.Client.Width = 800
|
|
m.state.Client.Height = 600
|
|
m.title.Title.TitleWindow = 21
|
|
m.title.Title.Client = xorg.Foreground{Known: true, Window: 21, X: -20, Y: 10, Width: 400, Height: 300}
|
|
c.foregroundChanged()
|
|
if w.Focus() != r.notices[0].ID() {
|
|
t.Fatal("focused desktop child not matched")
|
|
}
|
|
m.title.Title.Focused = false
|
|
c.changed()
|
|
if w.Focus().Valid() {
|
|
t.Fatal("fallback desktop title claimed focus")
|
|
}
|
|
}
|
|
func TestWineReviewDropsOldResultsAndRestarts(t *testing.T) {
|
|
c, r, m, w := reviewFocusSetup(t)
|
|
c.changed()
|
|
old := review.Result{FlatUnit: review.Line, FlatFocus: w.Focus(), DocumentEpoch: w.DocumentEpoch()}
|
|
if !c.resultCurrent(old) {
|
|
t.Fatal("current result rejected")
|
|
}
|
|
c.changed()
|
|
if !c.resultCurrent(old) {
|
|
t.Fatal("duplicate retired cursor")
|
|
}
|
|
r.notices[0].Revision++
|
|
c.changed()
|
|
if c.resultCurrent(old) {
|
|
t.Fatal("revision retained queued result")
|
|
}
|
|
old = review.Result{FlatUnit: review.Line, FlatFocus: w.Focus(), DocumentEpoch: w.DocumentEpoch()}
|
|
r.notices = nil
|
|
c.changed()
|
|
if c.resultCurrent(old) || w.Focus().Valid() {
|
|
t.Fatal("dead provider remained active")
|
|
}
|
|
m.state.Client.Known = false
|
|
c.changed()
|
|
if w.Focus().Valid() {
|
|
t.Fatal("unknown foreground accepted")
|
|
}
|
|
// Native ownership is restored by the normal native event path after retirement.
|
|
native := atspi.NewObjectID(":1.8", "/linux")
|
|
w.SyncFocus(native)
|
|
c.changed()
|
|
if w.Focus() != native {
|
|
t.Fatal("Wine retirement erased restored Linux focus")
|
|
}
|
|
}
|
|
func TestWineReviewEmptyFocusCanReachOutput(t *testing.T) {
|
|
c, r, _, w := reviewFocusSetup(t)
|
|
n := r.notices[0]
|
|
root := wine.ReviewID(n.Sender, n.Instance, n.Window, n.Window)
|
|
empty := n.ID()
|
|
output := wine.ReviewID(n.Sender, n.Instance, n.Window, "output")
|
|
geometry := func(b atspi.Rectangle) atspi.Geometry {
|
|
return atspi.Geometry{Bounds: b, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true}
|
|
}
|
|
tree, err := atspi.NewAccessibleTree(root, []atspi.TreeNode{
|
|
{Snapshot: atspi.Snapshot{BusName: root.BusName, Path: root.Path, Role: "window"}, Geometry: geometry(n.Bounds), Children: []atspi.ObjectID{empty, output}},
|
|
{Snapshot: atspi.Snapshot{BusName: empty.BusName, Path: empty.Path, Role: "text"}, Geometry: geometry(atspi.Rectangle{X: 0, Y: 40, Width: 200, Height: 20}), Text: atspi.VisibleText{Known: true, CaretKnown: true}},
|
|
{Snapshot: atspi.Snapshot{BusName: output.BusName, Path: output.Path, Role: "text"}, Geometry: geometry(atspi.Rectangle{X: 0, Y: 100, Width: 200, Height: 20}), Text: atspi.VisibleText{Known: true, Ranges: []atspi.TextRange{{Start: 0, End: 11, Text: "alpha beta!", Bounds: atspi.Rectangle{X: 0, Y: 100, Width: 200, Height: 20}}}}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c.changed()
|
|
cursor := new(review.FlatCursor)
|
|
if err = cursor.Refresh(context.Background(), tree, w.Focus()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _, err = cursor.Read(review.Line, 0)
|
|
position, _, err := cursor.Read(review.Line, 1)
|
|
if err != nil || position.Text != "alpha beta!" {
|
|
t.Fatalf("empty anchor: %+v %v", position, err)
|
|
}
|
|
}
|