feat(commons): FlowStep/FlowGraph projection + FlowGraphBuilder for the Flow Debugger
Central-side projection of ScriptTrace/ApiInbound audit events (design §7.2): lanes Central/Site/Tags, in-execution chaining, spawner→child edges (RouteCall by childExecutionId), inferred write→satisfied-wait echo edge, synthesized Response node. ScriptTraceExtra is the camelCase Extra payload shared by every emitter. Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging;
|
||||
|
||||
/// <summary>
|
||||
/// Flow Debugger design 2026-09-05 §7.2: lane/edge/boundary rules over a Delmia-shaped
|
||||
/// event list, truncation pass-through, and total-ness on unknown kinds.
|
||||
/// </summary>
|
||||
public class FlowGraphBuilderTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly Guid Root = Guid.NewGuid();
|
||||
private static readonly Guid SiteRun = Guid.NewGuid();
|
||||
private static readonly Guid Cascade = Guid.NewGuid();
|
||||
|
||||
/// <summary>Inbound → RouteCall → site ScriptRun → write → wait → write, plus a value-change ScriptRun spawned by the second write.</summary>
|
||||
private static List<AuditEvent> DelmiaShaped()
|
||||
{
|
||||
var inbound = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0, actor: "delmia-key", target: "DelmiaRecipeDownload", sourceNode: "central-a",
|
||||
executionId: Root, durationMs: 900, requestSummary: "{\"MachineCode\":\"M1\"}", responseSummary: "{\"ok\":true}");
|
||||
var route = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0.AddMilliseconds(10), actor: "inbound-api", target: "M1.ProcessRecipeDownload", sourceNode: "central-a",
|
||||
executionId: Root, durationMs: 850, extra: new ScriptTraceExtra { ChildExecutionId = SiteRun }.ToJson());
|
||||
var run = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0.AddMilliseconds(20), actor: "ProcessRecipeDownload", target: "ProcessRecipeDownload", sourceNode: "node-a",
|
||||
executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 800,
|
||||
extra: new ScriptTraceExtra { TriggerKind = "call" }.ToJson());
|
||||
var write1 = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0.AddMilliseconds(30), actor: "ProcessRecipeDownload", target: "DelmiaReceiver.RecipePath", sourceNode: "node-a",
|
||||
executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 5,
|
||||
extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("DelmiaReceiver.RecipePath", "C:\\r.nc", true, null)] }.ToJson());
|
||||
var wait = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWait, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0.AddMilliseconds(40), actor: "ProcessRecipeDownload", target: "DelmiaReceiver.Processed", sourceNode: "node-a",
|
||||
executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 700,
|
||||
extra: new ScriptTraceExtra { WaitAttribute = "DelmiaReceiver.Processed", WaitOutcome = "satisfied", ObservedValue = "true" }.ToJson());
|
||||
var write2 = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0.AddMilliseconds(750), actor: "ProcessRecipeDownload", target: "DelmiaReceiver.Trigger", sourceNode: "node-a",
|
||||
executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 3,
|
||||
extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("DelmiaReceiver.Trigger", "false", true, null)] }.ToJson());
|
||||
var cascade = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
|
||||
occurredAtUtc: T0.AddMilliseconds(760), actor: "OnTriggerReset", target: "OnTriggerReset", sourceNode: "node-a",
|
||||
executionId: Cascade, parentExecutionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 10,
|
||||
extra: new ScriptTraceExtra { TriggerKind = "valuechange", TriggerAttribute = "DelmiaReceiver.Trigger", TriggerValue = "false" }.ToJson());
|
||||
|
||||
// Deliberately shuffled: the builder must order by time.
|
||||
return [wait, cascade, inbound, write2, run, route, write1];
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DelmiaShape_ProjectsLanes_Edges_AndSynthesizedResponse()
|
||||
{
|
||||
var graph = FlowGraphBuilder.Build(DelmiaShaped());
|
||||
|
||||
Assert.False(graph.Predicted);
|
||||
Assert.False(graph.Truncated);
|
||||
Assert.Equal(8, graph.Steps.Count); // 7 events + synthesized Response
|
||||
|
||||
var kinds = graph.Steps.Select(s => s.Kind).ToArray();
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
FlowStepKind.InboundRequest, FlowStepKind.RouteCall, FlowStepKind.ScriptRun, FlowStepKind.AttributeWrite,
|
||||
FlowStepKind.AttributeWait, FlowStepKind.AttributeWrite, FlowStepKind.ScriptRun, FlowStepKind.Response,
|
||||
},
|
||||
kinds);
|
||||
|
||||
Assert.Equal(FlowLane.Central, graph.Steps[0].Lane);
|
||||
Assert.Equal(FlowLane.Central, graph.Steps[1].Lane);
|
||||
Assert.Equal(FlowLane.Site, graph.Steps[2].Lane);
|
||||
Assert.Equal(FlowLane.Tags, graph.Steps[3].Lane);
|
||||
Assert.Equal(FlowLane.Site, graph.Steps[4].Lane);
|
||||
Assert.Equal(FlowLane.Tags, graph.Steps[5].Lane);
|
||||
Assert.Equal(FlowLane.Site, graph.Steps[6].Lane);
|
||||
Assert.Equal(FlowLane.Central, graph.Steps[7].Lane);
|
||||
|
||||
// Exactly two boundary crossings: route → site run, and last site step → response.
|
||||
var crossing = graph.Edges.Where(e => e.CrossesBoundary).ToList();
|
||||
Assert.Equal(2, crossing.Count);
|
||||
Assert.Contains(crossing, e => e.FromEventId == graph.Steps[1].EventId && e.ToEventId == graph.Steps[2].EventId);
|
||||
Assert.Contains(crossing, e => e.FromEventId == graph.Steps[6].EventId && e.ToEventId == graph.Steps[7].EventId);
|
||||
|
||||
// One inferred echo edge: first write → satisfied wait.
|
||||
var external = Assert.Single(graph.Edges, e => e.External);
|
||||
Assert.Equal(graph.Steps[3].EventId, external.FromEventId);
|
||||
Assert.Equal(graph.Steps[4].EventId, external.ToEventId);
|
||||
|
||||
// The cascade run hangs off the write that spawned it (same site, no boundary).
|
||||
Assert.Contains(graph.Edges, e => e.FromEventId == graph.Steps[5].EventId && e.ToEventId == graph.Steps[6].EventId && !e.CrossesBoundary);
|
||||
|
||||
// Inbound → route call chained inside the root execution.
|
||||
Assert.Contains(graph.Edges, e => e.FromEventId == graph.Steps[0].EventId && e.ToEventId == graph.Steps[1].EventId);
|
||||
|
||||
// Projection detail.
|
||||
Assert.Equal("→ " + SiteRun.ToString("D")[..8], graph.Steps[1].Subtitle);
|
||||
Assert.Equal("M1 · call", graph.Steps[2].Subtitle);
|
||||
Assert.Equal("DelmiaReceiver.RecipePath = C:\\r.nc", graph.Steps[3].Subtitle);
|
||||
Assert.Equal("satisfied · true", graph.Steps[4].Subtitle);
|
||||
Assert.Equal("{\"ok\":true}", graph.Steps[7].OutputJson);
|
||||
Assert.All(graph.Steps, s => Assert.Equal(FlowStepOutcome.Ok, s.Outcome));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_IsDeterministic_ResponseIdIsStable()
|
||||
{
|
||||
var events = DelmiaShaped();
|
||||
var a = FlowGraphBuilder.Build(events);
|
||||
var b = FlowGraphBuilder.Build(events);
|
||||
Assert.Equal(a.Steps.Select(s => s.EventId), b.Steps.Select(s => s.EventId));
|
||||
Assert.Equal(a.Edges, b.Edges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Truncated_PassesThrough()
|
||||
{
|
||||
Assert.True(FlowGraphBuilder.Build(DelmiaShaped(), truncated: true).Truncated);
|
||||
Assert.True(FlowGraphBuilder.Build(Array.Empty<AuditEvent>(), truncated: true).Truncated);
|
||||
Assert.Empty(FlowGraphBuilder.Build(Array.Empty<AuditEvent>()).Steps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownKind_ProjectsToUnknown_NeverThrows()
|
||||
{
|
||||
var weird = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = T0,
|
||||
Actor = "x",
|
||||
Action = "Future.Thing",
|
||||
Outcome = AuditOutcome.Success,
|
||||
DetailsJson = "{\"channel\":\"Future\",\"kind\":\"Thing\",\"extra\":\"not json at all\"}",
|
||||
};
|
||||
var garbage = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = T0,
|
||||
Actor = "x",
|
||||
Action = "?",
|
||||
Outcome = AuditOutcome.Failure,
|
||||
DetailsJson = "{{{",
|
||||
};
|
||||
|
||||
var graph = FlowGraphBuilder.Build([weird, garbage]);
|
||||
|
||||
Assert.Equal(2, graph.Steps.Count);
|
||||
Assert.All(graph.Steps, s => Assert.Equal(FlowStepKind.Unknown, s.Kind));
|
||||
Assert.All(graph.Steps, s => Assert.Null(s.Extra));
|
||||
Assert.Empty(graph.Edges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedScriptRun_WithTimeoutMessage_IsTimedOut()
|
||||
{
|
||||
var run = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Failed,
|
||||
occurredAtUtc: T0, target: "S", executionId: SiteRun, errorMessage: "Script timed out after 30s");
|
||||
var write = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Failed,
|
||||
occurredAtUtc: T0.AddMilliseconds(1), target: "A", executionId: SiteRun, errorMessage: "rejected");
|
||||
|
||||
var graph = FlowGraphBuilder.Build([run, write]);
|
||||
|
||||
Assert.Equal(FlowStepOutcome.TimedOut, graph.Steps[0].Outcome);
|
||||
Assert.Equal(FlowStepOutcome.Failed, graph.Steps[1].Outcome);
|
||||
Assert.Single(graph.Edges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptTraceExtra_RoundTrips_CamelCase_AndIgnoresUnknownMembers()
|
||||
{
|
||||
var extra = new ScriptTraceExtra { ChildExecutionId = SiteRun, WaitTimeoutMs = 1500, Source = "flow-debugger" };
|
||||
var json = extra.ToJson();
|
||||
Assert.Contains("\"childExecutionId\"", json);
|
||||
Assert.DoesNotContain("triggerKind", json);
|
||||
|
||||
var back = ScriptTraceExtra.TryParse(json.Insert(1, "\"futureField\":1,"));
|
||||
Assert.Equal(extra, back);
|
||||
Assert.Null(ScriptTraceExtra.TryParse(null));
|
||||
Assert.Null(ScriptTraceExtra.TryParse("nope"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user