After commit c018f5fd29 Fenrir is being switched over to go. If you do not want to be part of the testing process, please switch to master.

This commit is contained in:
Storm Dragon committed 2026-10-27 20:30:55 -04:00
1 parent c018f5fd29
commit 0e50de4c76
1047 files changed
+58364 -63747

No files matched your search

+220
View File
@@ -0,0 +1,220 @@
// 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)
}
}