package review import ( "context" "reflect" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" ) type currentLineCapturer struct { *readAllCapturer content atspi.DocumentContent reads int } func (capturer *currentLineCapturer) ReadFocusedLine(context.Context, atspi.ObjectID) (atspi.DocumentContent, error) { capturer.reads++ return capturer.content, nil } func TestCurrentLineUsesNativeFocusOutsideBrowse(t *testing.T) { source := atspi.Snapshot{BusName: webID("/entry").BusName, Path: "/entry", Role: "text"} capturer := ¤tLineCapturer{content: atspi.DocumentContent{Text: "Native caret line", Snapshot: source, SourceContext: []atspi.Snapshot{source, {Role: "frame"}, {Role: "application"}}}} worker, cancel := startWorker(t, capturer, time.Second) defer cancel() worker.SyncFocus(webID("/entry")) for _, style := range []FlatSpeech{FlatSpeak, FlatSpell, FlatPhonetic} { if err := worker.ReadCurrentLine(false, style); err != nil { t.Fatal(err) } result := waitResult(t, worker) if !result.CurrentLine || result.CurrentLineBrowse || result.FlatSpeech != style || result.Err != nil || result.DocumentCaretText != "Native caret line" { t.Fatalf("native current line: %+v", result) } if !reflect.DeepEqual(result.DocumentCaretContext, capturer.content.SourceContext) { t.Fatal("native current line lost its owned privacy context") } } if capturer.reads != 3 { t.Fatal("native line was cached") } } func TestCurrentLineNativeMissingContextPreservesLocalText(t *testing.T) { capturer := ¤tLineCapturer{content: atspi.DocumentContent{Text: "Local line"}} worker, _ := NewWorker(capturer, time.Second) worker.SyncFocus(webID("/entry")) result := worker.read_current_line(t.Context(), nil, false) if result.Err != nil || result.DocumentCaretText != "Local line" || len(result.DocumentCaretContext) != 0 { t.Fatalf("missing privacy metadata changed local reading: %+v", result) } } func TestCurrentLineReadsWholeBrowseLineWithoutMoving(t *testing.T) { reader := caretTextReader{webID("/text"): "First line\nSecond café 猫\nThird", webID("/next"): "Next"} cursor := newCaretTextCursor(t, reader) capturer := &readAllCapturer{tree: cursor.tree, caretTextReader: reader} worker, _ := NewWorker(capturer, time.Second) worker.SyncFocus(webID("/text")) for range 15 { if _, err := cursor.MoveCaret(context.Background(), reader, CaretNextCharacter); err != nil { t.Fatal(err) } } before := append([]documentPlace(nil), cursor.places...) result := worker.read_current_line(context.Background(), cursor, true) if result.Err != nil || strings.TrimSpace(result.DocumentCaretText) != "Second café 猫" { t.Fatalf("whole browse line from middle: %+v", result) } owner, err := cursor.tree.DocumentContext(result.DocumentPosition.Object) if err != nil || len(result.DocumentCaretContext) == 0 || !reflect.DeepEqual(result.DocumentCaretContext, owner.Ancestors) { t.Fatal("current-line report lost its captured source ancestry", err) } if !reflect.DeepEqual(cursor.places, before) || capturer.nativePlacements != 0 { t.Fatal("report changed browse or native caret") } } func TestCurrentLineJoinsBrowseFragmentsWithoutMoving(t *testing.T) { cursor, reader := fragment_cursor(t, "paragraph") if _, err := cursor.MoveCaret(context.Background(), reader, CaretNextWord); err != nil { t.Fatal(err) } capturer := &readAllCapturer{tree: cursor.tree, caretTextReader: reader.caretTextReader} // Retain the fixture's geometry so the report can collect both fragments. source := ¤tLineFragmentCapturer{readAllCapturer: capturer, fragmentTextReader: reader} worker, _ := NewWorker(source, time.Second) worker.SyncFocus(webID("/one")) before := append([]documentPlace(nil), cursor.places...) result := worker.read_current_line(context.Background(), cursor, true) if result.Err != nil || result.DocumentCaretText != "One two." || !reflect.DeepEqual(cursor.places, before) { t.Fatalf("fragmented current line: %+v", result) } if result.DocumentCaretLine == nil || len(result.DocumentCaretLine.SourceContexts) != 2 || len(result.DocumentCaretContext) == 0 { t.Fatal("fragmented report lost source ancestry") } } func TestCurrentLineReadsBrowseControlLabel(t *testing.T) { doc := atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/doc", Role: "document web"} button := atspi.Snapshot{BusName: doc.BusName, Path: "/button", Role: "push button", Name: "Example button"} tree, err := atspi.NewAccessibleTree(doc.ID(), []atspi.TreeNode{ {Snapshot: doc, Children: []atspi.ObjectID{button.ID()}}, {Snapshot: button}, }) if err != nil { t.Fatal(err) } cursor, _ := NewDocumentCursor(tree) if _, err := cursor.SyncFocus(context.Background(), button.ID()); err != nil { t.Fatal(err) } worker, _ := NewWorker(&readAllCapturer{tree: tree, caretTextReader: caretTextReader{}}, time.Second) worker.SyncFocus(button.ID()) result := worker.read_current_line(context.Background(), cursor, true) if result.Err != nil || result.DocumentCaretText != "Example button" { t.Fatalf("browse control label: %+v", result) } } func TestCurrentLineUsesRecapturedSourceContext(t *testing.T) { text := caretTextReader{webID("/text"): "First\nSecond", webID("/next"): "Last"} cursor := newCaretTextCursor(t, text) fresh := newCaretTextCursor(t, text).tree capturer := &sequenceReadAllCapturer{caretTextReader: text, trees: []*atspi.AccessibleTree{fresh}, invalidateTree: cursor.tree, staleAt: 2} worker, _ := NewWorker(capturer, time.Second) worker.SyncFocus(webID("/text")) result := worker.read_current_line(t.Context(), cursor, true) if result.Err != nil || strings.TrimSpace(result.DocumentCaretText) != "First" || capturer.index != 1 { t.Fatalf("current line did not recover capture: %+v", result) } owner, err := fresh.DocumentContext(result.DocumentPosition.Object) if err != nil || len(result.DocumentCaretContext) == 0 || !reflect.DeepEqual(result.DocumentCaretContext, owner.Ancestors) { t.Fatal("report did not retain the replacement tree's source context", err) } } type currentLineStaleContextCapturer struct{ *readAllCapturer } func (capturer *currentLineStaleContextCapturer) ReadDocumentCaret(ctx context.Context, reference atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { text, err := capturer.caretTextReader.ReadDocumentCaret(ctx, reference, offset, unit) if unit == atspi.DocumentCaretLineRemainder { capturer.tree.InvalidateSnapshot(webID("/doc")) } return text, err } func TestCurrentLinePreservesLocalTextWithStaleContext(t *testing.T) { text := caretTextReader{webID("/text"): "Local line", webID("/next"): "Last"} cursor := newCaretTextCursor(t, text) capturer := ¤tLineStaleContextCapturer{&readAllCapturer{caretTextReader: text, tree: cursor.tree}} worker, _ := NewWorker(capturer, time.Second) worker.SyncFocus(webID("/text")) result := worker.read_current_line(t.Context(), cursor, true) if result.Err != nil || result.DocumentCaretText != "Local line" || len(result.DocumentCaretContext) != 0 { t.Fatalf("stale ancestry must leave local reading available without export metadata: %+v", result) } } type currentLineFragmentCapturer struct { *readAllCapturer fragmentTextReader } func (capturer *currentLineFragmentCapturer) ReadDocumentCaret(ctx context.Context, ref atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { return capturer.fragmentTextReader.ReadDocumentCaret(ctx, ref, offset, unit) }