Add "auto save non-read only profiles" preference (silent)

New global setting in Preferences -> General, right after "Browse for
profiles folder": a list "Auto-save non-read-only profiles" with Never /
Every 2 / 5 / 10 / 15 / 20 / 30 minutes. When enabled, RemSound periodically
saves the current profile, but ONLY if it is a real saved profile, is NOT
read-only, and has unsaved changes -- and it saves SILENTLY (no save cue,
no confirmation), so it never interrupts the user.

- AppConfig.AutoSaveNonReadOnlyMinutes (machine-wide, 0 = Never = default).
- SaveProfileTo gains a playCue flag; auto-save passes playCue: false.
- MainForm autoSaveTimer + ApplyAutoSaveTimer + ShouldAutoSave guard,
  re-applied live when the setting changes in Preferences.
- New self-test "Auto-save non-read-only profiles": options list, AppConfig
  persistence, the guard (read-only / blank / unchanged all skipped), and the
  timer turning on/off. Gate 28/28.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-13 23:32:17 +01:00
co-authored by Claude Opus 4.8
parent 71fb065233
commit 26903950f7
4 changed files with 170 additions and 5 deletions
+54 -1
View File
@@ -78,6 +78,7 @@ internal static class SelfTest
RunStep(results, "Dialog accessibility (names + mnemonics)", AccessibilityAudit);
RunStep(results, "Main window coverage (all tabs + controls)", MainWindowCoverage);
RunStep(results, "Main window profile round-trip (controls load + save)", MainWindowProfileRoundTrip);
RunStep(results, "Auto-save non-read-only profiles (options + guard + silent timer)", AutoSaveNonReadOnlyProfiles);
var failed = results.Count(r => r.Status == "FAIL");
var skipped = results.Count(r => r.Status == "SKIP");
@@ -1105,7 +1106,7 @@ internal static class SelfTest
("Recording settings", () => new RecordingSettingsDialog(new RecordingSettings())),
("Preferences", () => new PreferencesDialog(
new RemSoundSettingsStore("RemSound"), null,
() => false, _ => { }, () => { }, () => 0, () => { }, () => { }, _ => { },
() => false, _ => { }, () => { }, () => 0, () => { }, () => { }, () => { }, _ => { },
() => (default(RouterMappingStatus), (IPEndPoint?)null, ""),
_ => { }, _ => { })),
("Service profile", () => new ServiceProfileDialog(RemSound.Core.Profile.NewBlank(), false)),
@@ -1209,6 +1210,58 @@ internal static class SelfTest
}
}
/// <summary>The "auto save non-read only profiles" preference (2026-07-13): the exact option list Ed
/// asked for, the guard that only auto-saves a real non-read-only dirty profile, the AppConfig
/// persistence, and that the timer turns on/off from the interval. The silence guarantee (no save cue)
/// is structural — the sole auto-save caller passes playCue: false — so we assert the guard, not audio.</summary>
private static string? AutoSaveNonReadOnlyProfiles()
{
// 1. The option rows are exactly Never / 2 / 5 / 10 / 15 / 20 / 30 minutes, in order.
var opts = PreferencesDialog.AutoSaveMinuteOptionsForTest;
var expected = new[] { 0, 2, 5, 10, 15, 20, 30 };
Check(opts.Count == expected.Length, $"auto-save must offer {expected.Length} options (got {opts.Count})");
for (var i = 0; i < expected.Length; i++)
Check(opts[i] == expected[i], $"auto-save option {i} must be {expected[i]} minutes (got {opts[i]})");
// 2. AppConfig persists the chosen interval across a save/load. Done in place (the gate runs
// against a throwaway --config-dir) and restored in a finally so we leave no trace.
var original = AppConfig.Load().AutoSaveNonReadOnlyMinutes;
try
{
var cfg = AppConfig.Load();
cfg.AutoSaveNonReadOnlyMinutes = 15;
cfg.Save();
Check(AppConfig.Load().AutoSaveNonReadOnlyMinutes == 15, "the auto-save interval must persist through AppConfig");
}
finally
{
var restore = AppConfig.Load();
restore.AutoSaveNonReadOnlyMinutes = original;
try { restore.Save(); } catch { /* best effort */ }
}
// 3. The guard: only a real, saved, non-read-only, dirty profile may be auto-saved.
Check(MainForm.ShouldAutoSave(true, "mine", readOnly: false, dirty: true), "a dirty non-read-only profile must auto-save");
Check(!MainForm.ShouldAutoSave(true, "mine", readOnly: true, dirty: true), "a read-only profile must never auto-save");
Check(!MainForm.ShouldAutoSave(true, "mine", readOnly: false, dirty: false), "an unchanged profile must not auto-save");
Check(!MainForm.ShouldAutoSave(true, "", readOnly: false, dirty: true), "a blank template (no title) must not auto-save");
Check(!MainForm.ShouldAutoSave(false, "mine", readOnly: false, dirty: true), "with no store there is nothing to auto-save");
// 4. The timer turns on with the right interval, and off when set to Never.
MainForm mf;
try { mf = new MainForm(null, RemSound.Core.Profile.NewBlank(), null, null, headless: true); }
catch (Exception ex) { return Skip($"headless MainForm could not be constructed: {ex.GetType().Name}: {ex.Message}"); }
using (mf)
{
mf.ApplyAutoSaveTimer(5);
Check(mf.AutoSaveTimerEnabledForTest, "a 5-minute setting must start the auto-save timer");
Check(mf.AutoSaveTimerIntervalForTest == 5 * 60 * 1000, $"5 minutes must be 300000 ms (got {mf.AutoSaveTimerIntervalForTest})");
mf.ApplyAutoSaveTimer(0);
Check(!mf.AutoSaveTimerEnabledForTest, "Never (0) must stop the auto-save timer");
}
return "options, persistence, guard (read-only/blank/unchanged skipped), and silent timer all verified";
}
private static int CountControls(Control root, Func<Control, bool> predicate)
{
var n = 0;