// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "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" ) func native_where_am_i_handler(t *testing.T) (*eventReviewHandler, *fakeSpeechQueue, review.Result) { t.Helper() result := native_current_line_result() result.WhereAmI, result.DocumentFocus = true, result.DocumentPosition.Snapshot.ID() result.DocumentPosition.Snapshot.Name = "Native colour." worker, err := review.NewWorker(&fakeAdapter{}, time.Second) if err != nil { t.Fatal(err) } worker.SyncFocus(result.DocumentFocus) queue := &fakeSpeechQueue{requests: make(chan speechRequest, 4), spoken: make(chan string, 4)} handler := &eventReviewHandler{state: &eventLoopState{}, objectReview: worker, speech: queue, logger: slog.New(slog.DiscardHandler), focusGate: &focusGate{}, syncForeground: func() {}} handler.state.setCurrent(result.DocumentPosition.Snapshot) handler.state.presentationOptions = presentation.DefaultOptions() result.WhereAmIRevision = handler.state.currentRevision return handler, queue, result } func native_where_am_i_request(t *testing.T, privacy string) speechRequest { t.Helper() handler, queue, result := native_where_am_i_handler(t) switch privacy { case "password": result.DocumentPosition.Snapshot.Role = "password text" case "ancestor", "unknown", "invalid": role := privacy if privacy == "ancestor" { role = "password text" } result.DocumentCaretContext[1].Role = role 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" case "changed-text": result.DocumentPosition.Snapshot.Name = "Fresh public text" case "cached-ancestor": handler.state.current.SpeechContext = []atspi.Snapshot{{Role: "password text"}} case "fresh-ancestor": result.DocumentPosition.Snapshot.SpeechContext = []atspi.Snapshot{{Role: "password text"}} case "failure": result.Err = errors.New("provider unavailable") } handler.handle(result) return <-queue.requests } func TestRemoteNativeWhereAmISourcePrivacy(t *testing.T) { for _, privacy := range []string{"ordinary", "password", "ancestor", "unknown", "invalid", "defunct", "missing", "truncated", "mismatched", "foreign", "changed-text", "cached-ancestor", "fresh-ancestor", "failure"} { t.Run(privacy, func(t *testing.T) { request := native_where_am_i_request(t, privacy) if exportable_speech(request) != (privacy == "ordinary") { t.Fatalf("privacy %s produced exportable=%v", privacy, exportable_speech(request)) } if !strings.Contains(request.Text, "Native colour.") || request.Cause != "where-am-i" { t.Fatal("privacy verification changed the local report", request.Text) } }) } } func TestRemoteNativeWhereAmIRetiresCapture(t *testing.T) { for _, change := range []string{"snapshot", "focus", "epoch", "lock", "handoff", "foreground"} { t.Run(change, func(t *testing.T) { handler, queue, result := native_where_am_i_handler(t) switch change { case "snapshot": handler.state.setCurrent(handler.state.current) case "focus": handler.objectReview.SyncFocus(atspi.NewObjectID(result.DocumentFocus.BusName, "/other")) case "epoch": handler.objectReview.ResetDocument(result.DocumentFocus) case "lock": handler.focusGate.SetLocked(true, nil, nil, nil, nil, &brailleWorker{}) case "handoff": handler.focusGate.SetSuspended(true) case "foreground": handler.syncForeground = func() { handler.focusGate.SetSuspended(true) } } handler.handle(result) if len(queue.requests) != 0 { t.Fatal("retired native Where Am I capture spoke") } }) } } type whereAmICapturer struct { *fakeAdapter content atspi.DocumentContent } type whereAmIObservationQueue struct { *fakeSpeechQueue active bool } func (queue *whereAmIObservationQueue) remote_observation_active() bool { return queue.active } func (capturer *whereAmICapturer) ReadFocusedSnapshot(context.Context, atspi.ObjectID) (atspi.DocumentContent, error) { return capturer.content, nil } func TestRemoteNativeWhereAmIQueuesCapture(t *testing.T) { handler, queue, result := native_where_am_i_handler(t) capturer := &whereAmICapturer{fakeAdapter: &fakeAdapter{}, content: atspi.DocumentContent{ Snapshot: result.DocumentPosition.Snapshot, SourceContext: result.DocumentCaretContext}} worker, err := review.NewWorker(capturer, time.Second) if err != nil { t.Fatal(err) } worker.SyncFocus(result.DocumentFocus) handler.objectReview = worker observedQueue := &whereAmIObservationQueue{fakeSpeechQueue: queue} handler.speech = observedQueue handler.whereAmI() if request := <-queue.requests; exportable_speech(request) { t.Fatal("inactive observation authorized native export") } <-queue.spoken observedQueue.active = true handler.whereAmI() if len(queue.requests) != 0 { t.Fatal("native capture bypassed the review worker") } if err := worker.Start(t.Context()); err != nil { t.Fatal(err) } t.Cleanup(func() { worker.Stop(context.Background()) }) select { case captured := <-worker.Results(): handler.handle(captured) if request := <-queue.requests; !exportable_speech(request) { t.Fatal("ordinary native report stayed private after fresh verification") } case <-time.After(time.Second): t.Fatal("native report did not complete") } } func TestRemoteNativeWhereAmIResolvedOutput(t *testing.T) { worker, session, _, _ := observation_fixture(t) if !worker.remote_observation_active() { t.Fatal("live software observer did not enable source verification") } worker.textTransform = func(text string) string { return strings.ReplaceAll(text, "colour", "color") } for _, privacy := range []string{"ordinary", "ancestor", "changed-text", "failure"} { if err := worker.EnqueueAndWait(t.Context(), native_where_am_i_request(t, privacy)); err != nil { t.Fatal(err) } for len(worker.speaker.(*observationSpeaker).spoken) > 0 { want := <-worker.speaker.(*observationSpeaker).spoken if privacy == "ordinary" { value := observed_speech(t, session) if value.Kind != "speak" || len(value.Parts) != 2 || value.Parts[0].Text != want || !value.Parts[1].End { t.Fatal("native Where Am I export lost resolved text or chunks", value) } } } worker.InterruptForUser() if value := observed_speech(t, session); value.Kind != "cancel" { t.Fatal("private native report reached the child", value) } } session.RevokeRemote() if worker.remote_observation_active() { t.Fatal("revoked observer still enabled native verification") } }