Adjusted starting speech rate for new users. Fixed error that could happen when figuring out where to write changes.
This commit is contained in:
1 parent
f989628a5a
commit
3b7a44c224
19 files changed
+163
-49
No files matched your search
@@ -181,8 +181,10 @@ Default settings locations:
|
||||
- Root: `/etc/fenrirscreenreader/settings/settings.toml`.
|
||||
|
||||
Fenrir loads the first existing file, or built-in defaults if neither exists.
|
||||
Use `--config PATH` to select a specific file. To create a personal settings
|
||||
file without opening devices:
|
||||
Python `settings.conf` files are ignored. Regular users save to their personal
|
||||
TOML file even when startup loaded system defaults; root saves to the system
|
||||
file. `--config PATH` explicitly selects both the load and save path.
|
||||
To create a personal settings file without opening devices:
|
||||
|
||||
```bash
|
||||
./fenrir --write-config ~/.local/share/stormux/fenrirscreenreader/settings/settings.toml
|
||||
|
||||
+2
-10
@@ -65,7 +65,7 @@ func run_with_ready(args []string, stdout, stderr io.Writer, ready func() error)
|
||||
checkConfig := flags.Bool("check-config", false, "validate settings and exit without opening devices")
|
||||
writeConfig := flags.String("write-config", "", "atomically write effective settings to PATH and exit")
|
||||
driver := flags.String("speech-driver", "speech-dispatcher", "speech driver ID (overrides presentation.speech.driver)")
|
||||
rate := flags.Int("speech-rate", 80, "Speech Dispatcher rate (-100 to 100)")
|
||||
rate := flags.Int("speech-rate", config.Defaults().Presentation.Speech.Rate, "Speech Dispatcher rate (-100 to 100)")
|
||||
module := flags.String("speech-module", "", "Speech Dispatcher output module (empty uses server default)")
|
||||
debugFile := flags.String("debug-file", "", "append timing and reader-decision diagnostics without text or key values")
|
||||
var devices []string
|
||||
@@ -160,14 +160,6 @@ func run_with_ready(args []string, stdout, stderr io.Writer, ready func() error)
|
||||
slog.Info("reader starting", "pid", os.Getpid(), "terminal_mode", *terminalMode, "at", time.Now())
|
||||
ctx, stop := termination_context()
|
||||
defer stop()
|
||||
if path == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, "fenrir:", err)
|
||||
return 1
|
||||
}
|
||||
path = config.Paths(home, os.Geteuid())[0]
|
||||
}
|
||||
if *terminalMode {
|
||||
err = app.Start_terminal(ctx, settings, path, bindings)
|
||||
} else {
|
||||
@@ -227,7 +219,7 @@ func load_settings(flags *flag.FlagSet, requested string, rate int, module strin
|
||||
if err != nil {
|
||||
return config.Settings{}, "", err
|
||||
}
|
||||
settings, path, err := config.Load(requested, config.Paths(home, os.Geteuid()))
|
||||
settings, path, err := config.Load_startup(requested, config.Paths(home, os.Geteuid()))
|
||||
if err != nil {
|
||||
return config.Settings{}, path, err
|
||||
}
|
||||
|
||||
+12
-1
@@ -19,6 +19,7 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -28,6 +29,16 @@ import (
|
||||
"git.stormux.org/storm/fenrir/internal/version"
|
||||
)
|
||||
|
||||
func TestStartupUsesAccountSaveDestination(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
flags := flag.NewFlagSet("fenrir", flag.ContinueOnError)
|
||||
_, path, err := load_settings(flags, "", config.Defaults().Presentation.Speech.Rate, "", nil, "", "speech-dispatcher", "")
|
||||
if expected := config.Paths(home, os.Geteuid())[0]; err != nil || path != expected {
|
||||
t.Fatalf("save destination=%q, want %q: %v", path, expected, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIWithoutOpeningDevices(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
args []string
|
||||
@@ -35,7 +46,7 @@ func TestCLIWithoutOpeningDevices(t *testing.T) {
|
||||
text string
|
||||
}{
|
||||
{[]string{"--help"}, 0, "keypad 7/8/9"},
|
||||
{[]string{"--help"}, 0, "(default 80)"},
|
||||
{[]string{"--help"}, 0, "(default -20)"},
|
||||
{[]string{"-f", "--help"}, 0, "stay in the foreground"},
|
||||
{[]string{"-f", "--version"}, 0, "fenrir " + version.Label()},
|
||||
{[]string{"--version"}, 0, "fenrir " + version.Label()},
|
||||
|
||||
@@ -23,7 +23,8 @@ hardware_device = ""
|
||||
hardware_baud_rate = 0
|
||||
enabled = true
|
||||
# Shared -100 to 100 scale; hardware drivers map it to native ranges.
|
||||
rate = 80
|
||||
# -20 corresponds to 0.4 on Python Fenrir's 0.0 to 1.0 scale.
|
||||
rate = -20
|
||||
# Empty choices use the server default. Voice names are module-specific.
|
||||
module = ""
|
||||
language = ""
|
||||
|
||||
+5
-3
@@ -448,9 +448,11 @@ Root: \f[CR]/etc/fenrirscreenreader/settings/settings.toml\f[R].
|
||||
\f[CR]\-\-config PATH\f[R]: use that file explicitly.
|
||||
.PP
|
||||
If default files are absent, Fenrir uses built\-in defaults.
|
||||
An explicitly selected file must exist.
|
||||
Saving targets the selected file; if startup used built\-in defaults,
|
||||
saving creates the first default path for that account.
|
||||
Python \f[CR]settings.conf\f[R] files are ignored.
|
||||
Regular users save to their personal TOML file, including when startup
|
||||
loaded system defaults; root saves to the system file.
|
||||
\f[CR]\-\-config PATH\f[R] explicitly selects both the load and save path,
|
||||
and that file must exist at startup.
|
||||
.PP
|
||||
Create a personal settings file and validate it without opening devices:
|
||||
.IP
|
||||
|
||||
+5
-3
@@ -346,9 +346,11 @@ Fenrir loads one settings file rather than merging files.
|
||||
- Root: `/etc/fenrirscreenreader/settings/settings.toml`.
|
||||
- `--config PATH`: use that file explicitly.
|
||||
|
||||
If default files are absent, Fenrir uses built-in defaults. An explicitly
|
||||
selected file must exist. Saving targets the selected file; if startup used
|
||||
built-in defaults, saving creates the first default path for that account.
|
||||
If default files are absent, Fenrir uses built-in defaults. Python
|
||||
`settings.conf` files are ignored. Regular users save to their personal TOML
|
||||
file, including when startup loaded system defaults; root saves to the system
|
||||
file. `--config PATH` explicitly selects both the load and save path, and that
|
||||
file must exist at startup.
|
||||
|
||||
Create a personal settings file and validate it without opening devices:
|
||||
|
||||
|
||||
+5
-4
@@ -272,10 +272,11 @@ options. Fenrir loads one settings file rather than merging files.
|
||||
- Root: /etc/fenrirscreenreader/settings/settings.toml.
|
||||
- --config PATH: use that file explicitly.
|
||||
|
||||
If default files are absent, Fenrir uses built-in defaults. An
|
||||
explicitly selected file must exist. Saving targets the selected file;
|
||||
if startup used built-in defaults, saving creates the first default path
|
||||
for that account.
|
||||
If default files are absent, Fenrir uses built-in defaults. Python
|
||||
settings.conf files are ignored. Regular users save to their personal TOML
|
||||
file, including when startup loaded system defaults; root saves to the system
|
||||
file. --config PATH explicitly selects both the load and save path, and that
|
||||
file must exist at startup.
|
||||
|
||||
Create a personal settings file and validate it without opening devices:
|
||||
|
||||
|
||||
@@ -271,7 +271,7 @@ func TestAttributeSessionToggleQuickSaveAndFreshStartup(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved, _, err := config.Load(path, nil)
|
||||
if err != nil || saved.Reading.Attributes != mode || saved.Reading.AttributeFormat != "fenrirBold" || saved.Presentation.Speech.Rate != 90 {
|
||||
if err != nil || saved.Reading.Attributes != mode || saved.Reading.AttributeFormat != "fenrirBold" || saved.Presentation.Speech.Rate != -10 {
|
||||
t.Fatal("temporary toggle persisted", saved, err)
|
||||
}
|
||||
settings = saved
|
||||
|
||||
@@ -95,7 +95,7 @@ func (a *application) handle_command(id command.ID) {
|
||||
case command.Help:
|
||||
a.ui.open_tutorial(a.options.Bindings, a.options.Scripts, a.settings.live.General.ScriptKeys)
|
||||
case command.Version:
|
||||
a.present(i18n.Format("Fenrir screen reader version {0}-{1}", version.Build, version.Channel), "")
|
||||
a.present(i18n.Format("Fenrir screen reader version {0}-{1}", version.Version, version.Channel), "")
|
||||
case command.Time:
|
||||
text, _ := datetime.Format_time(a.settings.live.Presentation.DateTime.TimeFormat, time.Now())
|
||||
a.present(text, "")
|
||||
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
|
||||
func TestQuickSpeechSelectionAdjustmentAndBounds(t *testing.T) {
|
||||
settings := config.Defaults()
|
||||
settings.Presentation.Speech.Rate = 80 // Exercise the upper rate bound.
|
||||
voice := &fakeVoice{}
|
||||
menu := new_quick_settings("speech-dispatcher")
|
||||
for _, testCase := range []struct {
|
||||
@@ -89,14 +90,14 @@ func TestHardwareQuickSettingsOnlyOfferSupportedControls(t *testing.T) {
|
||||
menu.selectSpeech = func(config.Speech, func(error, bool)) {
|
||||
t.Fatal("hardware numeric adjustment tried reopening the device")
|
||||
}
|
||||
for _, want := range []string{"speech pitch 0", "speech volume 100", "speech rate 80"} {
|
||||
for _, want := range []string{"speech pitch 0", "speech volume 100", "speech rate -20"} {
|
||||
menu.command(command.NextQuickEntry, &settings, voice)
|
||||
if got := voice.messages[len(voice.messages)-1]; got != want {
|
||||
t.Fatal(driver, got, want)
|
||||
}
|
||||
}
|
||||
menu.command(command.NextQuickValue, &settings, voice)
|
||||
if settings.Presentation.Speech.Rate != 90 || voice.parameters[len(voice.parameters)-1].Rate != 90 {
|
||||
if settings.Presentation.Speech.Rate != -10 || voice.parameters[len(voice.parameters)-1].Rate != -10 {
|
||||
t.Fatal(settings, voice.parameters)
|
||||
}
|
||||
}
|
||||
@@ -124,7 +125,7 @@ func TestQuickChangesSurviveMenuCancelAndExplicitSaveThenQuit(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved, _, err := config.Load(path, nil)
|
||||
if err != nil || saved.Presentation.Speech.Rate != 90 || saved.Presentation.Speech.Pitch != -10 || !saved.Reading.Automatic || saved.Reading.Mode != "table" || !reflect.DeepEqual(saved.General, base.General) {
|
||||
if err != nil || saved.Presentation.Speech.Rate != -10 || saved.Presentation.Speech.Pitch != -10 || !saved.Reading.Automatic || saved.Reading.Mode != "table" || !reflect.DeepEqual(saved.General, base.General) {
|
||||
t.Fatal(saved, err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(voice.messages, "|"), "Settings saved.") {
|
||||
@@ -208,7 +209,7 @@ func TestSessionMuteDoesNotPersistAcrossRestart(t *testing.T) {
|
||||
if err != nil || settings.Presentation.Speech.Enabled != trial.enabled {
|
||||
t.Fatal("session mute changed saved speech enablement", settings.Presentation.Speech, err)
|
||||
}
|
||||
if trial.save && settings.Presentation.Speech.Rate != 90 {
|
||||
if trial.save && settings.Presentation.Speech.Rate != -10 {
|
||||
t.Fatal("quick speech adjustment was not saved", settings.Presentation.Speech)
|
||||
}
|
||||
}
|
||||
@@ -277,7 +278,7 @@ func TestSessionAutomaticReadingDoesNotPersistAcrossRestart(t *testing.T) {
|
||||
if err != nil || settings.Reading.Automatic != trial.automatic || !settings.Presentation.Speech.Enabled {
|
||||
t.Fatal("session reading toggle changed saved startup behavior", settings, err)
|
||||
}
|
||||
if trial.save && settings.Presentation.Speech.Rate != 90 {
|
||||
if trial.save && settings.Presentation.Speech.Rate != -10 {
|
||||
t.Fatal("quick adjustment was not saved", settings.Presentation.Speech)
|
||||
}
|
||||
}
|
||||
@@ -305,7 +306,7 @@ func TestSaveFailureKeepsRunningQuickSettings(t *testing.T) {
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("save hung")
|
||||
}
|
||||
if saver.busy || settings.Presentation.Speech.Rate != 90 || voice.messages[len(voice.messages)-1] != "Failed to save settings." {
|
||||
if saver.busy || settings.Presentation.Speech.Rate != -10 || voice.messages[len(voice.messages)-1] != "Failed to save settings." {
|
||||
t.Fatal(settings, voice.messages)
|
||||
}
|
||||
}
|
||||
@@ -341,7 +342,7 @@ func TestQuickCategoriesRememberSelectionAndWrap(t *testing.T) {
|
||||
t.Fatalf("%s: got %q, want %q", trial.id, got, trial.want)
|
||||
}
|
||||
}
|
||||
if menu := new_quick_settings("speech-dispatcher"); !menu.command(command.CurrentQuickEntry, &settings, voice) || voice.messages[len(voice.messages)-1] != "speech rate 80" {
|
||||
if menu := new_quick_settings("speech-dispatcher"); !menu.command(command.CurrentQuickEntry, &settings, voice) || voice.messages[len(voice.messages)-1] != "speech rate -20" {
|
||||
t.Fatal("new session did not reset selection", voice.messages)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ func TestMenuSelectionFailurePreservesDiskAndCommittedSaveSurvivesClose(t *testi
|
||||
t.Fatal("failure changed disk/live", outcome)
|
||||
}
|
||||
} else {
|
||||
if outcome != nil || a.settings.live.Presentation.Speech.Voice != "Base Voice" || a.settings.live.Presentation.Speech.Rate != 90 || a.settings.resetBase.Presentation.Speech.Rate != 80 || a.settings.resetBase.Presentation.Speech.Voice != "Base Voice" {
|
||||
if outcome != nil || a.settings.live.Presentation.Speech.Voice != "Base Voice" || a.settings.live.Presentation.Speech.Rate != -10 || a.settings.resetBase.Presentation.Speech.Rate != -20 || a.settings.resetBase.Presentation.Speech.Voice != "Base Voice" {
|
||||
t.Fatal("committed selection or newer numeric override lost", outcome, a.settings.live, a.settings.resetBase)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"git.stormux.org/storm/fenrir/internal/version"
|
||||
)
|
||||
|
||||
func TestVersionUsesBuildMetadataInBothModes(t *testing.T) {
|
||||
func TestVersionUsesReleaseMetadataInBothModes(t *testing.T) {
|
||||
for _, terminalMode := range []bool{false, true} {
|
||||
voice := &fakeVoice{}
|
||||
a, err := new_application(context.Background(), &fakeSource{}, &fakeKeys{}, voice, RunOptions{Settings: config.Defaults(), TerminalMode: terminalMode})
|
||||
|
||||
@@ -163,7 +163,7 @@ type Reading struct {
|
||||
}
|
||||
|
||||
func Defaults() Settings {
|
||||
return Settings{Screen: Screen{IgnoredVTs: []int{}}, Review: Review{LineBreak: true, EndOfScreen: true, LeaveReviewOnCursorChange: true, LeaveReviewOnScreenChange: true}, Prompt: Prompt{ExactMatches: []string{}, CustomPatterns: []string{}}, Version: 3, Presentation: Presentation{DateTime: DateTime{TimeFormat: "locale", DateFormat: "locale"}, Progress: Progress{Enabled: true}, Sound: Sound{Enabled: true, Volume: 0.7, Driver: "pulse", Theme: "default", Device: "default"}, Speech: Speech{Enabled: true, Rate: 80, Volume: 100, Driver: "speech-dispatcher"}}, Keyboard: Keyboard{MultiPressTimeoutMS: int(input.DefaultMultiPressTimeout / time.Millisecond), Layout: "desktop", CharacterEcho: true}, Reading: Reading{Emoticons: true, RepeatedSymbolsMin: 4, Spellcheck: true, SpellcheckSound: true, Attributes: "none", AttributeFormat: presentation.Default_attribute_format, Indentation: "none", RaisePitchForCapitals: true, Automatic: true, CursorEcho: true, Mode: "cursor", PunctuationProfile: "default", PunctuationLevel: "some"}, Clipboard: Clipboard{NumberOfClipboards: 200, ExportPath: "/tmp/fenrirClipboard"}, General: General{ScriptPath: "/usr/share/fenrirscreenreader/scripts", ScriptKeys: []string{"KEY_COMPOSE"}}, Scripts: Scripts{Workers: 2, QueueLimit: 16, OutputBytes: 1 << 20}, Remote: Remote{Enabled: true, EnableCommandRemote: true, EnableSettingsRemote: true}}
|
||||
return Settings{Screen: Screen{IgnoredVTs: []int{}}, Review: Review{LineBreak: true, EndOfScreen: true, LeaveReviewOnCursorChange: true, LeaveReviewOnScreenChange: true}, Prompt: Prompt{ExactMatches: []string{}, CustomPatterns: []string{}}, Version: 3, Presentation: Presentation{DateTime: DateTime{TimeFormat: "locale", DateFormat: "locale"}, Progress: Progress{Enabled: true}, Sound: Sound{Enabled: true, Volume: 0.7, Driver: "pulse", Theme: "default", Device: "default"}, Speech: Speech{Enabled: true, Rate: -20, Volume: 100, Driver: "speech-dispatcher"}}, Keyboard: Keyboard{MultiPressTimeoutMS: int(input.DefaultMultiPressTimeout / time.Millisecond), Layout: "desktop", CharacterEcho: true}, Reading: Reading{Emoticons: true, RepeatedSymbolsMin: 4, Spellcheck: true, SpellcheckSound: true, Attributes: "none", AttributeFormat: presentation.Default_attribute_format, Indentation: "none", RaisePitchForCapitals: true, Automatic: true, CursorEcho: true, Mode: "cursor", PunctuationProfile: "default", PunctuationLevel: "some"}, Clipboard: Clipboard{NumberOfClipboards: 200, ExportPath: "/tmp/fenrirClipboard"}, General: General{ScriptPath: "/usr/share/fenrirscreenreader/scripts", ScriptKeys: []string{"KEY_COMPOSE"}}, Scripts: Scripts{Workers: 2, QueueLimit: 16, OutputBytes: 1 << 20}, Remote: Remote{Enabled: true, EnableCommandRemote: true, EnableSettingsRemote: true}}
|
||||
}
|
||||
|
||||
func (s Settings) Validate() error {
|
||||
@@ -234,9 +234,8 @@ func (s Settings) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Paths follows Python Fenrir's selection: root uses the system file; a normal
|
||||
// user first checks their personal file, then the system file. Sudo also uses
|
||||
// the system settings, just as Python does.
|
||||
// Paths lists TOML settings in load order. The first path is also the default
|
||||
// save destination: personal settings for a normal user, system settings for root.
|
||||
func Paths(home string, uid int) []string {
|
||||
if uid == 0 {
|
||||
return []string{SystemPath}
|
||||
@@ -293,6 +292,17 @@ func Load(requested string, paths []string) (Settings, string, error) {
|
||||
return Defaults(), "", nil
|
||||
}
|
||||
|
||||
// Load_startup loads settings and returns their save destination. System
|
||||
// fallback settings seed a user's first save without making /etc the destination.
|
||||
// An explicit --config path remains the destination; errors name the source.
|
||||
func Load_startup(requested string, paths []string) (Settings, string, error) {
|
||||
settings, path, err := Load(requested, paths)
|
||||
if err == nil && requested == "" && len(paths) != 0 {
|
||||
path = paths[0]
|
||||
}
|
||||
return settings, path, err
|
||||
}
|
||||
|
||||
// Save validates before touching disk and replaces the file atomically. The
|
||||
// caller retains its last good in-memory settings on a load/save error.
|
||||
func Save(path string, settings Settings) error {
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestSettingsSelectionDoesNotHideInvalidOrExplicitMissingFiles(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, selected, err = Load("", []string{userPath, systemPath})
|
||||
if err != nil || selected != userPath || settings.Presentation.Sound.Enabled || settings.Presentation.Speech.Rate != 80 {
|
||||
if err != nil || selected != userPath || settings.Presentation.Sound.Enabled || settings.Presentation.Speech.Rate != -20 {
|
||||
t.Fatalf("user selection merged system values: %+v %s %v", settings, selected, err)
|
||||
}
|
||||
if err := os.WriteFile(userPath, []byte("invalid = true"), 0600); err != nil {
|
||||
@@ -151,7 +151,7 @@ func TestShippedExampleMatchesDefaults(t *testing.T) {
|
||||
func TestSpeechParameterDefaultsAndBounds(t *testing.T) {
|
||||
for _, data := range []string{"version=1\n", "version=2\n", "version=3\n"} {
|
||||
settings, err := Decode([]byte(data))
|
||||
if err != nil || settings.Presentation.Speech.Pitch != 0 || settings.Presentation.Speech.Volume != 100 {
|
||||
if err != nil || settings.Presentation.Speech.Rate != -20 || settings.Presentation.Speech.Pitch != 0 || settings.Presentation.Speech.Volume != 100 {
|
||||
t.Fatal(settings, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func decode_v1(data []byte) (Settings, error) {
|
||||
General General `toml:"general"`
|
||||
Scripts Scripts `toml:"scripts"`
|
||||
Remote Remote `toml:"remote"`
|
||||
}{Review: defaults.Review, Version: 1, Speech: oldSpeech{Enabled: true, Rate: 80}, Sound: defaults.Presentation.Sound, Keyboard: defaults.Keyboard, Reading: defaults.Reading, Clipboard: defaults.Clipboard, General: defaults.General, Scripts: defaults.Scripts, Remote: defaults.Remote}
|
||||
}{Review: defaults.Review, Version: 1, Speech: oldSpeech{Enabled: true, Rate: defaults.Presentation.Speech.Rate}, Sound: defaults.Presentation.Sound, Keyboard: defaults.Keyboard, Reading: defaults.Reading, Clipboard: defaults.Clipboard, General: defaults.General, Scripts: defaults.Scripts, Remote: defaults.Remote}
|
||||
if err := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields().Decode(&old); err != nil {
|
||||
return Settings{}, fmt.Errorf("parse version 1 settings: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStartupSaveDestinationAndLegacySettingsIgnored(t *testing.T) {
|
||||
for _, scenario := range []string{"defaults", "system fallback", "personal", "explicit", "root"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
paths := Paths(root, 1000)
|
||||
userPath := paths[0]
|
||||
// Substitute an isolated system directory without touching /etc.
|
||||
systemPath := filepath.Join(root, "etc", "settings.toml")
|
||||
paths[1] = systemPath
|
||||
legacyPath := filepath.Join(filepath.Dir(userPath), "settings.conf")
|
||||
legacy := []byte("[speech]\nrate = 19\n")
|
||||
if err := os.MkdirAll(filepath.Dir(userPath), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(legacyPath, legacy, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requested, destination, rate := "", userPath, -20
|
||||
if scenario != "defaults" {
|
||||
system := Defaults()
|
||||
system.Presentation.Speech.Rate = 30
|
||||
if err := Save(systemPath, system); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rate = 30
|
||||
}
|
||||
switch scenario {
|
||||
case "personal":
|
||||
personal := Defaults()
|
||||
personal.Presentation.Speech.Rate = 40
|
||||
if err := Save(userPath, personal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rate = 40
|
||||
case "explicit":
|
||||
requested, destination = systemPath, systemPath
|
||||
case "root":
|
||||
paths, destination = []string{systemPath}, systemPath
|
||||
}
|
||||
systemBefore, _ := os.ReadFile(systemPath)
|
||||
settings, path, err := Load_startup(requested, paths)
|
||||
if err != nil || path != destination || settings.Presentation.Speech.Rate != rate {
|
||||
t.Fatalf("startup: destination=%q rate=%d err=%v", path, settings.Presentation.Speech.Rate, err)
|
||||
}
|
||||
// Follow the settings menu's document save workflow, then restart.
|
||||
doc, err := Open_document(path, settings)
|
||||
if err != nil || doc.Settings.Presentation.Speech.Rate != rate {
|
||||
t.Fatalf("open settings: %+v %v", doc, err)
|
||||
}
|
||||
settings.Presentation.Speech.Rate = 55
|
||||
if err := doc.Save(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restarted, path, err := Load_startup(requested, paths)
|
||||
if err != nil || path != destination || restarted.Presentation.Speech.Rate != 55 {
|
||||
t.Fatalf("restart: destination=%q rate=%d err=%v", path, restarted.Presentation.Speech.Rate, err)
|
||||
}
|
||||
if destination != systemPath {
|
||||
systemAfter, _ := os.ReadFile(systemPath)
|
||||
if !bytes.Equal(systemBefore, systemAfter) {
|
||||
t.Fatal("personal save modified the system file")
|
||||
}
|
||||
}
|
||||
if data, err := os.ReadFile(legacyPath); err != nil || !bytes.Equal(data, legacy) {
|
||||
t.Fatal("legacy settings changed", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartupErrorsIdentifySource(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
userPath, systemPath := filepath.Join(root, "user.toml"), filepath.Join(root, "system.toml")
|
||||
if err := os.WriteFile(systemPath, []byte("invalid=true"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, path, err := Load_startup("", []string{userPath, systemPath}); err == nil || path != systemPath {
|
||||
t.Fatal("invalid fallback did not identify source", path, err)
|
||||
}
|
||||
if _, path, err := Load_startup(userPath, []string{systemPath}); err == nil || path != userPath {
|
||||
t.Fatal("explicit missing file did not identify source", path, err)
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ func TestSettingsFormCoversSchemaAndReturnsIsolatedDraft(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if draft.Presentation.Speech.Enabled || draft.Presentation.Speech.Rate != 81 || !base.Presentation.Speech.Enabled || base.Presentation.Speech.Rate != 80 {
|
||||
if draft.Presentation.Speech.Enabled || draft.Presentation.Speech.Rate != -19 || !base.Presentation.Speech.Enabled || base.Presentation.Speech.Rate != -20 {
|
||||
t.Fatal("draft isolation", draft, base)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,9 @@
|
||||
package version
|
||||
|
||||
// Keep editable release metadata here, following Cthulhu's version package.
|
||||
const Version = "2026.10.27"
|
||||
const Version = "2026.10.28"
|
||||
const Channel = "testing"
|
||||
|
||||
// Build defaults to Version; release builders can override it using -ldflags -X.
|
||||
var Build = Version
|
||||
|
||||
func Label() string {
|
||||
return Build + "-" + Channel
|
||||
return Version + "-" + Channel
|
||||
}
|
||||
@@ -330,7 +330,7 @@ def main():
|
||||
server = SpeechServer(root / "speech.sock")
|
||||
(root / "settings.toml").write_text(
|
||||
'version=3\n[presentation.sound]\nenabled=false\n'
|
||||
'[presentation.speech]\n'
|
||||
'[presentation.speech]\nrate=80\n'
|
||||
'[presentation.time_and_date]\ntime_format="24-hour-words"\ndate_format="numbers-ymd"\n'
|
||||
'[screen]\nignored_vts=[1,7,8,10,63]\n'
|
||||
f'[remote]\nsocket_file="{root / "remote.sock"}"\n'
|
||||
|
||||
Reference in new issue
Block a user