Skip to content

Commit 05384f7

Browse files
feat(conversion): edit-sync, multi-IH, validation hardening
- UpdateInPlace(doc, json[, ih]) for in-place CAEX updates from FPB.js JSON with element/connection/process add-update-remove and orphan cleanup. - Single-IH overloads (Convert(doc, ih), FindFpdInstanceHierarchies, UpdateInPlace(doc, json, ih)) so callers can target one IH instead of always the first. - CreateEmptyFpdInstanceHierarchy(doc, ihName, processName) for new-IH workflows in the editor plugin. - ID stability across Convert/UpdateInPlace cycles (NormalizeId now returns a bare GUID on null/empty fallback; sub-process AML IDs are derived deterministically from the parent PO via a name-based UUIDv5). - Connection-endpoint typing (Flow vs Usage), sub-process refObj integrity, and ProcessOperator name checks added to the validator. xUnit parallelisation disabled because Aml.Engine keeps a process-wide static cache that races when multiple CAEXDocument instances are built in parallel.
1 parent 970224a commit 05384f7

4 files changed

Lines changed: 1654 additions & 26 deletions

File tree

dotnet/FpbMapper.Conversion/CaexToFpbJson.cs

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,43 @@ namespace FpbMapper.Conversion;
99
/// </summary>
1010
public static class CaexToFpbJson
1111
{
12+
/// <summary>
13+
/// Convert the first InstanceHierarchy in <paramref name="doc"/> to FPB.JS JSON.
14+
/// Kept for legacy callers (Web mapper, file-based export). Multi-IH users
15+
/// should call the <see cref="Convert(CAEXDocument, InstanceHierarchyType)"/>
16+
/// overload and target each IH explicitly.
17+
/// </summary>
1218
public static ConversionResult<string> Convert(CAEXDocument doc)
1319
{
14-
var warnings = new List<string>();
1520
var caex = doc.CAEXFile;
16-
17-
// Find the first InstanceHierarchy
1821
var ih = caex.InstanceHierarchy.FirstOrDefault()
1922
?? throw new InvalidOperationException("No InstanceHierarchy found");
23+
return Convert(doc, ih);
24+
}
25+
26+
/// <summary>
27+
/// Find every InstanceHierarchy in <paramref name="doc"/> that holds at least
28+
/// one FPD_Process IE. Plugins surface one viewer-tab per IH in the returned
29+
/// order so the user can edit each FPD process independently.
30+
/// </summary>
31+
public static IReadOnlyList<InstanceHierarchyType> FindFpdInstanceHierarchies(CAEXDocument doc)
32+
{
33+
if (doc?.CAEXFile == null) return Array.Empty<InstanceHierarchyType>();
34+
var processSuc = ElementToSuc["fpb:Process"];
35+
return doc.CAEXFile.InstanceHierarchy
36+
.Where(ih => ih.InternalElement.Any(ie => ie.RefBaseSystemUnitPath == processSuc))
37+
.ToList();
38+
}
39+
40+
/// <summary>
41+
/// Convert a specific InstanceHierarchy to FPB.JS JSON. Use when a document
42+
/// holds several independent FPD models that the plugin renders side-by-side.
43+
/// </summary>
44+
public static ConversionResult<string> Convert(CAEXDocument doc, InstanceHierarchyType ih)
45+
{
46+
var warnings = new List<string>();
47+
if (ih == null)
48+
throw new ArgumentNullException(nameof(ih));
2049

2150
// Collect all FPD_Process InternalElements (flat in IH)
2251
var allProcessIEs = ih.InternalElement
@@ -29,6 +58,7 @@ public static ConversionResult<string> Convert(CAEXDocument doc)
2958
// Build refObj lookups
3059
var processRefObjMap = new Dictionary<string, string>(); // process AML ID -> parent PO ID
3160
var poRefObjMap = new Dictionary<string, string>(); // PO AML ID -> child process AML ID
61+
var poToProcessAmlId = new Dictionary<string, string>(); // PO AML ID -> parent process AML ID
3262

3363
foreach (var procIE in allProcessIEs)
3464
{
@@ -40,6 +70,7 @@ public static ConversionResult<string> Convert(CAEXDocument doc)
4070
{
4171
if (ie.RefBaseSystemUnitPath == ElementToSuc["fpb:ProcessOperator"])
4272
{
73+
poToProcessAmlId[ie.ID] = procIE.ID;
4374
var poRefProcess = GetRefProcessValue(ie);
4475
if (poRefProcess != null)
4576
poRefObjMap[ie.ID] = poRefProcess;
@@ -86,11 +117,25 @@ public static ConversionResult<string> Convert(CAEXDocument doc)
86117
var proc = (Dictionary<string, object>)entry["process"];
87118

88119
// isDecomposedProcessOperator: resolve AML PO ID -> FPB.JS PO ID
120+
// parent should point at the process containing the parent PO, NOT the PO itself.
121+
// If any lookup fails we still emit a brace-free ID via NormalizeId so the
122+
// JSON stays consistent with Phase 2.A (no {xxx} sneaks through).
89123
if (proc.TryGetValue("isDecomposedProcessOperator", out var iDPO) && iDPO is string idpo && !string.IsNullOrEmpty(idpo))
90124
{
91-
if (amlToFpbId.TryGetValue(idpo, out var poFpbId))
92-
proc["isDecomposedProcessOperator"] = poFpbId;
93-
proc["parent"] = proc["isDecomposedProcessOperator"];
125+
proc["isDecomposedProcessOperator"] = amlToFpbId.TryGetValue(idpo, out var poFpbId)
126+
? poFpbId
127+
: NormalizeId(idpo);
128+
129+
if (poToProcessAmlId.TryGetValue(idpo, out var parentProcAmlId)
130+
&& processIdMap.TryGetValue(parentProcAmlId, out var parentProcFpbId))
131+
{
132+
proc["parent"] = parentProcFpbId;
133+
}
134+
else
135+
{
136+
proc["parent"] = proc["isDecomposedProcessOperator"];
137+
}
138+
94139
proc["id"] = proc["isDecomposedProcessOperator"];
95140
}
96141

@@ -165,7 +210,7 @@ private static void ParseProcess(
165210
if (systemLimitIE != null)
166211
{
167212
var slName = ParseShortName(systemLimitIE) ?? systemLimitIE.Name ?? "SystemLimit";
168-
systemLimitId = NewId();
213+
systemLimitId = NormalizeId(systemLimitIE.ID);
169214
var slVisual = ParseViewInformation(systemLimitIE);
170215

171216
var slData = new Dictionary<string, object>
@@ -186,8 +231,8 @@ private static void ParseProcess(
186231
}
187232
}
188233

189-
// Assign process ID
190-
var processId = NewId();
234+
// Assign process ID — take the AML ID (stripped) so round-trips stay stable.
235+
var processId = NormalizeId(processIE.ID);
191236
processIdMap[processIE.ID] = processId;
192237

193238
var processRefObj = GetRefObjValue(processIE);
@@ -210,7 +255,8 @@ private static void ParseProcess(
210255
if (!SucToElement.TryGetValue(sucPath, out var fpbType)) continue;
211256
if (fpbType == "fpb:SystemLimit" || fpbType == "fpb:Process") continue;
212257

213-
var elemId = NewId();
258+
// Element ID takes the AML ID (stripped) so UpdateInPlace can match later.
259+
var elemId = NormalizeId(ie.ID);
214260
var name = ParseShortName(ie) ?? ie.Name ?? "";
215261

216262
elementIdMap[ie.ID] = elemId;
@@ -282,7 +328,9 @@ private static void ParseProcess(
282328
elementVisualInformation.Add(visual);
283329
}
284330

285-
elementsContainerIds.Add(elemId);
331+
// TechnicalResource lives outside the SystemLimit, not inside it
332+
if (fpbType != "fpb:TechnicalResource")
333+
elementsContainerIds.Add(elemId);
286334
if (StateTypes.Contains(fpbType)) stateIds.Add(elemId);
287335
if (fpbType == "fpb:ProcessOperator") poIds.Add(elemId);
288336
}
@@ -309,7 +357,9 @@ private static void ParseProcess(
309357
var outSide = sideA.Direction == "out" ? sideA : sideB;
310358
var inSide = sideA.Direction == "in" ? sideA : sideB;
311359

312-
var flowId = NewId();
360+
// Flow ID takes the InternalLink ID (stripped) so updates can match.
361+
// CAEX InternalLink IDs are optional — NormalizeId falls back to NewId().
362+
var flowId = NormalizeId(link.ID);
313363
var flowType = outSide.FlowType;
314364

315365
var flowData = new Dictionary<string, object>
@@ -365,7 +415,9 @@ private static void ParseProcess(
365415
if (!tgtList.Contains((string)sourceElem["id"])) tgtList.Add((string)sourceElem["id"]);
366416
}
367417

368-
elementsContainerIds.Add(flowId);
418+
// Usage connects to TechnicalResources outside the SystemLimit
419+
if (flowType != "fpb:Usage")
420+
elementsContainerIds.Add(flowId);
369421
}
370422

371423
// Compute inTandemWith
@@ -406,7 +458,9 @@ private static void ParseProcess(
406458
["id"] = processId,
407459
["elementsContainer"] = systemLimitId != null
408460
? new List<string>(new[] { systemLimitId }.Concat(
409-
elementDataInformation.Where(e => (string)e["$type"] == "fpb:TechnicalResource")
461+
elementDataInformation
462+
.Where(e => (string)e["$type"] == "fpb:TechnicalResource"
463+
|| (string)e["$type"] == "fpb:Usage")
410464
.Select(e => (string)e["id"])))
411465
: new List<string>(),
412466
["isDecomposedProcessOperator"] = parentPOId ?? (object)"",
@@ -642,6 +696,27 @@ private static string ExtractInterfaceId(string refPartnerSide)
642696

643697
private static string NewId() => Guid.NewGuid().ToString("B");
644698

699+
/// <summary>
700+
/// Convert an AML element ID (typically CAEX B-format like "{xxx-yyy}") to the
701+
/// raw FPB.JS-side ID format (no braces). Preserves IDs for downstream round-trips
702+
/// — UpdateInPlace in FpbJsonToCaex can match by the same value back to the AML
703+
/// element. Falls back to a fresh raw GUID if the AML ID is null/empty.
704+
/// </summary>
705+
/// <remarks>
706+
/// NOTE: the fallback path must NOT call <see cref="NewId"/>, which formats as
707+
/// "{xxx-yyy}". If we leaked a braced ID into the JSON output, FPB.JS would
708+
/// store it internally with braces and the next UpdateInPlace lookup would
709+
/// miss every connection (linkIndex keys are bare). This bit users hard on
710+
/// AML files whose InternalLinks had no explicit ID.
711+
/// </remarks>
712+
private static string NormalizeId(string? amlId)
713+
{
714+
if (string.IsNullOrEmpty(amlId)) return Guid.NewGuid().ToString(); // bare, no braces
715+
if (amlId.Length >= 2 && amlId[0] == '{' && amlId[^1] == '}')
716+
return amlId.Substring(1, amlId.Length - 2);
717+
return amlId;
718+
}
719+
645720
private class InterfaceInfo
646721
{
647722
public string ElementId { get; set; } = "";

0 commit comments

Comments
 (0)