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