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

+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) {
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)
}
+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}
}
if hasContent {
request.origin = review_speech_origin(result.FlatPosition.SourceContexts...)
set_review_spelling(&request, result.FlatPosition.Text, result.FlatSpeech)
}
if result.FlatWarning {
+27
View File
@@ -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 {
@@ -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...)
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