276 lines
11 KiB
Go
276 lines
11 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/presentation"
|
|
"git.stormux.org/storm/cthulhu/internal/review"
|
|
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
|
)
|
|
|
|
func document_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)
|
|
options := presentation.DefaultOptions()
|
|
options.SayAllStyle = "sentence"
|
|
switch kind {
|
|
case "caret":
|
|
presentDocumentCaretResult(logger, queue, result, options)
|
|
case "navigation":
|
|
presentDocumentResult(logger, queue, result, options)
|
|
case "read-all":
|
|
presentReadAllResult(logger, queue, result, options)
|
|
case "current-line":
|
|
result.CurrentLineBrowse = true
|
|
return current_line_observation_request(t, result)
|
|
}
|
|
select {
|
|
case request := <-queue.requests:
|
|
return request
|
|
default:
|
|
t.Fatal("document producer did not enqueue speech")
|
|
return speechRequest{}
|
|
}
|
|
}
|
|
|
|
func TestRemoteDocumentSpeechSourcePrivacy(t *testing.T) {
|
|
for _, kind := range []string{"caret", "navigation", "read-all"} {
|
|
for _, source := range []string{"ordinary", "password", "ancestor", "snapshot-ancestor", "unknown", "invalid", "defunct", "unknown-ancestor", "defunct-ancestor", "unknown-snapshot-ancestor"} {
|
|
t.Run(kind+"/"+source, func(t *testing.T) {
|
|
snapshot := atspi.Snapshot{Path: "/text", Role: "text", Name: "Reading. Next sentence."}
|
|
ancestors := []atspi.Snapshot{}
|
|
switch source {
|
|
case "unknown", "invalid":
|
|
snapshot.Role = source
|
|
case "defunct":
|
|
snapshot.Defunct = true
|
|
case "unknown-ancestor":
|
|
ancestors = []atspi.Snapshot{{Role: "unknown"}}
|
|
case "defunct-ancestor":
|
|
ancestors = []atspi.Snapshot{{Role: "section", Defunct: true}}
|
|
case "unknown-snapshot-ancestor":
|
|
snapshot.SpeechContext = []atspi.Snapshot{{Role: "unknown"}}
|
|
case "password":
|
|
snapshot.Role = "password text"
|
|
case "ancestor":
|
|
// Same identity is removed from caret/navigation context.
|
|
ancestors = []atspi.Snapshot{{Path: "/text", Role: "password text"}}
|
|
case "snapshot-ancestor":
|
|
snapshot.SpeechContext = []atspi.Snapshot{{Role: "password text"}}
|
|
}
|
|
result := review.Result{Moved: true, DocumentCaretText: "Reading. Next sentence.",
|
|
DocumentCaretCommand: review.CaretNextLine, DocumentCaretContext: ancestors,
|
|
DocumentPosition: review.DocumentPosition{Snapshot: snapshot}, ReadAllContext: t.Context(),
|
|
ReadAllLines: []atspi.DocumentLine{{Context: ancestors, Contents: []atspi.DocumentContent{{Snapshot: snapshot, Text: "Reading. Next sentence."}}}}}
|
|
request := document_observation_request(t, kind, result)
|
|
if exportable_speech(request) != (source == "ordinary") {
|
|
t.Fatalf("source %s produced exportable=%v", source, exportable_speech(request))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
for _, line := range []atspi.DocumentLine{
|
|
{},
|
|
{Contents: []atspi.DocumentContent{{Text: "text without source metadata"}}},
|
|
{Contents: []atspi.DocumentContent{{Snapshot: atspi.Snapshot{Role: "text"}}, {Snapshot: atspi.Snapshot{Role: "password text"}}}},
|
|
} {
|
|
if document_line_speech_origin(line) == speechOriginLocal {
|
|
t.Fatal("unclassified or mixed private line was exportable")
|
|
}
|
|
}
|
|
for _, kind := range []string{"caret", "navigation", "read-all"} {
|
|
request := document_observation_request(t, kind, review.Result{Err: errors.New("fixture failure"), ReadAllContext: t.Context()})
|
|
if exportable_speech(request) {
|
|
t.Fatal("local failure status was exported", kind)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRemoteDocumentSpeechFragmentPrivacy(t *testing.T) {
|
|
worker, session, _, _ := observation_fixture(t)
|
|
for _, kind := range []string{"caret", "read-all"} {
|
|
for _, source := range []string{"ordinary", "password", "ancestor", "snapshot-ancestor", "unknown", "unknown-role", "invalid", "defunct", "missing"} {
|
|
t.Run(kind+"/"+source, func(t *testing.T) {
|
|
caret := atspi.Snapshot{Path: "/caret", Role: "static"}
|
|
neighbor := atspi.Snapshot{Path: "/neighbor", Role: "static"}
|
|
context := []atspi.Snapshot{neighbor, {Role: "section"}, {Role: "document web"}}
|
|
switch source {
|
|
case "password":
|
|
context[0].Role = "password text"
|
|
case "ancestor":
|
|
context[1].Role = "password text"
|
|
case "snapshot-ancestor":
|
|
context[0].SpeechContext = []atspi.Snapshot{{Role: "password text"}}
|
|
case "unknown":
|
|
context[0].Role = ""
|
|
case "unknown-role", "invalid":
|
|
context[0].Role = strings.TrimSuffix(source, "-role")
|
|
case "defunct":
|
|
context[0].Defunct = true
|
|
case "missing":
|
|
context = nil
|
|
}
|
|
// The presentation has already flattened two fragments to one
|
|
// string. The caret and its ancestors are ordinary in every case.
|
|
line := atspi.DocumentLine{Contents: []atspi.DocumentContent{{Snapshot: caret, Text: "Joined colour."}},
|
|
SourceContexts: [][]atspi.Snapshot{{caret, {Role: "document web"}}, context}}
|
|
result := review.Result{Moved: true, DocumentCaretCommand: review.CaretNextLine,
|
|
DocumentPosition: review.DocumentPosition{Snapshot: caret}, DocumentCaretText: "Joined colour.",
|
|
DocumentCaretContext: []atspi.Snapshot{caret, {Role: "document web"}}, DocumentCaretLine: &line,
|
|
ReadAllContext: t.Context(), ReadAllLines: []atspi.DocumentLine{line}}
|
|
request := document_observation_request(t, kind, result)
|
|
if exportable_speech(request) != (source == "ordinary") {
|
|
t.Fatalf("fragment %s produced exportable=%v", source, exportable_speech(request))
|
|
}
|
|
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if source == "ordinary" {
|
|
if value := observed_speech(t, session); value.Kind != "speak" || value.Parts[0].Text != "Joined colour." {
|
|
t.Fatal("ordinary fragmented reading was not exported", value)
|
|
}
|
|
}
|
|
worker.InterruptForUser()
|
|
if value := observed_speech(t, session); value.Kind != "cancel" {
|
|
t.Fatal("fragment privacy failed before cancellation", value)
|
|
}
|
|
select {
|
|
case text := <-worker.speaker.(*observationSpeaker).spoken:
|
|
if text != "Joined colour." {
|
|
t.Fatal("source classification changed local reading", text)
|
|
}
|
|
default:
|
|
t.Fatal("private fragmented reading did not reach local speech")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRemoteDocumentSpeechCapturedNeighborPrivacy(t *testing.T) {
|
|
id := func(path string) atspi.ObjectID { return atspi.NewObjectID(":1.42", path) }
|
|
node := func(path, role string, children ...atspi.ObjectID) atspi.TreeNode {
|
|
return atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: ":1.42", Path: path, Role: role}, Children: children}
|
|
}
|
|
public := node("/public", "static")
|
|
private := node("/private", "static")
|
|
for index, leaf := range []*atspi.TreeNode{&public, &private} {
|
|
text := []string{"Public text.", "Private text."}[index]
|
|
bounds := atspi.Rectangle{X: int32(index * 100), Y: 10, Width: 100, Height: 20}
|
|
leaf.Geometry = atspi.Geometry{Bounds: bounds, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true}
|
|
leaf.Text = atspi.VisibleText{Known: true, Ranges: []atspi.TextRange{{Start: 0, End: len(text), Text: text, Bounds: bounds}}}
|
|
}
|
|
tree, err := atspi.NewAccessibleTree(id("/doc"), []atspi.TreeNode{
|
|
node("/doc", "document web", id("/public"), id("/password")), public,
|
|
node("/password", "password text", id("/private")), private,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reference, _ := tree.Reference(id("/public"))
|
|
line, err := tree.DocumentLineAtOffset(t.Context(), reference, 0)
|
|
if err != nil || len(line.Contents) != 2 {
|
|
t.Fatal("fixture did not assemble both visible sources", err)
|
|
}
|
|
// Only the neighbor has a password ancestor; the caret's line context
|
|
// cannot classify it. Exercise the real tree-to-presentation handoff.
|
|
for _, ancestor := range line.Context {
|
|
if ancestor.Role == "password text" {
|
|
t.Fatal("fixture caret has a password ancestor")
|
|
}
|
|
}
|
|
request := document_observation_request(t, "read-all", review.Result{ReadAllContext: t.Context(), ReadAllLines: []atspi.DocumentLine{line}})
|
|
if len(request.ReadAll) == 0 || exportable_speech(request) {
|
|
t.Fatal("captured neighbor ancestry was lost before speech admission")
|
|
}
|
|
}
|
|
|
|
func (speaker *observationSpeaker) SpeakAndWait(ctx context.Context, text string, began func()) error {
|
|
if began != nil {
|
|
began()
|
|
}
|
|
return speaker.Speak(ctx, text)
|
|
}
|
|
|
|
func TestRemoteReadAllObservationResolvedChunksAndPrivateBatch(t *testing.T) {
|
|
worker, session, _, _ := observation_fixture(t)
|
|
worker.textTransform = func(text string) string { return strings.ReplaceAll(text, "colour", "color") }
|
|
line := atspi.DocumentLine{Contents: []atspi.DocumentContent{{Snapshot: atspi.Snapshot{Role: "text"}, Text: "First colour. Second colour."}}}
|
|
request := document_observation_request(t, "read-all", review.Result{ReadAllContext: t.Context(), ReadAllLines: []atspi.DocumentLine{line}})
|
|
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, want := range []string{"First color.", "Second color."} {
|
|
value := observed_speech(t, session)
|
|
if value.Kind != "speak" || len(value.Parts) != 2 || value.Parts[0].Text != want || !value.Parts[1].End {
|
|
t.Fatal("Say All did not export resolved sentence boundaries", value)
|
|
}
|
|
}
|
|
private := line
|
|
private.Context = []atspi.Snapshot{{Role: "password text"}}
|
|
request = document_observation_request(t, "read-all", review.Result{ReadAllContext: t.Context(), ReadAllLines: []atspi.DocumentLine{line, private, line}})
|
|
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
worker.InterruptForUser()
|
|
if value := observed_speech(t, session); value.Kind != "cancel" {
|
|
t.Fatal("mixed private Say All batch was exported", value)
|
|
}
|
|
}
|
|
|
|
func TestRemoteReadAllObservationKeepsOriginalLease(t *testing.T) {
|
|
worker, session, _, _ := observation_fixture(t)
|
|
speaker := &continuousSpeaker{started: make(chan string, 2), finish: make(chan struct{}, 2)}
|
|
worker.speaker = speaker // No requests have been admitted yet.
|
|
worker.Enqueue(speechRequest{origin: speechOriginLocal, ReadAll: []readAllPart{{Text: "First"}, {Text: "Second"}}})
|
|
select {
|
|
case <-speaker.started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Say All did not start")
|
|
}
|
|
if value := observed_speech(t, session); value.Parts[0].Text != "First" {
|
|
t.Fatal("first line missing", value)
|
|
}
|
|
session.RevokeRemote()
|
|
var event tentaclewire.SessionEvent
|
|
if json.Unmarshal(observation_call(t, session, "event"), &event) != nil || event.Kind != "remote.revoked" {
|
|
t.Fatal("missing revocation")
|
|
}
|
|
approval, err := session.BeginRemoteApproval("controlled", []string{"output-observe"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := session.AuthorizeRemoteWithApproval(approval, "controlled", []string{"output-observe"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
observation_call(t, session, "open")
|
|
speaker.finish <- struct{}{}
|
|
select {
|
|
case text := <-speaker.started:
|
|
if text != "Second" {
|
|
t.Fatal("local reading changed", text)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("local reading stalled across reconnection")
|
|
}
|
|
speaker.finish <- struct{}{}
|
|
if err := worker.EnqueueAndWait(t.Context(), speechRequest{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
worker.InterruptForUser()
|
|
if value := observed_speech(t, session); value.Kind != "cancel" {
|
|
t.Fatal("old Say All continued under replacement consent", value)
|
|
}
|
|
}
|