feat(central-ui): FlowDebugService — management-command bridge + predicted graph
IFlowDebugService/FlowDebugService (scoped, registered beside IDataConnectionMoveService). Run/replay go to the per-node ManagementActor through ManagementActorHolder with the Blazor principal projected to AuthenticatedUser, exactly as DataConnectionMoveService does; the FlowDebugRunResult payload is deserialised (camelCase) and fed to FlowGraphBuilder, while ManagementError/Unauthorized/holder-null/transport faults collapse into FlowDebugOutcome with the server's error code verbatim (STANDBY_NODE reaches the page untouched). Prediction is DB-only: PredictMethodAsync hands FlowShapePredictor a memoised resolver that maps a route target → Instance.UniqueName → template parent chain (nearest definition wins, cycle guarded) → TemplateScript.Code; PredictValueChangeAsync roots at the write and appends each script whose value-change trigger names the attribute (same attributeName/attribute keys the site's TriggerRouting parser reads). Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
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.ManagementService;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
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) and the
|
||||
/// prediction resolver wiring over substituted repositories.
|
||||
/// </summary>
|
||||
public sealed class FlowDebugServiceTests : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions ServerJson = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
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 List<ManagementEnvelope> _received = new();
|
||||
|
||||
public void Dispose() => _system.Terminate().Wait(TimeSpan.FromSeconds(5));
|
||||
|
||||
private FlowDebugService CreateService(object? reply, string role = "Deployer")
|
||||
{
|
||||
var holder = new ManagementActorHolder();
|
||||
if (reply != null)
|
||||
holder.ActorRef = _system.ActorOf(Props.Create(() => new ScriptedManagementActor(reply, _received)));
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||
new Claim(JwtTokenService.RoleClaimType, role),
|
||||
};
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, NullLogger<FlowDebugService>.Instance);
|
||||
}
|
||||
|
||||
private static string SuccessPayload(int events, bool completed = true, bool truncated = false)
|
||||
{
|
||||
var root = Guid.NewGuid();
|
||||
var child = Guid.NewGuid();
|
||||
var t0 = DateTime.UtcNow;
|
||||
var list = new List<AuditEvent>
|
||||
{
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0, target: "POST /api/M", executionId: root, durationMs: 100,
|
||||
requestSummary: """{"MachineCode":"Z28061"}""", responseSummary: """{"Result":true}"""),
|
||||
};
|
||||
if (events > 1)
|
||||
list.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0.AddMilliseconds(1), target: "I.S", executionId: root, durationMs: 90,
|
||||
extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()));
|
||||
if (events > 2)
|
||||
list.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0.AddMilliseconds(2), target: "S", executionId: child, parentExecutionId: root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "I", durationMs: 80));
|
||||
|
||||
var result = new FlowDebugRunResult(root, completed, null, null, list, truncated, t0, t0.AddSeconds(1));
|
||||
return JsonSerializer.Serialize(result, ServerJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunMethod_HolderNull_ReportsServiceUnavailable()
|
||||
{
|
||||
var svc = CreateService(reply: null);
|
||||
|
||||
var outcome = await svc.RunMethodAsync(4, null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Null(outcome.Graph);
|
||||
Assert.Equal("SERVICE_UNAVAILABLE", outcome.ErrorCode);
|
||||
Assert.Empty(_received);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunMethod_ManagementError_MapsCodeAndMessageVerbatim()
|
||||
{
|
||||
var svc = CreateService(new ManagementError("c", "This node is standby.", "STANDBY_NODE"));
|
||||
|
||||
var outcome = await svc.RunMethodAsync(4, null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal("STANDBY_NODE", outcome.ErrorCode);
|
||||
Assert.Equal("This node is standby.", outcome.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunMethod_Unauthorized_MapsToUnauthorizedCode()
|
||||
{
|
||||
var svc = CreateService(new ManagementUnauthorized("c", "Deployer role required."), role: "Viewer");
|
||||
|
||||
var outcome = await svc.RunMethodAsync(4, null);
|
||||
|
||||
Assert.Equal("UNAUTHORIZED", outcome.ErrorCode);
|
||||
Assert.Equal("Deployer role required.", outcome.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunMethod_Success_BuildsGraphWithTheReturnedSteps_AndSendsTheTypedCommandForThePrincipal()
|
||||
{
|
||||
var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 3)));
|
||||
using var doc = JsonDocument.Parse("""{"MachineCode":"Z28061"}""");
|
||||
|
||||
var outcome = await svc.RunMethodAsync(4, doc.RootElement.Clone());
|
||||
|
||||
Assert.True(outcome.Success);
|
||||
Assert.NotNull(outcome.Graph);
|
||||
Assert.NotNull(outcome.Raw);
|
||||
Assert.True(outcome.Raw!.Completed);
|
||||
// 3 returned events + the synthesized Response node.
|
||||
Assert.Equal(4, outcome.Graph!.Steps.Count);
|
||||
Assert.Contains(outcome.Graph.Steps, s => s.Kind == FlowStepKind.ScriptRun && s.Lane == FlowLane.Site);
|
||||
|
||||
var envelope = Assert.Single(_received);
|
||||
var cmd = Assert.IsType<FlowDebugRunMethodCommand>(envelope.Command);
|
||||
Assert.Equal(4, cmd.ApiMethodId);
|
||||
Assert.Equal("Z28061", cmd.Parameters!.Value.GetProperty("MachineCode").GetString());
|
||||
Assert.Equal("tester", envelope.User.Username);
|
||||
Assert.Contains("Deployer", envelope.User.Roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunValueChange_AndReplay_SendTheirCommands()
|
||||
{
|
||||
var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 1)));
|
||||
var exec = Guid.NewGuid();
|
||||
|
||||
var vc = await svc.RunValueChangeAsync(7, "Speed", "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 rpCmd = Assert.IsType<FlowDebugReplayCommand>(_received[1].Command);
|
||||
Assert.Equal(exec, rpCmd.ExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_TruncatedResult_FlagsTheGraph()
|
||||
{
|
||||
var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 2, truncated: true)));
|
||||
|
||||
var outcome = await svc.RunMethodAsync(4, null);
|
||||
|
||||
Assert.True(outcome.Success);
|
||||
Assert.True(outcome.Graph!.Truncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PredictMethod_ResolvesConstantRouteTargets_ThroughInstanceTemplateChain()
|
||||
{
|
||||
_inboundApi.GetApiMethodByIdAsync(4, Arg.Any<CancellationToken>())
|
||||
.Returns(new ApiMethod("Delmia", """return await Route.To("Sim01").Call("Process", new { });""") { Id = 4 });
|
||||
_templates.GetInstanceByUniqueNameAsync("Sim01", Arg.Any<CancellationToken>())
|
||||
.Returns(new Instance("Sim01") { Id = 9, TemplateId = 20, SiteId = 1 });
|
||||
// Derived template 20 has no scripts of its own; its base 10 defines Process.
|
||||
_templates.GetScriptsByTemplateIdAsync(20, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>([]));
|
||||
_templates.GetTemplateByIdAsync(20, Arg.Any<CancellationToken>())
|
||||
.Returns(new Template("Derived") { Id = 20, ParentTemplateId = 10 });
|
||||
_templates.GetScriptsByTemplateIdAsync(10, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<TemplateScript>>(
|
||||
[
|
||||
new TemplateScript("Process", """await Attributes.SetAsync("Cmd", 1); await Attributes.WaitAsync("Ack", true, System.TimeSpan.FromSeconds(1));""") { Id = 1, TemplateId = 10 },
|
||||
]));
|
||||
_templates.GetTemplateByIdAsync(10, Arg.Any<CancellationToken>())
|
||||
.Returns(new Template("Base") { Id = 10, ParentTemplateId = null });
|
||||
|
||||
var svc = CreateService(reply: null);
|
||||
var flow = await svc.PredictMethodAsync(4);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
FlowStepKind.InboundRequest, FlowStepKind.RouteCall, FlowStepKind.ScriptRun,
|
||||
FlowStepKind.AttributeWrite, FlowStepKind.AttributeWait, FlowStepKind.Response,
|
||||
], flow.Nodes.Select(n => n.Kind).ToList());
|
||||
Assert.Equal("Sim01.Process", flow.Nodes[2].Title);
|
||||
Assert.Equal("Sim01.Cmd", flow.Nodes[3].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PredictMethod_UnknownMethod_IsEmpty()
|
||||
{
|
||||
_inboundApi.GetApiMethodByIdAsync(99, Arg.Any<CancellationToken>()).Returns((ApiMethod?)null);
|
||||
var svc = CreateService(reply: null);
|
||||
|
||||
var flow = await svc.PredictMethodAsync(99);
|
||||
|
||||
Assert.Empty(flow.Nodes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PredictValueChange_RootsAtTheWrite_AndAppendsEachTriggeredScript()
|
||||
{
|
||||
_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 });
|
||||
|
||||
var svc = CreateService(reply: null);
|
||||
var flow = await svc.PredictValueChangeAsync(9, "Flag");
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
(FlowStepKind.AttributeWrite, "Sim01.Flag"),
|
||||
(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()
|
||||
{
|
||||
_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 });
|
||||
|
||||
var svc = CreateService(reply: null);
|
||||
var flow = await svc.PredictValueChangeAsync(9, "Flag");
|
||||
|
||||
var root = Assert.Single(flow.Nodes);
|
||||
Assert.Contains("no value-change script", root.Note);
|
||||
}
|
||||
|
||||
private sealed class StubAuth : AuthenticationStateProvider
|
||||
{
|
||||
private readonly ClaimsPrincipal _user;
|
||||
public StubAuth(ClaimsPrincipal user) => _user = user;
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(_user));
|
||||
}
|
||||
|
||||
private sealed class ScriptedManagementActor : ReceiveActor
|
||||
{
|
||||
public ScriptedManagementActor(object reply, List<ManagementEnvelope> received)
|
||||
{
|
||||
Receive<ManagementEnvelope>(env =>
|
||||
{
|
||||
lock (received) received.Add(env);
|
||||
Sender.Tell(reply);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user