feat(central-ui): add Min time between runs field to the script form

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).
This commit is contained in:
Joseph Doherty
2026-05-18 16:44:15 -04:00
parent 437fe154e7
commit 01509a045f
5 changed files with 241 additions and 2 deletions

View File

@@ -98,6 +98,8 @@
private string _scriptCode = string.Empty; private string _scriptCode = string.Empty;
private string? _scriptTriggerType; private string? _scriptTriggerType;
private string? _scriptTriggerConfig; private string? _scriptTriggerConfig;
private string? _scriptMinTimeValue;
private string _scriptMinTimeUnit = "sec";
private string? _scriptParameters; private string? _scriptParameters;
private string? _scriptReturn; private string? _scriptReturn;
private bool _scriptIsLocked; private bool _scriptIsLocked;
@@ -880,6 +882,47 @@
Changed="@OnScriptTriggerChanged" Changed="@OnScriptTriggerChanged"
AvailableAttributes="@BuildAlarmAttributeChoices()" /> AvailableAttributes="@BuildAlarmAttributeChoices()" />
</div> </div>
@if (ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(_scriptTriggerType))
{
<div class="col-12">
<label class="form-label">Min time between runs</label>
<div class="row g-2" style="max-width: 420px;">
<div class="col-7">
<input type="number" min="1" step="1" class="form-control"
placeholder="(optional)"
@bind="_scriptMinTimeValue" @bind:event="oninput" />
</div>
<div class="col-5">
<select class="form-select" @bind="_scriptMinTimeUnit">
<option value="ms">milliseconds</option>
<option value="sec">seconds</option>
<option value="min">minutes</option>
</select>
</div>
</div>
@if (ScriptTriggerIsWhileTrue())
{
<div class="form-text">
This is the re-fire interval for the
<strong>WhileTrue</strong> trigger above.
</div>
@if (DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit) is null)
{
<div class="alert alert-warning py-1 px-2 small mt-1 mb-0">
The WhileTrue trigger has no interval set — the script
will fire only once. Set a value here to make it re-fire.
</div>
}
}
else
{
<div class="form-text">
Optional throttle — skips trigger invocations that fire
sooner than this.
</div>
}
</div>
}
<div class="col-12"> <div class="col-12">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_scriptIsLocked" id="scriptLocked" /> <input class="form-check-input" type="checkbox" @bind="_scriptIsLocked" id="scriptLocked" />
@@ -1461,6 +1504,19 @@
_scriptTriggerConfig = v.Config; _scriptTriggerConfig = v.Config;
} }
/// <summary>
/// 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).
/// </summary>
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() private void BeginAddScript()
{ {
_showScriptForm = true; _showScriptForm = true;
@@ -1470,6 +1526,7 @@
_scriptCode = string.Empty; _scriptCode = string.Empty;
_scriptTriggerType = null; _scriptTriggerType = null;
_scriptTriggerConfig = null; _scriptTriggerConfig = null;
(_scriptMinTimeValue, _scriptMinTimeUnit) = DurationInput.Split(null);
_scriptParameters = null; _scriptParameters = null;
_scriptReturn = null; _scriptReturn = null;
_scriptIsLocked = false; _scriptIsLocked = false;
@@ -1486,6 +1543,7 @@
_scriptCode = script.Code; _scriptCode = script.Code;
_scriptTriggerType = script.TriggerType; _scriptTriggerType = script.TriggerType;
_scriptTriggerConfig = script.TriggerConfiguration; _scriptTriggerConfig = script.TriggerConfiguration;
(_scriptMinTimeValue, _scriptMinTimeUnit) = DurationInput.Split(script.MinTimeBetweenRuns);
_scriptParameters = script.ParameterDefinitions; _scriptParameters = script.ParameterDefinitions;
_scriptReturn = script.ReturnDefinition; _scriptReturn = script.ReturnDefinition;
_scriptIsLocked = script.IsLocked; _scriptIsLocked = script.IsLocked;
@@ -1581,7 +1639,7 @@
ParameterDefinitions = _scriptParameters, ParameterDefinitions = _scriptParameters,
ReturnDefinition = _scriptReturn, ReturnDefinition = _scriptReturn,
IsLocked = _scriptIsLocked, IsLocked = _scriptIsLocked,
MinTimeBetweenRuns = existing.MinTimeBetweenRuns, MinTimeBetweenRuns = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit),
IsInherited = existing.IsInherited, IsInherited = existing.IsInherited,
LockedInDerived = existing.LockedInDerived, LockedInDerived = existing.LockedInDerived,
}; };
@@ -1606,7 +1664,8 @@
TriggerConfiguration = _scriptTriggerConfig?.Trim(), TriggerConfiguration = _scriptTriggerConfig?.Trim(),
ParameterDefinitions = _scriptParameters, ParameterDefinitions = _scriptParameters,
ReturnDefinition = _scriptReturn, ReturnDefinition = _scriptReturn,
IsLocked = _scriptIsLocked IsLocked = _scriptIsLocked,
MinTimeBetweenRuns = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit)
}; };
var addResult = await TemplateService.AddScriptAsync(_selectedTemplate.Id, script, user); var addResult = await TemplateService.AddScriptAsync(_selectedTemplate.Id, script, user);

View File

@@ -0,0 +1,50 @@
using System.Globalization;
namespace ScadaLink.CentralUI.Components.Shared;
/// <summary>
/// Converts a <see cref="TimeSpan"/> to and from the number+unit pair behind a
/// duration input (milliseconds / seconds / minutes). A blank or non-positive
/// number represents "unset" (a <c>null</c> duration).
/// </summary>
internal static class DurationInput
{
/// <summary>The unit tokens a duration input offers, smallest first.</summary>
internal static readonly string[] Units = { "ms", "sec", "min" };
/// <summary>
/// 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
/// <c>sec</c> unit.
/// </summary>
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");
}
/// <summary>
/// Composes a number+unit pair into a duration. A blank, unparseable, or
/// non-positive value yields <c>null</c> (unset).
/// </summary>
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);
}
}

View File

@@ -81,6 +81,17 @@ internal static class ScriptTriggerConfigCodec
}; };
} }
/// <summary>
/// Whether a trigger type honours the script's <c>MinTimeBetweenRuns</c>.
/// 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.
/// </summary>
internal static bool SupportsMinTimeBetweenRuns(string? triggerType) =>
ParseKind(triggerType) is ScriptTriggerKind.ValueChange
or ScriptTriggerKind.Conditional
or ScriptTriggerKind.Expression;
/// <summary>Canonical <c>TriggerType</c> string for a kind; null for None/Unknown.</summary> /// <summary>Canonical <c>TriggerType</c> string for a kind; null for None/Unknown.</summary>
internal static string? KindToString(ScriptTriggerKind kind) => kind switch internal static string? KindToString(ScriptTriggerKind kind) => kind switch
{ {

View File

@@ -0,0 +1,98 @@
using ScadaLink.CentralUI.Components.Shared;
namespace ScadaLink.CentralUI.Tests.Shared;
/// <summary>
/// Coverage for <see cref="DurationInput"/>, the number+unit codec behind the
/// script form's "Min time between runs" field.
/// </summary>
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);
}
}

View File

@@ -117,6 +117,27 @@ public class ScriptTriggerConfigCodecTests
Assert.Equal(mode, reparsed.Mode); 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] [Theory]
[InlineData(false)] [InlineData(false)]
[InlineData(true)] [InlineData(true)]