Export reviewed object and flat reading to Remote

This commit is contained in:
ak47 committed 2026-11-05 16:50:52 -05:00
1 parent 49be8309fe
commit c03ffa7409
11 files changed
+425 -10

No files matched your search

+26 -3
View File
@@ -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 classifies all spoken lines before admission; a sensitive or unclassified line
keeps the entire batch private, including sentence-mode continuations. Local keeps the entire batch private, including sentence-mode continuations. Local
navigation failures stay private. Derived phonetic hints retain their parent's 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 reading-context announcements, separate key-name echo and notification paths
still need explicit source and sensitivity review. 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, them when flattening fragments into a single spoken string. A password source,
password ancestor, unknown role or failed context lookup keeps the whole line 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 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 metadata does not prevent local reading.
review into export.
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 Browse-mode current-line reporting, spelling and phonetic spelling now export
only with the command's captured source ancestry. The review worker retains 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. 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). 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 Next: verify real Speech Dispatcher scheduling and audible local/remote
arbitration. Physical input capture/release and ordinary reading while connected arbitration. Physical input capture/release and ordinary reading while connected
still need acceptance. still need acceptance.
+4 -1
View File
@@ -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) { func presentObjectReviewResult(logger *slog.Logger, speech speechEnqueuer, result review.Result, options presentation.Options) {
text := "" text := ""
origin := speechOriginUnknown
var spoken presentation.Speech var spoken presentation.Speech
role := "system" role := "system"
if result.Err != nil { if result.Err != nil {
@@ -259,15 +260,17 @@ func presentObjectReviewResult(logger *slog.Logger, speech speechEnqueuer, resul
text = objectReviewBoundaryMessage(result.Direction) text = objectReviewBoundaryMessage(result.Direction)
} else { } else {
options.EnableSound = false options.EnableSound = false
origin = review_speech_origin(result.Position.SourceContext)
spoken = presentation.FocusSpeechWithOptions(result.Position.Current, options) spoken = presentation.FocusSpeechWithOptions(result.Position.Current, options)
text = spoken.Text text = spoken.Text
role = result.Position.Current.Role role = result.Position.Current.Role
if text == "" { if text == "" {
text = "Could not find current location." text = "Could not find current location."
role = "system" 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) logger.Debug("queued object review presentation", "direction", result.Direction.String(), "moved", result.Moved, "role", role)
} }
+1
View File
@@ -96,6 +96,7 @@ func presentFlatReviewResult(logger *slog.Logger, speech speechEnqueuer, result
request.Line = &speechLine{Object: result.FlatPosition.Object, Raw: result.FlatPosition.Text} request.Line = &speechLine{Object: result.FlatPosition.Object, Raw: result.FlatPosition.Text}
} }
if hasContent { if hasContent {
request.origin = review_speech_origin(result.FlatPosition.SourceContexts...)
set_review_spelling(&request, result.FlatPosition.Text, result.FlatSpeech) set_review_spelling(&request, result.FlatPosition.Text, result.FlatSpeech)
} }
if result.FlatWarning { if result.FlatWarning {
+27
View File
@@ -76,6 +76,33 @@ func document_line_speech_origin(line atspi.DocumentLine) speechOrigin {
return speechOriginLocal 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 { func exportable_speech(request speechRequest) bool {
if request.origin != speechOriginLocal || request.Role == "password text" || if request.origin != speechOriginLocal || request.Role == "password text" ||
request.ExcludeFromHistory || request.Confirmation || request.Uninterruptible || request.ForceSpeech { request.ExcludeFromHistory || request.Confirmation || request.Uninterruptible || request.ForceSpeech {
@@ -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
}
}
}
}
@@ -351,7 +351,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
runtime, forms, shortcut := remote_command_fixture(t, configure...) runtime, forms, shortcut := remote_command_fixture(t, configure...)
if role == "controlled" { if role == "controlled" {
worker := runtime.output.speech 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") } worker.textTransform = func(text string) string { return strings.ReplaceAll(text, "colour", "color") }
if err := worker.Start(t.Context()); err != nil { if err := worker.Start(t.Context()); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -572,6 +572,14 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
t.Fatal(err) 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() worker.InterruptForUser()
var received strings.Builder var received strings.Builder
ends, frames := 0, 0 ends, frames := 0, 0
@@ -607,8 +615,8 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
t.Fatal("reader output did not reach the relay in order") 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 { if received.String() != "color "+remoteText+"First color.Second color.Current color.Review color.staticFlat color. Review color." || frames < 8 || ends != 7 {
t.Fatal("observation leaked private text or lost resolved output/boundaries") 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 // Reopening returns to this attempt, with no second consent or
+8 -1
View File
@@ -30,6 +30,9 @@ var ErrNoReviewWindow = errors.New("focused object has no review window")
type ReviewSnapshot struct { type ReviewSnapshot struct {
Nodes []TreeNode Nodes []TreeNode
References map[ObjectID]ObjectReference 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 // 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] entry = tree.nodes[root]
} }
found: found:
context, _ := tree.ancestryLocked(newObjectReference(root, entry))
var nodes []TreeNode var nodes []TreeNode
references := make(map[ObjectID]ObjectReference) references := make(map[ObjectID]ObjectReference)
pending := []ObjectID{root} pending := []ObjectID{root}
@@ -73,6 +77,9 @@ found:
if !ok { if !ok {
return ReviewSnapshot{}, ErrStaleObject return ReviewSnapshot{}, ErrStaleObject
} }
if entry.snapshotInvalid {
context = nil
}
if entry.childrenInvalid { if entry.childrenInvalid {
return ReviewSnapshot{}, ErrIncompleteTree return ReviewSnapshot{}, ErrIncompleteTree
} }
@@ -91,5 +98,5 @@ found:
pending = append(pending, entry.children[i]) pending = append(pending, entry.children[i])
} }
} }
return ReviewSnapshot{Nodes: nodes, References: references}, nil return ReviewSnapshot{Nodes: nodes, References: references, Context: context}, nil
} }
+27
View File
@@ -161,6 +161,33 @@ func (tree *AccessibleTree) Resolve(reference ObjectReference) (Snapshot, error)
return entry.snapshot, nil 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 // InvalidateSnapshot refreshes presentation and matching data without retiring
// a live identity. A renamed button is still the same browse-caret position. // a live identity. A renamed button is still the same browse-caret position.
func (tree *AccessibleTree) InvalidateSnapshot(id ObjectID) int { func (tree *AccessibleTree) InvalidateSnapshot(id ObjectID) int {
+7
View File
@@ -62,6 +62,9 @@ type Position struct {
Current atspi.Snapshot Current atspi.Snapshot
Previous atspi.Snapshot Previous atspi.Snapshot
HasPrevious bool 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 // 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) return Position{}, fmt.Errorf("resolve current review object: %w", err)
} }
position := Position{Current: current} 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() { if cursor.previous.Valid() {
previous, previousErr := cursor.tree.Resolve(cursor.previous) previous, previousErr := cursor.tree.Resolve(cursor.previous)
if previousErr == nil { if previousErr == nil {
+31 -2
View File
@@ -48,6 +48,9 @@ type FlatPosition struct {
Offset int Offset int
HasOffset bool HasOffset bool
Bounds atspi.Rectangle 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 } type flatLocation struct{ line, zone, character int }
@@ -64,6 +67,8 @@ type FlatCursor struct {
tree *atspi.AccessibleTree tree *atspi.AccessibleTree
references map[atspi.ObjectID]atspi.ObjectReference references map[atspi.ObjectID]atspi.ObjectReference
parents map[atspi.ObjectID]atspi.ObjectID parents map[atspi.ObjectID]atspi.ObjectID
snapshots map[atspi.ObjectID]atspi.Snapshot
rootContext []atspi.Snapshot
verticalTargetX int64 verticalTargetX int64
hasVerticalTarget bool hasVerticalTarget bool
started bool started bool
@@ -85,8 +90,10 @@ func (cursor *FlatCursor) Refresh(ctx context.Context, tree *atspi.AccessibleTre
return err return err
} }
next := FlatCursor{lines: lines, focus: focus, tree: tree, references: snapshot.References, completeness: completeness, 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 { for _, n := range nodes {
next.snapshots[n.Snapshot.ID()] = n.Snapshot
for _, child := range n.Children { for _, child := range n.Children {
next.parents[child] = n.Snapshot.ID() next.parents[child] = n.Snapshot.ID()
} }
@@ -390,11 +397,13 @@ func (cursor *FlatCursor) position(unit FlatUnit) FlatPosition {
zone := cursor.zone() zone := cursor.zone()
start, end := cursor.location.character, min(cursor.location.character+1, len(zone.runes)) start, end := cursor.location.character, min(cursor.location.character+1, len(zone.runes))
text := "" text := ""
var contexts [][]atspi.Snapshot
switch unit { switch unit {
case Line: case Line:
var parts []string var parts []string
for _, z := range cursor.lines[cursor.location.line].zones { for _, z := range cursor.lines[cursor.location.line].zones {
parts = append(parts, z.text) parts = append(parts, z.text)
contexts = append(contexts, cursor.sourceContext(z.object))
} }
text = strings.Join(parts, " ") text = strings.Join(parts, " ")
case Word: case Word:
@@ -404,7 +413,27 @@ func (cursor *FlatCursor) position(unit FlatUnit) FlatPosition {
} }
if unit != Line { if unit != Line {
text = string(zone.runes[start:end]) text = string(zone.runes[start:end])
contexts = [][]atspi.Snapshot{cursor.sourceContext(zone.object)}
} }
return FlatPosition{Text: text, Object: zone.object, Offset: zone.start + start, 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
} }
+95
View File
@@ -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)
}
}