cc1bbf3975
FlowLane.External (3); FlowStepKind.External replaced by ApiCall/DbCall/Notify; FlowStepOutcome gains Pending/Parked; new FlowLifecycleRow + additive FlowStep.Lifecycle; FlowEdge gains Async/Spawn (default false). Predictor and graph-view references renamed compile-only (full work in Tasks 6/7). Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
219 lines
10 KiB
C#
219 lines
10 KiB
C#
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
|
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="FlowShapePredictor"/> (Flow Debugger design 2026-09-05 §5.5) against
|
|
/// the Delmia recipe-download scripts verbatim from
|
|
/// <c>docs/api-specs/Delmia-Recipe-Download-API.md</c> §4.
|
|
/// </summary>
|
|
public class FlowShapePredictorTests
|
|
{
|
|
private const string DelmiaMethodScript = """
|
|
try {
|
|
System.Func<string, object> 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<object,bool> IsTrue = v => { var s=v?.ToString(); return (v is bool b && b)||s=="1"||string.Equals(s,"true",System.StringComparison.OrdinalIgnoreCase); };
|
|
System.Func<string,object?> 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<string, object?> {
|
|
{ "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);
|
|
}
|
|
}
|