// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "encoding/json" "testing" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) func TestRemoteSpeechContextObservationTransitions(t *testing.T) { worker, session, _, _ := observation_fixture(t) worker.reading = func() config.ReadingSettings { return config.DefaultSettings().Reading } list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list", Name: "Items"} for _, request := range []speechRequest{ {origin: speechOriginLocal, Text: "Inside", Context: []atspi.Snapshot{list}}, {origin: speechOriginLocal, Text: "Still inside", Context: []atspi.Snapshot{list}}, {origin: speechOriginLocal, Text: "Outside", Context: []atspi.Snapshot{}}, } { if err := worker.EnqueueAndWait(t.Context(), request); err != nil { t.Fatal(err) } } for _, want := range []string{"Items list", "Inside", "Still inside", "Leaving Items list.", "Outside"} { if got := <-worker.speaker.(*observationSpeaker).spoken; got != want { t.Fatalf("local context speech = %q, want %q", got, want) } if got := observed_speech(t, session); got.Kind != "speak" || got.Parts[0].Text != want || !got.Parts[1].End || got.Parts[0].Characters { t.Fatalf("observed context speech = %+v, want %q", got, want) } } worker.InterruptForUser() if got := observed_speech(t, session); got.Kind != "cancel" { t.Fatal("unexpected context output", got) } } func TestRemoteSpeechContextObservationPrivateSources(t *testing.T) { for _, privacy := range []string{"unclassified", "password", "unknown", "invalid", "defunct", "ancestor", "history", "remote", "reset"} { t.Run(privacy, func(t *testing.T) { worker, session, _, _ := observation_fixture(t) worker.reading = func() config.ReadingSettings { return config.DefaultSettings().Reading } list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list", Name: "Private"} request := speechRequest{origin: speechOriginLocal, Text: "Inside", Context: []atspi.Snapshot{list}} switch privacy { case "unclassified": request.origin = speechOriginUnknown case "password": // Presentation may retain only an article/list while discarding // a private outer ancestor. Preserve the original classification. request.origin = accessibility_speech_origin(atspi.Snapshot{Role: "text", SpeechContext: []atspi.Snapshot{list, {Role: "password text"}}}) case "unknown", "invalid": request.Context = append(request.Context, atspi.Snapshot{Role: privacy}) case "defunct": request.Context[0].Defunct = true case "ancestor": request.Context[0].SpeechContext = []atspi.Snapshot{{Role: "password text"}} case "history": request.ExcludeFromHistory = true case "remote": request.origin = speechOriginRemote case "reset": request.ResetContext = true } // Unknown/defunct ancestry is checked independently even if a // producer classified only its source. Keep content private here // so this test isolates context admission and cached exit labels. request.Text = "" if err := worker.EnqueueAndWait(t.Context(), request); err != nil { t.Fatal(err) } if err := worker.EnqueueAndWait(t.Context(), speechRequest{origin: speechOriginLocal, Text: "Outside", Context: []atspi.Snapshot{}}); err != nil { t.Fatal(err) } if got := observed_speech(t, session); got.Kind != "speak" || got.Parts[0].Text != "Outside" { t.Fatal("private context or cached label exported", got) } worker.InterruptForUser() if got := observed_speech(t, session); got.Kind != "cancel" { t.Fatal("unexpected private context output", got) } if privacy != "reset" { if got := <-worker.speaker.(*observationSpeaker).spoken; got != "Private list" { t.Fatal("privacy changed local context", got) } // The second request must retain ordinary local exit speech. <-worker.speaker.(*observationSpeaker).spoken // Empty content. if got := <-worker.speaker.(*observationSpeaker).spoken; got != "Leaving Private list." { t.Fatal("privacy changed local exit", got) } } }) } } func TestRemoteSpeechContextObservationCachedLabelsKeepOriginalLease(t *testing.T) { for _, priorConsent := range []bool{false, true} { t.Run(map[bool]string{false: "before-consent", true: "replacement"}[priorConsent], func(t *testing.T) { worker, session, _, _ := observation_fixture(t) worker.reading = func() config.ReadingSettings { return config.DefaultSettings().Reading } revoke := func() { t.Helper() session.RevokeRemote() var event tentaclewire.SessionEvent if json.Unmarshal(observation_call(t, session, "event"), &event) != nil || event.Kind != "remote.revoked" { t.Fatal("expected old lease revocation") } } if !priorConsent { revoke() } list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list", Name: "Old"} if err := worker.EnqueueAndWait(t.Context(), speechRequest{origin: speechOriginLocal, Text: "Inside", Context: []atspi.Snapshot{list}}); err != nil { t.Fatal(err) } if priorConsent { for _, want := range []string{"Old list", "Inside"} { if got := observed_speech(t, session); got.Kind != "speak" || got.Parts[0].Text != want { t.Fatal("missing original lease output", got) } } revoke() } approval, err := session.BeginRemoteApproval("controlled", []string{"output-observe"}) if err != nil { t.Fatal(err) } if _, err := session.AuthorizeRemoteWithApproval(approval, "controlled", []string{"output-observe"}); err != nil { t.Fatal(err) } observation_call(t, session, "open") if err := worker.EnqueueAndWait(t.Context(), speechRequest{origin: speechOriginLocal, Text: "Outside", Context: []atspi.Snapshot{}}); err != nil { t.Fatal(err) } if got := observed_speech(t, session); got.Kind != "speak" || got.Parts[0].Text != "Outside" { t.Fatal("cached exit label acquired new consent", got) } for _, want := range []string{"Old list", "Inside", "Leaving Old list.", "Outside"} { if got := <-worker.speaker.(*observationSpeaker).spoken; got != want { t.Fatalf("local speech = %q, want %q", got, want) } } worker.InterruptForUser() if got := observed_speech(t, session); got.Kind != "cancel" { t.Fatal("unexpected replacement output", got) } }) } } func TestRemoteSpeechContextObservationSayAllAndSpelling(t *testing.T) { worker, session, _, _ := observation_fixture(t) worker.reading = func() config.ReadingSettings { return config.DefaultSettings().Reading } list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list", Name: "Items"} for _, request := range []speechRequest{ {origin: speechOriginLocal, ReadAll: []readAllPart{{Text: "First", Context: []atspi.Snapshot{list}}, {Text: "Second", Context: []atspi.Snapshot{}}}}, {origin: speechOriginLocal, Spelling: []string{"a", "a"}, Character: true, Context: []atspi.Snapshot{list}}, } { if err := worker.EnqueueAndWait(t.Context(), request); err != nil { t.Fatal(err) } } for index, want := range []string{"Items list", "First", "Leaving Items list.", "Second", "Items list", "a", "a"} { if got := observed_speech(t, session); got.Kind != "speak" || got.Parts[0].Text != want || got.Parts[0].Characters != (index >= 5) { t.Fatalf("context/chunk/spelling boundary lost, want %q: %+v", want, got) } } worker.InterruptForUser() if got := observed_speech(t, session); got.Kind != "cancel" { t.Fatal("repeated context during spelling", got) } }