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