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; /// /// 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. /// 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(); /// Inbound → RouteCall → site ScriptRun → write → wait → write, plus a value-change ScriptRun spawned by the second write. private static List 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(), truncated: true).Truncated); Assert.Empty(FlowGraphBuilder.Build(Array.Empty()).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")); } }