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 WITHIN each execution: the builder must order by time inside one execution. // The executions themselves are given in emission order (root, site run, cascade) — since arch-review // F5 that is what fixes their relative order, because they do not share a clock. return [inbound, route, wait, run, write2, write1, cascade]; } [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 one boundary crossing: route → site run. The Response links from the RouteCall (D9), Central → Central. var crossing = Assert.Single(graph.Edges, e => e.CrossesBoundary); Assert.Equal(graph.Steps[1].EventId, crossing.FromEventId); Assert.Equal(graph.Steps[2].EventId, crossing.ToEventId); Assert.Contains(graph.Edges, e => e.FromEventId == graph.Steps[1].EventId && e.ToEventId == graph.Steps[7].EventId && !e.CrossesBoundary); Assert.DoesNotContain(graph.Edges, 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) — a Spawn edge (D9); // the route → run edge and every in-chain edge are not. var spawn = Assert.Single(graph.Edges, e => e.Spawn); Assert.Equal(graph.Steps[5].EventId, spawn.FromEventId); Assert.Equal(graph.Steps[6].EventId, spawn.ToEventId); Assert.False(spawn.CrossesBoundary); Assert.Contains(graph.Edges, e => e.FromEventId == graph.Steps[1].EventId && e.ToEventId == graph.Steps[2].EventId && !e.Spawn); Assert.DoesNotContain(graph.Edges, e => e.Async); // 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")); } // ---- Outbound calls (2026-09-06 plan, Task 2) -------------------------------------------- private static AuditEvent SiteRunRow(int ms, Guid executionId = default) => ScadaBridgeAuditEventFactory.Create( AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, occurredAtUtc: T0.AddMilliseconds(ms), actor: "S", target: "S", sourceNode: "node-a", executionId: executionId == default ? SiteRun : executionId, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 800, extra: new ScriptTraceExtra { TriggerKind = "call" }.ToJson()); private static AuditEvent WriteRow(int ms, string attr) => ScadaBridgeAuditEventFactory.Create( AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered, occurredAtUtc: T0.AddMilliseconds(ms), actor: "S", target: attr, sourceNode: "node-a", executionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 2, extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail(attr, "1", true, null)] }.ToJson()); private static AuditEvent Outbound(AuditChannel channel, AuditKind kind, AuditStatus status, int ms, Guid? correlation, string target, string node = "node-a", string? error = null, int? durationMs = null, string? request = null, string? response = null) => ScadaBridgeAuditEventFactory.Create(channel, kind, status, occurredAtUtc: T0.AddMilliseconds(ms), actor: "S", target: target, sourceNode: node, correlationId: correlation, executionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", errorMessage: error, durationMs: durationMs, requestSummary: request, responseSummary: response); [Fact] public void CachedApiCall_FourRows_FoldIntoOneAsyncNode() { var corr = Guid.NewGuid(); var events = new List { SiteRunRow(20), WriteRow(30, "A"), Outbound(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted, 35, corr, "Delmia.AckRecipe", request: "{\"id\":1}"), WriteRow(40, "B"), Outbound(AuditChannel.ApiOutbound, AuditKind.ApiCallCached, AuditStatus.Forwarded, 2000, corr, "Delmia.AckRecipe"), Outbound(AuditChannel.ApiOutbound, AuditKind.ApiCallCached, AuditStatus.Attempted, 3000, corr, "Delmia.AckRecipe", error: "503", durationMs: 40), Outbound(AuditChannel.ApiOutbound, AuditKind.CachedResolve, AuditStatus.Delivered, 4135, corr, "Delmia.AckRecipe", response: "{\"ok\":true}"), }; var graph = FlowGraphBuilder.Build(events); Assert.Equal(4, graph.Steps.Count); var api = Assert.Single(graph.Steps, s => s.Kind == FlowStepKind.ApiCall); Assert.Equal(FlowLane.External, api.Lane); Assert.Equal(events[2].EventId, api.EventId); Assert.Equal(T0.AddMilliseconds(35), api.OccurredAtUtc.UtcDateTime); Assert.Equal(4, api.Lifecycle!.Count); Assert.Equal(new[] { "CachedSubmit", "ApiCallCached", "ApiCallCached", "CachedResolve" }, api.Lifecycle.Select(r => r.Kind)); Assert.Equal(corr, api.CorrelationId); Assert.Equal("503", api.Lifecycle[2].ErrorMessage); Assert.Equal(FlowStepOutcome.Ok, api.Outcome); Assert.Equal(4100, api.DurationMs); Assert.Equal("{\"id\":1}", api.InputJson); Assert.Equal("{\"ok\":true}", api.OutputJson); Assert.Equal("Delivered · cached", api.Subtitle); Assert.Null(api.ErrorMessage); Assert.Equal(SiteRun, api.ExecutionId); var run = graph.Steps[0]; var w1 = graph.Steps[1]; var w2 = graph.Steps[3]; Assert.Equal(new[] { FlowStepKind.ScriptRun, FlowStepKind.AttributeWrite, FlowStepKind.ApiCall, FlowStepKind.AttributeWrite }, graph.Steps.Select(s => s.Kind)); var async = Assert.Single(graph.Edges, e => e.Async); Assert.Equal(w1.EventId, async.FromEventId); Assert.Equal(api.EventId, async.ToEventId); Assert.Contains(graph.Edges, e => e.FromEventId == run.EventId && e.ToEventId == w1.EventId && !e.Async); Assert.Contains(graph.Edges, e => e.FromEventId == w1.EventId && e.ToEventId == w2.EventId && !e.Async); Assert.DoesNotContain(graph.Edges, e => e.FromEventId == api.EventId); Assert.Equal(3, graph.Edges.Count); } [Fact] public void SyncApiCall_SitsInChain_NoAsyncEdge() { var events = new List { SiteRunRow(20), WriteRow(30, "A"), Outbound(AuditChannel.ApiOutbound, AuditKind.ApiCall, AuditStatus.Delivered, 35, null, "Delmia.AckRecipe", durationMs: 212), WriteRow(40, "B"), }; var graph = FlowGraphBuilder.Build(events); var api = Assert.Single(graph.Steps, s => s.Kind == FlowStepKind.ApiCall); Assert.Equal(212, api.DurationMs); Assert.Single(api.Lifecycle!); Assert.Equal("Delivered · sync", api.Subtitle); Assert.DoesNotContain(graph.Edges, e => e.Async); Assert.Contains(graph.Edges, e => e.FromEventId == graph.Steps[1].EventId && e.ToEventId == api.EventId); Assert.Contains(graph.Edges, e => e.FromEventId == api.EventId && e.ToEventId == graph.Steps[3].EventId); Assert.DoesNotContain(graph.Edges, e => e.FromEventId == graph.Steps[1].EventId && e.ToEventId == graph.Steps[3].EventId); } [Fact] public void TwoSyncCalls_NullCorrelation_AreNotFoldedTogether() { var events = new List { SiteRunRow(20), Outbound(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Delivered, 30, null, "MesDb"), Outbound(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Failed, 40, null, "MesDb", error: "deadlock"), }; var graph = FlowGraphBuilder.Build(events); Assert.Equal(2, graph.Steps.Count(s => s.Kind == FlowStepKind.DbCall)); Assert.Equal(FlowStepOutcome.Failed, graph.Steps[2].Outcome); Assert.Equal(2, graph.Edges.Count); } [Fact] public void NotifySend_PlusCentralDeliverParked_FoldsIntoOneParkedNotifyNode() { var notificationId = Guid.NewGuid(); var events = new List { SiteRunRow(20), WriteRow(30, "A"), Outbound(AuditChannel.Notification, AuditKind.NotifySend, AuditStatus.Submitted, 35, notificationId, "Planners", request: "{\"subject\":\"hi\"}"), Outbound(AuditChannel.Notification, AuditKind.NotifyDeliver, AuditStatus.Attempted, 5000, notificationId, "Planners", node: "central-a", error: "smtp down", durationMs: 30), Outbound(AuditChannel.Notification, AuditKind.NotifyDeliver, AuditStatus.Parked, 9000, notificationId, "Planners", node: "central-a", error: "retries exhausted"), }; var graph = FlowGraphBuilder.Build(events); var notify = Assert.Single(graph.Steps, s => s.Kind == FlowStepKind.Notify); Assert.Equal(FlowStepOutcome.Parked, notify.Outcome); Assert.Equal(3, notify.Lifecycle!.Count); Assert.Equal("central-a", notify.Lifecycle[2].Node); Assert.Equal("retries exhausted", notify.ErrorMessage); Assert.Equal(8965, notify.DurationMs); Assert.Equal("Parked", notify.Subtitle); Assert.Equal("node-a", notify.Node); var async = Assert.Single(graph.Edges, e => e.Async); Assert.Equal(graph.Steps[1].EventId, async.FromEventId); Assert.Equal(notify.EventId, async.ToEventId); } [Fact] public void CachedSubmitOnly_IsPending_WithNoDuration() { var events = new List { SiteRunRow(20), Outbound(AuditChannel.DbOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted, 35, Guid.NewGuid(), "MesDb"), }; var graph = FlowGraphBuilder.Build(events); var db = Assert.Single(graph.Steps, s => s.Kind == FlowStepKind.DbCall); Assert.Equal(FlowStepOutcome.Pending, db.Outcome); Assert.Null(db.DurationMs); Assert.Single(db.Lifecycle!); var async = Assert.Single(graph.Edges); Assert.True(async.Async); Assert.Equal(graph.Steps[0].EventId, async.FromEventId); } [Fact] public void ResponseLinksFromTheInboundExecutionsLastStep_NotTheLastCascadedStep() { var events = DelmiaShaped(); var graph = FlowGraphBuilder.Build(events); var response = Assert.Single(graph.Steps, s => s.Kind == FlowStepKind.Response); var into = Assert.Single(graph.Edges, e => e.ToEventId == response.EventId); Assert.Equal(graph.Steps[1].EventId, into.FromEventId); Assert.Equal(FlowStepKind.RouteCall, graph.Steps[1].Kind); } [Fact] public void ResponseFallsBackToTheInboundRow_WhenNothingElseSharesItsExecution() { var inbound = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered, occurredAtUtc: T0, actor: "k", target: "Ping", sourceNode: "central-a", executionId: Root, durationMs: 5, responseSummary: "{}"); var graph = FlowGraphBuilder.Build([inbound]); Assert.Equal(2, graph.Steps.Count); var edge = Assert.Single(graph.Edges); Assert.Equal(graph.Steps[0].EventId, edge.FromEventId); Assert.Equal(graph.Steps[1].EventId, edge.ToEventId); } // ---- Arch-review F5: emission order between executions, timestamps within one ------------ /// /// Central and site clocks differ. The four rows are given in EMISSION order (the Live path's /// collector order); the graph must keep the executions in that order whichever way the skew runs. /// private static List SkewedRouteShape(int siteSkewMs) { var inbound = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered, occurredAtUtc: T0, actor: "k", target: "M", sourceNode: "central-a", executionId: Root, durationMs: 900, requestSummary: "{}", responseSummary: "{\"ok\":true}"); var route = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered, occurredAtUtc: T0.AddMilliseconds(50), target: "M1.S", sourceNode: "central-a", executionId: Root, extra: new ScriptTraceExtra { ChildExecutionId = SiteRun }.ToJson()); var run = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, occurredAtUtc: T0.AddMilliseconds(siteSkewMs), actor: "S", target: "S", sourceNode: "node-a", executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 20, extra: new ScriptTraceExtra { TriggerKind = "call" }.ToJson()); var write = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered, occurredAtUtc: T0.AddMilliseconds(siteSkewMs + 10), actor: "S", target: "A", sourceNode: "node-a", executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 2, extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("A", "1", true, null)] }.ToJson()); return [inbound, route, run, write]; } [Fact] public void SiteClockAhead_SiteStepsStillFollowTheirRouteCall_AndPrecedeResponse() { var events = SkewedRouteShape(siteSkewMs: 2000); // site 2 s ahead of central var graph = FlowGraphBuilder.Build(events); Assert.Equal( new[] { FlowStepKind.InboundRequest, FlowStepKind.RouteCall, FlowStepKind.ScriptRun, FlowStepKind.AttributeWrite, FlowStepKind.Response, }, graph.Steps.Select(s => s.Kind)); Assert.Equal(events.Select(e => e.EventId), graph.Steps.Take(4).Select(s => s.EventId)); // The Response time still comes off the inbound row's own clock (inbound + DurationMs). Assert.Equal(T0.AddMilliseconds(900), graph.Steps[4].OccurredAtUtc.UtcDateTime); } [Fact] public void SiteClockBehind_SiteStepsDoNotSortAheadOfTheInboundRequest() { var events = SkewedRouteShape(siteSkewMs: -2000); // site 2 s behind central var graph = FlowGraphBuilder.Build(events); Assert.Equal( new[] { FlowStepKind.InboundRequest, FlowStepKind.RouteCall, FlowStepKind.ScriptRun, FlowStepKind.AttributeWrite, FlowStepKind.Response, }, graph.Steps.Select(s => s.Kind)); Assert.Equal(events.Select(e => e.EventId), graph.Steps.Take(4).Select(s => s.EventId)); } [Fact] public void SiteClockBehind_TriggerCascade_SpawnsFromTheLastParentStep_NotTheFirst() { // Mixed-version shape: no RouteCall row, so the spawner is found by position, and a site // clock behind central used to push every parent step "after" the child → parentChain[0]. var inbound = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered, occurredAtUtc: T0, actor: "k", target: "M", sourceNode: "central-a", executionId: Root, durationMs: 900); var centralDb = ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Delivered, occurredAtUtc: T0.AddMilliseconds(10), actor: "api:M", target: "CfgDb", sourceNode: "central-a", executionId: Root); var run = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, occurredAtUtc: T0.AddMilliseconds(-2000), actor: "S", target: "S", sourceNode: "node-a", executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 20, extra: new ScriptTraceExtra { TriggerKind = "call" }.ToJson()); var graph = FlowGraphBuilder.Build([inbound, centralDb, run]); var spawn = Assert.Single(graph.Edges, e => e.Spawn); Assert.Equal(centralDb.EventId, spawn.FromEventId); // the LAST parent step, not the inbound row Assert.Equal(run.EventId, spawn.ToEventId); } [Fact] public void SameClock_TriggerCascade_SpawnsFromTheWriteThatFiredIt_NotTheParentsLastStep() { // Review follow-up: executions are contiguous blocks, so "the last parent step BEFORE the child // in display order" is always the parent's last step. Parent and child of a cascade share the // SITE clock, so the write that fired the trigger is the last parent step at or before the child. var run = SiteRunRow(0); var writeA = WriteRow(10, "DelmiaReceiver.Trigger"); var wait = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWait, AuditStatus.Delivered, occurredAtUtc: T0.AddMilliseconds(20), actor: "S", target: "DelmiaReceiver.Processed", sourceNode: "node-a", executionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 5, extra: new ScriptTraceExtra { WaitAttribute = "DelmiaReceiver.Processed", WaitOutcome = "satisfied" }.ToJson()); var writeB = WriteRow(30, "DelmiaReceiver.Ack"); var cascade = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, occurredAtUtc: T0.AddMilliseconds(15), actor: "OnTrigger", target: "OnTrigger", sourceNode: "node-a", executionId: Cascade, parentExecutionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 4, extra: new ScriptTraceExtra { TriggerKind = "valuechange", TriggerAttribute = "DelmiaReceiver.Trigger" }.ToJson()); var graph = FlowGraphBuilder.Build([run, writeA, wait, writeB, cascade]); var spawn = Assert.Single(graph.Edges, e => e.Spawn); Assert.Equal(writeA.EventId, spawn.FromEventId); Assert.Equal(cascade.EventId, spawn.ToEventId); } [Fact] public void SimultaneousWrites_TriggerCascade_SpawnsFromTheWriteOfTheTriggerAttribute_NotTheLastWrite() { // Rig 2026-09-08: a run wrote Result, ResultText, Flag=false and Processed inside the same millisecond, // and the value-change child (trigger attribute Flag) was drawn spawning from Processed — the last write // at or before the child. The child names its trigger attribute, so that write wins outright. var run = SiteRunRow(0); var result = WriteRow(10, "DelmiaReceiver.Result"); var flag = WriteRow(10, "DelmiaReceiver.Flag"); var processed = WriteRow(10, "DelmiaReceiver.Processed"); var cascade = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, occurredAtUtc: T0.AddMilliseconds(10), actor: "OnFlag", target: "OnFlag", sourceNode: "node-a", executionId: Cascade, parentExecutionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 3, extra: new ScriptTraceExtra { TriggerKind = "valuechange", TriggerAttribute = "delmiareceiver.flag" }.ToJson()); var graph = FlowGraphBuilder.Build([run, result, flag, processed, cascade]); var spawn = Assert.Single(graph.Edges, e => e.Spawn); Assert.Equal(flag.EventId, spawn.FromEventId); Assert.Equal(cascade.EventId, spawn.ToEventId); } [Fact] public void TriggerCascade_WhoseAttributeTheParentNeverWrote_FallsBackToTheTimeRule() { // The device echo of a DCL-sourced change, or a trace from an older site that names an attribute // the parent chain has no write for: the attribute rule finds nothing and the time rule still applies. var run = SiteRunRow(0); var writeA = WriteRow(10, "DelmiaReceiver.A"); var writeB = WriteRow(30, "DelmiaReceiver.B"); var cascade = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, occurredAtUtc: T0.AddMilliseconds(15), actor: "OnX", target: "OnX", sourceNode: "node-a", executionId: Cascade, parentExecutionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 3, extra: new ScriptTraceExtra { TriggerKind = "valuechange", TriggerAttribute = "DelmiaReceiver.X" }.ToJson()); var graph = FlowGraphBuilder.Build([run, writeA, writeB, cascade]); var spawn = Assert.Single(graph.Edges, e => e.Spawn); Assert.Equal(writeA.EventId, spawn.FromEventId); } [Fact] public void FoldedNode_TakesTheEmissionIndexOfItsEarliestEmittedRow() { // The folded step keeps the earliest-by-TIME row's id (the resolve here is emitted first), // so its sequence must be the minimum input index over the rows folded into it. var corr = Guid.NewGuid(); var resolve = Outbound(AuditChannel.ApiOutbound, AuditKind.CachedResolve, AuditStatus.Delivered, 4000, corr, "Delmia.Ack", response: "res"); var submit = Outbound(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted, 35, corr, "Delmia.Ack", request: "req"); var run = SiteRunRow(20, Cascade); // Emission order interleaves the executions: the folded node's earliest row is index 0, so // its execution leads — even though the id the folded step keeps (the submit) is index 2. var graph = FlowGraphBuilder.Build([resolve, run, submit]); var api = Assert.Single(graph.Steps, s => s.Kind == FlowStepKind.ApiCall); Assert.Equal(submit.EventId, api.EventId); Assert.Equal(new[] { FlowStepKind.ApiCall, FlowStepKind.ScriptRun }, graph.Steps.Select(s => s.Kind)); } // ---- Review findings on Task 2 (2026-09-06) -------------------------------------------- [Fact] public void LeadingAsyncSteps_HangOffTheSpawner_NeverOrphaned() { // A routed run whose ScriptTrace rows are absent (TraceAllCalls off / older site): only its outbound rows survive. var route = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered, occurredAtUtc: T0.AddMilliseconds(10), target: "M1.S", sourceNode: "central-a", executionId: Root, extra: new ScriptTraceExtra { ChildExecutionId = SiteRun }.ToJson()); var cached = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted, occurredAtUtc: T0.AddMilliseconds(20), target: "Delmia.Ack", correlationId: Guid.NewGuid(), executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a"); var notify = ScadaBridgeAuditEventFactory.Create(AuditChannel.Notification, AuditKind.NotifySend, AuditStatus.Submitted, occurredAtUtc: T0.AddMilliseconds(30), target: "Planners", correlationId: Guid.NewGuid(), executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a"); var sync = ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Delivered, occurredAtUtc: T0.AddMilliseconds(40), target: "MesDb", executionId: SiteRun, parentExecutionId: Root, sourceSiteId: "site-a"); var graph = FlowGraphBuilder.Build([route, cached, notify, sync]); var routeStep = graph.Steps[0]; var reached = graph.Edges.Select(e => e.ToEventId).ToHashSet(); Assert.All(graph.Steps.Skip(1), s => Assert.Contains(s.EventId, reached)); Assert.Equal(2, graph.Edges.Count(e => e.Async && e.FromEventId == routeStep.EventId)); var syncEdge = Assert.Single(graph.Edges, e => e.ToEventId == graph.Steps[3].EventId); Assert.Equal(routeStep.EventId, syncEdge.FromEventId); Assert.False(syncEdge.Async); Assert.False(syncEdge.Spawn); Assert.Equal(3, graph.Edges.Count); } [Fact] public void OnlyNotifyRows_InAnExecution_AllLinkFromTheSpawner() { var run = SiteRunRow(20); var n1 = ScadaBridgeAuditEventFactory.Create(AuditChannel.Notification, AuditKind.NotifySend, AuditStatus.Submitted, occurredAtUtc: T0.AddMilliseconds(30), target: "A", correlationId: Guid.NewGuid(), executionId: Cascade, parentExecutionId: SiteRun, sourceSiteId: "site-a"); var n2 = ScadaBridgeAuditEventFactory.Create(AuditChannel.Notification, AuditKind.NotifySend, AuditStatus.Submitted, occurredAtUtc: T0.AddMilliseconds(31), target: "B", correlationId: Guid.NewGuid(), executionId: Cascade, parentExecutionId: SiteRun, sourceSiteId: "site-a"); var graph = FlowGraphBuilder.Build([run, n1, n2]); Assert.Equal(2, graph.Edges.Count); Assert.All(graph.Edges, e => { Assert.True(e.Async); Assert.True(e.Spawn); Assert.Equal(graph.Steps[0].EventId, e.FromEventId); }); } [Fact] public void Fold_IsOrderIndependent_AndTiesBreakByLifecycleRank() { var corr = Guid.NewGuid(); var submit = Outbound(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted, 35, corr, "Delmia.Ack", request: "req"); var resolve = Outbound(AuditChannel.ApiOutbound, AuditKind.CachedResolve, AuditStatus.Delivered, 35, corr, "Delmia.Ack", response: "res"); var a = FlowGraphBuilder.Build([SiteRunRow(20), resolve, submit]); var b = FlowGraphBuilder.Build([SiteRunRow(20), submit, resolve]); var sa = Assert.Single(a.Steps, s => s.Kind == FlowStepKind.ApiCall); var sb = Assert.Single(b.Steps, s => s.Kind == FlowStepKind.ApiCall); Assert.Equal(submit.EventId, sa.EventId); Assert.Equal(sa.EventId, sb.EventId); Assert.Equal("req", sa.InputJson); Assert.Equal("res", sa.OutputJson); Assert.Equal(FlowStepOutcome.Ok, sa.Outcome); Assert.Equal(0, sa.DurationMs); Assert.True(FlowGraphBuilder.IsAsync(sa)); Assert.Equal(sa.Lifecycle!.Select(r => r.Kind), sb.Lifecycle!.Select(r => r.Kind)); } [Fact] public void CachedStep_WhoseSubmitRowIsMissing_IsStillAsync() { var corr = Guid.NewGuid(); var graph = FlowGraphBuilder.Build([ SiteRunRow(20), Outbound(AuditChannel.ApiOutbound, AuditKind.ApiCallCached, AuditStatus.Attempted, 3000, corr, "Delmia.Ack"), Outbound(AuditChannel.ApiOutbound, AuditKind.CachedResolve, AuditStatus.Delivered, 4000, corr, "Delmia.Ack"), ]); var api = Assert.Single(graph.Steps, s => s.Kind == FlowStepKind.ApiCall); Assert.True(FlowGraphBuilder.IsAsync(api)); Assert.Single(graph.Edges, e => e.Async && e.ToEventId == api.EventId); } [Fact] public void ExternalLaneEdges_NeverCountAsClusterBoundaryCrossings() { var inbound = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered, occurredAtUtc: T0, target: "M", sourceNode: "central-a", executionId: Root, durationMs: 50, responseSummary: "{}"); var centralDb = ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Delivered, occurredAtUtc: T0.AddMilliseconds(5), actor: "api:M", target: "CfgDb", sourceNode: "central-a", executionId: Root, extra: "{\"op\":\"read\",\"rowsReturned\":2}"); var graph = FlowGraphBuilder.Build([inbound, centralDb]); Assert.DoesNotContain(graph.Edges, e => e.CrossesBoundary); Assert.Equal("read · 2 rows", graph.Steps[1].Subtitle); // Response links from the DB call — the inbound execution's last sync step. var into = Assert.Single(graph.Edges, e => e.ToEventId == graph.Steps[2].EventId); Assert.Equal(graph.Steps[1].EventId, into.FromEventId); } // ---- Script entry point (2026-09-08 plan, Task 6) ----------------------------------------- [Fact] public void RouteCallRoot_WithoutInboundRow_BuildsCentralToSite_AndNoResponse() { // Flow Debugger script entry (design 2026-09-08 §4): a direct script call's root is an // operator-attributed RouteCall row — there is no InboundRequest and no Response. var root = Guid.NewGuid(); var run = Guid.NewGuid(); var events = new List { ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered, occurredAtUtc: T0, actor: "ops", target: "M1.Manual", sourceNode: "central-a", executionId: root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 50, requestSummary: "{\"n\":3}", responseSummary: "42", extra: new ScriptTraceExtra { Source = "flow-debugger", ChildExecutionId = run }.ToJson()), ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, occurredAtUtc: T0.AddMilliseconds(5), actor: "Manual", target: "Manual", sourceNode: "node-a", executionId: run, parentExecutionId: root, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 40, extra: new ScriptTraceExtra { TriggerKind = "call" }.ToJson()), }; var graph = FlowGraphBuilder.Build(events); Assert.Equal([FlowStepKind.RouteCall, FlowStepKind.ScriptRun], graph.Steps.Select(s => s.Kind).ToList()); Assert.Equal([FlowLane.Central, FlowLane.Site], graph.Steps.Select(s => s.Lane).ToList()); var edge = Assert.Single(graph.Edges); Assert.Equal((graph.Steps[0].EventId, graph.Steps[1].EventId, true), (edge.FromEventId, edge.ToEventId, edge.CrossesBoundary)); Assert.DoesNotContain(graph.Steps, s => s.Kind == FlowStepKind.Response); } }