221 lines
10 KiB
Go
221 lines
10 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/fenrir/internal/command"
|
|
"git.stormux.org/storm/fenrir/internal/config"
|
|
"git.stormux.org/storm/fenrir/internal/control"
|
|
"git.stormux.org/storm/fenrir/internal/input"
|
|
"git.stormux.org/storm/fenrir/internal/screen"
|
|
)
|
|
|
|
func persistence_application(t *testing.T, terminalMode bool) (*application, *fakeVoice) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
t.Cleanup(cancel)
|
|
base := config.Defaults()
|
|
base.Presentation.Speech.Rate = 24
|
|
base.Screen.IgnoredVTs = []int{2}
|
|
path := filepath.Join(t.TempDir(), "settings.toml")
|
|
if err := config.Save(path, base); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
voice := &fakeVoice{}
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true, Rows: 1, Cols: 5, Cells: []rune("alpha")}}
|
|
keys := &fakeKeys{poll: func(bool, func(input.Decision)) error { return nil }}
|
|
a, err := new_application(ctx, source, keys, voice, RunOptions{Settings: base, ConfigPath: path, TerminalMode: terminalMode, Sounds: &fakeSounds{}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(a.ui.close)
|
|
if err := a.read_screen(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return a, voice
|
|
}
|
|
|
|
func persistence_request(a *application, text string) chan string {
|
|
reply := make(chan string, 1)
|
|
a.remote_request(control.Request{UID: os.Geteuid(), Text: "SETTING " + text, Reply: reply})
|
|
return reply
|
|
}
|
|
|
|
func persistence_reply(t *testing.T, reply chan string, wantError bool) {
|
|
t.Helper()
|
|
select {
|
|
case text := <-reply:
|
|
if (text != "") != wantError {
|
|
t.Fatal("unexpected persistence response", text, wantError)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("persistence reply missing")
|
|
}
|
|
}
|
|
|
|
func finish_persistence(t *testing.T, a *application, wantError bool) {
|
|
t.Helper()
|
|
select {
|
|
case err := <-a.saver.results:
|
|
if (err != nil) != wantError {
|
|
t.Fatal("unexpected save result", err)
|
|
}
|
|
a.settings_save_result(err)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("save worker stalled")
|
|
}
|
|
}
|
|
|
|
func TestRemoteResetRestoresLoadedValuesAndPreservesSessionState(t *testing.T) {
|
|
for _, terminalMode := range []bool{false, true} {
|
|
a, _ := persistence_application(t, terminalMode)
|
|
base := a.settings.resetBase
|
|
windowReply := make(chan string, 1)
|
|
a.remote_request(control.Request{UID: os.Geteuid(), Text: "COMMAND WINDOW 0 0 4 0", Reply: windowReply})
|
|
persistence_reply(t, windowReply, false)
|
|
regionRevision := a.engine.Region_revision()
|
|
a.settings.saveDraft.Keyboard.Layout = "laptop"
|
|
for _, id := range []command.ID{command.Mute, command.AutoRead, command.ToggleAttributes} {
|
|
a.handle_input(input.Decision{Command: id})
|
|
}
|
|
a.handle_input(input.Decision{Command: command.NextQuickValue})
|
|
persistence_reply(t, persistence_request(a, "SET speech#rate=0.2;sound#volume=0.3;screen#ignore_screen=7,8;general#punctuation_level=all;keyboard#word_echo=True"), false)
|
|
persistence_reply(t, persistence_request(a, "RESET"), false)
|
|
if !a.muted || a.engine.Automatic_reading() || a.settings.live.Presentation.Speech.Rate != base.Presentation.Speech.Rate || !reflect.DeepEqual(a.settings.live.Screen, base.Screen) || a.settings.live.Keyboard.WordEcho || a.settings.live.Presentation.Sound != base.Presentation.Sound || a.settings.live.Reading.PunctuationLevel != base.Reading.PunctuationLevel || a.settings.saveDraft.Keyboard.Layout != "laptop" {
|
|
t.Fatal("reset lost session overrides, loaded values or next-launch choices", a.settings.live, a.settings.saveDraft)
|
|
}
|
|
if got := a.engine.Command(command.ToggleAttributes).Text; got != "announcement of attributes disabled" {
|
|
t.Fatal("unrelated remote update/reset lost the session attribute toggle", got)
|
|
}
|
|
if !a.settings.saveDraft.Presentation.Speech.Enabled || !a.settings.saveDraft.Reading.Automatic || a.quick.changed || len(a.settings.remoteFields) != 0 || a.engine.Region_revision() != regionRevision {
|
|
t.Fatal("reset persisted unrelated session state or retained overrides")
|
|
}
|
|
// A successful menu save becomes the next reset baseline too.
|
|
menuSaved := a.settings.saveDraft
|
|
menuSaved.Presentation.Speech.Rate = 44
|
|
a.apply_saved_settings(menuSaved, false)
|
|
persistence_reply(t, persistence_request(a, "SET speech#rate=0.1;speech#enabled=False;reading#automatic=False;sound#enabled=False;sound#progress_monitoring=False;reading#cursor_echo=False;keyboard#char_echo_mode=2;keyboard#echo_on_capslock=True"), false)
|
|
persistence_reply(t, persistence_request(a, "RESET"), false)
|
|
if a.muted || !a.engine.Automatic_reading() || a.settings.live.Presentation != menuSaved.Presentation || a.settings.live.Keyboard.CharacterEcho != menuSaved.Keyboard.CharacterEcho || a.settings.live.Keyboard.EchoOnCapsLock != menuSaved.Keyboard.EchoOnCapsLock || a.settings.live.Reading.CursorEcho != menuSaved.Reading.CursorEcho {
|
|
t.Fatal("reset did not restore explicitly overridden booleans or menu baseline", a.settings.live)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRemoteSaveAsRepliesAfterWriteAndRetainsNewerOverrides(t *testing.T) {
|
|
for _, terminalMode := range []bool{false, true} {
|
|
a, _ := persistence_application(t, terminalMode)
|
|
defaultPath := a.options.ConfigPath
|
|
exportPath := filepath.Join(t.TempDir(), "export with spaces.toml")
|
|
for _, id := range []command.ID{command.Mute, command.AutoRead} {
|
|
a.handle_input(input.Decision{Command: id})
|
|
}
|
|
persistence_reply(t, persistence_request(a, "SET speech#rate=0.2;sound#volume=0.3"), false)
|
|
reply := persistence_request(a, "SAVEAS "+exportPath)
|
|
select {
|
|
case response := <-reply:
|
|
t.Fatal("save acknowledged before result processing", response)
|
|
default:
|
|
}
|
|
persistence_reply(t, persistence_request(a, "RESET"), true)
|
|
persistence_reply(t, persistence_request(a, "SAVE"), true)
|
|
// Keep input and later SET requests responsive while disk work runs.
|
|
a.handle_input(input.Decision{Command: command.CurrentLine})
|
|
persistence_reply(t, persistence_request(a, "SET speech#rate=0.8;keyboard#word_echo=True"), false)
|
|
finish_persistence(t, a, false)
|
|
persistence_reply(t, reply, false)
|
|
saved, _, err := config.Load(exportPath, nil)
|
|
if err != nil || saved.Presentation.Speech.Rate != -60 || saved.Presentation.Sound.Volume != 0.3 || !saved.Presentation.Speech.Enabled || !saved.Reading.Automatic || saved.Keyboard.WordEcho {
|
|
t.Fatal("save did not retain its admitted snapshot", saved, err)
|
|
}
|
|
info, err := os.Stat(exportPath)
|
|
if err != nil || info.Mode().Perm() != 0600 {
|
|
t.Fatal("export permissions", info, err)
|
|
}
|
|
original, _, err := config.Load(defaultPath, nil)
|
|
if err != nil || original.Presentation.Speech.Rate != 24 || a.options.ConfigPath != defaultPath {
|
|
t.Fatal("SAVEAS changed the default destination", original, err)
|
|
}
|
|
persistence_reply(t, persistence_request(a, "RESET"), false)
|
|
if a.settings.live.Presentation.Speech.Rate != -60 || a.settings.live.Keyboard.WordEcho || !a.muted || a.engine.Automatic_reading() {
|
|
t.Fatal("save completion lost newer changes or reset session state", a.settings.live)
|
|
}
|
|
persistence_reply(t, persistence_request(a, "SET speech#rate=0.9"), false)
|
|
reply = persistence_request(a, "SAVE")
|
|
// The existing normal quit waits for the admitted write and reply.
|
|
a.handle_input(input.Decision{Command: command.Quit})
|
|
if err := a.loop(nil); err != nil || a.ctx.Err() != nil {
|
|
t.Fatal("save/quit failed", err, a.ctx.Err())
|
|
}
|
|
persistence_reply(t, reply, false)
|
|
current, _, err := config.Load(defaultPath, nil)
|
|
if err != nil || current.Presentation.Speech.Rate != 80 {
|
|
t.Fatal("SAVE did not use original configured path", current, err)
|
|
}
|
|
unchanged, _, err := config.Load(exportPath, nil)
|
|
if err != nil || !reflect.DeepEqual(unchanged, saved) {
|
|
t.Fatal("later SAVE overwrote export", unchanged, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRemotePersistenceFailureAndAdmissionPreserveState(t *testing.T) {
|
|
a, _ := persistence_application(t, true)
|
|
base := a.settings.resetBase
|
|
persistence_reply(t, persistence_request(a, "SET speech#pitch=0.2;speech#volume=0.6;speech#auto_read_incoming=False"), false)
|
|
for _, text := range []string{"SAVEAS", "SAVE unexpected", "RESET unexpected", "SAVEAS bad\x00path", "SAVEAS bad\npath"} {
|
|
persistence_reply(t, persistence_request(a, text), true)
|
|
if a.saver.busy {
|
|
t.Fatal("malformed operation started a worker", text)
|
|
}
|
|
}
|
|
a.ui.active = true
|
|
persistence_reply(t, persistence_request(a, "RESET"), true)
|
|
persistence_reply(t, persistence_request(a, "SAVE"), true)
|
|
a.ui.active = false
|
|
a.settings.live.Remote.EnableSettingsRemote = false
|
|
persistence_reply(t, persistence_request(a, "RESET"), true)
|
|
persistence_reply(t, persistence_request(a, "SAVEAS "+filepath.Join(t.TempDir(), "denied.toml")), true)
|
|
a.settings.live.Remote.EnableSettingsRemote = true
|
|
denied := make(chan string, 1)
|
|
a.remote_request(control.Request{UID: os.Geteuid() + 1, Text: "SETTING RESET", Reply: denied})
|
|
persistence_reply(t, denied, true)
|
|
if response, deferred := a.remote_settings_request("SAVEAS ignored.toml", control.Request{UID: os.Geteuid() + 1}); response != "SAVEAS requires the reader owner or root" || deferred || a.saver.busy {
|
|
t.Fatal("SAVEAS granted arbitrary privileged writes", response, deferred)
|
|
}
|
|
reply := persistence_request(a, "SAVEAS "+t.TempDir()) // A directory cannot be a TOML file.
|
|
finish_persistence(t, a, true)
|
|
select {
|
|
case response := <-reply:
|
|
if !strings.HasPrefix(response, "Could not save settings:") {
|
|
t.Fatal(response)
|
|
}
|
|
default:
|
|
t.Fatal("failed write did not return an error")
|
|
}
|
|
if !reflect.DeepEqual(a.settings.resetBase, base) || len(a.settings.remoteFields) == 0 {
|
|
t.Fatal("failed save advanced the reset baseline")
|
|
}
|
|
pipePath := filepath.Join(t.TempDir(), "blocked-settings")
|
|
if err := syscall.Mkfifo(pipePath, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reply = persistence_request(a, "SAVEAS "+pipePath)
|
|
finish_persistence(t, a, true)
|
|
persistence_reply(t, reply, true)
|
|
persistence_reply(t, persistence_request(a, "RESET"), false)
|
|
if a.settings.live.Presentation.Speech != base.Presentation.Speech || !a.engine.Automatic_reading() {
|
|
t.Fatal("failed-save overrides were not reset", a.settings.live)
|
|
}
|
|
}
|