313 lines
9.2 KiB
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)
|
|
}
|
|
}
|
|
}
|