Files
cthulhu/internal/atspi/review_cache_native_test.go

254 lines
7.6 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/logging"
)
func TestNativeFlatReviewPreservesProviderCache(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_REVIEW_CACHE") != "1" {
t.Skip("requires private Xvfb/AT-SPI session")
}
test_native_review_provider(t, "cache")
}
func TestNativeFlatReviewBoundsSlowLongRanges(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_REVIEW_BOUNDS") != "1" {
t.Skip("requires private Xvfb/AT-SPI session")
}
test_native_review_provider(t, "bounds")
}
func TestNativeFlatReviewClippedViewport(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_REVIEW_CLIPPED") != "1" {
t.Skip("requires private Xvfb/AT-SPI session")
}
test_native_review_provider(t, "clipped")
}
func TestNativeFlatReviewLargeTextViewport(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_REVIEW_VIEWPORT") != "1" {
t.Skip("requires private Xvfb/AT-SPI session")
}
// Adapter ownership is process-global; each mode runs in its own process.
mode := os.Getenv("CTHULHU_NATIVE_REVIEW_VIEWPORT_MODE")
if mode != "lines" && mode != "long" {
t.Fatal("set viewport mode to lines or long")
}
test_native_review_provider(t, mode)
}
func TestNativeFlatReviewUnavailableSubtree(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_REVIEW_SUBTREE") != "1" {
t.Skip("requires private Xvfb/AT-SPI session")
}
test_native_review_provider(t, "children")
}
func TestNativeFlatReviewZeroAreaGlyph(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_REVIEW_GLYPHS") != "1" {
t.Skip("requires private Xvfb/AT-SPI session")
}
test_native_review_provider(t, "glyphs")
}
func test_native_review_provider(t *testing.T, mode string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
flags, err := exec.CommandContext(ctx, "pkg-config", "--cflags", "--libs", "gtk+-3.0").Output()
if err != nil {
t.Fatal(err)
}
binary := filepath.Join(t.TempDir(), "review-cache-fixture")
args := append([]string{"../../tests/review_cache_fixture.c", "-o", binary}, strings.Fields(string(flags))...)
if output, err := exec.CommandContext(ctx, "cc", args...).CombinedOutput(); err != nil {
t.Fatalf("build fixture: %v: %s", err, output)
}
var diagnostics bytes.Buffer
adapter := New()
adapter.SetCaptureLogger(logging.New(&diagnostics, logging.Options{Level: slog.LevelDebug}))
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
fixture := exec.CommandContext(ctx, binary, mode)
fixture.Stderr = os.Stderr
input, err := fixture.StdinPipe()
if err != nil {
t.Fatal(err)
}
output, err := fixture.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := fixture.Start(); err != nil {
t.Fatal(err)
}
defer func() {
_ = fixture.Process.Kill()
_ = fixture.Wait()
}()
if output, err := exec.CommandContext(ctx, "xdotool", "search", "--sync", "--onlyvisible", "--name", "^Review cache probe$", "windowfocus").CombinedOutput(); err != nil {
t.Fatalf("focus fixture: %v: %s", err, output)
}
var focus ObjectID
for !focus.Valid() {
select {
case event := <-adapter.Events():
if event.Type == WindowActivatedEvent && event.WindowTitle == "Review cache probe" {
focus = event.Source.ID()
}
case <-ctx.Done():
t.Fatal("fixture focus missing")
}
}
scanner := bufio.NewScanner(output)
command := func(request, want string) {
t.Helper()
if _, err := fmt.Fprintln(input, request); err != nil {
t.Fatal(err)
}
if !scanner.Scan() || scanner.Text() != want {
t.Fatalf("fixture %s: %q, %v", request, scanner.Text(), scanner.Err())
}
}
if mode == "bounds" || mode == "clipped" || mode == "long" || mode == "lines" || mode == "glyphs" {
command("measure", "measuring")
} else if mode != "children" {
command("arm", "armed")
}
if mode == "lines" {
command("scroll", "scrolled")
}
tree, err := adapter.CaptureReviewWindow(ctx, focus)
if err != nil {
t.Fatal(err)
}
var timing struct {
Message string `json:"message"`
RootScope string `json:"root_scope"`
CapturedNodes int `json:"captured_nodes"`
Stages []captureStageTiming `json:"stages"`
}
if err := json.Unmarshal(diagnostics.Bytes(), &timing); err != nil {
t.Fatalf("capture diagnostics: %v: %s", err, diagnostics.Bytes())
}
if timing.Message != "flat review capture timing" || timing.RootScope != "window" || timing.CapturedNodes == 0 {
t.Fatalf("missing capture scope/progress: %+v", timing)
}
if mode == "children" {
broken := findNamedReference(t, tree, "Broken subtree")
if !broken.Valid() {
t.Fatal("broken subtree fixture missing")
}
command("arm", "armed")
tree.InvalidateChildren(broken.ID())
tree, err = adapter.CaptureReviewWindow(ctx, focus)
if err != nil {
t.Fatal(err)
}
snapshot, err := tree.ReviewWindow(ctx, focus)
if err != nil {
t.Fatal(err)
}
omitted, healthy := false, false
for _, node := range snapshot.Nodes {
omitted = omitted || node.Snapshot.ID() == broken.ID() && node.ChildrenUnavailable
for _, span := range node.Text.Ranges {
healthy = healthy || span.Text == "Cached review text"
}
}
if !omitted || !healthy {
t.Fatalf("subtree recovery: omission=%v healthy=%v", omitted, healthy)
}
return
}
for _, want := range []string{"queue-wait", "root-selection", "snapshot", "geometry", "visible-text", "table-viewport", "child-count", "child-enumeration", "tree-installation"} {
found := false
for _, stage := range timing.Stages {
if stage.Stage == want && stage.Calls > 0 && stage.Duration > 0 {
found = true
}
}
if !found {
t.Errorf("capture timing missing stage %q: %+v", want, timing)
}
}
if mode == "bounds" || mode == "clipped" || mode == "long" || mode == "lines" || mode == "glyphs" {
label := findNamedReference(t, tree, "Long review label")
if !label.Valid() {
t.Fatal("long label missing from review")
}
text, err := tree.VisibleText(label)
if err != nil || !text.Known || text.Limited || len(text.Ranges) == 0 {
t.Fatalf("long label text: %+v, %v", text, err)
}
if mode == "bounds" {
command("largest", "largest 128")
command("repeated", "repeated 0")
}
if mode == "lines" && text.Ranges[0].Start <= maxReviewCharacters {
t.Fatalf("line viewport did not seek scrolled native offsets: %d", text.Ranges[0].Start)
}
if mode == "glyphs" {
for _, span := range text.Ranges {
if span.Start <= 5 && span.End > 5 {
t.Fatalf("zero-area glyph included as visible text: %+v", span)
}
}
}
if mode == "clipped" {
characters := 0
for _, span := range text.Ranges {
characters += utf8.RuneCountInString(span.Text)
}
if characters <= maxReviewRanges || len(text.Ranges) > 21 {
t.Fatalf("fixture must capture hundreds of characters as visual spans: characters=%d ranges=%d", characters, len(text.Ranges))
}
}
return
}
label := findNamedReference(t, tree, "Cached review text")
if !label.Valid() {
t.Fatal("cached label missing from review")
}
command("count", "count 0")
command("change", "changed")
for {
select {
case event := <-adapter.Events():
if event.Source.ID() == label.ID() && strings.HasPrefix(event.Type, "object:text-changed:") {
goto changed
}
case <-ctx.Done():
t.Fatal("text change missing")
}
}
changed:
tree, err = adapter.CaptureReviewWindow(ctx, focus)
if err != nil {
t.Fatal(err)
}
if !findNamedReference(t, tree, "Changed review text").Valid() {
t.Fatal("dirty label retained old provider data")
}
}