666 lines
27 KiB
Go
666 lines
27 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/config"
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/interaction"
|
|
"git.stormux.org/storm/cthulhu/internal/preferences"
|
|
"git.stormux.org/storm/cthulhu/internal/speech"
|
|
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
|
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
|
sdk "git.stormux.org/storm/cthulhu/sdk/go/tentacle"
|
|
)
|
|
|
|
// A private protocol fixture, not an installable or bundled tentacle.
|
|
func reader_probe_extensions() sdk.Extensions {
|
|
return sdk.Extensions{Pages: []sdk.PreferencePage{{ID: "probe", Title: "Extension probe", Command: "apply", Elements: []sdk.Element{
|
|
{ID: "rate", Label: "Probe rate", Kind: sdk.Entry, Required: true, MaxLength: 3},
|
|
{ID: "enabled", Label: "Probe enabled", Kind: sdk.CheckBox},
|
|
{ID: "choice", Label: "Probe choice", Kind: sdk.List, Items: []sdk.ListItem{{ID: "first", Label: "First choice"}, {ID: "second", Label: "Second choice"}}},
|
|
}, Values: map[string]sdk.Value{"rate": {Text: "70"}, "enabled": {}, "choice": {Selected: "first"}}}},
|
|
QuickSettings: []sdk.QuickSetting{{ID: "probe", Label: "Probe mode", Command: "apply", Selected: "default", Options: []sdk.ListItem{{ID: "default", Label: "Default"}, {ID: "fast", Label: "Fast"}, {ID: "unavailable", Label: "Unavailable"}}}}}
|
|
}
|
|
func TestTentacleReaderExtensionPeer(t *testing.T) {
|
|
if len(os.Args) < 3 || os.Args[len(os.Args)-2] != "reader-extension-peer" {
|
|
return
|
|
}
|
|
mode := os.Args[len(os.Args)-1]
|
|
commands := []sdk.Command{{ID: "apply", Title: "Apply probe", Presentation: true, Interaction: true, Reader: true, Handle: func(ctx context.Context, arguments json.RawMessage) (any, error) {
|
|
var event sdk.ExtensionEvent
|
|
if err := json.Unmarshal(arguments, &event); err != nil {
|
|
return nil, err
|
|
}
|
|
if mode == "reject" {
|
|
return nil, errors.New("rejected")
|
|
}
|
|
if mode == "crash" {
|
|
os.Exit(3)
|
|
}
|
|
if event.Event == "exit" {
|
|
os.Exit(3)
|
|
}
|
|
if mode == "slow" {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(time.Minute):
|
|
return nil, nil
|
|
}
|
|
}
|
|
if event.Event == "preferences.open" {
|
|
if mode == "load-denied" {
|
|
configuration := event.Reader.DefaultSpeech
|
|
return sdk.Interaction{Reader: &sdk.ReaderRequest{Speech: &configuration}}, nil
|
|
}
|
|
if mode == "load-snapshot" {
|
|
return sdk.Interaction{Reader: &sdk.ReaderRequest{Snapshot: true, Module: "alpha"}, Next: "receive"}, nil
|
|
}
|
|
extensions := event.Reader.Extensions
|
|
extensions.Pages[0].Elements[1].Label = "Loaded probe enabled"
|
|
return sdk.Interaction{Reader: &sdk.ReaderRequest{Extensions: &extensions}}, nil
|
|
}
|
|
if event.Event == "preferences.apply" {
|
|
value, err := strconv.ParseFloat(event.Values["rate"].Text, 64)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
configuration := event.Reader.Speech
|
|
configuration.DefaultVoice.Rate = &value
|
|
extensions := event.Reader.Extensions
|
|
extensions.Pages[0].Values = event.Values
|
|
return sdk.Interaction{Reader: &sdk.ReaderRequest{Speech: &configuration, Extensions: &extensions}}, nil
|
|
}
|
|
if event.Event == "quick-setting" {
|
|
if event.Selected == "default" {
|
|
return sdk.Interaction{Reader: &sdk.ReaderRequest{RestoreSpeech: true}}, nil
|
|
}
|
|
configuration := event.Reader.Speech
|
|
rate := 80.0
|
|
configuration.DefaultVoice.Rate = &rate
|
|
if event.Selected == "unavailable" {
|
|
configuration.Module = "unavailable"
|
|
}
|
|
return sdk.Interaction{Reader: &sdk.ReaderRequest{Speech: &configuration}}, nil
|
|
}
|
|
if event.Event == "snapshot" {
|
|
return sdk.Interaction{Reader: &sdk.ReaderRequest{Snapshot: true}, Next: "receive"}, nil
|
|
}
|
|
return nil, errors.New("unknown event")
|
|
}}, {ID: "receive", Title: "Receive probe", Presentation: true, Interaction: true, Reader: true, Handle: func(ctx context.Context, arguments json.RawMessage) (any, error) {
|
|
var submission sdk.Submission
|
|
if err := json.Unmarshal(arguments, &submission); err != nil {
|
|
return nil, err
|
|
}
|
|
if submission.Reader == nil || submission.Error != "" {
|
|
return nil, errors.New("no reader state")
|
|
}
|
|
if mode == "load-snapshot" {
|
|
if len(submission.Reader.Voices) != 1 || submission.Reader.Voices[0].Name != "Example voice" {
|
|
return nil, errors.New("missing voice catalog")
|
|
}
|
|
extensions := submission.Reader.Extensions
|
|
extensions.Pages[0].Elements[1].Label = "Loaded catalog probe"
|
|
return sdk.Interaction{Reader: &sdk.ReaderRequest{Extensions: &extensions}}, nil
|
|
}
|
|
configuration := submission.Reader.DefaultSpeech
|
|
rate := 77.0
|
|
configuration.DefaultVoice.Rate = &rate
|
|
return sdk.Interaction{Reader: &sdk.ReaderRequest{Speech: &configuration}}, nil
|
|
}}}
|
|
extensions := reader_probe_extensions()
|
|
if strings.HasPrefix(mode, "load") {
|
|
extensions.Pages[0].Load = true
|
|
}
|
|
if err := sdk.ServeWithExtensions(os.Stdin, os.Stdout, commands, extensions); err != nil {
|
|
os.Exit(1)
|
|
}
|
|
os.Exit(0)
|
|
}
|
|
|
|
type readerSpeechRecorder struct {
|
|
values []config.Settings
|
|
failure string
|
|
hook func()
|
|
}
|
|
|
|
func (recorder *readerSpeechRecorder) UpdateConfiguration(ctx context.Context, next config.Settings) error {
|
|
if recorder.hook != nil {
|
|
recorder.hook()
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if next.SpeechOutputModule == recorder.failure && recorder.failure != "" {
|
|
return errors.New("module unavailable")
|
|
}
|
|
recorder.values = append(recorder.values, copy_reader_value(next))
|
|
return nil
|
|
}
|
|
func reader_extension_fixture(t *testing.T, mode string) (*preferencesWorker, *tentacle.Session, *readerSpeechRecorder) {
|
|
t.Helper()
|
|
manager, err := tentacle.NewManager(tentacle.DefaultLimits(), nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
defer cancel()
|
|
if err := manager.Shutdown(ctx); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
executable, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session, err := manager.Start(t.Context(), tentacle.Spec{ID: "reader-probe", Program: []string{executable, "-test.run=^TestTentacleReaderExtensionPeer$", "--", "reader-extension-peer", mode}, Presentation: true, Interaction: true, ReaderSettings: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case <-session.Ready():
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("handshake timed out")
|
|
}
|
|
if err := session.Activate(); err != nil {
|
|
state, reason := session.Status()
|
|
t.Fatal(err, state, reason)
|
|
}
|
|
runtime := newTentacleRuntime(slog.New(slog.DiscardHandler), &tentacleBindingRecorder{}, "", "")
|
|
runtime.output = tentacleOutputFixture(t)
|
|
runtime.publishCommands([]*tentacle.Session{session})
|
|
settings := config.DefaultSettings()
|
|
settings.Path = filepath.Join(t.TempDir(), "settings.toml")
|
|
rate := 50.0
|
|
settings.DefaultVoice.Rate = &rate
|
|
recorder := &readerSpeechRecorder{failure: "unavailable"}
|
|
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, recorder)
|
|
worker.tentacles = runtime
|
|
runtime.readerSettings = worker
|
|
worker.live = &liveSettings{}
|
|
worker.live.Store(settings)
|
|
worker.discover = func(context.Context, string) (speech.Catalog, error) {
|
|
return speech.Catalog{"alpha": {{Name: "Example voice", Language: "en"}}}, nil
|
|
}
|
|
worker.sync_extensions(t.Context())
|
|
return worker, session, recorder
|
|
}
|
|
func reader_page_request(worker *preferencesWorker, rate string) preferences.Request {
|
|
page := worker.extension_pages()[0]
|
|
values := copy_reader_value(page.Values)
|
|
values["rate"] = interaction.Value{Text: rate}
|
|
return preferences.Request{Values: preferences.FromSettings(worker.settings), ExtensionPages: []preferences.PageDraft{{Session: page.Session, ID: page.ID, Revision: page.Revision, Values: values}}}
|
|
}
|
|
func TestReaderExtensionPageApplyCancelSaveAndReopen(t *testing.T) {
|
|
worker, session, recorder := reader_extension_fixture(t, "normal")
|
|
before := copy_reader_value(worker.settings)
|
|
// Creating or editing a draft has no effect until Apply reaches the owner.
|
|
request := reader_page_request(worker, "73")
|
|
if len(recorder.values) != 0 || worker.speechOverride != nil {
|
|
t.Fatal("draft changed runtime")
|
|
}
|
|
request.Save = true
|
|
response := worker.apply(t.Context(), request)
|
|
if !response.Accepted {
|
|
t.Fatal(response.Message)
|
|
}
|
|
if len(recorder.values) != 1 || *recorder.values[0].DefaultVoice.Rate != 73 || *worker.live.Load().DefaultVoice.Rate != 73 {
|
|
t.Fatal("override was not applied")
|
|
}
|
|
if !reflect.DeepEqual(worker.settings, before) {
|
|
t.Fatal("override overwrote base settings")
|
|
}
|
|
if worker.extension_pages()[0].Values["rate"].Text != "73" {
|
|
t.Fatal("draft not committed")
|
|
}
|
|
store, _ := config.NewStore(worker.settings.Path)
|
|
saved, err := store.Load()
|
|
if err != nil || *saved.DefaultVoice.Rate != 50 {
|
|
t.Fatal("override was persisted as normal speech", err)
|
|
}
|
|
persisted, err := (&tentacle.PageStore{Path: worker.pageStore.Path}).Load()
|
|
if err != nil || persisted[session.TentacleID()]["probe"]["rate"].Text != "73" {
|
|
t.Fatal(persisted, err)
|
|
}
|
|
// Re-registering after a new session loads only saved page values, not speech.
|
|
worker.extensions = map[string]*readerExtension{}
|
|
worker.speechOverride, worker.speechOverrideOwner = nil, ""
|
|
worker.sync_extensions(t.Context())
|
|
if worker.extension_pages()[0].Values["rate"].Text != "73" || worker.speechOverride != nil {
|
|
t.Fatal("page restoration activated speech")
|
|
}
|
|
response = worker.apply(t.Context(), request)
|
|
if response.Accepted {
|
|
t.Fatal("accepted a stale revision")
|
|
}
|
|
}
|
|
func TestReaderExtensionQuickNavigationRollbackDefaultAndBaseEdits(t *testing.T) {
|
|
worker, _, recorder := reader_extension_fixture(t, "normal")
|
|
var messages []string
|
|
worker.announce = func(message string) { messages = append(messages, message) }
|
|
worker.speechSetting = 4
|
|
worker.handleSpeechCommand(t.Context(), input.SelectNextSpeechSetting)
|
|
if messages[len(messages)-1] != "Probe mode Default" {
|
|
t.Fatal(messages)
|
|
}
|
|
worker.handleSpeechCommand(t.Context(), input.IncreaseSpeechSetting)
|
|
if *worker.live.Load().DefaultVoice.Rate != 80 || *worker.settings.DefaultVoice.Rate != 50 {
|
|
t.Fatal("quick override corrupted base")
|
|
}
|
|
worker.handleSpeechCommand(t.Context(), input.IncreaseSpeechSetting)
|
|
if messages[len(messages)-1] != "Probe mode was not changed" || worker.quick_settings()[0].setting.Selected != "fast" || *worker.live.Load().DefaultVoice.Rate != 80 {
|
|
t.Fatal("failed switch committed")
|
|
}
|
|
// Built-in changes edit the active temporary override, not the normal voice.
|
|
worker.quickSettingKey = ""
|
|
worker.speechSetting = 0
|
|
worker.handleSpeechCommand(t.Context(), input.IncreaseSpeechSetting)
|
|
if *worker.live.Load().DefaultVoice.Rate != 81 || *worker.settings.DefaultVoice.Rate != 50 {
|
|
t.Fatal("built-in adjustment changed base")
|
|
}
|
|
// A core preference edit still targets and saves the ordinary settings.
|
|
next := worker.settings
|
|
pitch := 6.0
|
|
next.DefaultVoice.AveragePitch = &pitch
|
|
response := worker.applyNext(t.Context(), next, false, false)
|
|
if !response.Accepted || *worker.live.Load().DefaultVoice.Rate != 81 {
|
|
t.Fatal(response.Message)
|
|
}
|
|
worker.quickSettingKey = worker.quick_settings()[0].key
|
|
worker.handleSpeechCommand(t.Context(), input.DecreaseSpeechSetting)
|
|
if worker.speechOverride != nil || *worker.live.Load().DefaultVoice.Rate != 50 || *worker.live.Load().DefaultVoice.AveragePitch != 6 {
|
|
t.Fatal("default did not restore edited base")
|
|
}
|
|
if len(recorder.values) < 3 {
|
|
t.Fatal("backend not exercised")
|
|
}
|
|
}
|
|
func TestReaderExtensionRejectionCrashAndFailedApplyLeaveDraftUncommitted(t *testing.T) {
|
|
for _, mode := range []string{"reject", "crash", "normal"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
worker, _, recorder := reader_extension_fixture(t, mode)
|
|
request := reader_page_request(worker, "74")
|
|
if mode == "normal" {
|
|
worker.braille = &recordingBrailleSettings{err: errors.New("braille failed")}
|
|
request.Values.EnableBraille = !worker.settings.EnableBraille
|
|
}
|
|
response := worker.apply(t.Context(), request)
|
|
if response.Accepted || worker.speechOverride != nil || *worker.live.Load().DefaultVoice.Rate != 50 {
|
|
t.Fatal("failed apply committed", response.Message)
|
|
}
|
|
if mode == "normal" {
|
|
if len(recorder.values) != 2 || *recorder.values[0].DefaultVoice.Rate != 74 || *recorder.values[1].DefaultVoice.Rate != 50 {
|
|
t.Fatal("speech not rolled back after later failure", recorder.values)
|
|
}
|
|
if worker.extension_pages()[0].Values["rate"].Text != "70" {
|
|
t.Fatal("page values committed on failure")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func TestReaderExtensionRevocationRestoresAndRemovesContributions(t *testing.T) {
|
|
worker, session, _ := reader_extension_fixture(t, "normal")
|
|
worker.quickSettingKey = worker.quick_settings()[0].key
|
|
value := tentaclewire.SpeechFromSettings(worker.settings)
|
|
rate := 82.0
|
|
value.DefaultVoice.Rate = &rate
|
|
if _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Speech: &value}, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session.Disable()
|
|
worker.sync_extensions(t.Context())
|
|
if worker.speechOverride != nil || len(worker.extension_pages()) != 0 || len(worker.quick_settings()) != 0 || *worker.live.Load().DefaultVoice.Rate != 50 {
|
|
t.Fatal("revoked contributions survived")
|
|
}
|
|
if _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Speech: &value}, false); err == nil {
|
|
t.Fatal("late effect applied")
|
|
}
|
|
message := ""
|
|
worker.announce = func(text string) { message = text }
|
|
worker.handleSpeechCommand(t.Context(), input.IncreaseSpeechSetting)
|
|
if message != "The selected speech setting is no longer available" || worker.settings.DefaultVoice.Family != nil {
|
|
t.Fatal("a removed quick setting silently adjusted a different setting", message)
|
|
}
|
|
}
|
|
func TestReaderExtensionRevocationDuringBackendPreparation(t *testing.T) {
|
|
worker, session, recorder := reader_extension_fixture(t, "normal")
|
|
value := tentaclewire.SpeechFromSettings(worker.settings)
|
|
rate := 82.0
|
|
value.DefaultVoice.Rate = &rate
|
|
recorder.hook = func() { session.Disable() }
|
|
_, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Speech: &value}, false)
|
|
if err == nil || worker.speechOverride != nil || *worker.live.Load().DefaultVoice.Rate != 50 {
|
|
t.Fatal("revoked update committed", err)
|
|
}
|
|
}
|
|
func TestReaderExtensionSnapshotBoundsAndPreferenceWindowGate(t *testing.T) {
|
|
worker, session, _ := reader_extension_fixture(t, "normal")
|
|
worker.discover = func(context.Context, string) (speech.Catalog, error) {
|
|
voices := make([]speech.Voice, 10000)
|
|
for index := range voices {
|
|
voices[index] = speech.Voice{Name: strings.Repeat("Voice", 40), Language: "en"}
|
|
}
|
|
return speech.Catalog{"alpha": voices, "beta": nil}, nil
|
|
}
|
|
state, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Snapshot: true, Module: "alpha"}, true)
|
|
if err != nil || !state.CatalogTruncated || len(state.Voices) == 0 || len(state.Modules) != 2 {
|
|
t.Fatal(state, err)
|
|
}
|
|
data, _ := json.Marshal(tentaclewire.Submission{Reader: state})
|
|
if len(data) > tentacle.MaxReaderArgumentBytes {
|
|
t.Fatal("oversized continuation")
|
|
}
|
|
value := state.Speech
|
|
if _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Speech: &value}, true); err == nil {
|
|
t.Fatal("changed speech behind preferences draft")
|
|
}
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
cancel()
|
|
if _, err := worker.apply_reader_request(ctx, session, tentaclewire.ReaderRequest{Speech: &value}, false); err == nil {
|
|
t.Fatal("cancelled request applied")
|
|
}
|
|
page := worker.extension_pages()[0]
|
|
page.Values["rate"] = interaction.Value{Text: "1"}
|
|
if worker.extension_pages()[0].Values["rate"].Text != "70" {
|
|
t.Fatal("snapshot aliases page values")
|
|
}
|
|
}
|
|
func TestReaderExtensionSlowCallbackTimesOutAndRetiresSession(t *testing.T) {
|
|
worker, session, _ := reader_extension_fixture(t, "slow")
|
|
ctx, cancel := context.WithTimeout(t.Context(), 40*time.Millisecond)
|
|
defer cancel()
|
|
response := worker.apply(ctx, reader_page_request(worker, "75"))
|
|
if response.Accepted || worker.speechOverride != nil {
|
|
t.Fatal("timeout applied")
|
|
}
|
|
waitTentacle(t, func() bool { state, _ := session.Status(); return state != tentacle.Active })
|
|
worker.sync_extensions(t.Context())
|
|
if len(worker.extension_pages()) != 0 {
|
|
t.Fatal("timed-out page survived")
|
|
}
|
|
}
|
|
func TestReaderExtensionWorkerMailboxAndProtocolContinuation(t *testing.T) {
|
|
worker, session, _ := reader_extension_fixture(t, "normal")
|
|
// The real runtime interaction asks the worker for a snapshot, receives it
|
|
// through the protocol continuation, and proposes a speech change.
|
|
if err := worker.Start(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
defer cancel()
|
|
if err := worker.Stop(ctx); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
command := tentacleCommand{session: session, id: "apply", reader: true, interactive: true, presentation: true}
|
|
raw, _ := json.Marshal(sdk.Interaction{Reader: &sdk.ReaderRequest{Snapshot: true}, Next: "receive"})
|
|
worker.tentacles.interact(t.Context(), command, raw)
|
|
// Query through the same owner mailbox; do not read mutable worker state.
|
|
state, err := worker.request_reader(t.Context(), session, tentaclewire.ReaderRequest{Snapshot: true})
|
|
if err != nil || state.Speech.DefaultVoice.Rate == nil || *state.Speech.DefaultVoice.Rate != 77 {
|
|
t.Fatal(state, err)
|
|
}
|
|
session.Disable()
|
|
worker.tentacles.signal_extensions()
|
|
// Sync is owner-serialized by the next request, proving queued late work is rejected.
|
|
if _, err := worker.request_reader(t.Context(), session, tentaclewire.ReaderRequest{RestoreSpeech: true}); err == nil {
|
|
t.Fatal("revoked mailbox work accepted")
|
|
}
|
|
}
|
|
func TestReaderExtensionPersistFailureReportsSessionOnly(t *testing.T) {
|
|
worker, _, _ := reader_extension_fixture(t, "normal")
|
|
if err := os.WriteFile(worker.pageStore.Path, []byte(`{}`), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request := reader_page_request(worker, "76")
|
|
request.Save = true
|
|
request.Close = true
|
|
response := worker.apply(t.Context(), request)
|
|
if !response.Accepted || response.Close || !strings.Contains(response.Message, "could not be saved") {
|
|
t.Fatal(response)
|
|
}
|
|
data, _ := os.ReadFile(worker.pageStore.Path)
|
|
if !bytes.Equal(data, []byte(`{}`)) {
|
|
t.Fatal("overwrote external changes")
|
|
}
|
|
}
|
|
|
|
func TestReaderExtensionApplicationOverlayAndDisabledSpeech(t *testing.T) {
|
|
worker, session, recorder := reader_extension_fixture(t, "normal")
|
|
value := tentaclewire.SpeechFromSettings(worker.settings)
|
|
rate := 83.0
|
|
value.DefaultVoice.Rate = &rate
|
|
if _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Speech: &value}, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
appSettings := worker.settings
|
|
appRate := 61.0
|
|
appSettings.DefaultVoice.Rate = &appRate
|
|
appSettings.SpeechVerbosityLevel = 0
|
|
worker.appSettings["Example application"] = appSettings
|
|
worker.switchApplication(t.Context(), "Example application")
|
|
if *worker.live.Load().DefaultVoice.Rate != 83 || worker.live.Load().SpeechVerbosityLevel != 0 {
|
|
t.Fatal("application switch lost override or app preferences")
|
|
}
|
|
if _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{RestoreSpeech: true}, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if *worker.live.Load().DefaultVoice.Rate != 61 {
|
|
t.Fatal("did not restore current application's voice")
|
|
}
|
|
next := worker.settings
|
|
next.EnableSpeech = false
|
|
if err := worker.applyRuntime(t.Context(), next); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
count := len(recorder.values)
|
|
if _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Speech: &value}, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(recorder.values) != count || worker.live.Load().EnableSpeech {
|
|
t.Fatal("override enabled or connected disabled speech")
|
|
}
|
|
next.EnableSpeech = true
|
|
if err := worker.applyRuntime(t.Context(), next); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(recorder.values) != count+1 || *recorder.values[len(recorder.values)-1].DefaultVoice.Rate != 83 {
|
|
t.Fatal("reenabling did not apply pending override")
|
|
}
|
|
next.EnableSpeech = false
|
|
if err := worker.applyRuntime(t.Context(), next); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
count = len(recorder.values)
|
|
session.Disable()
|
|
worker.sync_extensions(t.Context())
|
|
if worker.speechOverride != nil || len(recorder.values) != count {
|
|
t.Fatal("disabled speech required a backend restoration")
|
|
}
|
|
}
|
|
func TestReaderExtensionOwnershipLockAndAtomicContributionUpdate(t *testing.T) {
|
|
worker, first, _ := reader_extension_fixture(t, "normal")
|
|
_, second, _ := reader_extension_fixture(t, "other")
|
|
worker.tentacles.publishCommands([]*tentacle.Session{first, second})
|
|
worker.sync_extensions(t.Context())
|
|
value := tentaclewire.SpeechFromSettings(worker.settings)
|
|
rate := 85.0
|
|
value.DefaultVoice.Rate = &rate
|
|
if _, err := worker.apply_reader_request(t.Context(), first, tentaclewire.ReaderRequest{Speech: &value}, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, request := range []tentaclewire.ReaderRequest{{Speech: &value}, {RestoreSpeech: true}} {
|
|
if _, err := worker.apply_reader_request(t.Context(), second, request, false); err == nil {
|
|
t.Fatal("another session changed the owner's override")
|
|
}
|
|
}
|
|
extensions := copy_reader_value(worker.extensions[first.Identity()].value)
|
|
extensions.QuickSettings[0].Label = "Renamed probe"
|
|
value.Module = "unavailable"
|
|
if _, err := worker.apply_reader_request(t.Context(), first, tentaclewire.ReaderRequest{Speech: &value, Extensions: &extensions}, false); err == nil {
|
|
t.Fatal("unavailable module accepted")
|
|
}
|
|
if worker.extensions[first.Identity()].value.QuickSettings[0].Label != "Probe mode" {
|
|
t.Fatal("partial contribution update committed")
|
|
}
|
|
worker.tentacles.output.gate.secure.Store(true)
|
|
if _, err := worker.apply_reader_request(t.Context(), first, tentaclewire.ReaderRequest{RestoreSpeech: true}, false); err == nil {
|
|
t.Fatal("locked reader accepted request")
|
|
}
|
|
worker.tentacles.output.gate.secure.Store(false)
|
|
worker.tentacles.output.gate.suspended.Store(true)
|
|
if _, err := worker.apply_reader_request(t.Context(), first, tentaclewire.ReaderRequest{Snapshot: true}, false); err == nil {
|
|
t.Fatal("suspended reader exposed state")
|
|
}
|
|
worker.tentacles.output.gate.suspended.Store(false)
|
|
}
|
|
func TestReaderExtensionRestorationFailureRetainsWorkingVoiceAndReports(t *testing.T) {
|
|
worker, session, recorder := reader_extension_fixture(t, "normal")
|
|
value := tentaclewire.SpeechFromSettings(worker.settings)
|
|
value.Module = "alpha"
|
|
if _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Speech: &value}, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
worker.settings.SpeechOutputModule = "unavailable"
|
|
message := ""
|
|
worker.announce = func(text string) { message = text }
|
|
session.Disable()
|
|
worker.sync_extensions(t.Context())
|
|
if worker.speechOverride == nil || worker.live.Load().SpeechOutputModule != "alpha" || !strings.Contains(message, "could not be restored") {
|
|
t.Fatal("failed restoration lost working speech", message)
|
|
}
|
|
if len(worker.extension_pages()) != 0 || len(worker.quick_settings()) != 0 {
|
|
t.Fatal("revoked UI survived failed restoration")
|
|
}
|
|
recorder.failure = ""
|
|
worker.sync_extensions(t.Context())
|
|
if worker.speechOverride != nil || worker.live.Load().SpeechOutputModule != "unavailable" {
|
|
t.Fatal("later restoration did not recover")
|
|
}
|
|
}
|
|
|
|
func TestReaderExtensionManagedLifecycleRestoresWithoutAnotherCommand(t *testing.T) {
|
|
for _, ending := range []string{"disable", "remove", "exit", "emergency"} {
|
|
t.Run(ending, func(t *testing.T) {
|
|
directory := t.TempDir()
|
|
runtime, _ := managed_tentacle_fixture(t, filepath.Join(directory, "tentacles.toml"), false)
|
|
runtime.output = tentacleOutputFixture(t)
|
|
settings := config.DefaultSettings()
|
|
settings.Path = filepath.Join(directory, "settings.toml")
|
|
baseRate := 50.0
|
|
settings.DefaultVoice.Rate = &baseRate
|
|
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, &readerSpeechRecorder{})
|
|
worker.tentacles = runtime
|
|
runtime.readerSettings = worker
|
|
worker.live = &liveSettings{}
|
|
worker.live.Store(settings)
|
|
worker.discover = func(context.Context, string) (speech.Catalog, error) { return speech.Catalog{}, nil }
|
|
if err := runtime.Start(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := worker.Start(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
|
|
defer cancel()
|
|
if err := worker.Stop(ctx); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
executable, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entry := tentacle.Entry{ID: "managed-reader-probe", Enabled: true, Presentation: true, Interaction: true, ReaderSettings: true, Program: []string{executable, "-test.run=^TestTentacleReaderExtensionPeer$", "--", "reader-extension-peer", "normal"}}
|
|
manage_tentacle(t, runtime, "add", entry.ID, &entry)
|
|
sessions := runtime.reader_sessions()
|
|
if len(sessions) != 1 {
|
|
t.Fatal("reader session not published", runtime.Snapshot())
|
|
}
|
|
var session *tentacle.Session
|
|
for _, value := range sessions {
|
|
session = value
|
|
}
|
|
configuration := tentaclewire.SpeechFromSettings(settings)
|
|
rate := 87.0
|
|
configuration.DefaultVoice.Rate = &rate
|
|
if _, err := worker.request_reader(t.Context(), session, tentaclewire.ReaderRequest{Speech: &configuration}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if *worker.live.Load().DefaultVoice.Rate != 87 {
|
|
t.Fatal("override not active")
|
|
}
|
|
switch ending {
|
|
case "disable", "remove":
|
|
manage_tentacle(t, runtime, ending, entry.ID, nil)
|
|
case "exit":
|
|
replies, err := session.Invoke(t.Context(), "apply", json.RawMessage(`{"event":"exit"}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result := <-replies; result.Err == nil {
|
|
t.Fatal("child did not exit")
|
|
}
|
|
case "emergency":
|
|
runtime.Disable()
|
|
}
|
|
// Observe the independent atomic snapshot only. No request or command
|
|
// is sent to the preferences worker to trigger reconciliation.
|
|
waitTentacle(t, func() bool {
|
|
return worker.live.Load().DefaultVoice.Rate != nil && *worker.live.Load().DefaultVoice.Rate == 50
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReaderExtensionPageLoadCanInspectCatalogWithoutChangingSpeech(t *testing.T) {
|
|
for _, mode := range []string{"load", "load-snapshot", "load-denied"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
worker, _, recorder := reader_extension_fixture(t, mode)
|
|
response, err := worker.initial_response(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(recorder.values) != 0 || worker.speechOverride != nil {
|
|
t.Fatal("opening preferences changed speech")
|
|
}
|
|
if len(response.ExtensionPages) != 1 || response.ExtensionPages[0].Values["rate"].Text != "70" {
|
|
t.Fatal("load discarded page values")
|
|
}
|
|
label := response.ExtensionPages[0].Elements[1].Label
|
|
switch mode {
|
|
case "load":
|
|
if label != "Loaded probe enabled" {
|
|
t.Fatal(label)
|
|
}
|
|
case "load-snapshot":
|
|
if label != "Loaded catalog probe" {
|
|
t.Fatal("catalog continuation did not refresh page", label)
|
|
}
|
|
case "load-denied":
|
|
if label != "Probe enabled" || !strings.Contains(response.Message, "could not load") {
|
|
t.Fatal("speech-changing load was not rejected", label, response.Message)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|