Files
cthulhu/internal/app/browser_link_sounds_native_test.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)
}
}