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