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:
@@ -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);
|
||||||
|
|||||||
50
src/ScadaLink.CentralUI/Components/Shared/DurationInput.cs
Normal file
50
src/ScadaLink.CentralUI/Components/Shared/DurationInput.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
98
tests/ScadaLink.CentralUI.Tests/Shared/DurationInputTests.cs
Normal file
98
tests/ScadaLink.CentralUI.Tests/Shared/DurationInputTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)]
|
||||||
|
|||||||
Reference in New Issue
Block a user