diff --git a/README.md b/README.md index 8775b3b6..6c184872 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/fenrir/main.go b/cmd/fenrir/main.go index 740bdc59..45423d50 100644 --- a/cmd/fenrir/main.go +++ b/cmd/fenrir/main.go @@ -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 } diff --git a/cmd/fenrir/main_test.go b/cmd/fenrir/main_test.go index 0bf28238..73f1380e 100644 --- a/cmd/fenrir/main_test.go +++ b/cmd/fenrir/main_test.go @@ -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()}, diff --git a/config/settings/settings.toml b/config/settings/settings.toml index a4ea9902..0be39be5 100644 --- a/config/settings/settings.toml +++ b/config/settings/settings.toml @@ -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 = "" diff --git a/docs/fenrir.1 b/docs/fenrir.1 index dede57da..8eafd4d0 100644 --- a/docs/fenrir.1 +++ b/docs/fenrir.1 @@ -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 diff --git a/docs/user.md b/docs/user.md index 97e805b3..5d049215 100644 --- a/docs/user.md +++ b/docs/user.md @@ -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: diff --git a/docs/user.txt b/docs/user.txt index 13c1c237..029e35b9 100644 --- a/docs/user.txt +++ b/docs/user.txt @@ -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: diff --git a/internal/app/attributes_test.go b/internal/app/attributes_test.go index cc6296ec..477a3700 100644 --- a/internal/app/attributes_test.go +++ b/internal/app/attributes_test.go @@ -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 diff --git a/internal/app/commands.go b/internal/app/commands.go index 0e93af44..2c3145cc 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -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, "") diff --git a/internal/app/quick_settings_test.go b/internal/app/quick_settings_test.go index a2ce34d1..7b27c591 100644 --- a/internal/app/quick_settings_test.go +++ b/internal/app/quick_settings_test.go @@ -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) } } diff --git a/internal/app/speech_selection_test.go b/internal/app/speech_selection_test.go index 78ebd6e8..f06a0ca1 100644 --- a/internal/app/speech_selection_test.go +++ b/internal/app/speech_selection_test.go @@ -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) } } diff --git a/internal/app/version_test.go b/internal/app/version_test.go index 8daf4086..deecd903 100644 --- a/internal/app/version_test.go +++ b/internal/app/version_test.go @@ -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}) diff --git a/internal/config/config.go b/internal/config/config.go index 448c1062..c7991efa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c5c0565c..3af5135e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) } } diff --git a/internal/config/migrate.go b/internal/config/migrate.go index 0926c345..5419f7bd 100644 --- a/internal/config/migrate.go +++ b/internal/config/migrate.go @@ -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) } diff --git a/internal/config/startup_test.go b/internal/config/startup_test.go new file mode 100644 index 00000000..07bd106e --- /dev/null +++ b/internal/config/startup_test.go @@ -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) + } +} diff --git a/internal/preferences/settings_test.go b/internal/preferences/settings_test.go index d120428b..1eaffa17 100644 --- a/internal/preferences/settings_test.go +++ b/internal/preferences/settings_test.go @@ -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) } } diff --git a/internal/version/version.go b/internal/version/version.go index 1a2fe431..3d1329de 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -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 } diff --git a/tests/native_terminal.py b/tests/native_terminal.py index edb94317..e538d8f3 100644 --- a/tests/native_terminal.py +++ b/tests/native_terminal.py @@ -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'