// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "errors" "fmt" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/review" ) func current_line_observation_request(t *testing.T, result review.Result) speechRequest { t.Helper() focus := atspi.NewObjectID(":1.42", "/focus") worker, err := review.NewWorker(&fakeAdapter{}, time.Second) if err != nil { t.Fatal(err) } worker.SyncFocus(focus) queue := &fakeSpeechQueue{requests: make(chan speechRequest, 1), spoken: make(chan string, 1)} handler := eventReviewHandler{objectReview: worker, speech: queue, focusGate: &focusGate{}, browser: &browserModeController{ready: true, mode: review.BrowseMode, focus: focus, document: result.DocumentPosition.Document.ID()}} result.DocumentFocus = focus handler.present_current_line(result) select { case request := <-queue.requests: return request default: t.Fatal("current-line producer did not enqueue speech") return speechRequest{} } } func TestRemoteCurrentLineSourcePrivacy(t *testing.T) { for _, style := range []review.FlatSpeech{review.FlatSpeak, review.FlatSpell, review.FlatPhonetic} { for _, source := range []string{"ordinary", "password", "ancestor", "snapshot-ancestor", "unknown", "unknown-ancestor", "missing-context", "ordinary-neighbor", "neighbor", "missing-neighbor", "native", "failure"} { t.Run(fmt.Sprintf("%s/%d", source, style), func(t *testing.T) { snapshot := atspi.Snapshot{Role: "static"} ancestors := []atspi.Snapshot{snapshot, {Role: "paragraph"}, {Role: "document web"}} result := review.Result{CurrentLineBrowse: true, FlatSpeech: style, DocumentCaretText: "book", DocumentPosition: review.DocumentPosition{Snapshot: snapshot}, DocumentCaretContext: ancestors} switch source { case "password": result.DocumentPosition.Snapshot.Role = "password text" case "ancestor": ancestors[1].Role = "password text" case "snapshot-ancestor": result.DocumentPosition.Snapshot.SpeechContext = []atspi.Snapshot{{Role: "password text"}} case "unknown": result.DocumentPosition.Snapshot.Role = "" case "unknown-ancestor": ancestors[1].Role = "" case "missing-context": result.DocumentCaretContext = nil case "ordinary-neighbor", "neighbor", "missing-neighbor": neighbor := []atspi.Snapshot{{Role: "static"}, {Role: "password text"}} if source == "ordinary-neighbor" { neighbor[1].Role = "paragraph" } else if source == "missing-neighbor" { neighbor = nil } result.DocumentCaretLine = &atspi.DocumentLine{Contents: []atspi.DocumentContent{{Snapshot: snapshot, Text: "book"}}, SourceContexts: [][]atspi.Snapshot{ancestors, neighbor}} case "native": result.CurrentLineBrowse = false result.DocumentCaretContext = nil case "failure": result.Err = errors.New("fixture unavailable") } request := current_line_observation_request(t, result) if exportable_speech(request) != (source == "ordinary" || source == "ordinary-neighbor") { t.Fatalf("source %s produced exportable=%v", source, exportable_speech(request)) } if source != "failure" && request.Text != "book" { t.Fatal("privacy classification changed local text") } }) } } } func TestRemoteCurrentLineResolvedOutput(t *testing.T) { worker, session, _, _ := observation_fixture(t) worker.textTransform = func(text string) string { return strings.ReplaceAll(text, "colour", "color") } for _, probe := range []struct { text string style review.FlatSpeech want []string characters bool }{ {"Current colour.", review.FlatSpeak, []string{"Current color."}, false}, {"book", review.FlatSpell, []string{"b", "o", "o", "k"}, true}, {"book", review.FlatPhonetic, []string{"bravo", "oscar", "oscar", "kilo"}, false}, {"\n", review.FlatSpeak, []string{"blank"}, false}, } { source := atspi.Snapshot{Role: "static"} result := review.Result{CurrentLineBrowse: true, FlatSpeech: probe.style, DocumentCaretText: probe.text, DocumentPosition: review.DocumentPosition{Snapshot: source}, DocumentCaretContext: []atspi.Snapshot{source, {Role: "paragraph"}, {Role: "document web"}}} if err := worker.EnqueueAndWait(t.Context(), current_line_observation_request(t, result)); err != nil { t.Fatal(err) } for _, want := range probe.want { value := observed_speech(t, session) if value.Kind != "speak" || len(value.Parts) != 2 || value.Parts[0].Text != want || value.Parts[0].Characters != probe.characters || !value.Parts[1].End { t.Fatal("current-line delivery lost resolved text, repetition or character state", value) } if got := <-worker.speaker.(*observationSpeaker).spoken; got != want { t.Fatalf("local reading %q, want %q", got, want) } } // A sensitive ancestor must suppress every spelling/phonetic part, // while the complete local reading remains available. result.DocumentCaretContext[1].Role = "password text" if err := worker.EnqueueAndWait(t.Context(), current_line_observation_request(t, result)); err != nil { t.Fatal(err) } worker.InterruptForUser() if value := observed_speech(t, session); value.Kind != "cancel" { t.Fatal("sensitive current-line output reached the child", value) } for _, want := range probe.want { if got := <-worker.speaker.(*observationSpeaker).spoken; got != want { t.Fatalf("private local reading %q, want %q", got, want) } } } } func native_current_line_result() review.Result { source := atspi.Snapshot{BusName: ":1.42", Path: "/focus", Role: "text"} return review.Result{DocumentCaretText: "Native colour.", DocumentPosition: review.DocumentPosition{Snapshot: source}, DocumentCaretContext: []atspi.Snapshot{source, {BusName: source.BusName, Path: "/window", Role: "frame"}, {BusName: source.BusName, Path: "/app", Role: "application"}}} } func TestRemoteNativeCurrentLineSourcePrivacy(t *testing.T) { for _, privacy := range []string{"ordinary", "password", "ancestor", "unknown", "invalid", "defunct", "missing", "truncated", "mismatched", "foreign", "failure"} { t.Run(privacy, func(t *testing.T) { result := native_current_line_result() switch privacy { case "password": result.DocumentPosition.Snapshot.Role = "password text" case "ancestor": result.DocumentCaretContext[1].Role = "password text" case "unknown": result.DocumentCaretContext[1].Role = "unknown" case "invalid": result.DocumentCaretContext[1].Role = "invalid" case "defunct": result.DocumentCaretContext[1].Defunct = true case "missing": result.DocumentCaretContext = nil case "truncated": result.DocumentCaretContext = result.DocumentCaretContext[:2] case "mismatched": result.DocumentCaretContext[0].Path = "/other" case "foreign": result.DocumentPosition.Snapshot.Path = "/other" result.DocumentCaretContext[0].Path = "/other" case "failure": result.Err = errors.New("fixture unavailable") } request := current_line_observation_request(t, result) if exportable_speech(request) != (privacy == "ordinary") { t.Fatalf("native source %s produced exportable=%v", privacy, exportable_speech(request)) } if privacy != "failure" && request.Text != result.DocumentCaretText { t.Fatal("privacy classification changed local native reading") } }) } } func TestRemoteNativeCurrentLineResolvedOutput(t *testing.T) { worker, session, _, _ := observation_fixture(t) worker.textTransform = func(text string) string { return strings.ReplaceAll(text, "colour", "color") } for _, probe := range []struct { style review.FlatSpeech text string want []string characters bool }{ {review.FlatSpeak, "Native colour.", []string{"Native color."}, false}, {review.FlatSpell, "book", []string{"b", "o", "o", "k"}, true}, {review.FlatPhonetic, "book", []string{"bravo", "oscar", "oscar", "kilo"}, false}, {review.FlatSpeak, "\n", []string{"blank"}, false}, } { result := native_current_line_result() result.FlatSpeech, result.DocumentCaretText = probe.style, probe.text for _, private := range []bool{false, true} { if private { result.DocumentCaretContext[1].Role = "password text" } if err := worker.EnqueueAndWait(t.Context(), current_line_observation_request(t, result)); err != nil { t.Fatal(err) } for _, want := range probe.want { if !private { value := observed_speech(t, session) if value.Kind != "speak" || len(value.Parts) != 2 || value.Parts[0].Text != want || value.Parts[0].Characters != probe.characters || !value.Parts[1].End { t.Fatal("native report lost resolved output or spelling boundaries", value) } } if got := <-worker.speaker.(*observationSpeaker).spoken; got != want { t.Fatalf("native local reading %q, want %q", got, want) } } worker.InterruptForUser() if value := observed_speech(t, session); value.Kind != "cancel" { t.Fatal("private native output reached the child", value) } } } }