using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; using ZB.MOM.WW.ScadaBridge.ScriptAnalysis; namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests; /// /// Tests for (Flow Debugger design 2026-09-05 §5.5) against /// the Delmia recipe-download scripts verbatim from /// docs/api-specs/Delmia-Recipe-Download-API.md §4. /// public class FlowShapePredictorTests { private const string DelmiaMethodScript = """ try { System.Func P = k => Parameters.ContainsKey(k) ? Parameters[k] : null; var code = (P("MachineCode") as string ?? "").Trim(); if (string.IsNullOrEmpty(code)) return new { Result = false, ResultText = "MachineCode is required" }; var suf = code.Substring(code.Length - 1); return await Route.To(code).Call("ProcessRecipeDownload", new { DownloadPath = P("DownloadPath"), WorkOrderNumber = P("WorkOrderNumber"), PartNumber = P("PartNumber"), JobStepNumber = P("JobStepNumber"), Username = P("Username"), }); } catch (Exception ex) { return new { Result = false, ResultText = "RecipeDownload failed: " + ex.Message }; } """; private const string DelmiaSiteScript = """ // Generic DELMIA recipe-download — performed DIRECTLY on the composed DelmiaReceiver child var recv = Children["DelmiaRecv"]; System.Func IsTrue = v => { var s=v?.ToString(); return (v is bool b && b)||s=="1"||string.Equals(s,"true",System.StringComparison.OrdinalIgnoreCase); }; System.Func P = k => Parameters.ContainsKey(k) ? Parameters[k] : null; if (!IsTrue(recv.Attributes["ReadyFlag"])) return new { Result=false, ResultText="Receiver ready flag not set to true" }; try { recv.Attributes["RecipeProcessedFlag"] = false; var data = new System.Collections.Generic.Dictionary { { "DownloadPath", P("DownloadPath") }, { "WorkOrderNumber", P("WorkOrderNumber") }, { "PartNumber", P("PartNumber") }, { "JobStepNumber", P("JobStepNumber") }, { "Username", P("Username") }, }; if (!await recv.Attributes.WriteBatchAndWaitAsync(data, "RecipeDownloadFlag", true, "RecipeProcessedFlag", true, System.TimeSpan.FromSeconds(25))) return new { Result=false, ResultText="Timeout waiting for recipe to be processed" }; return new { Result = IsTrue(recv.Attributes["RecipeProcessResult"]), ResultText = recv.Attributes["RecipeProcessResultText"]?.ToString() ?? "" }; } catch (Exception ex) { return new { Result=false, ResultText="RecipeDownload failed: " + ex.Message }; } """; private static string? AnySiteScript(string instanceCode, string scriptName) => scriptName == "ProcessRecipeDownload" ? DelmiaSiteScript : null; [Fact] public void Delmia_MethodPlusSiteScript_PredictsTheFullSwimlaneShape() { var flow = FlowShapePredictor.PredictInboundMethod("DelmiaRecipeDownload", DelmiaMethodScript, AnySiteScript); // Request → route call → site run → RecipeProcessedFlag write → batch write → wait → response. // (The InboundRequest node IS the method-script run: the real graph records no separate // "method script" step, so the prediction mirrors that to keep the overlay honest.) var kinds = flow.Nodes.Select(n => (n.Kind, n.Lane)).ToList(); Assert.Equal( [ (FlowStepKind.InboundRequest, FlowLane.Central), (FlowStepKind.RouteCall, FlowLane.Central), (FlowStepKind.ScriptRun, FlowLane.Site), (FlowStepKind.AttributeWrite, FlowLane.Tags), (FlowStepKind.AttributeWrite, FlowLane.Tags), (FlowStepKind.AttributeWait, FlowLane.Site), (FlowStepKind.Response, FlowLane.Central), ], kinds); Assert.Equal("POST /api/DelmiaRecipeDownload", flow.Nodes[0].Title); // The route target is `code`, computed from Parameters — input-dependent. var route = flow.Nodes[1]; Assert.True(route.InputDependent); Assert.Contains("Route.To(code)", route.Title); Assert.Contains("ProcessRecipeDownload", route.Title); // The early-return guard that precedes the route call is surfaced on it. Assert.Contains("early return: string.IsNullOrEmpty(code)", route.Note); Assert.Equal("ProcessRecipeDownload", flow.Nodes[2].Title); Assert.Contains("RecipeProcessedFlag", flow.Nodes[3].Title); Assert.Contains("early return: !IsTrue(recv.Attributes[\"ReadyFlag\"])", flow.Nodes[3].Note); Assert.Contains("RecipeDownloadFlag", flow.Nodes[4].Title); Assert.Contains("RecipeProcessedFlag", flow.Nodes[5].Title); Assert.False(flow.Nodes[5].InputDependent); // Chain edges: n1→n2 (central), n2→n3 crosses, n3→n4, n4→n5, n5→n6, n6→n7 crosses back. Assert.Equal(6, flow.Edges.Count); Assert.All(flow.Edges.Zip(flow.Nodes.Skip(1)), pair => Assert.Equal(pair.Second.Id, pair.First.To)); Assert.True(flow.Edges[1].CrossesBoundary); Assert.False(flow.Edges[2].CrossesBoundary); Assert.True(flow.Edges[^1].CrossesBoundary); } [Fact] public void ConstantRouteTarget_IsNotInputDependent_AndQualifiesSiteTitles() { const string script = """ return await Route.To("Z28061Sim").Call("ProcessRecipeDownload", new { A = 1 }); """; var flow = FlowShapePredictor.PredictInboundMethod("M", script, AnySiteScript); var route = flow.Nodes.Single(n => n.Kind == FlowStepKind.RouteCall); Assert.False(route.InputDependent); Assert.Equal("Z28061Sim.ProcessRecipeDownload", route.Title); Assert.Null(route.Note); var run = flow.Nodes.Single(n => n.Kind == FlowStepKind.ScriptRun); Assert.Equal("Z28061Sim.ProcessRecipeDownload", run.Title); var write = flow.Nodes.First(n => n.Kind == FlowStepKind.AttributeWrite); Assert.StartsWith("Z28061Sim.", write.Title); } [Fact] public void ResolverReturningNull_LeavesTheSiteRunUnexpanded() { const string script = """return await Route.To("X").Call("S");"""; var flow = FlowShapePredictor.PredictInboundMethod("M", script, (_, _) => null); Assert.Equal( [ FlowStepKind.InboundRequest, FlowStepKind.RouteCall, FlowStepKind.ScriptRun, FlowStepKind.Response, ], flow.Nodes.Select(n => n.Kind).ToList()); } [Fact] public void NoRecognisedCall_YieldsOnlyRequestAndResponse() { var flow = FlowShapePredictor.PredictInboundMethod("Ping", "return new { Ok = true };", AnySiteScript); Assert.Equal(2, flow.Nodes.Count); Assert.Equal(FlowStepKind.InboundRequest, flow.Nodes[0].Kind); Assert.Equal(FlowStepKind.Response, flow.Nodes[1].Kind); Assert.Null(flow.Nodes[0].Note); var edge = Assert.Single(flow.Edges); Assert.False(edge.CrossesBoundary); } [Fact] public void Recursion_StopsAtMaxDepth() { // Each site script calls the next one; the resolver always answers. static string? Chain(string _, string name) => $"""await Scripts.Call("{name}x");"""; const string method = """return await Route.To("I").Call("A");"""; var shallow = FlowShapePredictor.PredictInboundMethod("M", method, Chain, maxDepth: 2); var deep = FlowShapePredictor.PredictInboundMethod("M", method, Chain, maxDepth: 4); // maxDepth 2: A's body is walked at depth 1 (adds Ax), Ax's at depth 2 (adds Axx), // Axx is not expanded → 3 runs. maxDepth 4 → A, Ax, Axx, Axxx, Axxxx = 5. Assert.Equal(3, shallow.Nodes.Count(n => n.Kind == FlowStepKind.ScriptRun)); Assert.Equal(5, deep.Nodes.Count(n => n.Kind == FlowStepKind.ScriptRun)); Assert.Contains(shallow.Nodes, n => n.Note?.Contains("depth limit") == true); Assert.True(deep.Nodes.Count > shallow.Nodes.Count); } [Fact] public void MalformedCode_NeverThrows_AndNotesTheParseError() { var flow = FlowShapePredictor.PredictInboundMethod("M", "return await Route.To(\"X\").Call(", AnySiteScript); Assert.Equal(2, flow.Nodes.Count); Assert.Equal(FlowShapePredictor.ParseErrorNote, flow.Nodes[0].Note); Assert.Equal(FlowStepKind.Response, flow.Nodes[1].Kind); var site = FlowShapePredictor.PredictSiteScript("S", "if ("); var root = Assert.Single(site.Nodes); Assert.Equal(FlowShapePredictor.ParseErrorNote, root.Note); } [Fact] public void PredictSiteScript_RootsAtTheScriptRun_AndRecognisesAttributeOps() { const string body = """ await Attributes.SetAsync("Cmd", 1); await Attributes.WaitAsync("Ack", true, System.TimeSpan.FromSeconds(5)); var r = await ExternalSystem.Call("Mes", "Post", new { }); await Notify.Send("ops", "done"); """; var flow = FlowShapePredictor.PredictSiteScript("Handshake", body); Assert.Equal( [ (FlowStepKind.ScriptRun, "Handshake"), (FlowStepKind.AttributeWrite, "Cmd"), (FlowStepKind.AttributeWait, "Ack"), (FlowStepKind.ApiCall, "ExternalSystem.Call(Mes)"), (FlowStepKind.Notify, "Notify.Send"), ], flow.Nodes.Select(n => (n.Kind, n.Title)).ToList()); Assert.DoesNotContain(flow.Nodes, n => n.Lane == FlowLane.Central); Assert.All(flow.Edges, e => Assert.False(e.CrossesBoundary)); } [Fact] public void InputDependentAttributeName_IsFlagged() { var flow = FlowShapePredictor.PredictSiteScript("S", """Attributes[name] = 1;"""); var write = flow.Nodes.Single(n => n.Kind == FlowStepKind.AttributeWrite); Assert.True(write.InputDependent); Assert.Equal("name", write.Title); } }