diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs index 05aee3a1..d06936fb 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs @@ -75,13 +75,6 @@ public sealed class FlowDebugService : IFlowDebugService /// Upper bound on the template parent chain the script resolver walks (cycle guard). private const int MaxTemplateChain = 16; - /// - /// Ask-sizing fallback for a call-kind script that declares no timeout of its own — mirrors - /// SiteRuntimeOptions.ScriptExecutionTimeoutSeconds, the site default the run would - /// actually be bounded by (the UI has no site options to read it from). - /// - private const int DefaultScriptTimeoutSeconds = 30; - private static readonly JsonSerializerOptions ResultJsonOptions = new() { PropertyNameCaseInsensitive = true, @@ -244,14 +237,15 @@ public sealed class FlowDebugService : IFlowDebugService { // The server re-resolves the script and never trusts a client-supplied trigger kind // (design 2026-09-08 §2); the kind is read here only to size the Ask wait — a call kind - // is bounded by the script's own execution timeout, a write kind by the trigger-wait - // window. An unresolvable script falls back to the write-kind wait, which is the longer - // of the two for a default-timeout script. + // is bounded by the script's own execution timeout — falling back to the runner's own + // FlowDebugRunner.DefaultScriptTimeoutSeconds so client and server agree on the bound — + // a write kind by the trigger-wait window. An unresolvable script falls back + // to the write-kind wait, which is the longer of the two for a default-timeout script. var command = new FlowDebugRunScriptCommand(instanceId, scriptName, parameters, value); var option = (await GetInstanceScriptsAsync(instanceId, cancellationToken)) .FirstOrDefault(o => o.CanonicalName.Equals(scriptName, StringComparison.OrdinalIgnoreCase)); var timeout = option is { IsWriteKind: false } - ? RunAskTimeoutFor(option.ExecutionTimeoutSeconds ?? DefaultScriptTimeoutSeconds) + ? RunAskTimeoutFor(option.ExecutionTimeoutSeconds ?? FlowDebugRunner.DefaultScriptTimeoutSeconds) : ValueChangeAskTimeoutFor(command.WaitSeconds); return await SendAndBuildAsync(command, timeout, cancellationToken); @@ -411,8 +405,10 @@ public sealed class FlowDebugService : IFlowDebugService /// Loads an instance's scripts from its flattened configuration (design 2026-09-08 §2) — /// canonical names, effective trigger kinds and effective parameter definitions, i.e. exactly /// what the site's Instance Actor keys on — memoised for the life of this scoped service. - /// Returns null when the instance does not exist or the flatten failed; a flatten failure is - /// a debug-level note, never a page error, because the picker simply has nothing to offer. + /// Returns null when the instance does not exist (Debug — an id from a stale link) or the + /// flatten failed (Warning — the same severity DeploymentService and the Configure + /// page give it, since the instance could not be deployed either). Neither is a page error: + /// the picker simply has no scripts to offer. /// private async Task LoadScriptsAsync(int instanceId, CancellationToken ct) { @@ -426,7 +422,9 @@ public sealed class FlowDebugService : IFlowDebugService var flattened = await _pipeline.FlattenAndValidateAsync(instance.Id, ct, validateScripts: false); if (flattened.IsFailure) { - _logger.LogDebug("Flow Debugger could not flatten instance {InstanceId}: {Error}", instanceId, flattened.Error); + _logger.LogWarning( + "Flow Debugger could not flatten instance {InstanceId} ({UniqueName}): {Error}", + instanceId, instance.UniqueName, flattened.Error); return null; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs index 535da2ac..aa8c5230 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs @@ -371,6 +371,8 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.Equal(2, _received.Count); var vcCmd = Assert.IsType(_received[0].Command); Assert.Equal((7, "OnSpeed", "42"), (vcCmd.InstanceId, vcCmd.ScriptName, vcCmd.Value)); + // A write kind fires through the attribute, so it carries no parameters. + Assert.Null(vcCmd.Parameters); var rpCmd = Assert.IsType(_received[1].Command); Assert.Equal(exec, rpCmd.ExecutionId); } @@ -607,13 +609,36 @@ public sealed class FlowDebugServiceTests : IDisposable WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call", ExecutionTimeoutSeconds = 40 }); var raw = new FlowDebugRunResult(Guid.NewGuid(), true, null, null, [], false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow); var svc = CreateService(reply: new ManagementSuccess("c", JsonSerializer.Serialize(raw, ServerJson))); + using var doc = JsonDocument.Parse("""{"n":3}"""); - var outcome = await svc.RunScriptAsync(9, "Manual", null, null); + var outcome = await svc.RunScriptAsync(9, "Manual", doc.RootElement.Clone(), null); Assert.True(outcome.Success, outcome.Error); var env = Assert.Single(_received); var cmd = Assert.IsType(env.Command); Assert.Equal((9, "Manual"), (cmd.InstanceId, cmd.ScriptName)); + // A call kind carries its parameters verbatim and no value. + Assert.Equal(3, cmd.Parameters!.Value.GetProperty("n").GetInt32()); + Assert.Null(cmd.Value); + } + + /// + /// The picker, the prediction and the run of one instance share ONE flatten for the life of + /// the scoped service (design 2026-09-08 §5) — a regression here means every keystroke on the + /// page re-walks the template chain. + /// + [Fact] + public async Task GetInstanceScripts_ThenPredict_ThenRun_FlattenOnce() + { + WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call" }); + var raw = new FlowDebugRunResult(Guid.NewGuid(), true, null, null, [], false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow); + var svc = CreateService(reply: new ManagementSuccess("c", JsonSerializer.Serialize(raw, ServerJson))); + + await svc.GetInstanceScriptsAsync(9); + await svc.PredictScriptAsync(9, "Manual"); + await svc.RunScriptAsync(9, "Manual", null, null); + + await _pipeline.Received(1).FlattenAndValidateAsync(9, Arg.Any(), false, null); } private sealed class StubAuth : AuthenticationStateProvider