// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package review import ( "context" "fmt" "testing" "time" "unicode/utf8" "git.stormux.org/storm/cthulhu/internal/atspi" ) func inline_replacement_cursor(t *testing.T, role string, slot int) (*DocumentCursor, caretTextReader, []atspi.TreeNode) { t.Helper() reader := caretTextReader{webID("/before"): "Published", webID("/old"): "↺ by Publisher", webID("/after"): "Article continues"} paths := []string{"/before", "/after"} paths = append(paths[:slot], append([]string{"/old"}, paths[slot:]...)...) nodes := []atspi.TreeNode{ {Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/doc", Role: "document web"}, Children: []atspi.ObjectID{webID("/paragraph")}}, {Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/paragraph", Role: role}}, } if role == "link" { nodes[1].Snapshot.Focusable, nodes[1].Snapshot.SupportsClick = true, true } for _, path := range paths { nodes[1].Children = append(nodes[1].Children, webID(path)) nodes = append(nodes, atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: webID(path).BusName, Path: path, Role: "static", CharacterCount: utf8.RuneCountInString(reader[webID(path)])}}) } tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes) if err != nil { t.Fatal(err) } cursor, err := NewDocumentCursor(tree) if err != nil { t.Fatal(err) } if _, err := cursor.SyncFocus(context.Background(), webID("/paragraph")); err != nil { t.Fatal(err) } if _, err := cursor.MoveCaret(context.Background(), reader, CaretReadLine); err != nil { t.Fatal(err) } for index := 0; index < slot; index++ { if _, err := cursor.MoveCaret(context.Background(), reader, CaretNextLine); err != nil { t.Fatal(err) } } return cursor, reader, nodes } func TestDocumentCaretRecoversReplacedInlineText(t *testing.T) { for _, role := range []string{"paragraph", "heading", "section", "link"} { for slot := 0; slot < 3; slot++ { t.Run(fmt.Sprintf("%s/%d", role, slot), func(t *testing.T) { cursor, reader, nodes := inline_replacement_cursor(t, role, slot) ctx := context.Background() for generation := 0; generation < 4; generation++ { path := fmt.Sprintf("/replacement%d", generation) reader[webID(path)] = "↺ by Publisher" nodes[1].Children[slot] = webID(path) nodes[slot+2].Snapshot.Path = path if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil { t.Fatal(err) } position, recovery, err := cursor.sync_focus(ctx, webID("/paragraph")) if err != nil || recovery != "replaced-text-child" || position.Object.ID() != webID(path) { t.Fatalf("replacement %d: %+v, %s, %v", generation, position, recovery, err) } move := func(command DocumentCaretCommand, want string) { t.Helper() result, err := cursor.MoveCaret(ctx, reader, command) if err != nil || !result.Moved || result.Text != want { t.Fatalf("%s after replacement %d: %+v, %v; want %q", command, generation, result, err, want) } } move(CaretNextCharacter, " ") // Unicode offsets remain character based. move(CaretPreviousCharacter, "↺") if slot == 2 { move(CaretPreviousLine, "Article continues") move(CaretNextLine, "↺ by Publisher") } else { want := "Article continues" if slot == 0 { want = "Published" } move(CaretNextLine, want) move(CaretPreviousLine, "↺ by Publisher") } } }) } } } func TestDocumentLinkReplacementPreservesActivationOwnership(t *testing.T) { for _, kind := range []string{"same link", "replaced link", "named link", "editable link"} { t.Run(kind, func(t *testing.T) { cursor, _, nodes := inline_replacement_cursor(t, "link", 1) ctx := context.Background() originalTarget, err := cursor.ActivationTarget() if err != nil || originalTarget.ID() != webID("/paragraph") { t.Fatalf("original link target: %v, %v", originalTarget.ID(), err) } nodes[1].Children[1] = webID("/new") nodes[3].Snapshot.Path = "/new" switch kind { case "replaced link": cursor.tree.Invalidate(webID("/paragraph")) case "named link": nodes[1].Snapshot.ExplicitName = true case "editable link": nodes[1].Snapshot.Editable = true } if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil { t.Fatal(err) } if _, err := cursor.syncActionFocus(ctx, webID("/paragraph")); err == nil { t.Fatal("activation silently recovered a missing text target") } position, recovery, err := cursor.sync_focus(ctx, webID("/paragraph")) if err != nil { t.Fatal(err) } if kind != "same link" { if recovery == "replaced-text-child" || position.Object.ID() == webID("/new") { t.Fatal("changed link inherited the old text cursor") } return } target, err := cursor.ActivationTarget() if err != nil || recovery != "replaced-text-child" || target != originalTarget { t.Fatalf("navigation changed link ownership: target=%v recovery=%s err=%v", target.ID(), recovery, err) } }) } } func TestReadAllAfterInlineReplacementRecovery(t *testing.T) { cursor, reader, nodes := inline_replacement_cursor(t, "paragraph", 1) nodes[1].Children[1] = webID("/new") nodes[3].Snapshot.Path = "/new" reader[webID("/new")] = reader[webID("/old")] if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil { t.Fatal(err) } worker, _ := NewWorker(&readAllCapturer{tree: cursor.tree, caretTextReader: reader}, time.Second) worker.SyncFocus(webID("/paragraph")) result := worker.readAll(context.Background(), cursor) if result.Err != nil || len(result.ReadAllLines) != 2 { t.Fatalf("Read All after inline replacement: %+v", result) } for index, want := range []string{"↺ by Publisher", "Article continues"} { if got := result.ReadAllLines[index].Contents[0].Text; got != want { t.Fatalf("line %d = %q, want %q", index, got, want) } } } func TestReadAllRecoveryRejectsReplacedDocument(t *testing.T) { for _, refresh := range []bool{false, true} { t.Run(fmt.Sprint(refresh), func(t *testing.T) { cursor, reader, nodes := inline_replacement_cursor(t, "link", 1) cursor.tree.Invalidate(webID("/doc")) if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil { t.Fatal(err) } worker, _ := NewWorker(&readAllCapturer{tree: cursor.tree, caretTextReader: reader}, time.Second) worker.SyncFocus(webID("/paragraph")) if refresh { if _, err := worker.refresh_read_all_cursor(context.Background(), cursor, webID("/paragraph")); err == nil { t.Fatal("Read All refresh crossed a document generation") } return } result := worker.readAll(context.Background(), cursor) if result.Err == nil || len(result.ReadAllLines) != 0 { t.Fatal("Read All inherited the position in a replacement document") } }) } } func TestDocumentInlineReplacementRetainsAndClampsUnicodeOffset(t *testing.T) { for _, replacement := range []string{"↺ by Another publisher", "↺"} { t.Run(replacement, func(t *testing.T) { cursor, reader, nodes := inline_replacement_cursor(t, "paragraph", 1) ctx := context.Background() move, err := cursor.MoveCaret(ctx, reader, CaretNextWord) if err != nil || move.Position.Offset != 4 { t.Fatalf("word movement: offset=%d err=%v", move.Position.Offset, err) } nodes[1].Children[1] = webID("/new") nodes[3].Snapshot.Path = "/new" nodes[3].Snapshot.CharacterCount = utf8.RuneCountInString(replacement) reader[webID("/new")] = replacement if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil { t.Fatal(err) } position, recovery, err := cursor.sync_focus(ctx, webID("/paragraph")) wantOffset := min(move.Position.Offset, utf8.RuneCountInString(replacement)-1) if err != nil || recovery != "replaced-text-child" || position.Offset != wantOffset { t.Fatalf("replacement: offset=%d want=%d recovery=%s err=%v", position.Offset, wantOffset, recovery, err) } }) } } func TestDocumentInlineReplacementRejectsAmbiguousOrInteractiveChanges(t *testing.T) { for _, kind := range []string{"inserted child", "removed neighbor", "replaced neighbor", "reordered neighbors", "replaced parent", "replaced document", "hidden parent", "editable parent", "clickable parent", "focusable parent", "hidden child", "editable child", "clickable child", "focusable child", "named child", "password child", "native origin"} { t.Run(kind, func(t *testing.T) { cursor, reader, nodes := inline_replacement_cursor(t, "paragraph", 1) nodes[1].Children[1] = webID("/new") nodes[3].Snapshot.Path = "/new" reader[webID("/new")] = reader[webID("/old")] switch kind { case "inserted child": nodes[1].Children = append(nodes[1].Children[:2], webID("/extra"), webID("/after")) nodes = append(nodes, atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: webID("/extra").BusName, Path: "/extra", Role: "static", CharacterCount: 5}}) case "removed neighbor": nodes[1].Children = nodes[1].Children[1:] nodes = append(nodes[:2], nodes[3:]...) case "replaced neighbor": cursor.tree.Invalidate(webID("/before")) case "reordered neighbors": nodes[1].Children[0], nodes[1].Children[2] = nodes[1].Children[2], nodes[1].Children[0] case "replaced parent": cursor.tree.Invalidate(webID("/paragraph")) case "replaced document": cursor.tree.Invalidate(webID("/doc")) case "hidden parent": nodes[1].Geometry.VisibilityKnown = true case "editable parent": nodes[1].Snapshot.Editable = true case "clickable parent": nodes[1].Snapshot.SupportsClick = true case "focusable parent": nodes[1].Snapshot.Focusable = true case "hidden child": nodes[3].Geometry.VisibilityKnown = true case "editable child": nodes[3].Snapshot.Editable = true case "clickable child": nodes[3].Snapshot.SupportsClick = true case "focusable child": nodes[3].Snapshot.Focusable = true case "named child": nodes[3].Snapshot.ExplicitName = true case "password child": nodes[3].Snapshot.Role = "password text" case "native origin": cursor.places[0].origin = NativeNavigation } if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil { t.Fatal(err) } position, recovery, err := cursor.sync_focus(context.Background(), webID("/paragraph")) if err != nil || recovery == "replaced-text-child" || position.Object.ID() == webID("/new") { t.Fatalf("unsafe replacement accepted: object=%+v recovery=%s err=%v", position.Object.ID(), recovery, err) } }) } }