feat(central-ui): IFlowDebugService script entry — GetInstanceScripts / PredictScript / RunScript over the flattened configuration
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
@@ -17,7 +17,10 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||
using ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
@@ -27,8 +30,9 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
|
||||
/// <summary>
|
||||
/// Tests for <see cref="FlowDebugService"/> (Flow Debugger design 2026-09-05 §5.5/§7.1):
|
||||
/// the management-command bridge (holder null, error mapping, success → graph), the
|
||||
/// prediction resolver wiring over substituted repositories, and the Retry/Discard relay
|
||||
/// for a parked outbound node (follow-ups plan 2026-09-06 D4).
|
||||
/// prediction resolver wiring over substituted repositories, the script entry (design
|
||||
/// 2026-09-08 §5 — script list, prediction and run over the flattened configuration), and
|
||||
/// the Retry/Discard relay for a parked outbound node (follow-ups plan 2026-09-06 D4).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="CommunicationService"/> is a concrete class with non-virtual methods, so the
|
||||
@@ -48,6 +52,7 @@ public sealed class FlowDebugServiceTests : IDisposable
|
||||
private readonly ActorSystem _system = ActorSystem.Create("flow-debug-service-tests");
|
||||
private readonly IInboundApiRepository _inboundApi = Substitute.For<IInboundApiRepository>();
|
||||
private readonly ITemplateEngineRepository _templates = Substitute.For<ITemplateEngineRepository>();
|
||||
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
|
||||
private readonly List<ManagementEnvelope> _received = new();
|
||||
|
||||
// Relay probes: everything the scripted Site Call Audit / Notification Outbox actors receive.
|
||||
@@ -80,7 +85,21 @@ public sealed class FlowDebugServiceTests : IDisposable
|
||||
comms.SetSiteCallAudit(_system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this))));
|
||||
comms.SetNotificationOutbox(_system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this))));
|
||||
|
||||
return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, comms, NullLogger<FlowDebugService>.Instance);
|
||||
return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, _pipeline, comms, NullLogger<FlowDebugService>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wires the instance row plus the flattened configuration its scripts are read from
|
||||
/// (script entry, design 2026-09-08 §2 — the picker and the prediction both go through
|
||||
/// <see cref="IFlatteningPipeline"/>, never the raw template chain).
|
||||
/// </summary>
|
||||
private void WireFlattened(int instanceId, string uniqueName, params ResolvedScript[] scripts)
|
||||
{
|
||||
_templates.GetInstanceByIdAsync(instanceId, Arg.Any<CancellationToken>())
|
||||
.Returns(new Instance(uniqueName) { Id = instanceId, TemplateId = 10, SiteId = 1 });
|
||||
var cfg = new FlattenedConfiguration { InstanceUniqueName = uniqueName, TemplateId = 10, SiteId = 1, Scripts = scripts };
|
||||
_pipeline.FlattenAndValidateAsync(instanceId, Arg.Any<CancellationToken>(), false, null)
|
||||
.Returns(Result<FlatteningPipelineResult>.Success(new FlatteningPipelineResult(cfg, "h", new ValidationResult())));
|
||||
}
|
||||
|
||||
/// <summary>An External-lane step as <c>FlowStepProjector</c> would project it (positional per <see cref="FlowStep"/>).</summary>
|
||||
@@ -337,19 +356,21 @@ public sealed class FlowDebugServiceTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunValueChange_AndReplay_SendTheirCommands()
|
||||
public async Task RunWriteKindScript_AndReplay_SendTheirCommands()
|
||||
{
|
||||
WireFlattened(7, "Sim01",
|
||||
new ResolvedScript { CanonicalName = "OnSpeed", Code = "x", TriggerType = "ValueChange", TriggerConfiguration = "{\"attributeName\":\"Speed\"}" });
|
||||
var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 1)));
|
||||
var exec = Guid.NewGuid();
|
||||
|
||||
var vc = await svc.RunValueChangeAsync(7, "Speed", "42");
|
||||
var vc = await svc.RunScriptAsync(7, "OnSpeed", null, "42");
|
||||
var rp = await svc.ReplayAsync(exec);
|
||||
|
||||
Assert.True(vc.Success);
|
||||
Assert.True(rp.Success);
|
||||
Assert.Equal(2, _received.Count);
|
||||
var vcCmd = Assert.IsType<FlowDebugRunValueChangeCommand>(_received[0].Command);
|
||||
Assert.Equal((7, "Speed", "42"), (vcCmd.InstanceId, vcCmd.AttributeName, vcCmd.Value));
|
||||
var vcCmd = Assert.IsType<FlowDebugRunScriptCommand>(_received[0].Command);
|
||||
Assert.Equal((7, "OnSpeed", "42"), (vcCmd.InstanceId, vcCmd.ScriptName, vcCmd.Value));
|
||||
var rpCmd = Assert.IsType<FlowDebugReplayCommand>(_received[1].Command);
|
||||
Assert.Equal(exec, rpCmd.ExecutionId);
|
||||
}
|
||||
@@ -519,22 +540,36 @@ public sealed class FlowDebugServiceTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PredictValueChange_RootsAtTheWrite_AndAppendsEachTriggeredScript()
|
||||
public async Task GetInstanceScripts_ProjectsFlattenedScripts_WithTriggerAttributeAndKind_OrderedByName()
|
||||
{
|
||||
_templates.GetInstanceByIdAsync(9, Arg.Any<CancellationToken>())
|
||||
.Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 });
|
||||
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>(
|
||||
[
|
||||
new TemplateScript("OnFlag", """Attributes["Ack"] = true;""") { Id = 1, TemplateId = 10, TriggerType = "valuechange", TriggerConfiguration = """{"attributeName":"Flag"}""" },
|
||||
new TemplateScript("OnOther", "return 1;") { Id = 2, TemplateId = 10, TriggerType = "valuechange", TriggerConfiguration = """{"attributeName":"Other"}""" },
|
||||
new TemplateScript("Manual", "return 1;") { Id = 3, TemplateId = 10, TriggerType = "call" },
|
||||
]));
|
||||
_templates.GetTemplateByIdAsync(10, Arg.Any<CancellationToken>())
|
||||
.Returns(new Template("Base") { Id = 10 });
|
||||
WireFlattened(9, "Sim01",
|
||||
new ResolvedScript { CanonicalName = "Manual", Code = "return 1;", TriggerType = "Call", ParameterDefinitions = "{\"type\":\"object\"}", ExecutionTimeoutSeconds = 12 },
|
||||
new ResolvedScript { CanonicalName = "OnFlag", Code = "x", TriggerType = "valuechange", TriggerConfiguration = "{\"attributeName\":\"Flag\"}" },
|
||||
new ResolvedScript { CanonicalName = "Mod.Tick", Code = "x", TriggerType = "Interval", TriggerConfiguration = "{\"intervalMs\":1000}" });
|
||||
|
||||
var svc = CreateService(reply: null);
|
||||
var flow = await svc.PredictValueChangeAsync(9, "Flag");
|
||||
var options = await CreateService(reply: null).GetInstanceScriptsAsync(9);
|
||||
|
||||
Assert.Equal(["Manual", "Mod.Tick", "OnFlag"], options.Select(o => o.CanonicalName).ToList());
|
||||
var onFlag = options.Single(o => o.CanonicalName == "OnFlag");
|
||||
Assert.True(onFlag.IsWriteKind); Assert.Equal("Flag", onFlag.TriggerAttribute); Assert.Equal("valuechange: Flag", onFlag.TriggerLabel);
|
||||
var manual = options.Single(o => o.CanonicalName == "Manual");
|
||||
Assert.False(manual.IsWriteKind); Assert.Equal(12, manual.ExecutionTimeoutSeconds); Assert.Equal("Call", manual.TriggerLabel);
|
||||
Assert.False(options.Single(o => o.CanonicalName == "Mod.Tick").IsWriteKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetInstanceScripts_UnknownInstance_IsEmpty()
|
||||
{
|
||||
Assert.Empty(await CreateService(reply: null).GetInstanceScriptsAsync(404));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PredictScript_WriteKind_RootsAtTheWrite_AndAppendsTheScript()
|
||||
{
|
||||
WireFlattened(9, "Sim01",
|
||||
new ResolvedScript { CanonicalName = "OnFlag", Code = """Attributes["Ack"] = true;""", TriggerType = "ValueChange", TriggerConfiguration = "{\"attributeName\":\"Flag\"}" });
|
||||
|
||||
var flow = await CreateService(reply: null).PredictScriptAsync(9, "OnFlag");
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
@@ -542,25 +577,43 @@ public sealed class FlowDebugServiceTests : IDisposable
|
||||
(FlowStepKind.ScriptRun, "Sim01.OnFlag"),
|
||||
(FlowStepKind.AttributeWrite, "Ack"),
|
||||
], flow.Nodes.Select(n => (n.Kind, n.Title)).ToList());
|
||||
Assert.Equal(2, flow.Edges.Count);
|
||||
Assert.Equal(("n1", "n2"), (flow.Edges[0].From, flow.Edges[0].To));
|
||||
Assert.Equal(("n2", "n3"), (flow.Edges[1].From, flow.Edges[1].To));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PredictValueChange_NoTriggeredScript_NotesIt()
|
||||
public async Task PredictScript_CallKind_RootsAtARouteCall_ThenTheScript()
|
||||
{
|
||||
_templates.GetInstanceByIdAsync(9, Arg.Any<CancellationToken>())
|
||||
.Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 });
|
||||
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>([]));
|
||||
_templates.GetTemplateByIdAsync(10, Arg.Any<CancellationToken>()).Returns(new Template("Base") { Id = 10 });
|
||||
WireFlattened(9, "Sim01",
|
||||
new ResolvedScript { CanonicalName = "Manual", Code = """Attributes["Ack"] = true;""", TriggerType = "Call" });
|
||||
|
||||
var svc = CreateService(reply: null);
|
||||
var flow = await svc.PredictValueChangeAsync(9, "Flag");
|
||||
var flow = await CreateService(reply: null).PredictScriptAsync(9, "Manual");
|
||||
|
||||
var root = Assert.Single(flow.Nodes);
|
||||
Assert.Contains("no value-change script", root.Note);
|
||||
Assert.Equal(FlowStepKind.RouteCall, flow.Nodes[0].Kind);
|
||||
Assert.Equal(FlowLane.Central, flow.Nodes[0].Lane);
|
||||
Assert.Equal("Sim01.Manual", flow.Nodes[0].Title);
|
||||
Assert.Equal(FlowStepKind.ScriptRun, flow.Nodes[1].Kind);
|
||||
Assert.True(flow.Edges[0].CrossesBoundary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PredictScript_UnknownScript_IsEmpty()
|
||||
{
|
||||
WireFlattened(9, "Sim01");
|
||||
Assert.Same(PredictedFlow.Empty, await CreateService(reply: null).PredictScriptAsync(9, "Nope"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunScript_SendsTheCommand_WithTheScriptShape()
|
||||
{
|
||||
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)));
|
||||
|
||||
var outcome = await svc.RunScriptAsync(9, "Manual", null, null);
|
||||
|
||||
Assert.True(outcome.Success, outcome.Error);
|
||||
var env = Assert.Single(_received);
|
||||
var cmd = Assert.IsType<FlowDebugRunScriptCommand>(env.Command);
|
||||
Assert.Equal((9, "Manual"), (cmd.InstanceId, cmd.ScriptName));
|
||||
}
|
||||
|
||||
private sealed class StubAuth : AuthenticationStateProvider
|
||||
|
||||
Reference in New Issue
Block a user