feat(script-analysis): FlowShapePredictor — predicted flow graph from script syntax
Pure Roslyn syntax walk (SourceCodeKind.Script, no semantic model, DB-free) that turns a method or site script into PredictedNode/PredictedEdge chains in source order: Route.To(x) .Call/SetAttributes/WaitForAttribute, Scripts.Call, Attributes.WriteBatchAndWaitAsync/ WaitAsync/WaitForAsync/SetAsync, Attributes[x] = v, ExternalSystem/Database/Notify. String literals resolve; anything else is InputDependent with the expression text as title. An if-return guard becomes a note on the node that follows it (condition visited first, so a guard whose condition IS the traced call — Delmia's WriteBatchAndWaitAsync — attributes correctly). Routed/nested script bodies come from a caller-supplied resolver, recursion capped by maxDepth; parse errors and any walker fault degrade to request+response with a 'parse error' note. Predicted kinds are exactly the traced kinds so the page can overlay by kind + title. Deviation from the plan's node count: no separate central 'method script' node — the real graph records none, so the InboundRequest node stands for it. Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||
|
||||
/// <summary>A node of a predicted flow (Flow Debugger design 2026-09-05 §5.5 — the "Predicted" state).</summary>
|
||||
/// <param name="Id">Stable id within the flow (<c>n1</c>, <c>n2</c>, …).</param>
|
||||
/// <param name="Kind">The step kind this node would produce when run.</param>
|
||||
/// <param name="Lane">The swimlane it is drawn in.</param>
|
||||
/// <param name="Title">Display title — the route target, script name, attribute name, …</param>
|
||||
/// <param name="InputDependent">True when the target is computed from inputs (not a string literal) and could not be predicted statically.</param>
|
||||
/// <param name="Note">Advisory text — an early-return guard that precedes this node, a depth cap, a parse error.</param>
|
||||
public sealed record PredictedNode(string Id, FlowStepKind Kind, FlowLane Lane, string Title, bool InputDependent, string? Note);
|
||||
|
||||
/// <summary>A directed edge between two predicted nodes.</summary>
|
||||
/// <param name="From">Source node id.</param>
|
||||
/// <param name="To">Target node id.</param>
|
||||
/// <param name="CrossesBoundary">True when exactly one end sits in the Central lane (a central↔site hop).</param>
|
||||
public sealed record PredictedEdge(string From, string To, bool CrossesBoundary);
|
||||
|
||||
/// <summary>The predicted shape of a flow: nodes in source order plus the chain edges between them.</summary>
|
||||
public sealed record PredictedFlow(IReadOnlyList<PredictedNode> Nodes, IReadOnlyList<PredictedEdge> Edges)
|
||||
{
|
||||
/// <summary>An empty prediction.</summary>
|
||||
public static readonly PredictedFlow Empty = new([], []);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predicts the shape of an inbound-API or site-script flow from script <em>syntax</em> alone
|
||||
/// (Flow Debugger design 2026-09-05 §5.5): which routed calls, script calls, attribute writes
|
||||
/// and attribute waits the script would produce, in source order, before anything runs.
|
||||
///
|
||||
/// <para>
|
||||
/// This is a syntax walk, not a semantic one — it matches the same member-access chains the
|
||||
/// script API exposes (<c>Route.To(x).Call(y)</c>, <c>Scripts.Call(y)</c>,
|
||||
/// <c>….Attributes.WriteBatchAndWaitAsync(…)</c>, <c>….Attributes[x] = v</c>, …) by text with
|
||||
/// whitespace stripped, exactly as <see cref="ScriptTrustValidator"/>'s hardening walker does.
|
||||
/// String-literal arguments resolve to titles; anything else is flagged
|
||||
/// <see cref="PredictedNode.InputDependent"/>. Branching is not modelled: every recognised call
|
||||
/// is emitted once in source order, and an <c>if (…) return …;</c> guard that precedes the next
|
||||
/// node is surfaced as a note on that node. The predicted node kinds are exactly the kinds a
|
||||
/// traced run records, so the page can overlay the real graph on the prediction by kind + title.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Kept DB-free on purpose: a caller supplies <c>resolveRouteScript(instanceCode, scriptName)</c>
|
||||
/// to turn a route target into a site script body (Central UI resolves instance → template →
|
||||
/// script); this class only decides when to ask.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class FlowShapePredictor
|
||||
{
|
||||
/// <summary>Default recursion cap — an inbound method's routed site script is depth 1, a nested <c>Scripts.Call</c> depth 2.</summary>
|
||||
public const int DefaultMaxDepth = 4;
|
||||
|
||||
/// <summary>Note attached to the request node when the script does not parse.</summary>
|
||||
public const string ParseErrorNote = "parse error";
|
||||
|
||||
/// <summary>
|
||||
/// Predicts the flow of an inbound API method: an <see cref="FlowStepKind.InboundRequest"/>
|
||||
/// node first, the method script's calls, one hop into each routed site script the resolver
|
||||
/// can supply (recursively up to <paramref name="maxDepth"/>), and a
|
||||
/// <see cref="FlowStepKind.Response"/> node last.
|
||||
/// </summary>
|
||||
/// <param name="methodName">The inbound method name (titles the request node).</param>
|
||||
/// <param name="methodScript">The method's C# script body.</param>
|
||||
/// <param name="resolveRouteScript">
|
||||
/// <c>(instanceCode, scriptName)</c> → the site script body, or null when it cannot be
|
||||
/// resolved. Called with the literal instance code when the route target is a constant, and
|
||||
/// with the target expression's source text otherwise (so a resolver may still answer for a
|
||||
/// known expression, and a test resolver may answer for anything).
|
||||
/// </param>
|
||||
/// <param name="maxDepth">Recursion cap; nested script bodies beyond it are not expanded.</param>
|
||||
public static PredictedFlow PredictInboundMethod(
|
||||
string methodName,
|
||||
string methodScript,
|
||||
Func<string, string, string?> resolveRouteScript,
|
||||
int maxDepth = DefaultMaxDepth)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(resolveRouteScript);
|
||||
var b = new Builder(resolveRouteScript, Math.Max(1, maxDepth));
|
||||
var request = b.AddNode(FlowStepKind.InboundRequest, FlowLane.Central, $"POST /api/{methodName}", inputDependent: false, note: null);
|
||||
|
||||
try
|
||||
{
|
||||
if (!b.Walk(methodScript ?? string.Empty, FlowLane.Central, instanceCode: null, depth: 0))
|
||||
b.Annotate(request, ParseErrorNote);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A predictor must never take the page down; degrade to the request/response pair.
|
||||
b.Reset(keep: 1);
|
||||
b.Annotate(request, ParseErrorNote);
|
||||
}
|
||||
|
||||
b.AddNode(FlowStepKind.Response, FlowLane.Central, "Response", inputDependent: false, note: null);
|
||||
return b.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predicts the flow of a site script run: a <see cref="FlowStepKind.ScriptRun"/> node first,
|
||||
/// then the script's attribute writes/waits and nested calls. Nested <c>Scripts.Call</c>
|
||||
/// targets are not expanded (no resolver) — they appear as unexpanded ScriptRun nodes.
|
||||
/// </summary>
|
||||
/// <param name="scriptName">The script name (titles the root node).</param>
|
||||
/// <param name="scriptBody">The script's C# body.</param>
|
||||
/// <param name="maxDepth">Recursion cap.</param>
|
||||
public static PredictedFlow PredictSiteScript(string scriptName, string scriptBody, int maxDepth = DefaultMaxDepth)
|
||||
{
|
||||
var b = new Builder((_, _) => null, Math.Max(1, maxDepth));
|
||||
var root = b.AddNode(FlowStepKind.ScriptRun, FlowLane.Site, scriptName, inputDependent: false, note: null);
|
||||
|
||||
try
|
||||
{
|
||||
if (!b.Walk(scriptBody ?? string.Empty, FlowLane.Site, instanceCode: null, depth: 1))
|
||||
b.Annotate(root, ParseErrorNote);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
b.Reset(keep: 1);
|
||||
b.Annotate(root, ParseErrorNote);
|
||||
}
|
||||
|
||||
return b.Build();
|
||||
}
|
||||
|
||||
/// <summary>Accumulates nodes/edges and owns the chain ("previous node") state across nested walks.</summary>
|
||||
private sealed class Builder
|
||||
{
|
||||
private readonly Func<string, string, string?> _resolve;
|
||||
private readonly int _maxDepth;
|
||||
private readonly List<PredictedNode> _nodes = [];
|
||||
private readonly List<PredictedEdge> _edges = [];
|
||||
private string? _previous;
|
||||
private string? _pendingNote;
|
||||
|
||||
internal Builder(Func<string, string, string?> resolve, int maxDepth)
|
||||
{
|
||||
_resolve = resolve;
|
||||
_maxDepth = maxDepth;
|
||||
}
|
||||
|
||||
internal PredictedFlow Build() => new(_nodes.ToArray(), _edges.ToArray());
|
||||
|
||||
/// <summary>Adds a node, chains it to the previous one, and consumes any pending early-return note.</summary>
|
||||
internal string AddNode(FlowStepKind kind, FlowLane lane, string title, bool inputDependent, string? note)
|
||||
{
|
||||
var id = "n" + (_nodes.Count + 1).ToString(CultureInfo.InvariantCulture);
|
||||
var mergedNote = note ?? _pendingNote;
|
||||
if (note != null && _pendingNote != null)
|
||||
mergedNote = $"{_pendingNote}; {note}";
|
||||
_pendingNote = null;
|
||||
|
||||
_nodes.Add(new PredictedNode(id, kind, lane, title, inputDependent, mergedNote));
|
||||
if (_previous != null)
|
||||
{
|
||||
var fromLane = _nodes.First(n => n.Id == _previous).Lane;
|
||||
_edges.Add(new PredictedEdge(_previous, id, (fromLane == FlowLane.Central) != (lane == FlowLane.Central)));
|
||||
}
|
||||
|
||||
_previous = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
internal void Annotate(string id, string note)
|
||||
{
|
||||
var i = _nodes.FindIndex(n => n.Id == id);
|
||||
if (i < 0) return;
|
||||
var n = _nodes[i];
|
||||
_nodes[i] = n with { Note = n.Note == null ? note : $"{n.Note}; {note}" };
|
||||
}
|
||||
|
||||
/// <summary>Drops everything after the first <paramref name="keep"/> nodes (fault recovery).</summary>
|
||||
internal void Reset(int keep)
|
||||
{
|
||||
if (_nodes.Count > keep) _nodes.RemoveRange(keep, _nodes.Count - keep);
|
||||
var ids = _nodes.Select(n => n.Id).ToHashSet(StringComparer.Ordinal);
|
||||
_edges.RemoveAll(e => !ids.Contains(e.From) || !ids.Contains(e.To));
|
||||
_previous = _nodes.Count > 0 ? _nodes[^1].Id : null;
|
||||
_pendingNote = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks one script body in the given lane. Returns false when the body has parse
|
||||
/// errors (nothing is emitted for it), true otherwise.
|
||||
/// </summary>
|
||||
internal bool Walk(string code, FlowLane lane, string? instanceCode, int depth)
|
||||
{
|
||||
var tree = CSharpSyntaxTree.ParseText(code, new CSharpParseOptions(kind: SourceCodeKind.Script));
|
||||
if (tree.GetDiagnostics().Any(d => d.Severity == DiagnosticSeverity.Error))
|
||||
return false;
|
||||
|
||||
new Walker(this, lane, instanceCode, depth).Visit(tree.GetRoot());
|
||||
_pendingNote = null; // a trailing guard with nothing after it has no node to annotate
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void QueueNote(string note)
|
||||
=> _pendingNote = _pendingNote == null ? note : $"{_pendingNote}; {note}";
|
||||
|
||||
/// <summary>Expands a routed / nested script call one level, if the resolver can supply its body.</summary>
|
||||
internal void ExpandScript(string instanceCode, string scriptName, int depth)
|
||||
{
|
||||
if (depth >= _maxDepth)
|
||||
{
|
||||
Annotate(_previous!, "depth limit reached; not expanded");
|
||||
return;
|
||||
}
|
||||
|
||||
string? body;
|
||||
try
|
||||
{
|
||||
body = _resolve(instanceCode, scriptName);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
body = null;
|
||||
}
|
||||
|
||||
if (body == null)
|
||||
return;
|
||||
|
||||
if (!Walk(body, FlowLane.Site, instanceCode, depth + 1))
|
||||
Annotate(_previous!, ParseErrorNote);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Source-order visitor that turns recognised script-API calls into nodes.</summary>
|
||||
private sealed class Walker : CSharpSyntaxWalker
|
||||
{
|
||||
private readonly Builder _b;
|
||||
private readonly FlowLane _lane;
|
||||
private readonly string? _instanceCode;
|
||||
private readonly int _depth;
|
||||
|
||||
internal Walker(Builder b, FlowLane lane, string? instanceCode, int depth)
|
||||
{
|
||||
_b = b;
|
||||
_lane = lane;
|
||||
_instanceCode = instanceCode;
|
||||
_depth = depth;
|
||||
}
|
||||
|
||||
private static string Strip(string text) => new(text.Where(c => !char.IsWhiteSpace(c)).ToArray());
|
||||
|
||||
/// <summary>
|
||||
/// An <c>if (…) { … return …; }</c> guard becomes a note on the node that FOLLOWS it.
|
||||
/// The condition is visited first (it may itself contain a traced call — the Delmia
|
||||
/// site script's <c>if (!await …WriteBatchAndWaitAsync(…)) return …</c>), then the
|
||||
/// body, and only then is the note queued for whatever comes next.
|
||||
/// </summary>
|
||||
public override void VisitIfStatement(IfStatementSyntax node)
|
||||
{
|
||||
Visit(node.Condition);
|
||||
Visit(node.Statement);
|
||||
if (node.Else != null)
|
||||
Visit(node.Else);
|
||||
|
||||
var returnsEarly = node.Statement.DescendantNodesAndSelf().OfType<ReturnStatementSyntax>().Any()
|
||||
|| node.Statement.DescendantNodesAndSelf().OfType<ThrowStatementSyntax>().Any();
|
||||
if (returnsEarly)
|
||||
_b.QueueNote($"early return: {Condense(node.Condition.ToString())}");
|
||||
}
|
||||
|
||||
public override void VisitAssignmentExpression(AssignmentExpressionSyntax node)
|
||||
{
|
||||
// recv.Attributes["X"] = v / Attributes["X"] = v
|
||||
if (node.Left is ElementAccessExpressionSyntax ea
|
||||
&& Strip(ea.Expression.ToString()) is var recv
|
||||
&& (recv == "Attributes" || recv.EndsWith(".Attributes", StringComparison.Ordinal)))
|
||||
{
|
||||
var (attr, dependent) = LiteralOrExpr(ea.ArgumentList.Arguments.FirstOrDefault()?.Expression);
|
||||
_b.AddNode(FlowStepKind.AttributeWrite, FlowLane.Tags, Qualify(attr), dependent, null);
|
||||
}
|
||||
|
||||
base.VisitAssignmentExpression(node);
|
||||
}
|
||||
|
||||
public override void VisitInvocationExpression(InvocationExpressionSyntax node)
|
||||
{
|
||||
// Visit inner invocations first? No — source order for a chained call like
|
||||
// Route.To(x).Call(y) is the outer call; the inner To(x) is part of its target.
|
||||
// Nested invocations inside ARGUMENTS are visited after this node is emitted,
|
||||
// which is the order they would produce trace rows only when they are awaited
|
||||
// first — acceptable for a shape preview.
|
||||
var handled = TryHandle(node);
|
||||
if (!handled)
|
||||
base.VisitInvocationExpression(node);
|
||||
else
|
||||
foreach (var arg in node.ArgumentList.Arguments)
|
||||
Visit(arg.Expression);
|
||||
}
|
||||
|
||||
private bool TryHandle(InvocationExpressionSyntax node)
|
||||
{
|
||||
if (node.Expression is not MemberAccessExpressionSyntax ma)
|
||||
return false;
|
||||
|
||||
var member = ma.Name.Identifier.ValueText;
|
||||
var receiverText = Strip(ma.Expression.ToString());
|
||||
var args = node.ArgumentList.Arguments;
|
||||
|
||||
// Route.To(<target>).Call(<script>, …) / .SetAttributes(…) / .WaitForAttribute(…)
|
||||
if (ma.Expression is InvocationExpressionSyntax inner
|
||||
&& inner.Expression is MemberAccessExpressionSyntax innerMa
|
||||
&& Strip(innerMa.ToString()) == "Route.To")
|
||||
{
|
||||
var (target, targetDependent) = LiteralOrExpr(inner.ArgumentList.Arguments.FirstOrDefault()?.Expression);
|
||||
switch (member)
|
||||
{
|
||||
case "Call":
|
||||
{
|
||||
var (script, scriptDependent) = LiteralOrExpr(args.FirstOrDefault()?.Expression);
|
||||
var title = targetDependent ? $"Route.To({target}).Call({script})" : $"{target}.{script}";
|
||||
_b.AddNode(FlowStepKind.RouteCall, FlowLane.Central, title, targetDependent || scriptDependent,
|
||||
targetDependent ? "route target is computed from inputs" : null);
|
||||
if (!scriptDependent)
|
||||
{
|
||||
var runTitle = targetDependent ? script : $"{target}.{script}";
|
||||
_b.AddNode(FlowStepKind.ScriptRun, FlowLane.Site, runTitle, targetDependent, null);
|
||||
_b.ExpandScript(target, script, _depth);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "SetAttributes":
|
||||
_b.AddNode(FlowStepKind.AttributeWrite, FlowLane.Tags, $"{target}.SetAttributes", true, null);
|
||||
return true;
|
||||
case "WaitForAttribute":
|
||||
{
|
||||
var (attr, dependent) = LiteralOrExpr(args.FirstOrDefault()?.Expression);
|
||||
_b.AddNode(FlowStepKind.AttributeWait, FlowLane.Site, $"{target}.{attr}", targetDependent || dependent, null);
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false; // GetAttributes and friends are reads — no trace row, no node
|
||||
}
|
||||
}
|
||||
|
||||
// Scripts.Call("Name", …) — a nested script run on the same instance.
|
||||
if (receiverText == "Scripts" && member == "Call")
|
||||
{
|
||||
var (script, dependent) = LiteralOrExpr(args.FirstOrDefault()?.Expression);
|
||||
_b.AddNode(FlowStepKind.ScriptRun, FlowLane.Site, Qualify(script), dependent, null);
|
||||
if (!dependent && _instanceCode != null)
|
||||
_b.ExpandScript(_instanceCode, script, _depth);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ….Attributes.<op>(…)
|
||||
if (receiverText == "Attributes" || receiverText.EndsWith(".Attributes", StringComparison.Ordinal))
|
||||
{
|
||||
switch (member)
|
||||
{
|
||||
case "WriteBatchAndWaitAsync":
|
||||
{
|
||||
// (batch, triggerAttr, triggerValue, waitAttr, waitValue, timeout)
|
||||
var (trigger, triggerDependent) = LiteralOrExpr(args.ElementAtOrDefault(1)?.Expression);
|
||||
var (wait, waitDependent) = LiteralOrExpr(args.ElementAtOrDefault(3)?.Expression);
|
||||
_b.AddNode(FlowStepKind.AttributeWrite, FlowLane.Tags, Qualify($"batch + {trigger}"), triggerDependent, null);
|
||||
_b.AddNode(FlowStepKind.AttributeWait, FlowLane.Site, Qualify(wait), waitDependent, null);
|
||||
return true;
|
||||
}
|
||||
case "WaitAsync":
|
||||
case "WaitForAsync":
|
||||
{
|
||||
var (attr, dependent) = LiteralOrExpr(args.FirstOrDefault()?.Expression);
|
||||
_b.AddNode(FlowStepKind.AttributeWait, FlowLane.Site, Qualify(attr), dependent, null);
|
||||
return true;
|
||||
}
|
||||
case "SetAsync":
|
||||
{
|
||||
var (attr, dependent) = LiteralOrExpr(args.FirstOrDefault()?.Expression);
|
||||
_b.AddNode(FlowStepKind.AttributeWrite, FlowLane.Tags, Qualify(attr), dependent, null);
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ExternalSystem.Call / CachedCall, Database.*, Notify.Send — shown, never chained across.
|
||||
if (receiverText == "ExternalSystem" && member is "Call" or "CachedCall")
|
||||
{
|
||||
var (system, dependent) = LiteralOrExpr(args.FirstOrDefault()?.Expression);
|
||||
_b.AddNode(FlowStepKind.External, _lane, $"ExternalSystem.{member}({system})", dependent, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (receiverText == "Database")
|
||||
{
|
||||
var (conn, dependent) = LiteralOrExpr(args.FirstOrDefault()?.Expression);
|
||||
_b.AddNode(FlowStepKind.External, _lane, $"Database.{member}({conn})", dependent, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (receiverText == "Notify" && member == "Send")
|
||||
{
|
||||
_b.AddNode(FlowStepKind.External, _lane, "Notify.Send", false, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private string Qualify(string name)
|
||||
=> _instanceCode == null ? name : $"{_instanceCode}.{name}";
|
||||
|
||||
/// <summary>A string literal resolves to its value; anything else is input-dependent and shown as its source text.</summary>
|
||||
private static (string Text, bool Dependent) LiteralOrExpr(ExpressionSyntax? expr)
|
||||
{
|
||||
if (expr == null)
|
||||
return ("?", true);
|
||||
if (expr is LiteralExpressionSyntax lit && lit.IsKind(SyntaxKind.StringLiteralExpression))
|
||||
return (lit.Token.ValueText, false);
|
||||
return (Condense(expr.ToString()), true);
|
||||
}
|
||||
|
||||
private static string Condense(string text)
|
||||
{
|
||||
var oneLine = string.Join(" ", text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).Select(l => l.Trim()));
|
||||
return oneLine.Length <= 80 ? oneLine : oneLine[..77] + "…";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
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.External, "ExternalSystem.Call(Mes)"),
|
||||
(FlowStepKind.External, "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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user