Files
cthulhu/internal/app/speech_context_observation_test.go

178 lines
7.5 KiB
Go

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