178 lines
7.5 KiB
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)
|
|
}
|
|
}
|