182 lines
6.2 KiB
Go
182 lines
6.2 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package review
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
)
|
|
|
|
func TestNativeBrowserFragmentLines(t *testing.T) {
|
|
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
|
|
t.Skip("requires private browser session")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
|
defer cancel()
|
|
adapter := atspi.New()
|
|
if err := adapter.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer adapter.Stop(context.Background())
|
|
// Reach the textarea without enabling the browser's native caret mode.
|
|
if output, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab", "Tab").CombinedOutput(); err != nil {
|
|
t.Fatalf("focus: %v %s", err, output)
|
|
}
|
|
var focus atspi.ObjectID
|
|
for !focus.Valid() {
|
|
select {
|
|
case event := <-adapter.Events():
|
|
if (event.Type == atspi.FocusEvent || event.Type == atspi.FocusedStateEvent && event.Detail1 == 1) && event.Source.Name == "Fragment probe focus" {
|
|
focus = event.Source.ID()
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
}
|
|
tree, err := adapter.CaptureDocument(ctx, focus)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cursor, err := NewDocumentCursor(tree)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := cursor.SyncFocus(ctx, focus); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reference, err := tree.Reference(focus)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
document, err := tree.Document(ctx, reference)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
glyphCount := 0
|
|
for _, node := range document.Nodes {
|
|
if node.Snapshot.Role == "static" && node.Snapshot.Name == "\uf005" && node.Snapshot.CharacterCount == 1 {
|
|
glyphCount++
|
|
}
|
|
}
|
|
if glyphCount < 5 {
|
|
t.Fatalf("fixture did not expose its star text objects: %d", glyphCount)
|
|
}
|
|
for _, step := range []struct {
|
|
command DocumentCaretCommand
|
|
want string
|
|
}{
|
|
{CaretDocumentStart, "Fragmented response"},
|
|
{CaretNextLine, "One two three."},
|
|
{CaretNextLine, "Four five six."},
|
|
{CaretPreviousLine, "One two three."},
|
|
{CaretLineEnd, "."},
|
|
{CaretNextLine, "Four five six."},
|
|
{CaretNextLine, "Next paragraph."},
|
|
{CaretNextLine, "Before"},
|
|
{CaretNextLine, "ordinary link"},
|
|
{CaretNextLine, "after."},
|
|
{CaretNextLine, "Split heading"},
|
|
{CaretNextLine, "Code first"},
|
|
{CaretNextLine, ""}, // Preserve the provider's separate preformatted newline.
|
|
{CaretNextLine, "Code second"},
|
|
{CaretNextLine, "Member rank"},
|
|
{CaretNextLine, "Join Date"}, // Skip the five separate decorative stars.
|
|
{CaretPreviousLine, "Member rank"},
|
|
{CaretNextLine, "Join Date"},
|
|
} {
|
|
move, err := cursor.MoveCaret(ctx, adapter, step.command)
|
|
if err != nil || !move.Moved || strings.TrimSpace(move.Text) != step.want {
|
|
t.Fatalf("%s: %+v, %v; want %q", step.command, move, err, step.want)
|
|
}
|
|
}
|
|
control, err := cursor.MoveCaret(ctx, adapter, CaretNextLine)
|
|
if err != nil || !control.Moved || control.Position.Snapshot.Role != "push button" && control.Position.Snapshot.Role != "button" || control.Position.Snapshot.Name != "Rating details" {
|
|
t.Fatalf("labeled icon control: %+v, %v", control, err)
|
|
}
|
|
for _, want := range []string{"Mixed \uf005 text.", "★"} {
|
|
move, err := cursor.MoveCaret(ctx, adapter, CaretNextLine)
|
|
if err != nil || !move.Moved || strings.TrimSpace(move.Text) != want {
|
|
t.Fatalf("meaningful symbol: %+v, %v; want %q", move, err, want)
|
|
}
|
|
}
|
|
if _, err := cursor.MoveCaret(ctx, adapter, CaretDocumentStart); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
inline, err := cursor.MoveCaret(ctx, adapter, CaretNextLine)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
worker, err := NewWorker(adapter, 3*time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
worker.SyncFocus(focus)
|
|
worker.SetDocumentBrailleEnabled(true)
|
|
if inline.Line == nil || len(inline.Line.Contents) < 2 {
|
|
t.Fatalf("missing inline fragments: %+v", inline)
|
|
}
|
|
content := inline.Line.Contents[1]
|
|
displayRoute := &documentBrailleRequest{ctx: ctx, focus: focus, document: inline.Position.Document.ID(), target: content.Snapshot.ID(),
|
|
line: content.RawText, start: content.Start, offset: content.Start + 1, done: make(chan error, 1)}
|
|
routed := worker.route_document_braille(ctx, cursor, request{brailleRoute: displayRoute})
|
|
if routed.Err != nil || !routed.Moved || routed.DocumentPosition.Offset != content.Start+1 || routed.DocumentCaretLine == nil || len(routed.DocumentCaretLine.Contents) < 2 {
|
|
t.Fatalf("native inline braille route = %+v", routed)
|
|
}
|
|
if err := <-displayRoute.done; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
worker.readDocumentBraille(ctx, routed)
|
|
// A mismatched displayed source must be rejected by the native owner.
|
|
ref, _ := tree.Reference(content.Snapshot.ID())
|
|
if err := adapter.RouteBraille(ctx, atspi.BrailleRoute{Position: ref, Focus: focus, Line: "changed", Start: content.Start, Offset: content.Start}); err == nil {
|
|
t.Fatal("native routing accepted changed display text")
|
|
}
|
|
if _, err := cursor.MoveCaret(ctx, adapter, CaretDocumentStart); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := cursor.MoveCaret(ctx, adapter, CaretNextLine); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result := worker.readAll(ctx, cursor)
|
|
if result.Err != nil || len(result.ReadAllLines) < 3 {
|
|
t.Fatalf("Read All: %+v", result)
|
|
}
|
|
for index, want := range []string{"One two three.", "Four five six.", "Next paragraph."} {
|
|
if got := result.ReadAllLines[index].Contents[0].Text; got != want {
|
|
t.Fatalf("Read All line %d: %q, want %q", index, got, want)
|
|
}
|
|
}
|
|
rankFound := false
|
|
for index, line := range result.ReadAllLines {
|
|
if line.Contents[0].Text == "Member rank" {
|
|
rankFound = true
|
|
if index+1 >= len(result.ReadAllLines) || result.ReadAllLines[index+1].Contents[0].Text != "Join Date" {
|
|
t.Fatal("Read All stopped on decorative rank icons")
|
|
}
|
|
}
|
|
}
|
|
if !rankFound {
|
|
t.Fatal("Read All did not reach member rank")
|
|
}
|
|
// Fragmented headings must also hand Down to following content as a unit.
|
|
if _, err := cursor.MoveCaret(ctx, adapter, CaretDocumentStart); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
move, err := cursor.Move(ctx, DocumentHeading, 2, false)
|
|
if err != nil || !move.Moved {
|
|
t.Fatalf("heading: %+v, %v", move, err)
|
|
}
|
|
next, err := cursor.MoveCaret(ctx, adapter, CaretNextLine)
|
|
if err != nil || next.Text != "Code first" {
|
|
t.Fatalf("Down after split heading: %+v, %v", next, err)
|
|
}
|
|
}
|