// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package atspi import ( "bufio" "context" "encoding/json" "image" "image/color" "os" "os/exec" "testing" "time" "git.stormux.org/storm/cthulhu/internal/ocr" ) func TestNativeOCRProviderGeometry(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_OCR_GEOMETRY") != "1" { t.Skip("requires private Xorg/AT-SPI session") } executable := os.Getenv("CTHULHU_OCR_CAPTURE_BINARY") if executable == "" { t.Fatal("set freshly built CTHULHU_OCR_CAPTURE_BINARY") } ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) defer cancel() adapter := New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer adapter.Stop(context.Background()) for _, scale := range []string{"1", "2"} { t.Run("scale"+scale, func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() fixture := exec.CommandContext(ctx, "/usr/bin/python3", "-u", "../../tests/ocr_geometry_fixture.py") fixture.Env = append(os.Environ(), "GDK_SCALE="+scale, "NO_AT_BRIDGE=0", "GTK_MODULES=gail:atk-bridge") stdin, err := fixture.StdinPipe() if err != nil { t.Fatal(err) } stdout, err := fixture.StdoutPipe() if err != nil { t.Fatal(err) } fixture.Stderr = os.Stderr if err = fixture.Start(); err != nil { t.Fatal(err) } defer func() { stdin.Close(); fixture.Wait() }() scanner := bufio.NewScanner(stdout) type state struct { Window uint64 Target [4]int Focus uint64 } read := func() state { t.Helper() if !scanner.Scan() { t.Fatal("fixture did not respond") } var s state if err = json.Unmarshal(scanner.Bytes(), &s); err != nil { t.Fatal(err) } return s } initial := read() action := func(name string) state { t.Helper() data, _ := json.Marshal(map[string]string{"action": name}) if _, err = stdin.Write(append(data, '\n')); err != nil { t.Fatal(err) } return read() } var tree *AccessibleTree var ref ObjectReference for !ref.Valid() { tree, err = adapter.CaptureDesktop(ctx) if err != nil { t.Fatal(err) } ref = findNamedReference(t, tree, "Painted target") if !ref.Valid() { select { case <-ctx.Done(): t.Fatal(ctx.Err()) case <-time.After(10 * time.Millisecond): } } } geometry, err := adapter.CaptureOCRGeometry(ctx, ref.ID(), nil) expected := image.Rect(initial.Target[0], initial.Target[1], initial.Target[2], initial.Target[3]) if err != nil || geometry.ScreenBounds != expected || geometry.Foreground.Client.Window != initial.Window { provider, _ := tree.Geometry(ref) t.Fatalf("geometry=%+v err=%v want=%v provider=%+v foreground=%+v", geometry, err, expected, provider, adapter.Foreground()) } result, err := ocr.NewCapturer(executable).Capture(ctx, ocr.CaptureRequest{Kind: ocr.ObjectSource, Bounds: geometry.ScreenBounds, Foreground: initial.Window, Generation: 1}) if err != nil { t.Fatal(err) } for _, point := range []image.Point{image.Pt(0, 0), image.Pt(result.Pixels.Bounds().Dx()-1, result.Pixels.Bounds().Dy()-1)} { if got := color.RGBAModel.Convert(result.Pixels.At(point.X, point.Y)); got != (color.RGBA{51, 102, 153, 255}) { t.Fatalf("mapped pixel %v = %v", point, got) } } zone := geometry.ProviderBounds zone.Width /= 2 zone.Height /= 2 part, err := adapter.CaptureOCRGeometry(ctx, ref.ID(), &zone) if err != nil || part.ScreenBounds.Dx() != expected.Dx()/2 || part.ScreenBounds.Dy() != expected.Dy()/2 { t.Fatalf("zone: %+v %v", part, err) } invalid := zone invalid.X -= 10 if _, err = adapter.CaptureOCRGeometry(ctx, ref.ID(), &invalid); err == nil { t.Fatal("outside zone accepted") } after := action("state") if after.Focus != initial.Focus { t.Fatal("geometry/capture stole focus") } protected := findNamedReference(t, tree, "Protected target") if !protected.Valid() { t.Fatal("protected entry missing") } if _, err = adapter.CaptureOCRGeometry(ctx, protected.ID(), nil); err != nil { t.Fatalf("masked password capture geometry unavailable: %v", err) } action("hide") if _, err = adapter.CaptureOCRGeometry(ctx, ref.ID(), nil); err == nil { t.Fatal("hidden source accepted") } }) } }