Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/CollisionDetectorTests.cs
T
Joseph Doherty b3f6833b36 fix(templateengine+centralui): resolve follow-ups #3 (derived-template collisions) and #7 (sandbox batch/wait surface)
#3 — CollisionDetector counted a derived template's IsInherited placeholder
rows as a distinct origin from the parent members the inheritance walk
re-adds, reporting a spurious "Naming collision" for every inherited row and
blocking any attribute/composition add to a derived template. CollectDirectMembers
now skips IsInherited rows on the direct-template and inherited-parent walks;
it keeps them for the composed-module walk, where placeholders are the sole
representation of a derived module's inherited members (that walk does not
climb the composed template's parent chain).

#7 — SandboxAttributeAccessor (Central UI Test-Run host) omitted
WriteBatchAndWaitAsync / WaitAsync / WaitForAsync, so the editor false-flagged
valid instance scripts with CS1061 even though `template validate` and the
deploy gate accept them. Added the five overloads mirroring the runtime
AttributeAccessor; they throw a labelled ScriptSandboxException if run in
Test Run (the central sandbox has no device-batch / event-waiter transport).

Tests: +3 CollisionDetector unit + 1 end-to-end TemplateService (derived add
now succeeds); +2 ScriptAnalysisService diagnose-clean. Each new test verified
to fail without its fix with the exact user-facing symptom. Full suites green
(TemplateEngine.Tests 438, CentralUI.Tests 866).

Docs: Component-TemplateEngine.md (inherited-placeholder collision rule),
Component-ScriptAnalysis.md (third sandbox surface + its compile-clean guard),
known-issues tracker #3/#7 marked resolved and the minor note promoted to #8.
2026-06-24 15:03:27 -04:00

192 lines
9.9 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"));
}
}