// 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) } }