diff --git a/README.md b/README.md index 0abeaf1..5a5753b 100644 --- a/README.md +++ b/README.md @@ -276,7 +276,7 @@ reference ambiguous. | `OnStart(Action action)` | `TrayAppBuilder` | What to run when the tool starts, in both modes. | | `OnStop(Action action)` | `TrayAppBuilder` | What to run on the way out, in both modes. | | `RefreshOn(Action subscribe)` | `TrayAppBuilder` | Hands the tool a delegate that refreshes the menu. | -| `Preferences(IPersistenceProvider provider, string key, TimeSpan? debounce)` | `TrayAppBuilder` | Remembers persisted toggles between runs. | +| `Preferences(IPersistenceProvider provider, string key, TimeSpan? debounce)` | `TrayAppBuilder` | Remembers persisted toggles between runs. `debounce` defaults to half a second; a negative value, `Timeout.InfiniteTimeSpan` or more than about 49.7 days throws `ArgumentOutOfRangeException`. | | `RunAsync(string[] args)` | `Task` | Parses the command line and runs the tool. 0 success, 1 the platform refused, 2 the command line was wrong. | ### `TrayMenu` diff --git a/TrayApp.Test/TrayAppBuilderTests.cs b/TrayApp.Test/TrayAppBuilderTests.cs index 0ec23c6..38d099b 100644 --- a/TrayApp.Test/TrayAppBuilderTests.cs +++ b/TrayApp.Test/TrayAppBuilderTests.cs @@ -128,6 +128,50 @@ public void Build_WithNoToggles_HasNoActiveState() Assert.IsNull(app.IsActive); } + [TestMethod] + [DataRow(-1000.0)] + [DataRow(-1.0)] + [DataRow(60.0 * 24 * 60 * 60 * 1000)] + public void Preferences_WithADebounceATimerCannotWaitFor_Throws(double milliseconds) + { + InMemoryPersistenceProvider provider = new(); + TrayAppBuilder builder = TrayAppBuilder.Create("demo"); + + ArgumentOutOfRangeException exception = Assert.ThrowsExactly( + () => builder.Preferences(provider, debounce: TimeSpan.FromMilliseconds(milliseconds))); + Assert.AreEqual("debounce", exception.ParamName); + } + + [TestMethod] + public void Preferences_WithAnInfiniteDebounce_Throws() + { + InMemoryPersistenceProvider provider = new(); + + _ = Assert.ThrowsExactly( + () => TrayAppBuilder.Create("demo").Preferences(provider, debounce: System.Threading.Timeout.InfiniteTimeSpan)); + } + + [TestMethod] + public void Preferences_WithZeroOrNoDebounce_IsAccepted() + { + InMemoryPersistenceProvider provider = new(); + + Assert.AreEqual(TimeSpan.Zero, TrayAppBuilder.Create("demo").Preferences(provider, debounce: TimeSpan.Zero).Build().PreferenceDebounce); + Assert.AreEqual(TimeSpan.FromMilliseconds(500), TrayAppBuilder.Create("demo").Preferences(provider).Build().PreferenceDebounce); + } + + [TestMethod] + public void Preferences_WithTheLongestDebounce_IsAcceptedAndATimerTakesIt() + { + InMemoryPersistenceProvider provider = new(); + TrayAppDefinition app = TrayAppBuilder.Create("demo") + .Preferences(provider, debounce: TrayAppBuilder.MaxPreferenceDebounce) + .Build(); + + using DebouncedPreferenceStore store = new(provider, "tray", app.PreferenceDebounce); + store.Set("a", true); + } + [TestMethod] public async Task LoadPreferences_RestoresOnlyThePersistedToggles() { diff --git a/TrayApp/TrayAppBuilder.cs b/TrayApp/TrayAppBuilder.cs index 3f8a656..43e77a0 100644 --- a/TrayApp/TrayAppBuilder.cs +++ b/TrayApp/TrayAppBuilder.cs @@ -52,6 +52,11 @@ public sealed class TrayAppBuilder private static readonly TimeSpan DefaultPreferenceDebounce = TimeSpan.FromMilliseconds(500); + /// + /// The longest debounce a due time can hold, about 49.7 days. + /// + internal static readonly TimeSpan MaxPreferenceDebounce = TimeSpan.FromMilliseconds(uint.MaxValue - 1.0); + private readonly string name; private readonly List items = []; private readonly List toggles = []; @@ -338,9 +343,16 @@ public TrayAppBuilder RefreshOn(Action subscribe) /// ktsu.Essentials.FileSystemProviders.Native in the tool, and hand the result in here. /// /// The key the state is stored under. The default suits a provider already namespaced by application. - /// How long a burst of changes is coalesced for, or for half a second. + /// + /// How long a burst of changes is coalesced for, from zero up to about 49.7 days, or + /// for half a second. + /// /// The builder, for chaining. /// or is . + /// + /// is negative, including , + /// or longer than a timer can wait. + /// /// /// A tool that never calls this does no I/O at all and needs no provider package: persistence is a /// dependency of the tool head, never of this library. @@ -350,6 +362,18 @@ public TrayAppBuilder Preferences(IPersistenceProvider provider, string Ensure.NotNull(provider); Ensure.NotNull(key); + // The debounce is only handed to a timer on the first click of a persisted toggle, from inside the + // tray's click handler, so a value the timer refuses would surface there as a crash far from its + // cause. Infinite is refused too: it would mean "never write until exit", and an exit by SIGTERM + // does not flush. + if (debounce is TimeSpan value && (value < TimeSpan.Zero || value > MaxPreferenceDebounce)) + { + throw new ArgumentOutOfRangeException( + nameof(debounce), + value, + "The preference debounce must be between zero and about 49.7 days."); + } + persistence = provider; preferenceKey = key; preferenceDebounce = debounce ?? DefaultPreferenceDebounce;