Files
cthulhu/internal/app/tentacle_reader_test.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)
}
}
})
}
}