874e32a93e
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
252 lines
13 KiB
C#
252 lines
13 KiB
C#
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests;
|
|
|
|
public class CollisionDetectorTests
|
|
{
|
|
// ========================================================================
|
|
// WP-12: Naming Collision Detection
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void DetectCollisions_NoCollisions_ReturnsEmpty()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
template.Attributes.Add(new TemplateAttribute("Speed") { Id = 1, TemplateId = 1, DataType = DataType.Float });
|
|
template.Alarms.Add(new TemplateAlarm("HighTemp") { Id = 1, TemplateId = 1, TriggerType = AlarmTriggerType.ValueMatch });
|
|
|
|
var all = new List<Template> { template };
|
|
var collisions = CollisionDetector.DetectCollisions(template, all);
|
|
|
|
Assert.Empty(collisions);
|
|
}
|
|
|
|
[Fact]
|
|
public void DetectCollisions_DifferentModulesNoPrefixCollision_ReturnsEmpty()
|
|
{
|
|
// Two composed modules with same member name but different instance names
|
|
var moduleA = new Template("ModuleA") { Id = 2 };
|
|
moduleA.Attributes.Add(new TemplateAttribute("Value") { Id = 10, TemplateId = 2, DataType = DataType.Float });
|
|
|
|
var moduleB = new Template("ModuleB") { Id = 3 };
|
|
moduleB.Attributes.Add(new TemplateAttribute("Value") { Id = 11, TemplateId = 3, DataType = DataType.Float });
|
|
|
|
var template = new Template("Pump") { Id = 1 };
|
|
template.Compositions.Add(new TemplateComposition("modA") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
|
|
template.Compositions.Add(new TemplateComposition("modB") { Id = 2, TemplateId = 1, ComposedTemplateId = 3 });
|
|
|
|
var all = new List<Template> { template, moduleA, moduleB };
|
|
var collisions = CollisionDetector.DetectCollisions(template, all);
|
|
|
|
// modA.Value and modB.Value are different canonical names => no collision
|
|
Assert.Empty(collisions);
|
|
}
|
|
|
|
[Fact]
|
|
public void DetectCollisions_DirectAndComposedNameCollision_ReturnsCollision()
|
|
{
|
|
// Template has a direct attribute "Speed"
|
|
// Composed module also has an attribute that would produce canonical name "Speed"
|
|
// This happens when a module's member has no prefix collision — actually
|
|
// composed members always have a prefix so this shouldn't collide.
|
|
// But a direct member "modA.Value" would collide with modA.Value from composition.
|
|
// Let's test: direct attr named "modA.Value" and composition modA with member "Value"
|
|
|
|
var module = new Template("Module") { Id = 2 };
|
|
module.Attributes.Add(new TemplateAttribute("Value") { Id = 10, TemplateId = 2, DataType = DataType.Float });
|
|
|
|
var template = new Template("Pump") { Id = 1 };
|
|
template.Attributes.Add(new TemplateAttribute("modA.Value") { Id = 1, TemplateId = 1, DataType = DataType.Float });
|
|
template.Compositions.Add(new TemplateComposition("modA") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
|
|
|
|
var all = new List<Template> { template, module };
|
|
var collisions = CollisionDetector.DetectCollisions(template, all);
|
|
|
|
Assert.NotEmpty(collisions);
|
|
Assert.Contains(collisions, c => c.Contains("modA.Value"));
|
|
}
|
|
|
|
[Fact]
|
|
public void DetectCollisions_NestedComposition_ReturnsCorrectCanonicalNames()
|
|
{
|
|
// Inner module
|
|
var inner = new Template("Inner") { Id = 3 };
|
|
inner.Attributes.Add(new TemplateAttribute("Pressure") { Id = 30, TemplateId = 3, DataType = DataType.Float });
|
|
|
|
// Outer module composes inner
|
|
var outer = new Template("Outer") { Id = 2 };
|
|
outer.Compositions.Add(new TemplateComposition("inner1") { Id = 1, TemplateId = 2, ComposedTemplateId = 3 });
|
|
|
|
// Main template composes outer
|
|
var main = new Template("Main") { Id = 1 };
|
|
main.Compositions.Add(new TemplateComposition("outer1") { Id = 2, TemplateId = 1, ComposedTemplateId = 2 });
|
|
|
|
var all = new List<Template> { main, outer, inner };
|
|
var collisions = CollisionDetector.DetectCollisions(main, all);
|
|
|
|
// No collision, just checking it doesn't crash on nested compositions
|
|
Assert.Empty(collisions);
|
|
}
|
|
|
|
[Fact]
|
|
public void DetectCollisions_InheritedMembersCollideWithComposed_ReturnsCollision()
|
|
{
|
|
// Parent has a direct attribute "modA.Temp"
|
|
var parent = new Template("Base") { Id = 1 };
|
|
parent.Attributes.Add(new TemplateAttribute("modA.Temp") { Id = 10, TemplateId = 1, DataType = DataType.Float });
|
|
|
|
// Module has attribute "Temp"
|
|
var module = new Template("Module") { Id = 3 };
|
|
module.Attributes.Add(new TemplateAttribute("Temp") { Id = 30, TemplateId = 3, DataType = DataType.Float });
|
|
|
|
// Child inherits from parent and composes module as "modA"
|
|
var child = new Template("Child") { Id = 2, ParentTemplateId = 1 };
|
|
child.Compositions.Add(new TemplateComposition("modA") { Id = 1, TemplateId = 2, ComposedTemplateId = 3 });
|
|
|
|
var all = new List<Template> { parent, child, module };
|
|
var collisions = CollisionDetector.DetectCollisions(child, all);
|
|
|
|
// "modA.Temp" from parent and "modA.Temp" from composed module
|
|
Assert.NotEmpty(collisions);
|
|
Assert.Contains(collisions, c => c.Contains("modA.Temp"));
|
|
}
|
|
|
|
// ========================================================================
|
|
// Follow-up #3: inherited placeholder rows must not self-collide with the
|
|
// parent rows the inheritance walk re-adds. Before the fix, every derived
|
|
// template reported a collision for each IsInherited row (child origin vs
|
|
// "parent 'X'" origin) — so there was no supported path to add an attribute
|
|
// or composition to a derived template.
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void DetectCollisions_DerivedTemplateWithInheritedPlaceholders_NoFalseCollision()
|
|
{
|
|
// Base carries the real attributes.
|
|
var parent = new Template("ReactorSide") { Id = 7 };
|
|
parent.Attributes.Add(new TemplateAttribute("DeltaVac") { Id = 70, TemplateId = 7, DataType = DataType.Float });
|
|
parent.Attributes.Add(new TemplateAttribute("ResultType") { Id = 71, TemplateId = 7, DataType = DataType.String });
|
|
|
|
// Derived child stores IsInherited placeholder copies of the base attrs
|
|
// PLUS a newly-added direct attribute (the member an author is trying to add).
|
|
var child = new Template("LeftReactorSide") { Id = 8, ParentTemplateId = 7 };
|
|
child.Attributes.Add(new TemplateAttribute("DeltaVac") { Id = 80, TemplateId = 8, DataType = DataType.Float, IsInherited = true });
|
|
child.Attributes.Add(new TemplateAttribute("ResultType") { Id = 81, TemplateId = 8, DataType = DataType.String, IsInherited = true });
|
|
child.Attributes.Add(new TemplateAttribute("SideName") { Id = 82, TemplateId = 8, DataType = DataType.String });
|
|
|
|
var all = new List<Template> { parent, child };
|
|
var collisions = CollisionDetector.DetectCollisions(child, all);
|
|
|
|
// The inherited placeholders are NOT genuine cross-origin duplicates.
|
|
Assert.Empty(collisions);
|
|
}
|
|
|
|
[Fact]
|
|
public void DetectCollisions_MultiLevelInheritedPlaceholders_NoFalseCollision()
|
|
{
|
|
// Grandparent → parent → child, with placeholder rows materialized at every
|
|
// level. The inherited-parent walk must skip the parent's own placeholders
|
|
// too, or the grandparent's real row collides with the parent's copy.
|
|
var grandparent = new Template("Root") { Id = 1 };
|
|
grandparent.Attributes.Add(new TemplateAttribute("Common") { Id = 10, TemplateId = 1, DataType = DataType.Float });
|
|
|
|
var parent = new Template("Mid") { Id = 2, ParentTemplateId = 1 };
|
|
parent.Attributes.Add(new TemplateAttribute("Common") { Id = 20, TemplateId = 2, DataType = DataType.Float, IsInherited = true });
|
|
|
|
var child = new Template("Leaf") { Id = 3, ParentTemplateId = 2 };
|
|
child.Attributes.Add(new TemplateAttribute("Common") { Id = 30, TemplateId = 3, DataType = DataType.Float, IsInherited = true });
|
|
|
|
var all = new List<Template> { grandparent, parent, child };
|
|
var collisions = CollisionDetector.DetectCollisions(child, all);
|
|
|
|
Assert.Empty(collisions);
|
|
}
|
|
|
|
[Fact]
|
|
public void DetectCollisions_DerivedTemplate_GenuineCollisionStillDetected_DespiteInheritedPlaceholder()
|
|
{
|
|
// Guard against over-suppression. The parent declares a real direct member
|
|
// "dup.Shared"; the child both (a) carries an IsInherited placeholder copy of
|
|
// it and (b) composes a module "dup" whose "Shared" member resolves to the
|
|
// same canonical name "dup.Shared". That is a genuine cross-origin collision
|
|
// (composed module vs inherited parent member) and must still be reported —
|
|
// even though the child's own placeholder row is now skipped.
|
|
var module = new Template("Module") { Id = 3 };
|
|
module.Attributes.Add(new TemplateAttribute("Shared") { Id = 30, TemplateId = 3, DataType = DataType.Float });
|
|
|
|
var parent = new Template("Base") { Id = 1 };
|
|
parent.Attributes.Add(new TemplateAttribute("dup.Shared") { Id = 10, TemplateId = 1, DataType = DataType.Float });
|
|
|
|
var child = new Template("Derived") { Id = 2, ParentTemplateId = 1 };
|
|
child.Attributes.Add(new TemplateAttribute("dup.Shared") { Id = 20, TemplateId = 2, DataType = DataType.Float, IsInherited = true });
|
|
child.Compositions.Add(new TemplateComposition("dup") { Id = 1, TemplateId = 2, ComposedTemplateId = 3 });
|
|
|
|
var all = new List<Template> { parent, child, module };
|
|
var collisions = CollisionDetector.DetectCollisions(child, all);
|
|
|
|
Assert.NotEmpty(collisions);
|
|
Assert.Contains(collisions, c => c.Contains("dup.Shared"));
|
|
}
|
|
|
|
// ========================================================================
|
|
// Task 10: repeated composition blindness. A template composed into TWO
|
|
// different slots was added to the global visited-set on the first slot, so
|
|
// the SECOND slot's subtree was never collected — the detector went blind to
|
|
// any collision reachable only through the second slot. The visited-set must
|
|
// be a recursion PATH set (removed on the way back out), not a global set, so
|
|
// siblings/other slots can re-descend the same template.
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void DetectCollisions_TemplateComposedTwiceWithinSubtree_SeesCollisionUnderSecondSlot()
|
|
{
|
|
// Z carries the leaf member "Val".
|
|
var z = new Template("Z") { Id = 4 };
|
|
z.Attributes.Add(new TemplateAttribute("Val") { Id = 40, TemplateId = 4, DataType = DataType.Float });
|
|
|
|
// Y composes Z as "z".
|
|
var y = new Template("Y") { Id = 3 };
|
|
y.Compositions.Add(new TemplateComposition("z") { Id = 1, TemplateId = 3, ComposedTemplateId = 4 });
|
|
|
|
// W composes Y into TWO slots (y1, y2). Because both slots are visited within
|
|
// the SAME CollectComposedMembers walk, they share one visited set — the
|
|
// second slot's subtree was previously never collected.
|
|
var w = new Template("W") { Id = 2 };
|
|
w.Compositions.Add(new TemplateComposition("y1") { Id = 2, TemplateId = 2, ComposedTemplateId = 3 });
|
|
w.Compositions.Add(new TemplateComposition("y2") { Id = 3, TemplateId = 2, ComposedTemplateId = 3 });
|
|
|
|
// X composes W as "w" AND declares a direct attribute whose path collides with
|
|
// a member reachable only via the SECOND slot: "w.y2.z.Val".
|
|
var x = new Template("X") { Id = 1 };
|
|
x.Attributes.Add(new TemplateAttribute("w.y2.z.Val") { Id = 10, TemplateId = 1, DataType = DataType.Float });
|
|
x.Compositions.Add(new TemplateComposition("w") { Id = 4, TemplateId = 1, ComposedTemplateId = 2 });
|
|
|
|
var all = new List<Template> { x, w, y, z };
|
|
var collisions = CollisionDetector.DetectCollisions(x, all);
|
|
|
|
// "w.y2.z.Val" exists both as X's direct attribute and via the w→y2→z→Val subtree.
|
|
Assert.NotEmpty(collisions);
|
|
Assert.Contains(collisions, c => c.Contains("w.y2.z.Val"));
|
|
}
|
|
|
|
[Fact]
|
|
public void DetectCollisions_CyclicComposition_DoesNotLoopForever()
|
|
{
|
|
// Genuine cycle guard: A composes B, B composes A. The recursion-path set
|
|
// must still short-circuit a template already on the ACTIVE path so the
|
|
// walk terminates instead of recursing infinitely.
|
|
var a = new Template("A") { Id = 1 };
|
|
a.Compositions.Add(new TemplateComposition("b") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
|
|
|
|
var b = new Template("B") { Id = 2 };
|
|
b.Compositions.Add(new TemplateComposition("a") { Id = 2, TemplateId = 2, ComposedTemplateId = 1 });
|
|
|
|
var all = new List<Template> { a, b };
|
|
|
|
// Must return (terminate) rather than stack-overflow / hang.
|
|
var collisions = CollisionDetector.DetectCollisions(a, all);
|
|
Assert.Empty(collisions);
|
|
}
|
|
}
|