200 lines
6.0 KiB
Go
200 lines
6.0 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"math"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
|
"git.stormux.org/storm/cthulhu/internal/presentation"
|
|
"git.stormux.org/storm/cthulhu/internal/review"
|
|
)
|
|
|
|
// Exercise real Xorg keys, native focus echoes, the review worker and the
|
|
// event-loop presentation filter together. No audio or desktop reader is used.
|
|
func TestNativeBrowserCaretLinkSounds(t *testing.T) {
|
|
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
|
|
t.Skip("requires private browser sound fixture")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second)
|
|
defer cancel()
|
|
adapter := atspi.New()
|
|
adapter.SetCaptureSoundGeometry(true)
|
|
if err := adapter.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer adapter.Stop(context.Background())
|
|
listener := xorg.New()
|
|
if err := listener.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer listener.Stop(context.Background())
|
|
commands := xorg.NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout)
|
|
if err := commands.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer commands.Stop(context.Background())
|
|
worker, err := review.NewWorker(adapter, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := worker.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer worker.Stop(context.Background())
|
|
browser := newBrowserModeController(listener, commands, adapter)
|
|
if err := browser.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer browser.Stop(context.Background())
|
|
output := &ocrTestOutput{speech: make(chan speechRequest, 128)}
|
|
options := presentation.Options{EnableSound: true, PlaySoundForRole: true,
|
|
SpatializeObjectSounds: true, RoleSoundPresentation: "sound_and_speech"}
|
|
loopDone, commandDone := make(chan struct{}), make(chan struct{})
|
|
go func() {
|
|
defer close(loopDone)
|
|
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), output, adapter, nil, worker,
|
|
20*time.Millisecond, nil, browser, nil, nil, options)
|
|
}()
|
|
go func() {
|
|
defer close(commandDone)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case event := <-commands.Commands():
|
|
browser.observeCommand(event.Command)
|
|
if command := documentCaretCommand(event.Command); command != 0 {
|
|
worker.MoveDocumentCaret(command)
|
|
} else if target, level, backwards := documentReviewCommand(event.Command); target != 0 {
|
|
worker.MoveDocument(target, level, backwards)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
defer func() { cancel(); <-loopDone; <-commandDone }()
|
|
key := func(chord string) {
|
|
t.Helper()
|
|
if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil {
|
|
t.Fatalf("keys: %v %s", err, out)
|
|
}
|
|
}
|
|
// Wait for a presentation, then retain late native focus echoes through a
|
|
// quiet interval. Counting every link cue catches conflicting duplicates.
|
|
collect := func(want string) []speechRequest {
|
|
t.Helper()
|
|
deadline := time.NewTimer(3 * time.Second)
|
|
defer deadline.Stop()
|
|
quiet := time.NewTimer(200 * time.Millisecond)
|
|
defer quiet.Stop()
|
|
matched := false
|
|
var requests []speechRequest
|
|
for {
|
|
select {
|
|
case request := <-output.speech:
|
|
requests = append(requests, request)
|
|
matched = matched || strings.Contains(request.Text, want)
|
|
quiet.Reset(200 * time.Millisecond)
|
|
case <-quiet.C:
|
|
if matched {
|
|
return requests
|
|
}
|
|
case <-deadline.C:
|
|
t.Fatalf("missing %q; requests=%+v", want, requests)
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
}
|
|
}
|
|
heading := false
|
|
for attempt := 0; attempt < 25 && !heading; attempt++ {
|
|
key("Tab")
|
|
timer := time.NewTimer(150 * time.Millisecond)
|
|
drain:
|
|
for {
|
|
select {
|
|
case request := <-output.speech:
|
|
if strings.Contains(request.Text, "Sound fixture start") {
|
|
heading = true
|
|
break drain
|
|
}
|
|
case <-timer.C:
|
|
break drain
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
}
|
|
timer.Stop()
|
|
}
|
|
if !heading {
|
|
t.Fatal("fixture heading focus missing")
|
|
}
|
|
// Focus speech can arrive before asynchronous mode resolution installs
|
|
// the browse grabs. Wait for X11 to acknowledge them before sending the
|
|
// first caret command; native Ctrl+Home at the heading can emit no event.
|
|
for !commands.BrowseNavigationEnabled() {
|
|
select {
|
|
case <-time.After(5 * time.Millisecond):
|
|
case <-ctx.Done():
|
|
t.Fatal("browse grabs were not installed", ctx.Err())
|
|
}
|
|
}
|
|
check := func(chord, want string, pan *float64) float64 {
|
|
t.Helper()
|
|
key(chord)
|
|
requests := collect(want)
|
|
var pans []float64
|
|
for _, request := range requests {
|
|
for _, cue := range request.Sound {
|
|
if cue.Name == "link" || cue.Name == "link_visited" {
|
|
pans = append(pans, cue.Pan)
|
|
}
|
|
}
|
|
}
|
|
if len(pans) != 1 {
|
|
t.Fatalf("%s produced %d link cues: %+v", chord, len(pans), requests)
|
|
}
|
|
if pan != nil && math.Abs(pans[0]-*pan) > 0.025 {
|
|
t.Fatalf("%s pan=%g, Tab pan=%g", chord, pans[0], *pan)
|
|
}
|
|
return pans[0]
|
|
}
|
|
for _, size := range []string{"1200 800", "900 600"} {
|
|
args := append([]string{"getwindowfocus", "windowsize"}, strings.Fields(size)...)
|
|
if out, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
|
|
t.Fatalf("resize: %v %s", err, out)
|
|
}
|
|
key("ctrl+Home")
|
|
collect("Sound fixture start")
|
|
names := []string{"Left link label", "Center link label", "Right link label"}
|
|
pans := make([]float64, 3)
|
|
for index, name := range names {
|
|
pans[index] = check("Tab", name, nil)
|
|
}
|
|
if pans[0] > -0.5 || math.Abs(pans[1]) > 0.2 || pans[2] < 0.5 {
|
|
t.Fatalf("native link positions: %v", pans)
|
|
}
|
|
key("ctrl+Home")
|
|
collect("Sound fixture start")
|
|
for index, name := range names {
|
|
check("Down", name, &pans[index])
|
|
}
|
|
check("ctrl+Right", "link", &pans[2])
|
|
check("ctrl+Left", "link", &pans[2])
|
|
check("Up", names[1], &pans[1])
|
|
check("k", names[2], &pans[2])
|
|
t.Logf("window %s: Tab, arrows, words and structural links agree at pans %v", size, pans)
|
|
}
|
|
}
|