From c03ffa740929517412847d3fc7719a1b52d94045 Mon Sep 17 00:00:00 2001 From: Angus Kola Date: Thu, 5 Nov 2026 16:50:52 -0500 Subject: [PATCH] Export reviewed object and flat reading to Remote --- docs/tentacle-remote-speech.md | 29 ++- internal/app/event_review_results.go | 5 +- internal/app/flat_review.go | 1 + internal/app/speech_observation.go | 27 +++ .../app/speech_review_observation_test.go | 188 ++++++++++++++++++ .../app/tentacle_remote_connection_test.go | 14 +- internal/atspi/review.go | 9 +- internal/atspi/tree.go | 27 +++ internal/review/cursor.go | 7 + internal/review/flat.go | 33 ++- internal/review/source_context_test.go | 95 +++++++++ 11 files changed, 425 insertions(+), 10 deletions(-) create mode 100644 internal/app/speech_review_observation_test.go create mode 100644 internal/review/source_context_test.go diff --git a/docs/tentacle-remote-speech.md b/docs/tentacle-remote-speech.md index 3d404ba..b0df489 100644 --- a/docs/tentacle-remote-speech.md +++ b/docs/tentacle-remote-speech.md @@ -98,7 +98,7 @@ in an assembled line must have a known role and no password ancestry. Say All classifies all spoken lines before admission; a sensitive or unclassified line keeps the entire batch private, including sentence-mode continuations. Local navigation failures stay private. Derived phonetic hints retain their parent's -origin and original observation ticket. Flat/object review, native current-line commands, +origin and original observation ticket. Native current-line commands, reading-context announcements, separate key-name echo and notification paths still need explicit source and sensitivity review. @@ -108,8 +108,20 @@ presentation context. Caret-line speech checks these sources, and Say All keeps them when flattening fragments into a single spoken string. A password source, password ancestor, unknown role or failed context lookup keeps the whole line private; a sensitive Say All line keeps its entire batch private. Missing source -metadata does not prevent local reading. These checks do not opt flat/object -review into export. +metadata does not prevent local reading. + +Object review now exports successful destination presentations only with complete +captured ancestry. Flat review retains the window's original snapshots and outer +ancestry alongside its layout. Each line checks every contributing zone; word +and character reports check their own source. Password sources/ancestors, unknown +or invalid roles, defunct sources and missing/stale context keep the report local. +An ancestor outside a captured menu/dialog still participates in that check. +Dirty snapshot metadata conservatively keeps the captured flat window private +without changing its local text. Layout text and privacy context remain from the +same capture across tree replacement. Boundaries and errors remain private. +Existing role chunks, pronunciation, spelling, phonetic expansion and character +delivery retain their normal ordering and original lease/generation admission. +Where Am I and other unreviewed producers remain private. Browse-mode current-line reporting, spelling and phonetic spelling now export only with the command's captured source ancestry. The review worker retains @@ -455,6 +467,17 @@ ordinary report and rejects a private joined neighbor. No full reader suite, native desktop, hardware or audible acceptance was performed for this increment. See [the live test steps](../../cthulhu-tentacles/remote/command.md#physical-keyboard-acceptance-still-pending). +The object/flat-review increment passed complete AT-SPI/review race suites, +focused application reading/speech/Remote race regressions, affected-package vet, +the existing speech regression harness, a separate reader build and Tentacles +`make check`. Source-tree cases check private and unknown ancestors, every joined +zone, outer menu ancestry, stale metadata and unchanged local reading. Worker +tests compare resolved local and exported speech, including spelling/phonetics. +The actual source command/private relay verifies ordinary exports, private/stale +exclusion and separate role utterance boundaries. An overlay restoring the +previous unclassified producers reproduces missing ordinary export. No full +reader suite, native desktop, hardware or audible checks were performed. + Next: verify real Speech Dispatcher scheduling and audible local/remote arbitration. Physical input capture/release and ordinary reading while connected still need acceptance. diff --git a/internal/app/event_review_results.go b/internal/app/event_review_results.go index cecc0ad..b8b14e1 100644 --- a/internal/app/event_review_results.go +++ b/internal/app/event_review_results.go @@ -250,6 +250,7 @@ func queueObjectReviewMove(logger *slog.Logger, worker *review.Worker, direction func presentObjectReviewResult(logger *slog.Logger, speech speechEnqueuer, result review.Result, options presentation.Options) { text := "" + origin := speechOriginUnknown var spoken presentation.Speech role := "system" if result.Err != nil { @@ -259,15 +260,17 @@ func presentObjectReviewResult(logger *slog.Logger, speech speechEnqueuer, resul text = objectReviewBoundaryMessage(result.Direction) } else { options.EnableSound = false + origin = review_speech_origin(result.Position.SourceContext) spoken = presentation.FocusSpeechWithOptions(result.Position.Current, options) text = spoken.Text role = result.Position.Current.Role if text == "" { text = "Could not find current location." role = "system" + origin = speechOriginStatus } } - speech.Enqueue(speechRequest{Text: text, Chunks: spoken.WithText(text).Chunks, Role: role, Cause: "object-review"}) + speech.Enqueue(speechRequest{origin: origin, Text: text, Chunks: spoken.WithText(text).Chunks, Role: role, Cause: "object-review"}) logger.Debug("queued object review presentation", "direction", result.Direction.String(), "moved", result.Moved, "role", role) } diff --git a/internal/app/flat_review.go b/internal/app/flat_review.go index 1e9ce72..9f522db 100644 --- a/internal/app/flat_review.go +++ b/internal/app/flat_review.go @@ -96,6 +96,7 @@ func presentFlatReviewResult(logger *slog.Logger, speech speechEnqueuer, result request.Line = &speechLine{Object: result.FlatPosition.Object, Raw: result.FlatPosition.Text} } if hasContent { + request.origin = review_speech_origin(result.FlatPosition.SourceContexts...) set_review_spelling(&request, result.FlatPosition.Text, result.FlatSpeech) } if result.FlatWarning { diff --git a/internal/app/speech_observation.go b/internal/app/speech_observation.go index 2337491..20affd5 100644 --- a/internal/app/speech_observation.go +++ b/internal/app/speech_observation.go @@ -76,6 +76,33 @@ func document_line_speech_origin(line atspi.DocumentLine) speechOrigin { return speechOriginLocal } +// Review privacy metadata comes from the same owned capture as its text. +// Missing ancestry and unknown roles deny export, including any joined zone. +func review_speech_origin(contexts ...[]atspi.Snapshot) speechOrigin { + if len(contexts) == 0 { + return speechOriginUnknown + } + for _, sources := range contexts { + if len(sources) == 0 { + return speechOriginUnknown + } + for _, source := range sources { + if origin := document_speech_origin(source, source.SpeechContext); origin != speechOriginLocal { + return origin + } + if source.Defunct || source.Role == "unknown" || source.Role == "invalid" { + return speechOriginUnknown + } + for _, ancestor := range source.SpeechContext { + if ancestor.Role == "" || ancestor.Role == "unknown" || ancestor.Role == "invalid" || ancestor.Defunct { + return speechOriginUnknown + } + } + } + } + return speechOriginLocal +} + func exportable_speech(request speechRequest) bool { if request.origin != speechOriginLocal || request.Role == "password text" || request.ExcludeFromHistory || request.Confirmation || request.Uninterruptible || request.ForceSpeech { diff --git a/internal/app/speech_review_observation_test.go b/internal/app/speech_review_observation_test.go new file mode 100644 index 0000000..569cfe9 --- /dev/null +++ b/internal/app/speech_review_observation_test.go @@ -0,0 +1,188 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "errors" + "log/slog" + "strings" + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/presentation" + "git.stormux.org/storm/cthulhu/internal/review" +) + +// Exercise the actual owned-tree cursors and presentation producers rather +// than attaching an allow flag to synthetic text. +func review_observation_result(t *testing.T, kind, privacy string) review.Result { + t.Helper() + id := func(path string) atspi.ObjectID { return atspi.NewObjectID(":1.42", path) } + node := func(path, role, text string, x int32) atspi.TreeNode { + bounds := atspi.Rectangle{X: x, Width: 100, Height: 20} + node := atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: ":1.42", Path: path, Role: role, Name: text}, + Geometry: atspi.Geometry{Bounds: bounds, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true}, + Text: atspi.VisibleText{Known: true}} + if text != "" { + node.Text.Ranges = []atspi.TextRange{{Text: text, End: len([]rune(text)), Bounds: bounds}} + } + return node + } + root := node("/window", "frame", "", 0) + root.Geometry.Bounds.Width = 500 + root.Children = []atspi.ObjectID{id("/parent")} + parent := node("/parent", "panel", "", 0) + parent.Geometry.Bounds.Width = 500 + parent.Children = []atspi.ObjectID{id("/first"), id("/second")} + first := node("/first", "static", "Flat colour.", 0) + second := node("/second", "static", "Review colour.", 120) + switch privacy { + case "password": + second.Snapshot.Role = "password text" + case "ancestor": + parent.Snapshot.Role = "password text" + case "snapshot-ancestor": + second.Snapshot.SpeechContext = []atspi.Snapshot{{Role: "password text"}} + case "unknown": + second.Snapshot.Role = "" + case "unknown-role", "invalid": + second.Snapshot.Role = strings.TrimSuffix(privacy, "-role") + case "unknown-ancestor": + parent.Snapshot.Role = "" + case "unknown-snapshot-ancestor": + second.Snapshot.SpeechContext = []atspi.Snapshot{{Role: ""}} + } + tree, err := atspi.NewAccessibleTree(root.Snapshot.ID(), []atspi.TreeNode{root, parent, first, second}) + if err != nil { + t.Fatal(err) + } + if privacy == "stale" { + tree.InvalidateSnapshot(parent.Snapshot.ID()) + } + var result review.Result + if kind == "object" { + cursor, _ := review.NewCursor(tree) + if _, err := cursor.SyncFocus(first.Snapshot.ID()); err != nil { + t.Fatal(err) + } + result.Position, result.Moved, result.Err = cursor.Move(review.NextSibling) + result.Direction = review.NextSibling + if privacy == "missing" { + result.Position.SourceContext = nil + } else if privacy == "defunct" { + result.Position.SourceContext[0].Defunct = true + } + } else { + cursor := &review.FlatCursor{} + if err := cursor.Refresh(t.Context(), tree, first.Snapshot.ID()); err != nil { + t.Fatal(err) + } + unit := review.Line + if kind == "word" { + unit = review.Word + } else if kind == "character" { + unit = review.Character + } + result.FlatPosition, result.Moved, result.Err = cursor.Read(unit, 0) + result.FlatUnit = unit + if privacy == "missing" { + result.FlatPosition.SourceContexts = append(result.FlatPosition.SourceContexts, nil) + } else if privacy == "defunct" { + result.FlatPosition.SourceContexts[1][0].Defunct = true + } + } + if result.Err != nil { + t.Fatal(result.Err) + } + return result +} + +func review_observation_request(t *testing.T, kind string, result review.Result) speechRequest { + t.Helper() + queue := &fakeSpeechQueue{requests: make(chan speechRequest, 1), spoken: make(chan string, 1)} + logger := slog.New(slog.DiscardHandler) + if kind == "object" { + presentObjectReviewResult(logger, queue, result, presentation.DefaultOptions()) + } else { + presentFlatReviewResult(logger, queue, result) + } + return <-queue.requests +} + +func TestRemoteReviewSourcePrivacy(t *testing.T) { + for _, kind := range []string{"object", "line"} { + for _, privacy := range []string{"ordinary", "password", "ancestor", "snapshot-ancestor", "unknown", "unknown-role", "invalid", "defunct", "unknown-ancestor", "unknown-snapshot-ancestor", "missing", "stale", "failure", "boundary"} { + t.Run(kind+"/"+privacy, func(t *testing.T) { + result := review_observation_result(t, kind, privacy) + if privacy == "failure" { + result.Err = errors.New("private fixture failure") + } else if privacy == "boundary" { + result.Moved = false + result.FlatStep = 1 + } + request := review_observation_request(t, kind, result) + if exportable_speech(request) != (privacy == "ordinary") { + t.Fatalf("privacy %s produced exportable=%v", privacy, exportable_speech(request)) + } + if privacy != "failure" && privacy != "boundary" && privacy != "password" && !strings.Contains(request.Text, "Review colour.") { + t.Fatal("privacy classification changed local reading", request.Text) + } + }) + } + } +} + +func TestRemoteFlatReviewChecksOnlySpokenZones(t *testing.T) { + for _, kind := range []string{"word", "character"} { + result := review_observation_result(t, kind, "snapshot-ancestor") + request := review_observation_request(t, kind, result) + if !exportable_speech(request) || len(result.FlatPosition.SourceContexts) != 1 { + t.Fatal("private neighboring zone blocked ordinary word/character", kind) + } + } +} + +func TestRemoteReviewResolvedOutput(t *testing.T) { + worker, session, _, _ := observation_fixture(t) + worker.textTransform = func(text string) string { return strings.ReplaceAll(text, "colour", "color") } + for _, kind := range []string{"object", "line"} { + for _, style := range []review.FlatSpeech{review.FlatSpeak, review.FlatSpell, review.FlatPhonetic} { + result := review_observation_result(t, kind, "ordinary") + result.FlatSpeech = style + request := review_observation_request(t, kind, result) + if err := worker.EnqueueAndWait(t.Context(), request); err != nil { + t.Fatal(err) + } + // Compare every exported part with actual local backend delivery; + // this includes pronunciation and repeated spelling characters. + for len(worker.speaker.(*observationSpeaker).spoken) > 0 { + want := <-worker.speaker.(*observationSpeaker).spoken + value := observed_speech(t, session) + characters := request.Character + if len([]rune(want)) != 1 { + characters = false // Expanded spaces/phonetic words use ordinary speech. + } + if value.Kind != "speak" || len(value.Parts) != 2 || value.Parts[0].Text != want || + value.Parts[0].Characters != characters || !value.Parts[1].End { + t.Fatal("review export differed from resolved local output", value, want) + } + } + result = review_observation_result(t, kind, "snapshot-ancestor") + result.FlatSpeech = style + if err := worker.EnqueueAndWait(t.Context(), review_observation_request(t, kind, result)); err != nil { + t.Fatal(err) + } + worker.InterruptForUser() + if value := observed_speech(t, session); value.Kind != "cancel" { + t.Fatal("private review reached the child", value) + } + if len(worker.speaker.(*observationSpeaker).spoken) == 0 { + t.Fatal("private review lost local speech") + } + for len(worker.speaker.(*observationSpeaker).spoken) > 0 { + <-worker.speaker.(*observationSpeaker).spoken + } + } + } +} diff --git a/internal/app/tentacle_remote_connection_test.go b/internal/app/tentacle_remote_connection_test.go index 2faf501..6baa134 100644 --- a/internal/app/tentacle_remote_connection_test.go +++ b/internal/app/tentacle_remote_connection_test.go @@ -351,7 +351,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { runtime, forms, shortcut := remote_command_fixture(t, configure...) if role == "controlled" { worker := runtime.output.speech - worker.speaker = &observationSpeaker{fakeSpeaker{spoken: make(chan string, 16)}} + worker.speaker = &observationSpeaker{fakeSpeaker{spoken: make(chan string, 64)}} worker.textTransform = func(text string) string { return strings.ReplaceAll(text, "colour", "color") } if err := worker.Start(t.Context()); err != nil { t.Fatal(err) @@ -572,6 +572,14 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { t.Fatal(err) } } + for _, kind := range []string{"object", "line"} { + for _, privacy := range []string{"ordinary", "snapshot-ancestor", "stale"} { + request := review_observation_request(t, kind, review_observation_result(t, kind, privacy)) + if err := worker.EnqueueAndWait(t.Context(), request); err != nil { + t.Fatal(err) + } + } + } worker.InterruptForUser() var received strings.Builder ends, frames := 0, 0 @@ -607,8 +615,8 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { t.Fatal("reader output did not reach the relay in order") } } - if received.String() != "color "+remoteText+"First color.Second color.Current color." || frames < 5 || ends != 4 { - t.Fatal("observation leaked private text or lost resolved output/boundaries") + if received.String() != "color "+remoteText+"First color.Second color.Current color.Review color.staticFlat color. Review color." || frames < 8 || ends != 7 { + t.Fatalf("observation leaked private text or lost resolved output/boundaries: frames=%d ends=%d suffix=%q", frames, ends, received.String()[max(0, received.Len()-160):]) } } // Reopening returns to this attempt, with no second consent or diff --git a/internal/atspi/review.go b/internal/atspi/review.go index e906bed..b88bc77 100644 --- a/internal/atspi/review.go +++ b/internal/atspi/review.go @@ -30,6 +30,9 @@ var ErrNoReviewWindow = errors.New("focused object has no review window") type ReviewSnapshot struct { Nodes []TreeNode References map[ObjectID]ObjectReference + // Context includes the window root and its ancestors. Nil context keeps + // review local when any captured snapshot's privacy metadata is dirty. + Context []Snapshot } // ReviewWindow copies the focused menu, dialog, or window in tree order under @@ -60,6 +63,7 @@ func (tree *AccessibleTree) ReviewWindow(ctx context.Context, focus ObjectID) (R entry = tree.nodes[root] } found: + context, _ := tree.ancestryLocked(newObjectReference(root, entry)) var nodes []TreeNode references := make(map[ObjectID]ObjectReference) pending := []ObjectID{root} @@ -73,6 +77,9 @@ found: if !ok { return ReviewSnapshot{}, ErrStaleObject } + if entry.snapshotInvalid { + context = nil + } if entry.childrenInvalid { return ReviewSnapshot{}, ErrIncompleteTree } @@ -91,5 +98,5 @@ found: pending = append(pending, entry.children[i]) } } - return ReviewSnapshot{Nodes: nodes, References: references}, nil + return ReviewSnapshot{Nodes: nodes, References: references, Context: context}, nil } diff --git a/internal/atspi/tree.go b/internal/atspi/tree.go index 577c667..75ccafb 100644 --- a/internal/atspi/tree.go +++ b/internal/atspi/tree.go @@ -161,6 +161,33 @@ func (tree *AccessibleTree) Resolve(reference ObjectReference) (Snapshot, error) return entry.snapshot, nil } +// Ancestry returns the source followed by its ancestors through the capture +// root under one lock. Missing or dirty snapshots yield no partial context. +func (tree *AccessibleTree) Ancestry(reference ObjectReference) ([]Snapshot, error) { + tree.mu.RLock() + defer tree.mu.RUnlock() + return tree.ancestryLocked(reference) +} + +func (tree *AccessibleTree) ancestryLocked(reference ObjectReference) ([]Snapshot, error) { + var sources []Snapshot + for { + entry, err := tree.resolveLocked(reference) + if err != nil { + return nil, err + } + sources = append(sources, entry.snapshot) + if !entry.hasParent { + return sources, nil + } + parent, ok := tree.nodes[entry.parent] + if !ok { + return nil, ErrStaleObject + } + reference = newObjectReference(entry.parent, parent) + } +} + // InvalidateSnapshot refreshes presentation and matching data without retiring // a live identity. A renamed button is still the same browse-caret position. func (tree *AccessibleTree) InvalidateSnapshot(id ObjectID) int { diff --git a/internal/review/cursor.go b/internal/review/cursor.go index 8ccc97f..54f4760 100644 --- a/internal/review/cursor.go +++ b/internal/review/cursor.go @@ -62,6 +62,9 @@ type Position struct { Current atspi.Snapshot Previous atspi.Snapshot HasPrevious bool + // SourceContext retains privacy ancestry independently of presentation. + // Missing/stale ancestors leave it empty without changing local review. + SourceContext []atspi.Snapshot } // Cursor owns object-navigation state over one AccessibleTree. Focus updates @@ -185,6 +188,10 @@ func (cursor *Cursor) positionLocked() (Position, error) { return Position{}, fmt.Errorf("resolve current review object: %w", err) } position := Position{Current: current} + position.SourceContext, _ = cursor.tree.Ancestry(cursor.current) + if len(position.SourceContext) > 0 { + position.Current = position.SourceContext[0] + } if cursor.previous.Valid() { previous, previousErr := cursor.tree.Resolve(cursor.previous) if previousErr == nil { diff --git a/internal/review/flat.go b/internal/review/flat.go index 8436be6..db761d6 100644 --- a/internal/review/flat.go +++ b/internal/review/flat.go @@ -48,6 +48,9 @@ type FlatPosition struct { Offset int HasOffset bool Bounds atspi.Rectangle + // SourceContexts includes every zone contributing text, in spoken order. + // An empty context marks unavailable privacy metadata. + SourceContexts [][]atspi.Snapshot } type flatLocation struct{ line, zone, character int } @@ -64,6 +67,8 @@ type FlatCursor struct { tree *atspi.AccessibleTree references map[atspi.ObjectID]atspi.ObjectReference parents map[atspi.ObjectID]atspi.ObjectID + snapshots map[atspi.ObjectID]atspi.Snapshot + rootContext []atspi.Snapshot verticalTargetX int64 hasVerticalTarget bool started bool @@ -85,8 +90,10 @@ func (cursor *FlatCursor) Refresh(ctx context.Context, tree *atspi.AccessibleTre return err } next := FlatCursor{lines: lines, focus: focus, tree: tree, references: snapshot.References, completeness: completeness, - parents: make(map[atspi.ObjectID]atspi.ObjectID, len(nodes))} + parents: make(map[atspi.ObjectID]atspi.ObjectID, len(nodes)), + snapshots: make(map[atspi.ObjectID]atspi.Snapshot, len(nodes)), rootContext: snapshot.Context} for _, n := range nodes { + next.snapshots[n.Snapshot.ID()] = n.Snapshot for _, child := range n.Children { next.parents[child] = n.Snapshot.ID() } @@ -390,11 +397,13 @@ func (cursor *FlatCursor) position(unit FlatUnit) FlatPosition { zone := cursor.zone() start, end := cursor.location.character, min(cursor.location.character+1, len(zone.runes)) text := "" + var contexts [][]atspi.Snapshot switch unit { case Line: var parts []string for _, z := range cursor.lines[cursor.location.line].zones { parts = append(parts, z.text) + contexts = append(contexts, cursor.sourceContext(z.object)) } text = strings.Join(parts, " ") case Word: @@ -404,7 +413,27 @@ func (cursor *FlatCursor) position(unit FlatUnit) FlatPosition { } if unit != Line { text = string(zone.runes[start:end]) + contexts = [][]atspi.Snapshot{cursor.sourceContext(zone.object)} } return FlatPosition{Text: text, Object: zone.object, Offset: zone.start + start, - HasOffset: zone.hasOffset, Bounds: zone.bounds} + HasOffset: zone.hasOffset, Bounds: zone.bounds, SourceContexts: contexts} +} + +func (cursor *FlatCursor) sourceContext(id atspi.ObjectID) []atspi.Snapshot { + if len(cursor.rootContext) == 0 { + return nil + } + var sources []atspi.Snapshot + for id.Valid() { + if id == cursor.rootContext[0].ID() { + return append(sources, cursor.rootContext...) + } + snapshot, ok := cursor.snapshots[id] + if !ok { + return nil + } + sources = append(sources, snapshot) + id = cursor.parents[id] + } + return nil } diff --git a/internal/review/source_context_test.go b/internal/review/source_context_test.go new file mode 100644 index 0000000..1bb8b6e --- /dev/null +++ b/internal/review/source_context_test.go @@ -0,0 +1,95 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func TestObjectReviewRetainsDestinationAncestry(t *testing.T) { + tree := newTree(t) + cursor, _ := NewCursor(tree) + cursor.SyncFocus(objectID("/first")) + position, moved, err := cursor.Move(NextSibling) + if err != nil || !moved || len(position.SourceContext) != 2 || + position.SourceContext[0].ID() != objectID("/second") || position.SourceContext[1].ID() != objectID("/root") { + t.Fatal("object review lost destination ancestry", position, err) + } + // Dirty privacy metadata must not suppress otherwise valid local review. + tree.InvalidateSnapshot(objectID("/root")) + position, moved, err = cursor.Move(FirstChild) + if err != nil || !moved || position.Current.Name != "Grandchild" || len(position.SourceContext) != 0 { + t.Fatal("stale ancestry changed local navigation or retained export metadata", position, err) + } +} + +func TestFlatReviewRetainsEverySpokenSourceAcrossReplacement(t *testing.T) { + tree := flatTree(t, flatNode("/a", "Alpha", 0, 0), flatNode("/b", "Beta", 250, 0)) + cursor := &FlatCursor{} + if err := cursor.Refresh(t.Context(), tree, objectID("/a")); err != nil { + t.Fatal(err) + } + position, _, err := cursor.Read(Line, 0) + if err != nil || position.Text != "Alpha Beta" || len(position.SourceContexts) != 2 { + t.Fatal("line lost a contributing zone", position, err) + } + for i, path := range []string{"/a", "/b"} { + context := position.SourceContexts[i] + if len(context) != 2 || context[0].ID() != objectID(path) || context[1].Role != "frame" { + t.Fatal("line source lost original ancestry", context) + } + } + // Text and ancestry must stay from the same atomic layout capture even if + // the live tree changes before the result leaves the worker. + window, _ := tree.ReviewWindow(t.Context(), objectID("/a")) + window.Nodes[2].Snapshot.Role = "password text" + if err := tree.Replace(objectID("/window"), window.Nodes); err != nil { + t.Fatal(err) + } + retained, _, err := cursor.Read(Line, 0) + if err != nil || retained.Text != position.Text || retained.SourceContexts[1][0].Role == "password text" { + t.Fatal("replacement mixed old text with new ancestry", retained, err) + } + if err := cursor.Refresh(t.Context(), tree, objectID("/a")); err != nil { + t.Fatal(err) + } + current, _, _ := cursor.Read(Line, 0) + if current.SourceContexts[len(current.SourceContexts)-1][0].Role != "password text" { + t.Fatal("refreshed layout retained old privacy metadata", current) + } +} + +func TestFlatReviewIncludesAncestorsOutsideMenuScope(t *testing.T) { + root := node("/root", "") + root.Snapshot.Role = "password text" + root.Children = []atspi.ObjectID{objectID("/menu")} + menu := flatNode("/menu", "", 0, 0) + menu.Text.Ranges = nil + menu.Snapshot.Role = "menu" + menu.Children = []atspi.ObjectID{objectID("/item")} + item := flatNode("/item", "Item", 0, 0) + tree, err := atspi.NewAccessibleTree(root.Snapshot.ID(), []atspi.TreeNode{root, menu, item}) + if err != nil { + t.Fatal(err) + } + cursor := &FlatCursor{} + if err := cursor.Refresh(t.Context(), tree, item.Snapshot.ID()); err != nil { + t.Fatal(err) + } + position, _, err := cursor.Read(Word, 0) + if err != nil || len(position.SourceContexts) != 1 || len(position.SourceContexts[0]) != 3 || + position.SourceContexts[0][2].Role != "password text" { + t.Fatal("menu scope hid a private ancestor", position, err) + } + tree.InvalidateSnapshot(root.Snapshot.ID()) + if err := cursor.Refresh(t.Context(), tree, item.Snapshot.ID()); err != nil { + t.Fatal(err) + } + position, _, err = cursor.Read(Word, 0) + if err != nil || position.Text != "Item" || len(position.SourceContexts[0]) != 0 { + t.Fatal("stale outer ancestor changed local text or allowed export", position, err) + } +}