Files
cthulhu/internal/review/flat_native_test.go

313 lines
9.2 KiB
Go

// Cthulhu screen reader.
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
package review
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func TestNativeFlatReview(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_FLAT") != "1" {
t.Skip("requires private Xvfb, D-Bus and GTK tree fixture")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer func() {
stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second)
defer stopCancel()
if err := adapter.Stop(stopCtx); err != nil {
t.Error(err)
}
}()
tree, err := adapter.CaptureDesktop(ctx)
if err != nil {
t.Fatal(err)
}
root, err := tree.Root()
if err != nil {
t.Fatal(err)
}
var focus atspi.ObjectID
pending := []atspi.ObjectReference{root}
for len(pending) > 0 {
ref := pending[len(pending)-1]
pending = pending[:len(pending)-1]
snapshot, err := tree.Resolve(ref)
if err != nil {
t.Fatal(err)
}
if snapshot.Name == "Review text probe" {
focus = snapshot.ID()
break
}
for child, ok, err := tree.FirstChild(ref); ok && err == nil; child, ok, err = tree.NextSibling(child) {
pending = append(pending, child)
}
}
if !focus.Valid() {
t.Fatal("review text fixture missing")
}
worker, err := NewWorker(adapter, 3*time.Second)
if err != nil {
t.Fatal(err)
}
if err := worker.Start(ctx); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
worker.SyncFocus(focus)
read := func(unit FlatUnit, step int) Result {
t.Helper()
if err := worker.ReadFlat(unit, step); err != nil {
t.Fatal(err)
}
select {
case result := <-worker.Results():
if result.Err != nil {
t.Fatal(result.Err)
}
return result
case <-ctx.Done():
t.Fatal(ctx.Err())
return Result{}
}
}
if result := read(Line, 0); result.FlatPosition.Text != "Café 猫" {
t.Fatalf("first native line: %q", result.FlatPosition.Text)
}
if result := read(Word, 1); strings.TrimSpace(result.FlatPosition.Text) != "猫" || result.FlatPosition.Offset != 5 {
t.Fatalf("native word: %#v", result.FlatPosition)
}
if result := read(Line, 1); result.FlatPosition.Text != "Second line" {
t.Fatalf("second native line: %q", result.FlatPosition.Text)
}
// The fixture acknowledges the mutation with its window title. Wait for
// invalidation on the adapter event stream, not an arbitrary delay.
if err := exec.CommandContext(ctx, "xdotool", "key", "t").Run(); err != nil {
t.Fatal(err)
}
for {
select {
case event := <-adapter.Events():
if event.Source.ID() == focus && strings.HasPrefix(event.Type, "object:text-changed:") {
goto changed
}
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
changed:
// Existing offset 7 now belongs to the first changed line. Review retains
// a location by source offset, not stale line index or text.
result := read(Line, 0)
if !strings.Contains(result.FlatPosition.Text, "Changed") && !strings.Contains(result.FlatPosition.Text, "New line") {
t.Fatalf("stale native content: %q", result.FlatPosition.Text)
}
}
func TestNativeTerminalFlatReview(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_TERMINAL_FLAT") != "1" {
t.Skip("requires private Xvfb, D-Bus and VTE fixture")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer func() {
stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second)
defer stopCancel()
if err := adapter.Stop(stopCtx); err != nil {
t.Error(err)
}
}()
tree, err := adapter.CaptureDesktop(ctx)
if err != nil {
t.Fatal(err)
}
root, err := tree.Root()
if err != nil {
t.Fatal(err)
}
var focus atspi.ObjectID
pending := []atspi.ObjectReference{root}
for len(pending) > 0 {
ref := pending[len(pending)-1]
pending = pending[:len(pending)-1]
snapshot, err := tree.Resolve(ref)
if err != nil {
t.Fatal(err)
}
if snapshot.Role == "terminal" {
focus = snapshot.ID()
break
}
for child, ok, err := tree.FirstChild(ref); ok && err == nil; child, ok, err = tree.NextSibling(child) {
pending = append(pending, child)
}
}
if !focus.Valid() {
t.Fatal("review text fixture missing")
}
worker, err := NewWorker(adapter, 3*time.Second)
if err != nil {
t.Fatal(err)
}
if err := worker.Start(ctx); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
worker.SyncFocus(focus)
read := func(unit FlatUnit, step int) Result {
t.Helper()
if err := worker.ReadFlat(unit, step); err != nil {
t.Fatal(err)
}
select {
case result := <-worker.Results():
if result.Err != nil {
t.Fatal(result.Err)
}
return result
case <-ctx.Done():
t.Fatal(ctx.Err())
return Result{}
}
}
switch os.Getenv("CTHULHU_VTE_MODE") {
case "nano":
if result := read(Line, 0); !strings.HasPrefix(result.FlatPosition.Text, "Row 00000 ") {
t.Fatalf("nano initial file line: %+v", result.FlatPosition)
}
for row := 1; row <= 30; row++ {
result := read(Line, 1)
if !strings.HasPrefix(result.FlatPosition.Text, fmt.Sprintf("Row %05d ", row)) {
t.Fatalf("nano row %d: %+v", row, result.FlatPosition)
}
if row == 30 && result.FlatPosition.Offset <= 4096 {
t.Fatalf("nano probe did not exceed old text limit: %+v", result.FlatPosition)
}
}
if result := read(Word, 1); strings.TrimSpace(result.FlatPosition.Text) != "00030" {
t.Fatalf("nano word: %+v", result.FlatPosition)
}
if result := read(Character, 0); result.FlatPosition.Text != "0" {
t.Fatalf("nano character: %+v", result.FlatPosition)
}
return
case "large":
if result := read(Line, 0); !strings.HasPrefix(result.FlatPosition.Text, "Row 49 ") || result.FlatPosition.Offset <= 4096 {
t.Fatalf("large terminal caret: %+v", result.FlatPosition)
}
read(WindowStart, 0)
for row := 0; row < 50; row++ {
step := 1
if row == 0 {
step = 0
}
result := read(Line, step)
if !strings.HasPrefix(result.FlatPosition.Text, fmt.Sprintf("Row %02d ", row)) || !result.FlatPosition.HasOffset {
t.Fatalf("large terminal row %d: %+v", row, result.FlatPosition)
}
}
if result := read(Character, 0); result.FlatPosition.Text != "R" || result.FlatPosition.Offset <= 4096 {
t.Fatalf("large terminal character: %+v", result.FlatPosition)
}
if result := read(Word, 1); strings.TrimSpace(result.FlatPosition.Text) != "49" || result.FlatPosition.Offset <= 4096 {
t.Fatalf("large terminal word: %+v", result.FlatPosition)
}
return
case "prompt":
if result := read(Line, 0); strings.TrimSpace(result.FlatPosition.Text) != "Café 猫>" {
t.Fatalf("prompt flat review: %+v", result.FlatPosition)
}
// Generate a fresh native focus event after the adapter has started.
// ReadFocusedText requires the GLib owner's current focus reference.
window := os.Getenv("CTHULHU_TREE_WINDOW")
if err := exec.CommandContext(ctx, "xdotool", "windowfocus", "0", "windowfocus", window).Run(); err != nil {
t.Fatal(err)
}
for {
select {
case event := <-adapter.Events():
if event.Source.ID() == focus && event.Type == "object:state-changed:focused" && event.Detail1 == 1 {
goto focused
}
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
focused:
if err := worker.ReadAll(ctx); err != nil {
t.Fatal(err)
}
select {
case result := <-worker.Results():
if result.Err != nil || len(result.ReadAllLines) != 1 || strings.TrimSpace(result.ReadAllLines[0].Contents[0].Text) != "Café 猫>" {
t.Fatalf("terminal prompt Say All: %+v", result)
}
case <-ctx.Done():
t.Fatal(ctx.Err())
}
return
}
for _, tc := range []struct {
unit FlatUnit
step int
text string
mustMove bool
}{
{WindowStart, 0, "Café 猫", false},
{Line, 1, "", false},
{Character, 0, "", false},
{Line, 0, "", false},
{Line, 1, "Second line", false},
{Line, 1, "Third line", false},
{Line, 1, "", false},
{Word, 1, "", true},
{Word, -1, "", true},
{Line, -1, "Third line", false},
{WindowEnd, 0, "", false},
{Line, 1, "", false},
{Character, 0, "", false},
{WindowStart, 0, "Café 猫", false},
{Word, 1, "猫", false},
{Character, 0, "猫", false},
} {
result := read(tc.unit, tc.step)
if strings.TrimSpace(result.FlatPosition.Text) != tc.text || !result.FlatPosition.HasOffset || (tc.mustMove && !result.Moved) {
t.Fatalf("unit=%v step=%d: %+v moved=%v, want %q with source offset", tc.unit, tc.step, result.FlatPosition, result.Moved, tc.text)
}
}
}