Files
cthulhu/internal/app/speech_document_observation_test.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)
}
}