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 3c0c7307..76ac00d0 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 @@ -117,6 +117,9 @@ 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). + private int? _scriptExecutionTimeoutSeconds; private string? _scriptFormError; private string _scriptModalTab = "trigger"; // "trigger" | "code" | "parameters" | "return" private MonacoEditor? _scriptEditor; @@ -1797,6 +1800,7 @@ _scriptParameters = null; _scriptReturn = null; _scriptIsLocked = false; + _scriptExecutionTimeoutSeconds = null; _scriptModalTab = "trigger"; ResetScriptTestRun(); } @@ -1814,6 +1818,9 @@ _scriptParameters = script.ParameterDefinitions; _scriptReturn = script.ReturnDefinition; _scriptIsLocked = script.IsLocked; + // Preserve any timeout set via Transport import — the UI has no authoring + // control for this field, so we round-trip the loaded value unchanged. + _scriptExecutionTimeoutSeconds = script.ExecutionTimeoutSeconds; _scriptModalTab = "trigger"; ResetScriptTestRun(); } @@ -1907,6 +1914,9 @@ 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, IsInherited = existing.IsInherited, LockedInDerived = existing.LockedInDerived, }; diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ExecutionActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ExecutionActorTests.cs index 62421045..f203f589 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ExecutionActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ExecutionActorTests.cs @@ -333,4 +333,42 @@ public class ExecutionActorTests : TestKit, IDisposable // ExpectTerminated would fail; with the override it stops within ~1s. ExpectTerminated(exec, TimeSpan.FromSeconds(10)); } + + [Fact] + public void AlarmExecutionActor_NullPerScriptTimeout_FallsBackToGlobal() + { + // M2.5 (#9): a null per-script timeout falls back to the global (1s here), + // so the busy loop is cancelled at the global value and the actor self-stops. + var compiled = CompileScript( + "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }"); + var instanceActor = CreateTestProbe(); + + var exec = ActorOf(Props.Create(() => new AlarmExecutionActor( + "HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature", + compiled, instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1), + NullLogger.Instance, /* executionTimeoutSeconds */ null))); + + Watch(exec); + // Global timeout (1s) must fire even when per-script is null. + ExpectTerminated(exec, TimeSpan.FromSeconds(10)); + } + + [Fact] + public void AlarmExecutionActor_NonPositivePerScriptTimeout_FallsBackToGlobal() + { + // M2.5 (#9): a non-positive per-script value (<= 0) is treated as "use + // global", so the busy loop is cancelled at the global (1s) value. + var compiled = CompileScript( + "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }"); + var instanceActor = CreateTestProbe(); + + var exec = ActorOf(Props.Create(() => new AlarmExecutionActor( + "HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature", + compiled, instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1), + NullLogger.Instance, /* executionTimeoutSeconds */ 0))); + + Watch(exec); + // Non-positive per-script timeout must be ignored; global (1s) must fire. + ExpectTerminated(exec, TimeSpan.FromSeconds(10)); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs index d2feb9bc..dbb09738 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs @@ -331,6 +331,66 @@ public class TemplateServiceTests Assert.Contains("Name", result.Error); } + [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. + var existing = new TemplateScript("OnStart", "return true;") + { + Id = 1, + TemplateId = 1, + ExecutionTimeoutSeconds = 30 // set via Transport import + }; + _repoMock.Setup(r => r.GetTemplateScriptByIdAsync(1, It.IsAny())).ReturnsAsync(existing); + var template = new Template("Pump") { Id = 1 }; + _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny())).ReturnsAsync(template); + + // Simulate what the UI now does: round-trip the loaded ExecutionTimeoutSeconds. + var proposed = new TemplateScript("OnStart", "return false;") + { + ExecutionTimeoutSeconds = existing.ExecutionTimeoutSeconds // round-trip + }; + + var result = await _service.UpdateScriptAsync(1, proposed, "admin"); + + Assert.True(result.IsSuccess); + Assert.Equal(30, result.Value.ExecutionTimeoutSeconds); + } + + [Fact] + public async Task UpdateScript_ExplicitNullTimeout_ClearsExecutionTimeoutSeconds() + { + // M2.5 (#9): a deliberate clear (e.g. via Transport or CLI setting null + // explicitly) must still work — the service must not guard against null. + var existing = new TemplateScript("OnStart", "return true;") + { + Id = 1, + TemplateId = 1, + ExecutionTimeoutSeconds = 30 + }; + _repoMock.Setup(r => r.GetTemplateScriptByIdAsync(1, It.IsAny())).ReturnsAsync(existing); + var template = new Template("Pump") { Id = 1 }; + _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny())).ReturnsAsync(template); + + // Explicit null — caller intentionally clears the timeout. + var proposed = new TemplateScript("OnStart", "return false;") + { + ExecutionTimeoutSeconds = null + }; + + var result = await _service.UpdateScriptAsync(1, proposed, "admin"); + + Assert.True(result.IsSuccess); + Assert.Null(result.Value.ExecutionTimeoutSeconds); + } + // ======================================================================== // WP-5: Shared Script CRUD (see SharedScriptServiceTests) // ========================================================================