// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "log/slog" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/presentation" "git.stormux.org/storm/cthulhu/internal/review" ) func where_am_i_observation_handler(t *testing.T, privacy string) (*eventReviewHandler, *fakeSpeechQueue) { t.Helper() // Use owned-tree ancestry, as document movement does, rather than treating // the presentation's filtered SpeechContext as a complete source capture. position := review_observation_result(t, "object", privacy).Position focus := atspi.NewObjectID(":1.42", "/document") worker, err := review.NewWorker(&fakeAdapter{}, time.Second) if err != nil { t.Fatal(err) } worker.SyncFocus(focus) 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.current = atspi.Snapshot{BusName: focus.BusName, Path: focus.Path, Role: "document web"} handler.state.presentationOptions = presentation.DefaultOptions() handler.handle(review.Result{Moved: true, DocumentCaretCommand: review.CaretNextLine, DocumentFocus: focus, DocumentPosition: review.DocumentPosition{Snapshot: position.Current}, DocumentCaretContext: position.SourceContext, DocumentCaretText: position.Current.Name}) <-queue.requests <-queue.spoken return handler, queue } func TestRemoteWhereAmIInvalidatesSource(t *testing.T) { for _, change := range []string{"same-object-update", "say-all-position", "focus", "epoch", "foreground", "missing-owner", "no-location"} { t.Run(change, func(t *testing.T) { handler, queue := where_am_i_observation_handler(t, "ordinary") switch change { case "same-object-update": // Even identical native text cannot reuse older capture ancestry. events := accessibilityEventHandler{state: handler.state, objectReview: handler.objectReview, logger: handler.logger} events.acceptFocusedEvent(atspi.NewEvent(atspi.FocusEvent, handler.state.current), input.Activity{}) case "say-all-position": handler.handle(review.Result{ReadAllPosition: true, DocumentFocus: handler.objectReview.Focus(), DocumentPosition: review.DocumentPosition{Snapshot: handler.state.current}}) case "focus": handler.objectReview.SyncFocus(atspi.NewObjectID(":1.42", "/other")) case "epoch": handler.objectReview.ResetDocument(handler.objectReview.Focus()) case "foreground": handler.syncForeground = func() { handler.state.setCurrent(handler.state.current) } case "missing-owner": handler.objectReview = nil case "no-location": handler.state.current.Name, handler.state.current.Role = "", "" } handler.whereAmI() request := <-queue.requests if exportable_speech(request) { t.Fatal("Where Am I reused retired source classification") } if change != "no-location" && !strings.Contains(request.Text, "Review colour.") { t.Fatal("source invalidation changed local reading", request.Text) } }) } } func TestRemoteWhereAmIRejectsMismatchedSource(t *testing.T) { handler, queue := where_am_i_observation_handler(t, "ordinary") current := handler.state.current handler.state.setCurrentDocument(review.Result{DocumentFocus: handler.objectReview.Focus(), DocumentPosition: review.DocumentPosition{Snapshot: current}, DocumentCaretContext: []atspi.Snapshot{{BusName: current.BusName, Path: "/other", Role: "static"}}}) handler.whereAmI() if exportable_speech(<-queue.requests) { t.Fatal("unrelated ancestry authorized Where Am I export") } } func TestRemoteWhereAmISecureFocus(t *testing.T) { for _, mode := range []string{"lock", "handoff", "foreground-handoff"} { handler, queue := where_am_i_observation_handler(t, "ordinary") if mode == "lock" { handler.focusGate.SetLocked(true, nil, nil, nil, nil, &brailleWorker{}) } else if mode == "foreground-handoff" { handler.syncForeground = func() { handler.focusGate.SetSuspended(true) } } else { handler.focusGate.SetSuspended(true) } handler.whereAmI() if len(queue.requests) != 0 { t.Fatal("Where Am I spoke during lock or handoff") } } } func TestRemoteWhereAmIResolvedOutput(t *testing.T) { worker, session, _, _ := observation_fixture(t) worker.textTransform = func(text string) string { return strings.ReplaceAll(text, "colour", "color") } for _, privacy := range []string{"ordinary", "snapshot-ancestor", "missing", "stale"} { request := where_am_i_observation_request(t, privacy) if err := worker.EnqueueAndWait(t.Context(), request); 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("Where Am I export differed from resolved local chunks", value, want) } } } worker.InterruptForUser() if value := observed_speech(t, session); value.Kind != "cancel" { t.Fatal("private Where Am I reached the child", value) } } } func where_am_i_observation_request(t *testing.T, privacy string) speechRequest { t.Helper() handler, queue := where_am_i_observation_handler(t, privacy) handler.whereAmI() return <-queue.requests } func TestRemoteWhereAmISourcePrivacy(t *testing.T) { for _, privacy := range []string{"ordinary", "password", "ancestor", "snapshot-ancestor", "unknown", "unknown-role", "invalid", "defunct", "unknown-ancestor", "unknown-snapshot-ancestor", "missing", "stale"} { t.Run(privacy, func(t *testing.T) { request := where_am_i_observation_request(t, privacy) if exportable_speech(request) != (privacy == "ordinary") { t.Fatalf("privacy %s produced exportable=%v", privacy, exportable_speech(request)) } if privacy != "password" && !strings.Contains(request.Text, "Review colour.") { t.Fatal("source privacy changed local Where Am I", request.Text) } }) } }