fix(template-engine): repeated composition no longer drops nested members — cycle guard keyed on recursion path, not global template id
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -262,7 +262,7 @@ public class FlatteningService
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedAttribute> attributes,
|
||||
HashSet<int> visited)
|
||||
HashSet<int> path)
|
||||
{
|
||||
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
|
||||
return;
|
||||
@@ -283,17 +283,27 @@ public class FlatteningService
|
||||
}
|
||||
|
||||
// Descend into nested compositions of every template in the chain.
|
||||
// Guard on the recursion PATH, not globally: composition is by-slot, so a
|
||||
// template legitimately appears twice under different prefixes; only a
|
||||
// template already on the CURRENT path is a cycle.
|
||||
foreach (var composedTemplate in composedChain)
|
||||
{
|
||||
if (!visited.Add(composedTemplate.Id))
|
||||
continue;
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
if (!path.Add(composedTemplate.Id))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
continue;
|
||||
|
||||
foreach (var nested in nestedCompositions)
|
||||
ResolveComposedAttributesRecursive(
|
||||
nested, $"{prefix}.{nested.InstanceName}",
|
||||
compositionMap, composedTemplateChains, attributes, visited);
|
||||
foreach (var nested in nestedCompositions)
|
||||
ResolveComposedAttributesRecursive(
|
||||
nested, $"{prefix}.{nested.InstanceName}",
|
||||
compositionMap, composedTemplateChains, attributes, path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
path.Remove(composedTemplate.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,7 +635,7 @@ public class FlatteningService
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedAlarm> alarms,
|
||||
Dictionary<string, int> alarmScriptIds,
|
||||
HashSet<int> visited)
|
||||
HashSet<int> path)
|
||||
{
|
||||
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
|
||||
return;
|
||||
@@ -646,17 +656,27 @@ public class FlatteningService
|
||||
}
|
||||
|
||||
// Descend into nested compositions of every template in the chain.
|
||||
// Guard on the recursion PATH, not globally: composition is by-slot, so a
|
||||
// template legitimately appears twice under different prefixes; only a
|
||||
// template already on the CURRENT path is a cycle.
|
||||
foreach (var composedTemplate in composedChain)
|
||||
{
|
||||
if (!visited.Add(composedTemplate.Id))
|
||||
continue;
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
if (!path.Add(composedTemplate.Id))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
continue;
|
||||
|
||||
foreach (var nested in nestedCompositions)
|
||||
ResolveComposedAlarmsRecursive(
|
||||
nested, $"{prefix}.{nested.InstanceName}",
|
||||
compositionMap, composedTemplateChains, alarms, alarmScriptIds, visited);
|
||||
foreach (var nested in nestedCompositions)
|
||||
ResolveComposedAlarmsRecursive(
|
||||
nested, $"{prefix}.{nested.InstanceName}",
|
||||
compositionMap, composedTemplateChains, alarms, alarmScriptIds, path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
path.Remove(composedTemplate.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -739,7 +759,7 @@ public class FlatteningService
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedNativeAlarmSource> sources,
|
||||
HashSet<int> visited)
|
||||
HashSet<int> path)
|
||||
{
|
||||
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
|
||||
return;
|
||||
@@ -758,17 +778,27 @@ public class FlatteningService
|
||||
}
|
||||
}
|
||||
|
||||
// Guard on the recursion PATH, not globally: composition is by-slot, so a
|
||||
// template legitimately appears twice under different prefixes; only a
|
||||
// template already on the CURRENT path is a cycle.
|
||||
foreach (var composedTemplate in composedChain)
|
||||
{
|
||||
if (!visited.Add(composedTemplate.Id))
|
||||
continue;
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
if (!path.Add(composedTemplate.Id))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
continue;
|
||||
|
||||
foreach (var nested in nestedCompositions)
|
||||
ResolveComposedNativeAlarmSourcesRecursive(
|
||||
nested, $"{prefix}.{nested.InstanceName}",
|
||||
compositionMap, composedTemplateChains, sources, visited);
|
||||
foreach (var nested in nestedCompositions)
|
||||
ResolveComposedNativeAlarmSourcesRecursive(
|
||||
nested, $"{prefix}.{nested.InstanceName}",
|
||||
compositionMap, composedTemplateChains, sources, path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
path.Remove(composedTemplate.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,7 +919,7 @@ public class FlatteningService
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedScript> scripts,
|
||||
Dictionary<int, string> scriptCanonicalById,
|
||||
HashSet<int> visited)
|
||||
HashSet<int> path)
|
||||
{
|
||||
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
|
||||
return;
|
||||
@@ -910,17 +940,27 @@ public class FlatteningService
|
||||
}
|
||||
|
||||
// Descend into nested compositions of every template in the chain.
|
||||
// Guard on the recursion PATH, not globally: composition is by-slot, so a
|
||||
// template legitimately appears twice under different prefixes; only a
|
||||
// template already on the CURRENT path is a cycle.
|
||||
foreach (var composedTemplate in composedChain)
|
||||
{
|
||||
if (!visited.Add(composedTemplate.Id))
|
||||
continue;
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
if (!path.Add(composedTemplate.Id))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
continue;
|
||||
|
||||
foreach (var nested in nestedCompositions)
|
||||
ResolveComposedScriptsRecursive(
|
||||
nested, $"{prefix}.{nested.InstanceName}", parentPath: prefix,
|
||||
compositionMap, composedTemplateChains, scripts, scriptCanonicalById, visited);
|
||||
foreach (var nested in nestedCompositions)
|
||||
ResolveComposedScriptsRecursive(
|
||||
nested, $"{prefix}.{nested.InstanceName}", parentPath: prefix,
|
||||
compositionMap, composedTemplateChains, scripts, scriptCanonicalById, path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
path.Remove(composedTemplate.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -851,4 +851,103 @@ public class FlatteningServiceTests
|
||||
Assert.Equal("List", attr.DataType); // type unchanged
|
||||
Assert.Equal("String", attr.ElementDataType); // element type preserved
|
||||
}
|
||||
|
||||
// ── Repeated composition: same template under multiple slots ──────────
|
||||
|
||||
[Fact]
|
||||
public void Flatten_SameTemplateComposedTwice_ResolvesNestedMembersUnderBothSlots()
|
||||
{
|
||||
// Root(1) composes X(2) as "x"; X composes Y(3) as "y1" AND "y2";
|
||||
// Y composes Z(4) as "z"; Z carries one member of each kind.
|
||||
// Because y1 and y2 share one recursion subtree, a globally-keyed cycle
|
||||
// guard would descend into Z under y1 but skip it under y2, dropping the
|
||||
// whole x.y2.z.* subtree. The path-keyed guard resolves both.
|
||||
var z = CreateTemplate(4, "Z");
|
||||
z.Attributes.Add(new TemplateAttribute("Val") { DataType = DataType.Double, Value = "1" });
|
||||
z.Alarms.Add(new TemplateAlarm("Alm")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Val\",\"high\":100}",
|
||||
PriorityLevel = 1
|
||||
});
|
||||
z.Scripts.Add(new TemplateScript("Run", "// run") { Id = 40 });
|
||||
z.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Src")
|
||||
{ ConnectionName = "Opc", SourceReference = "ns=2;s=Z" });
|
||||
|
||||
var y = CreateTemplate(3, "Y");
|
||||
var x = CreateTemplate(2, "X");
|
||||
var root = CreateTemplate(1, "Root");
|
||||
|
||||
var compositions = new Dictionary<int, IReadOnlyList<TemplateComposition>>
|
||||
{
|
||||
[1] = new List<TemplateComposition> { new("x") { ComposedTemplateId = 2 } },
|
||||
[2] = new List<TemplateComposition>
|
||||
{
|
||||
new("y1") { ComposedTemplateId = 3 },
|
||||
new("y2") { ComposedTemplateId = 3 }
|
||||
},
|
||||
[3] = new List<TemplateComposition> { new("z") { ComposedTemplateId = 4 } }
|
||||
};
|
||||
|
||||
var composedChains = new Dictionary<int, IReadOnlyList<Template>>
|
||||
{
|
||||
[2] = [x],
|
||||
[3] = [y],
|
||||
[4] = [z]
|
||||
};
|
||||
|
||||
var result = _sut.Flatten(
|
||||
CreateInstance(),
|
||||
[root],
|
||||
compositions,
|
||||
composedChains,
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
|
||||
Assert.Contains(result.Value.Attributes, a => a.CanonicalName == "x.y1.z.Val");
|
||||
Assert.Contains(result.Value.Attributes, a => a.CanonicalName == "x.y2.z.Val");
|
||||
|
||||
Assert.Contains(result.Value.Alarms, a => a.CanonicalName == "x.y1.z.Alm");
|
||||
Assert.Contains(result.Value.Alarms, a => a.CanonicalName == "x.y2.z.Alm");
|
||||
|
||||
Assert.Contains(result.Value.Scripts, s => s.CanonicalName == "x.y1.z.Run");
|
||||
Assert.Contains(result.Value.Scripts, s => s.CanonicalName == "x.y2.z.Run");
|
||||
|
||||
Assert.Contains(result.Value.NativeAlarmSources, s => s.CanonicalName == "x.y1.z.Src");
|
||||
Assert.Contains(result.Value.NativeAlarmSources, s => s.CanonicalName == "x.y2.z.Src");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_CompositionCycle_StillTerminates()
|
||||
{
|
||||
// X(1) composes Y(2) as "y"; Y composes X(1) as "x" — a composition
|
||||
// cycle. The path-keyed guard must still break the cycle (a template
|
||||
// already on the CURRENT path) so flattening terminates.
|
||||
var x = CreateTemplate(1, "X");
|
||||
x.Attributes.Add(new TemplateAttribute("Root") { DataType = DataType.String, Value = "r" });
|
||||
var y = CreateTemplate(2, "Y");
|
||||
|
||||
var compositions = new Dictionary<int, IReadOnlyList<TemplateComposition>>
|
||||
{
|
||||
[1] = new List<TemplateComposition> { new("y") { ComposedTemplateId = 2 } },
|
||||
[2] = new List<TemplateComposition> { new("x") { ComposedTemplateId = 1 } }
|
||||
};
|
||||
|
||||
var composedChains = new Dictionary<int, IReadOnlyList<Template>>
|
||||
{
|
||||
[1] = [x],
|
||||
[2] = [y]
|
||||
};
|
||||
|
||||
var result = _sut.Flatten(
|
||||
CreateInstance(),
|
||||
[x],
|
||||
compositions,
|
||||
composedChains,
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Contains(result.Value.Attributes, a => a.CanonicalName == "Root");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user