diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor index 1e094026..dce0f573 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor @@ -130,8 +130,8 @@ private string? _scriptParameters; private string? _scriptReturn; private bool _scriptIsLocked; - // Round-tripped from the loaded script so UI edits preserve a timeout set - // via Transport import (no authoring control in the UI — scoped out). + // Per-script execution-timeout override (seconds). Bound to the "Execution timeout" + // input on the trigger tab; null/0 means "use the site's global default" (#54). private int? _scriptExecutionTimeoutSeconds; private string? _scriptFormError; private string _scriptModalTab = "trigger"; // "trigger" | "code" | "parameters" | "return" @@ -1397,6 +1397,19 @@ } } +
+ +
+ + seconds +
+
+ Per-script execution timeout. Leave blank (or 0) to use the + site's global default. +
+
"Error" }; + // Normalizes the execution-timeout input: a null or non-positive value means + // "use the site default", so it is stored as null (matching the Site Runtime's + // own ≤0-means-default handling and the entity's documented contract). + private static int? NormalizeExecutionTimeout(int? seconds) + => seconds is > 0 ? seconds : null; + private async Task SaveScript() { if (_selectedTemplate == null) return; @@ -2150,9 +2169,7 @@ ReturnDefinition = _scriptReturn, IsLocked = _scriptIsLocked, MinTimeBetweenRuns = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit), - // Round-trip the loaded value — no UI control, so preserve - // any timeout set via Transport import unchanged. - ExecutionTimeoutSeconds = _scriptExecutionTimeoutSeconds, + ExecutionTimeoutSeconds = NormalizeExecutionTimeout(_scriptExecutionTimeoutSeconds), IsInherited = existing.IsInherited, LockedInDerived = existing.LockedInDerived, }; @@ -2178,7 +2195,8 @@ ParameterDefinitions = _scriptParameters, ReturnDefinition = _scriptReturn, IsLocked = _scriptIsLocked, - MinTimeBetweenRuns = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit) + MinTimeBetweenRuns = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit), + ExecutionTimeoutSeconds = NormalizeExecutionTimeout(_scriptExecutionTimeoutSeconds) }; var addResult = await TemplateService.AddScriptAsync(_selectedTemplate.Id, script, user); diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TemplateCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TemplateCommands.cs index 7493d77c..0063f8c1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TemplateCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TemplateCommands.cs @@ -29,8 +29,13 @@ public record AddTemplateNativeAlarmSourceCommand(int TemplateId, string Name, s public record UpdateTemplateNativeAlarmSourceCommand(int NativeAlarmSourceId, string Name, string ConnectionName, string SourceReference, string? ConditionFilter, string? Description, bool IsLocked); public record DeleteTemplateNativeAlarmSourceCommand(int NativeAlarmSourceId); public record ListTemplateNativeAlarmSourcesCommand(int TemplateId); -public record AddTemplateScriptCommand(int TemplateId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked, string? ParameterDefinitions = null, string? ReturnDefinition = null); -public record UpdateTemplateScriptCommand(int ScriptId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked, string? ParameterDefinitions = null, string? ReturnDefinition = null); +// MinTimeBetweenRuns/ExecutionTimeoutSeconds are additive trailing optionals (default null = "unset"): +// MinTimeBetweenRuns mirrors the TemplateScript.MinTimeBetweenRuns TimeSpan throttle/re-fire interval; +// ExecutionTimeoutSeconds mirrors the per-script TemplateScript.ExecutionTimeoutSeconds override (seconds; +// null/non-positive falls back to the site's global default). Both were previously settable only via +// Transport bundle import — these fields close the CLI/UI authoring gap (#54). Additive-only: never reorder. +public record AddTemplateScriptCommand(int TemplateId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked, string? ParameterDefinitions = null, string? ReturnDefinition = null, TimeSpan? MinTimeBetweenRuns = null, int? ExecutionTimeoutSeconds = null); +public record UpdateTemplateScriptCommand(int ScriptId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked, string? ParameterDefinitions = null, string? ReturnDefinition = null, TimeSpan? MinTimeBetweenRuns = null, int? ExecutionTimeoutSeconds = null); public record DeleteTemplateScriptCommand(int ScriptId); public record AddTemplateCompositionCommand(int TemplateId, string InstanceName, int ComposedTemplateId); public record DeleteTemplateCompositionCommand(int CompositionId); diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index d9e9e2c3..821c16a6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -2170,7 +2170,9 @@ public class ManagementActor : ReceiveActor TriggerConfiguration = cmd.TriggerConfiguration, IsLocked = cmd.IsLocked, ParameterDefinitions = cmd.ParameterDefinitions, - ReturnDefinition = cmd.ReturnDefinition + ReturnDefinition = cmd.ReturnDefinition, + MinTimeBetweenRuns = cmd.MinTimeBetweenRuns, + ExecutionTimeoutSeconds = cmd.ExecutionTimeoutSeconds }; var result = await svc.AddScriptAsync(cmd.TemplateId, script, user); return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error); @@ -2185,7 +2187,9 @@ public class ManagementActor : ReceiveActor TriggerConfiguration = cmd.TriggerConfiguration, IsLocked = cmd.IsLocked, ParameterDefinitions = cmd.ParameterDefinitions, - ReturnDefinition = cmd.ReturnDefinition + ReturnDefinition = cmd.ReturnDefinition, + MinTimeBetweenRuns = cmd.MinTimeBetweenRuns, + ExecutionTimeoutSeconds = cmd.ExecutionTimeoutSeconds }; var result = await svc.UpdateScriptAsync(cmd.ScriptId, script, user); return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateScriptTimingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateScriptTimingTests.cs new file mode 100644 index 00000000..7d4f7a0f --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateScriptTimingTests.cs @@ -0,0 +1,120 @@ +using System.CommandLine; +using ZB.MOM.WW.ScadaBridge.CLI.Commands; + +namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; + +/// +/// #54: template script add/update must expose --min-time-between-runs +/// (a duration with ms/s/min units) and --execution-timeout-seconds (an int) +/// so the per-script throttle/re-fire interval and the execution-timeout override — +/// previously settable only via Transport bundle import — are CLI-authorable. These +/// tests pin the option surface and the duration-parsing semantics of +/// . +/// +public class TemplateScriptTimingTests +{ + private static readonly Option Url = new("--url") { Recursive = true }; + private static readonly Option Username = new("--username") { Recursive = true }; + private static readonly Option Password = new("--password") { Recursive = true }; + private static readonly Option Format = CliOptions.CreateFormatOption(); + + private static Command ScriptGroup() + => TemplateCommands.Build(Url, Format, Username, Password) + .Subcommands.Single(c => c.Name == "script"); + + // ---- option surface ---- + + [Fact] + public void ScriptAdd_HasTimingOptions() + { + var add = ScriptGroup().Subcommands.Single(c => c.Name == "add"); + var names = add.Options.Select(o => o.Name).ToArray(); + Assert.Contains("--min-time-between-runs", names); + Assert.Contains("--execution-timeout-seconds", names); + } + + [Fact] + public void ScriptUpdate_HasTimingOptions() + { + var update = ScriptGroup().Subcommands.Single(c => c.Name == "update"); + var names = update.Options.Select(o => o.Name).ToArray(); + Assert.Contains("--min-time-between-runs", names); + Assert.Contains("--execution-timeout-seconds", names); + } + + // ---- --min-time-between-runs parsing ---- + + [Fact] + public void MinTime_Absent_IsUnsetNoError() + { + Assert.True(TemplateCommands.TryParseMinTimeBetweenRuns(null, out var d, out var err)); + Assert.Null(d); + Assert.Null(err); + } + + [Fact] + public void MinTime_Blank_IsUnsetNoError() + { + Assert.True(TemplateCommands.TryParseMinTimeBetweenRuns(" ", out var d, out var err)); + Assert.Null(d); + Assert.Null(err); + } + + [Theory] + [InlineData("500ms", 500)] + [InlineData("250MS", 250)] + public void MinTime_Milliseconds_Parsed(string value, int expectedMs) + { + Assert.True(TemplateCommands.TryParseMinTimeBetweenRuns(value, out var d, out var err)); + Assert.Null(err); + Assert.Equal(TimeSpan.FromMilliseconds(expectedMs), d); + } + + [Theory] + [InlineData("5")] // bare number → seconds + [InlineData("5s")] + [InlineData("5sec")] + [InlineData("5SEC")] + public void MinTime_Seconds_BareDefaultsToSeconds(string value) + { + Assert.True(TemplateCommands.TryParseMinTimeBetweenRuns(value, out var d, out var err)); + Assert.Null(err); + Assert.Equal(TimeSpan.FromSeconds(5), d); + } + + [Theory] + [InlineData("2m")] + [InlineData("2min")] + [InlineData("2MIN")] + public void MinTime_Minutes_Parsed(string value) + { + Assert.True(TemplateCommands.TryParseMinTimeBetweenRuns(value, out var d, out var err)); + Assert.Null(err); + Assert.Equal(TimeSpan.FromMinutes(2), d); + } + + [Theory] + [InlineData("0")] + [InlineData("0s")] + [InlineData("0min")] + public void MinTime_Zero_IsUnset(string value) + { + // 0 means "no throttle" → null, mirroring DurationInput.Compose's non-positive handling. + Assert.True(TemplateCommands.TryParseMinTimeBetweenRuns(value, out var d, out var err)); + Assert.Null(err); + Assert.Null(d); + } + + [Theory] + [InlineData("abc")] + [InlineData("5h")] // hours not supported + [InlineData("5days")] + [InlineData("-3")] + [InlineData("1.5s")] // non-integer + public void MinTime_Invalid_ReturnsError(string value) + { + Assert.False(TemplateCommands.TryParseMinTimeBetweenRuns(value, out var d, out var err)); + Assert.Null(d); + Assert.NotNull(err); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs index d8211da8..91b2c884 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs @@ -505,14 +505,11 @@ public class TemplateServiceTests [Fact] public async Task UpdateScript_UiEditPath_PreservesExistingExecutionTimeoutSeconds() { - // M2.5 (#9): ExecutionTimeoutSeconds has no authoring control in the UI. - // A UI-style update (proposed.ExecutionTimeoutSeconds == null) must NOT - // overwrite a timeout previously set via Transport import. - // - // The fix is in TemplateEdit.razor: it round-trips the loaded value, so - // proposed.ExecutionTimeoutSeconds will equal the existing value, not null. - // This test proves that when the round-trip is working, the service - // preserves the timeout end-to-end. + // #54: the UI now has an ExecutionTimeoutSeconds input, but it still loads + // (round-trips) the existing value into that input on edit, so leaving it + // untouched re-sends the same value rather than null. This test proves the + // service preserves the timeout end-to-end when the loaded value is re-sent + // — guarding the UI edit path against silently blanking a configured timeout. var existing = new TemplateScript("OnStart", "return true;") { Id = 1,