224 lines
8.9 KiB
Go
224 lines
8.9 KiB
Go
// 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)
|
|
}
|
|
}
|
|
}
|
|
}
|