From 01509a045f7e4e9123c51f16d057787522628565 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 18 May 2026 16:44:15 -0400 Subject: [PATCH] feat(central-ui): add Min time between runs field to the script form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The template script editor had no input for MinTimeBetweenRuns, so a WhileTrue trigger configured through the UI always saved a null interval and degraded to a single edge fire. The Add/Edit Script modal now has a "Min time between runs" number+unit (ms/sec/min) field. - Visible only for ValueChange / Conditional / Expression triggers — the auto-firing triggers MinTimeBetweenRuns throttles. Hidden for Interval (its own period is the cadence), Call (invoked explicitly, never throttled), and None. - For a WhileTrue Conditional/Expression trigger the field is labelled as the re-fire interval and shows a warning while it is blank. - Wired through the new-script and edit-script save paths (edit previously only preserved the existing value, never let the user change it). New DurationInput helper does the TimeSpan <-> number+unit conversion; ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns classifies trigger types. Both TDD'd — 21 new tests. CentralUI suite 316 green; verified end-to-end in the browser (visibility per trigger type, WhileTrue warning, save/reload round-trip). --- .../Pages/Design/TemplateEdit.razor | 63 +++++++++++- .../Components/Shared/DurationInput.cs | 50 ++++++++++ .../Shared/ScriptTriggerConfigCodec.cs | 11 +++ .../Shared/DurationInputTests.cs | 98 +++++++++++++++++++ .../Shared/ScriptTriggerConfigCodecTests.cs | 21 ++++ 5 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 src/ScadaLink.CentralUI/Components/Shared/DurationInput.cs create mode 100644 tests/ScadaLink.CentralUI.Tests/Shared/DurationInputTests.cs diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor index ac6a068..9e081ba 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor @@ -98,6 +98,8 @@ private string _scriptCode = string.Empty; private string? _scriptTriggerType; private string? _scriptTriggerConfig; + private string? _scriptMinTimeValue; + private string _scriptMinTimeUnit = "sec"; private string? _scriptParameters; private string? _scriptReturn; private bool _scriptIsLocked; @@ -880,6 +882,47 @@ Changed="@OnScriptTriggerChanged" AvailableAttributes="@BuildAlarmAttributeChoices()" /> + @if (ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(_scriptTriggerType)) + { +
+ +
+
+ +
+
+ +
+
+ @if (ScriptTriggerIsWhileTrue()) + { +
+ This is the re-fire interval for the + WhileTrue trigger above. +
+ @if (DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit) is null) + { +
+ The WhileTrue trigger has no interval set — the script + will fire only once. Set a value here to make it re-fire. +
+ } + } + else + { +
+ Optional throttle — skips trigger invocations that fire + sooner than this. +
+ } +
+ }
@@ -1461,6 +1504,19 @@ _scriptTriggerConfig = v.Config; } + /// + /// True when the current script trigger is a WhileTrue Conditional/Expression + /// trigger — the case where the "Min time between runs" interval is required + /// (it is the re-fire cadence). + /// + private bool ScriptTriggerIsWhileTrue() + { + var kind = ScriptTriggerConfigCodec.ParseKind(_scriptTriggerType); + return kind is ScriptTriggerKind.Conditional or ScriptTriggerKind.Expression + && ScriptTriggerConfigCodec.Parse(_scriptTriggerConfig, kind).Mode + == ScriptTriggerMode.WhileTrue; + } + private void BeginAddScript() { _showScriptForm = true; @@ -1470,6 +1526,7 @@ _scriptCode = string.Empty; _scriptTriggerType = null; _scriptTriggerConfig = null; + (_scriptMinTimeValue, _scriptMinTimeUnit) = DurationInput.Split(null); _scriptParameters = null; _scriptReturn = null; _scriptIsLocked = false; @@ -1486,6 +1543,7 @@ _scriptCode = script.Code; _scriptTriggerType = script.TriggerType; _scriptTriggerConfig = script.TriggerConfiguration; + (_scriptMinTimeValue, _scriptMinTimeUnit) = DurationInput.Split(script.MinTimeBetweenRuns); _scriptParameters = script.ParameterDefinitions; _scriptReturn = script.ReturnDefinition; _scriptIsLocked = script.IsLocked; @@ -1581,7 +1639,7 @@ ParameterDefinitions = _scriptParameters, ReturnDefinition = _scriptReturn, IsLocked = _scriptIsLocked, - MinTimeBetweenRuns = existing.MinTimeBetweenRuns, + MinTimeBetweenRuns = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit), IsInherited = existing.IsInherited, LockedInDerived = existing.LockedInDerived, }; @@ -1606,7 +1664,8 @@ TriggerConfiguration = _scriptTriggerConfig?.Trim(), ParameterDefinitions = _scriptParameters, ReturnDefinition = _scriptReturn, - IsLocked = _scriptIsLocked + IsLocked = _scriptIsLocked, + MinTimeBetweenRuns = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit) }; var addResult = await TemplateService.AddScriptAsync(_selectedTemplate.Id, script, user); diff --git a/src/ScadaLink.CentralUI/Components/Shared/DurationInput.cs b/src/ScadaLink.CentralUI/Components/Shared/DurationInput.cs new file mode 100644 index 0000000..68158ea --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Shared/DurationInput.cs @@ -0,0 +1,50 @@ +using System.Globalization; + +namespace ScadaLink.CentralUI.Components.Shared; + +/// +/// Converts a to and from the number+unit pair behind a +/// duration input (milliseconds / seconds / minutes). A blank or non-positive +/// number represents "unset" (a null duration). +/// +internal static class DurationInput +{ + /// The unit tokens a duration input offers, smallest first. + internal static readonly string[] Units = { "ms", "sec", "min" }; + + /// + /// Splits a duration into the largest whole unit that represents it exactly. + /// A null or non-positive duration yields a blank value and the default + /// sec unit. + /// + internal static (string? Value, string Unit) Split(TimeSpan? duration) + { + if (duration is not { } d || d <= TimeSpan.Zero) return (null, "sec"); + + var ms = (long)d.TotalMilliseconds; + if (ms % 60000 == 0) return ((ms / 60000).ToString(CultureInfo.InvariantCulture), "min"); + if (ms % 1000 == 0) return ((ms / 1000).ToString(CultureInfo.InvariantCulture), "sec"); + return (ms.ToString(CultureInfo.InvariantCulture), "ms"); + } + + /// + /// Composes a number+unit pair into a duration. A blank, unparseable, or + /// non-positive value yields null (unset). + /// + internal static TimeSpan? Compose(string? value, string unit) + { + if (!long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) + || n <= 0) + { + return null; + } + + var factorMs = unit switch + { + "min" => 60000L, + "ms" => 1L, + _ => 1000L, + }; + return TimeSpan.FromMilliseconds(n * factorMs); + } +} diff --git a/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs b/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs index 738e11d..142c0d6 100644 --- a/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs +++ b/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs @@ -81,6 +81,17 @@ internal static class ScriptTriggerConfigCodec }; } + /// + /// Whether a trigger type honours the script's MinTimeBetweenRuns. + /// True for the auto-firing triggers it throttles — ValueChange, Conditional, + /// Expression. False for Interval (its own period is the cadence), Call + /// (invoked explicitly, never throttled), and None/Unknown. + /// + internal static bool SupportsMinTimeBetweenRuns(string? triggerType) => + ParseKind(triggerType) is ScriptTriggerKind.ValueChange + or ScriptTriggerKind.Conditional + or ScriptTriggerKind.Expression; + /// Canonical TriggerType string for a kind; null for None/Unknown. internal static string? KindToString(ScriptTriggerKind kind) => kind switch { diff --git a/tests/ScadaLink.CentralUI.Tests/Shared/DurationInputTests.cs b/tests/ScadaLink.CentralUI.Tests/Shared/DurationInputTests.cs new file mode 100644 index 0000000..da72769 --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Shared/DurationInputTests.cs @@ -0,0 +1,98 @@ +using ScadaLink.CentralUI.Components.Shared; + +namespace ScadaLink.CentralUI.Tests.Shared; + +/// +/// Coverage for , the number+unit codec behind the +/// script form's "Min time between runs" field. +/// +public class DurationInputTests +{ + // ── Split: TimeSpan -> (value, unit) ─────────────────────────────────── + + [Fact] + public void Split_Null_ReturnsBlankWithSecondsUnit() + { + var (value, unit) = DurationInput.Split(null); + + Assert.Null(value); + Assert.Equal("sec", unit); + } + + [Fact] + public void Split_Zero_ReturnsBlank() + { + var (value, _) = DurationInput.Split(TimeSpan.Zero); + + Assert.Null(value); + } + + [Fact] + public void Split_WholeMinutes_UsesMinuteUnit() + { + var (value, unit) = DurationInput.Split(TimeSpan.FromMinutes(5)); + + Assert.Equal("5", value); + Assert.Equal("min", unit); + } + + [Fact] + public void Split_WholeSeconds_UsesSecondUnit() + { + var (value, unit) = DurationInput.Split(TimeSpan.FromSeconds(30)); + + Assert.Equal("30", value); + Assert.Equal("sec", unit); + } + + [Fact] + public void Split_SubSecond_UsesMillisecondUnit() + { + var (value, unit) = DurationInput.Split(TimeSpan.FromMilliseconds(250)); + + Assert.Equal("250", value); + Assert.Equal("ms", unit); + } + + // ── Compose: (value, unit) -> TimeSpan? ──────────────────────────────── + + [Fact] + public void Compose_Blank_ReturnsNull() => + Assert.Null(DurationInput.Compose(null, "sec")); + + [Fact] + public void Compose_Zero_ReturnsNull() => + Assert.Null(DurationInput.Compose("0", "sec")); + + [Fact] + public void Compose_Negative_ReturnsNull() => + Assert.Null(DurationInput.Compose("-5", "sec")); + + [Fact] + public void Compose_SecondsValue_BuildsDuration() => + Assert.Equal(TimeSpan.FromSeconds(30), DurationInput.Compose("30", "sec")); + + [Fact] + public void Compose_MinutesValue_BuildsDuration() => + Assert.Equal(TimeSpan.FromMinutes(5), DurationInput.Compose("5", "min")); + + [Fact] + public void Compose_MillisecondsValue_BuildsDuration() => + Assert.Equal(TimeSpan.FromMilliseconds(250), DurationInput.Compose("250", "ms")); + + // ── Round-trip ───────────────────────────────────────────────────────── + + [Theory] + [InlineData(250)] + [InlineData(30000)] + [InlineData(300000)] + public void RoundTrip_PreservesDuration(long milliseconds) + { + var original = TimeSpan.FromMilliseconds(milliseconds); + + var (value, unit) = DurationInput.Split(original); + var reparsed = DurationInput.Compose(value, unit); + + Assert.Equal(original, reparsed); + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Shared/ScriptTriggerConfigCodecTests.cs b/tests/ScadaLink.CentralUI.Tests/Shared/ScriptTriggerConfigCodecTests.cs index 5104ab5..135a735 100644 --- a/tests/ScadaLink.CentralUI.Tests/Shared/ScriptTriggerConfigCodecTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Shared/ScriptTriggerConfigCodecTests.cs @@ -117,6 +117,27 @@ public class ScriptTriggerConfigCodecTests Assert.Equal(mode, reparsed.Mode); } + // ── SupportsMinTimeBetweenRuns ───────────────────────────────────────── + + [Theory] + [InlineData("ValueChange")] + [InlineData("Conditional")] + [InlineData("Expression")] + public void SupportsMinTimeBetweenRuns_TrueForAutoTriggersThatThrottle(string triggerType) + { + Assert.True(ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(triggerType)); + } + + [Theory] + [InlineData("Interval")] // has its own period control + [InlineData("Call")] // invoked explicitly — no throttle applies + [InlineData(null)] // None — never runs automatically + [InlineData("Bogus")] // Unknown trigger type + public void SupportsMinTimeBetweenRuns_FalseForIntervalCallNoneAndUnknown(string? triggerType) + { + Assert.False(ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(triggerType)); + } + [Theory] [InlineData(false)] [InlineData(true)]