Export reviewed object and flat reading to Remote
This commit is contained in:
1 parent
49be8309fe
commit
c03ffa7409
11 files changed
+425
-10
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user