189 lines
7.0 KiB
Go
189 lines
7.0 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/presentation"
|
|
"git.stormux.org/storm/cthulhu/internal/review"
|
|
)
|
|
|
|
// Exercise the actual owned-tree cursors and presentation producers rather
|
|
// than attaching an allow flag to synthetic text.
|
|
func review_observation_result(t *testing.T, kind, privacy string) review.Result {
|
|
t.Helper()
|
|
id := func(path string) atspi.ObjectID { return atspi.NewObjectID(":1.42", path) }
|
|
node := func(path, role, text string, x int32) atspi.TreeNode {
|
|
bounds := atspi.Rectangle{X: x, Width: 100, Height: 20}
|
|
node := atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: ":1.42", Path: path, Role: role, Name: text},
|
|
Geometry: atspi.Geometry{Bounds: bounds, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true},
|
|
Text: atspi.VisibleText{Known: true}}
|
|
if text != "" {
|
|
node.Text.Ranges = []atspi.TextRange{{Text: text, End: len([]rune(text)), Bounds: bounds}}
|
|
}
|
|
return node
|
|
}
|
|
root := node("/window", "frame", "", 0)
|
|
root.Geometry.Bounds.Width = 500
|
|
root.Children = []atspi.ObjectID{id("/parent")}
|
|
parent := node("/parent", "panel", "", 0)
|
|
parent.Geometry.Bounds.Width = 500
|
|
parent.Children = []atspi.ObjectID{id("/first"), id("/second")}
|
|
first := node("/first", "static", "Flat colour.", 0)
|
|
second := node("/second", "static", "Review colour.", 120)
|
|
switch privacy {
|
|
case "password":
|
|
second.Snapshot.Role = "password text"
|
|
case "ancestor":
|
|
parent.Snapshot.Role = "password text"
|
|
case "snapshot-ancestor":
|
|
second.Snapshot.SpeechContext = []atspi.Snapshot{{Role: "password text"}}
|
|
case "unknown":
|
|
second.Snapshot.Role = ""
|
|
case "unknown-role", "invalid":
|
|
second.Snapshot.Role = strings.TrimSuffix(privacy, "-role")
|
|
case "unknown-ancestor":
|
|
parent.Snapshot.Role = ""
|
|
case "unknown-snapshot-ancestor":
|
|
second.Snapshot.SpeechContext = []atspi.Snapshot{{Role: ""}}
|
|
}
|
|
tree, err := atspi.NewAccessibleTree(root.Snapshot.ID(), []atspi.TreeNode{root, parent, first, second})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if privacy == "stale" {
|
|
tree.InvalidateSnapshot(parent.Snapshot.ID())
|
|
}
|
|
var result review.Result
|
|
if kind == "object" {
|
|
cursor, _ := review.NewCursor(tree)
|
|
if _, err := cursor.SyncFocus(first.Snapshot.ID()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result.Position, result.Moved, result.Err = cursor.Move(review.NextSibling)
|
|
result.Direction = review.NextSibling
|
|
if privacy == "missing" {
|
|
result.Position.SourceContext = nil
|
|
} else if privacy == "defunct" {
|
|
result.Position.SourceContext[0].Defunct = true
|
|
}
|
|
} else {
|
|
cursor := &review.FlatCursor{}
|
|
if err := cursor.Refresh(t.Context(), tree, first.Snapshot.ID()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
unit := review.Line
|
|
if kind == "word" {
|
|
unit = review.Word
|
|
} else if kind == "character" {
|
|
unit = review.Character
|
|
}
|
|
result.FlatPosition, result.Moved, result.Err = cursor.Read(unit, 0)
|
|
result.FlatUnit = unit
|
|
if privacy == "missing" {
|
|
result.FlatPosition.SourceContexts = append(result.FlatPosition.SourceContexts, nil)
|
|
} else if privacy == "defunct" {
|
|
result.FlatPosition.SourceContexts[1][0].Defunct = true
|
|
}
|
|
}
|
|
if result.Err != nil {
|
|
t.Fatal(result.Err)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func review_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)
|
|
if kind == "object" {
|
|
presentObjectReviewResult(logger, queue, result, presentation.DefaultOptions())
|
|
} else {
|
|
presentFlatReviewResult(logger, queue, result)
|
|
}
|
|
return <-queue.requests
|
|
}
|
|
|
|
func TestRemoteReviewSourcePrivacy(t *testing.T) {
|
|
for _, kind := range []string{"object", "line"} {
|
|
for _, privacy := range []string{"ordinary", "password", "ancestor", "snapshot-ancestor", "unknown", "unknown-role", "invalid", "defunct", "unknown-ancestor", "unknown-snapshot-ancestor", "missing", "stale", "failure", "boundary"} {
|
|
t.Run(kind+"/"+privacy, func(t *testing.T) {
|
|
result := review_observation_result(t, kind, privacy)
|
|
if privacy == "failure" {
|
|
result.Err = errors.New("private fixture failure")
|
|
} else if privacy == "boundary" {
|
|
result.Moved = false
|
|
result.FlatStep = 1
|
|
}
|
|
request := review_observation_request(t, kind, result)
|
|
if exportable_speech(request) != (privacy == "ordinary") {
|
|
t.Fatalf("privacy %s produced exportable=%v", privacy, exportable_speech(request))
|
|
}
|
|
if privacy != "failure" && privacy != "boundary" && privacy != "password" && !strings.Contains(request.Text, "Review colour.") {
|
|
t.Fatal("privacy classification changed local reading", request.Text)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRemoteFlatReviewChecksOnlySpokenZones(t *testing.T) {
|
|
for _, kind := range []string{"word", "character"} {
|
|
result := review_observation_result(t, kind, "snapshot-ancestor")
|
|
request := review_observation_request(t, kind, result)
|
|
if !exportable_speech(request) || len(result.FlatPosition.SourceContexts) != 1 {
|
|
t.Fatal("private neighboring zone blocked ordinary word/character", kind)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRemoteReviewResolvedOutput(t *testing.T) {
|
|
worker, session, _, _ := observation_fixture(t)
|
|
worker.textTransform = func(text string) string { return strings.ReplaceAll(text, "colour", "color") }
|
|
for _, kind := range []string{"object", "line"} {
|
|
for _, style := range []review.FlatSpeech{review.FlatSpeak, review.FlatSpell, review.FlatPhonetic} {
|
|
result := review_observation_result(t, kind, "ordinary")
|
|
result.FlatSpeech = style
|
|
request := review_observation_request(t, kind, result)
|
|
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Compare every exported part with actual local backend delivery;
|
|
// this includes pronunciation and repeated spelling characters.
|
|
for len(worker.speaker.(*observationSpeaker).spoken) > 0 {
|
|
want := <-worker.speaker.(*observationSpeaker).spoken
|
|
value := observed_speech(t, session)
|
|
characters := request.Character
|
|
if len([]rune(want)) != 1 {
|
|
characters = false // Expanded spaces/phonetic words use ordinary speech.
|
|
}
|
|
if value.Kind != "speak" || len(value.Parts) != 2 || value.Parts[0].Text != want ||
|
|
value.Parts[0].Characters != characters || !value.Parts[1].End {
|
|
t.Fatal("review export differed from resolved local output", value, want)
|
|
}
|
|
}
|
|
result = review_observation_result(t, kind, "snapshot-ancestor")
|
|
result.FlatSpeech = style
|
|
if err := worker.EnqueueAndWait(t.Context(), review_observation_request(t, kind, result)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
worker.InterruptForUser()
|
|
if value := observed_speech(t, session); value.Kind != "cancel" {
|
|
t.Fatal("private review reached the child", value)
|
|
}
|
|
if len(worker.speaker.(*observationSpeaker).spoken) == 0 {
|
|
t.Fatal("private review lost local speech")
|
|
}
|
|
for len(worker.speaker.(*observationSpeaker).spoken) > 0 {
|
|
<-worker.speaker.(*observationSpeaker).spoken
|
|
}
|
|
}
|
|
}
|
|
}
|