Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/FlowShapePredictorTests.cs
T

359 lines
16 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, "Mes.Post"),
(FlowStepKind.Notify, "Notify ops"),
], 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));
Assert.Equal(FlowLane.External, flow.Nodes[3].Lane);
Assert.Equal(FlowLane.External, flow.Nodes[4].Lane);
}
[Fact]
public void OutboundCalls_SitOnTheExternalLane_SyncInChain_CachedAndNotifyBranchAsync()
{
// D4/D8: a sync call blocks the run (in-chain); CachedCall/CachedWrite/Notify return at once, so
// they hang off the chain on Async edges and the chain continues from the last sync node.
const string body = """
var r = await ExternalSystem.CallAsync("Delmia", "AckRecipe", new { A = 1 });
await Database.CachedWriteAsync("MesDb", "INSERT INTO t VALUES (1)");
await Notify.SendAsync("Planners", "Recipe acked", "done");
await Database.ExecuteAsync("MesDb", "UPDATE t SET x = 1");
""";
var flow = FlowShapePredictor.PredictSiteScript("Handshake", body);
Assert.Equal(
[
(FlowStepKind.ScriptRun, FlowLane.Site, "Handshake"),
(FlowStepKind.ApiCall, FlowLane.External, "Delmia.AckRecipe"),
(FlowStepKind.DbCall, FlowLane.External, "MesDb · CachedWrite"),
(FlowStepKind.Notify, FlowLane.External, "Notify Planners"),
(FlowStepKind.DbCall, FlowLane.External, "MesDb · Execute"),
], flow.Nodes.Select(n => (n.Kind, n.Lane, n.Title)).ToList());
Assert.All(flow.Nodes, n => Assert.False(n.InputDependent));
Assert.Null(flow.Nodes[1].Note);
Assert.Equal("cached", flow.Nodes[2].Note);
Assert.Null(flow.Nodes[3].Note);
var (run, api, cached, notify, exec) = (flow.Nodes[0].Id, flow.Nodes[1].Id, flow.Nodes[2].Id, flow.Nodes[3].Id, flow.Nodes[4].Id);
Assert.Equal(
[
(run, api, false),
(api, cached, true),
(api, notify, true),
(api, exec, false),
], flow.Edges.Select(e => (e.From, e.To, e.Async)).ToList());
Assert.All(flow.Edges, e => Assert.False(e.CrossesBoundary));
}
[Fact]
public void CachedCall_IsAnAsyncApiCall_AndNotifyTo_ResolvesTheList()
{
// The site script API's real Notify shape is Notify.To(list).Send(subject, message).
const string body = """
await ExternalSystem.CachedCall("Delmia", "AckRecipe");
await Notify.To("Planners").Send("s", "m");
""";
var flow = FlowShapePredictor.PredictSiteScript("S", body);
Assert.Equal(
[
(FlowStepKind.ScriptRun, "S", (string?)null),
(FlowStepKind.ApiCall, "Delmia.AckRecipe", "cached"),
(FlowStepKind.Notify, "Notify Planners", null),
], flow.Nodes.Select(n => (n.Kind, n.Title, n.Note)).ToList());
Assert.All(flow.Nodes.Skip(1), n => Assert.Equal(FlowLane.External, n.Lane));
// Both branch off the run: neither advances the chain.
Assert.Equal([(flow.Nodes[0].Id, true), (flow.Nodes[0].Id, true)], flow.Edges.Select(e => (e.From, e.Async)).ToList());
}
[Fact]
public void OutboundCalls_WithComputedTargets_AreInputDependent()
{
const string body = """
await ExternalSystem.Call(sys, "M");
await Database.QueryAsync(conn, "SELECT 1");
await Notify.Send(list, "s");
""";
var flow = FlowShapePredictor.PredictSiteScript("S", body);
var outbound = flow.Nodes.Skip(1).ToList();
Assert.All(outbound, n => Assert.True(n.InputDependent));
Assert.Equal("ExternalSystem.Call(sys, M)", outbound[0].Title);
Assert.Equal("Database.Query(conn)", outbound[1].Title);
Assert.Equal("Notify.Send(list)", outbound[2].Title);
}
[Fact]
public void InboundMethod_DatabaseCall_CrossesFromCentralToExternal()
{
const string script = """
var rows = await Database.QuerySingleAsync<int>("ConfigDb", "SELECT 1");
return rows;
""";
var flow = FlowShapePredictor.PredictInboundMethod("M", script, AnySiteScript);
var db = flow.Nodes.Single(n => n.Kind == FlowStepKind.DbCall);
Assert.Equal(FlowLane.External, db.Lane);
Assert.Equal("ConfigDb · QuerySingle", db.Title);
var into = flow.Edges.Single(e => e.To == db.Id);
Assert.True(into.CrossesBoundary);
Assert.False(into.Async);
}
[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);
}
/// <summary>Gitea #36: the reset-aware overload predicts the reset write + echo wait ahead of the batch.</summary>
[Fact]
public void ResetAwareWriteBatchAndWait_PredictsResetWriteAndEchoWaitBeforeTheBatch()
{
const string body = """
var data = new System.Collections.Generic.Dictionary<string, object?> { { "MoveOutBatchID", 1 } };
var hs = await Children["RightMESReceiver"].Attributes.WriteBatchAndWaitAsync(
data, "MoveOutFlag", true, "MoveOutCompleteFlag", true, System.TimeSpan.FromSeconds(25),
"MoveOutCompleteFlag", false, System.TimeSpan.FromSeconds(10));
if (hs.ResetFailed) return new { WasSuccessful = false };
""";
var flow = FlowShapePredictor.PredictSiteScript("MesMoveOut", body);
Assert.Equal(
[
(FlowStepKind.ScriptRun, FlowLane.Site, "MesMoveOut"),
(FlowStepKind.AttributeWrite, FlowLane.Tags, "MoveOutCompleteFlag"),
(FlowStepKind.AttributeWait, FlowLane.Site, "MoveOutCompleteFlag"),
(FlowStepKind.AttributeWrite, FlowLane.Tags, "batch + MoveOutFlag"),
(FlowStepKind.AttributeWait, FlowLane.Site, "MoveOutCompleteFlag"),
], flow.Nodes.Select(n => (n.Kind, n.Lane, n.Title)).ToList());
Assert.Equal("reset", flow.Nodes[1].Note);
Assert.Equal("reset echo", flow.Nodes[2].Note);
}
/// <summary>Named reset arguments resolve by name, not position.</summary>
[Fact]
public void ResetAwareWriteBatchAndWait_NamedArguments_ResolveTheResetKey()
{
const string body = """
var hs = await Attributes.WriteBatchAndWaitAsync(data, "Trig", true, "Done", true, t,
resetKey: "Done", resetValue: false);
""";
var flow = FlowShapePredictor.PredictSiteScript("S", body);
Assert.Equal("Done", flow.Nodes[1].Title);
Assert.Equal(FlowStepKind.AttributeWrite, flow.Nodes[1].Kind);
Assert.Equal(5, flow.Nodes.Count);
}
}