refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,114 @@
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"));
}
}
@@ -0,0 +1,229 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests;
public class CycleDetectorTests
{
// ========================================================================
// WP-13: Inheritance cycle detection
// ========================================================================
[Fact]
public void DetectInheritanceCycle_SelfInheritance_ReturnsCycle()
{
var template = new Template("A") { Id = 1 };
var all = new List<Template> { template };
var result = CycleDetector.DetectInheritanceCycle(1, 1, all);
Assert.NotNull(result);
Assert.Contains("itself", result);
}
[Fact]
public void DetectInheritanceCycle_DirectCycle_ReturnsCycle()
{
var templateA = new Template("A") { Id = 1, ParentTemplateId = null };
var templateB = new Template("B") { Id = 2, ParentTemplateId = 1 };
var all = new List<Template> { templateA, templateB };
// A tries to inherit from B (B already inherits from A)
var result = CycleDetector.DetectInheritanceCycle(1, 2, all);
Assert.NotNull(result);
Assert.Contains("cycle", result, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void DetectInheritanceCycle_ThreeNodeCycle_ReturnsCycle()
{
var templateA = new Template("A") { Id = 1, ParentTemplateId = null };
var templateB = new Template("B") { Id = 2, ParentTemplateId = 1 };
var templateC = new Template("C") { Id = 3, ParentTemplateId = 2 };
var all = new List<Template> { templateA, templateB, templateC };
// A tries to inherit from C (C -> B -> A creates a cycle)
var result = CycleDetector.DetectInheritanceCycle(1, 3, all);
Assert.NotNull(result);
Assert.Contains("cycle", result, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void DetectInheritanceCycle_NoCycle_ReturnsNull()
{
var templateA = new Template("A") { Id = 1, ParentTemplateId = null };
var templateB = new Template("B") { Id = 2, ParentTemplateId = null };
var all = new List<Template> { templateA, templateB };
var result = CycleDetector.DetectInheritanceCycle(2, 1, all);
Assert.Null(result);
}
// ========================================================================
// WP-13: Composition cycle detection
// ========================================================================
[Fact]
public void DetectCompositionCycle_SelfComposition_ReturnsCycle()
{
var template = new Template("A") { Id = 1 };
var all = new List<Template> { template };
var result = CycleDetector.DetectCompositionCycle(1, 1, all);
Assert.NotNull(result);
Assert.Contains("compose itself", result);
}
[Fact]
public void DetectCompositionCycle_DirectCycle_ReturnsCycle()
{
var templateA = new Template("A") { Id = 1 };
templateA.Compositions.Add(new TemplateComposition("b1") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
var templateB = new Template("B") { Id = 2 };
var all = new List<Template> { templateA, templateB };
// B tries to compose A (A already composes B)
var result = CycleDetector.DetectCompositionCycle(2, 1, all);
Assert.NotNull(result);
Assert.Contains("cycle", result, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void DetectCompositionCycle_TransitiveCycle_ReturnsCycle()
{
var templateA = new Template("A") { Id = 1 };
templateA.Compositions.Add(new TemplateComposition("b1") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
var templateB = new Template("B") { Id = 2 };
templateB.Compositions.Add(new TemplateComposition("c1") { Id = 2, TemplateId = 2, ComposedTemplateId = 3 });
var templateC = new Template("C") { Id = 3 };
var all = new List<Template> { templateA, templateB, templateC };
// C tries to compose A => C -> A -> B -> C
var result = CycleDetector.DetectCompositionCycle(3, 1, all);
Assert.NotNull(result);
}
[Fact]
public void DetectCompositionCycle_NoCycle_ReturnsNull()
{
var templateA = new Template("A") { Id = 1 };
var templateB = new Template("B") { Id = 2 };
var all = new List<Template> { templateA, templateB };
var result = CycleDetector.DetectCompositionCycle(1, 2, all);
Assert.Null(result);
}
// ========================================================================
// WP-13: Cross-graph cycle detection (inheritance + composition)
// ========================================================================
[Fact]
public void DetectCrossGraphCycle_InheritanceCompositionCross_ReturnsCycle()
{
// A inherits from B, B composes C. If C tries to set parent = A, that's a cross-graph cycle.
var templateA = new Template("A") { Id = 1, ParentTemplateId = 2 };
var templateB = new Template("B") { Id = 2 };
templateB.Compositions.Add(new TemplateComposition("c1") { Id = 1, TemplateId = 2, ComposedTemplateId = 3 });
var templateC = new Template("C") { Id = 3 };
var all = new List<Template> { templateA, templateB, templateC };
// C tries to add parent = A
var result = CycleDetector.DetectCrossGraphCycle(3, 1, null, all);
Assert.NotNull(result);
Assert.Contains("Cross-graph cycle", result);
}
[Fact]
public void DetectCrossGraphCycle_NoCycle_ReturnsNull()
{
var templateA = new Template("A") { Id = 1 };
var templateB = new Template("B") { Id = 2 };
var templateC = new Template("C") { Id = 3 };
var all = new List<Template> { templateA, templateB, templateC };
var result = CycleDetector.DetectCrossGraphCycle(3, 1, 2, all);
Assert.Null(result);
}
// ========================================================================
// TemplateEngine-013: robustness against duplicate Ids and Id 0
// ========================================================================
[Fact]
public void DetectInheritanceCycle_DuplicateIdsInList_DoesNotThrow()
{
// Two not-yet-saved templates both carry Id == 0. ToDictionary(t => t.Id)
// would throw ArgumentException; the detector must tolerate it.
var templateA = new Template("A") { Id = 0 };
var templateB = new Template("B") { Id = 0 };
var saved = new Template("Saved") { Id = 1 };
var all = new List<Template> { templateA, templateB, saved };
var ex = Record.Exception(() => CycleDetector.DetectInheritanceCycle(1, 0, all));
Assert.Null(ex);
}
[Fact]
public void DetectCompositionCycle_DuplicateIdsInList_DoesNotThrow()
{
var templateA = new Template("A") { Id = 0 };
var templateB = new Template("B") { Id = 0 };
var all = new List<Template> { templateA, templateB };
var ex = Record.Exception(() => CycleDetector.DetectCompositionCycle(1, 2, all));
Assert.Null(ex);
}
[Fact]
public void DetectCrossGraphCycle_DuplicateIdsInList_DoesNotThrow()
{
var templateA = new Template("A") { Id = 0 };
var templateB = new Template("B") { Id = 0 };
var all = new List<Template> { templateA, templateB };
var ex = Record.Exception(() => CycleDetector.DetectCrossGraphCycle(5, 1, 2, all));
Assert.Null(ex);
}
[Fact]
public void DetectInheritanceCycle_RealIdZero_StillDetectsCycle()
{
// A template legitimately stored with Id 0 (in-memory / test scenario):
// a self-inheritance attempt must still be detected, not skipped as
// "no parent" by a 0-as-sentinel overload.
var template = new Template("Zero") { Id = 0 };
var all = new List<Template> { template };
var result = CycleDetector.DetectInheritanceCycle(0, 0, all);
Assert.NotNull(result);
Assert.Contains("itself", result);
}
[Fact]
public void DetectInheritanceCycle_ParentChainThroughIdZero_DetectsCycle()
{
// Child(1) -> parent Zero(0) -> parent Child(1): a cycle running through
// a template whose real Id is 0 must be detected, not silently skipped.
var zero = new Template("Zero") { Id = 0, ParentTemplateId = 1 };
var child = new Template("Child") { Id = 1, ParentTemplateId = null };
var all = new List<Template> { zero, child };
var result = CycleDetector.DetectInheritanceCycle(1, 0, all);
Assert.NotNull(result);
Assert.Contains("cycle", result, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,118 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Flattening;
public class DeploymentPackageTests
{
[Fact]
public void DeploymentPackage_JsonSerializable()
{
var package = new DeploymentPackage
{
InstanceUniqueName = "PumpStation1",
DeploymentId = "dep-abc123",
RevisionHash = "sha256:abcdef1234567890",
DeployedBy = "admin@company.com",
DeployedAtUtc = new DateTimeOffset(2026, 3, 16, 12, 0, 0, TimeSpan.Zero),
Configuration = new FlattenedConfiguration
{
InstanceUniqueName = "PumpStation1",
TemplateId = 1,
SiteId = 1,
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Temperature",
Value = "25.0",
DataType = "Double",
BoundDataConnectionId = 100,
BoundDataConnectionName = "OPC-Server1",
BoundDataConnectionProtocol = "OpcUa",
DataSourceReference = "ns=2;s=Temp"
}
],
Alarms =
[
new ResolvedAlarm
{
CanonicalName = "HighTemp",
TriggerType = "RangeViolation",
PriorityLevel = 1
}
],
Scripts =
[
new ResolvedScript
{
CanonicalName = "Monitor",
Code = "var x = Attributes[\"Temperature\"].Value;"
}
]
},
PreviousRevisionHash = null
};
var json = JsonSerializer.Serialize(package);
Assert.NotNull(json);
Assert.Contains("PumpStation1", json);
Assert.Contains("sha256:abcdef1234567890", json);
var deserialized = JsonSerializer.Deserialize<DeploymentPackage>(json);
Assert.NotNull(deserialized);
Assert.Equal("PumpStation1", deserialized.InstanceUniqueName);
Assert.Equal("dep-abc123", deserialized.DeploymentId);
Assert.Single(deserialized.Configuration.Attributes);
Assert.Equal("Temperature", deserialized.Configuration.Attributes[0].CanonicalName);
}
[Fact]
public void DeploymentPackage_WithDiff_Serializable()
{
var package = new DeploymentPackage
{
InstanceUniqueName = "Inst1",
DeploymentId = "dep-1",
RevisionHash = "sha256:new",
DeployedBy = "admin",
DeployedAtUtc = DateTimeOffset.UtcNow,
Configuration = new FlattenedConfiguration { InstanceUniqueName = "Inst1" },
Diff = new ConfigurationDiff
{
InstanceUniqueName = "Inst1",
OldRevisionHash = "sha256:old",
NewRevisionHash = "sha256:new",
AttributeChanges =
[
new DiffEntry<ResolvedAttribute>
{
CanonicalName = "Temp",
ChangeType = DiffChangeType.Changed,
OldValue = new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double" },
NewValue = new ResolvedAttribute { CanonicalName = "Temp", Value = "50", DataType = "Double" }
}
]
},
PreviousRevisionHash = "sha256:old"
};
var json = JsonSerializer.Serialize(package);
var deserialized = JsonSerializer.Deserialize<DeploymentPackage>(json);
Assert.NotNull(deserialized?.Diff);
Assert.True(deserialized.Diff.HasChanges);
Assert.Equal("sha256:old", deserialized.PreviousRevisionHash);
}
[Fact]
public void FlattenedConfiguration_DefaultValues()
{
var config = new FlattenedConfiguration();
Assert.Equal(string.Empty, config.InstanceUniqueName);
Assert.Empty(config.Attributes);
Assert.Empty(config.Alarms);
Assert.Empty(config.Scripts);
}
}
@@ -0,0 +1,372 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Flattening;
public class DiffServiceTests
{
private readonly DiffService _sut = new();
[Fact]
public void ComputeDiff_NullOldConfig_AllAdded()
{
var newConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double" },
new ResolvedAttribute { CanonicalName = "Status", Value = "OK", DataType = "String" }
],
Alarms =
[
new ResolvedAlarm { CanonicalName = "HighTemp", TriggerType = "RangeViolation" }
],
Scripts =
[
new ResolvedScript { CanonicalName = "Monitor", Code = "// code" }
]
};
var diff = _sut.ComputeDiff(null, newConfig);
Assert.True(diff.HasChanges);
Assert.Equal(2, diff.AttributeChanges.Count);
Assert.All(diff.AttributeChanges, c => Assert.Equal(DiffChangeType.Added, c.ChangeType));
Assert.Single(diff.AlarmChanges);
Assert.Single(diff.ScriptChanges);
}
[Fact]
public void ComputeDiff_IdenticalConfigs_NoChanges()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double" }],
Alarms = [],
Scripts = []
};
var diff = _sut.ComputeDiff(config, config);
Assert.False(diff.HasChanges);
Assert.Empty(diff.AttributeChanges);
}
[Fact]
public void ComputeDiff_AttributeRemoved_DetectedAsRemoved()
{
var oldConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double" },
new ResolvedAttribute { CanonicalName = "Removed", Value = "x", DataType = "String" }
]
};
var newConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double" }]
};
var diff = _sut.ComputeDiff(oldConfig, newConfig);
Assert.True(diff.HasChanges);
Assert.Single(diff.AttributeChanges);
Assert.Equal(DiffChangeType.Removed, diff.AttributeChanges[0].ChangeType);
Assert.Equal("Removed", diff.AttributeChanges[0].CanonicalName);
}
[Fact]
public void ComputeDiff_AttributeChanged_DetectedAsChanged()
{
var oldConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double" }]
};
var newConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", Value = "50", DataType = "Double" }]
};
var diff = _sut.ComputeDiff(oldConfig, newConfig);
Assert.True(diff.HasChanges);
Assert.Single(diff.AttributeChanges);
Assert.Equal(DiffChangeType.Changed, diff.AttributeChanges[0].ChangeType);
Assert.Equal("25", diff.AttributeChanges[0].OldValue?.Value);
Assert.Equal("50", diff.AttributeChanges[0].NewValue?.Value);
}
[Fact]
public void ComputeDiff_RevisionHashes_Included()
{
var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1" };
var diff = _sut.ComputeDiff(config, config, "sha256:old", "sha256:new");
Assert.Equal("sha256:old", diff.OldRevisionHash);
Assert.Equal("sha256:new", diff.NewRevisionHash);
}
[Fact]
public void ComputeDiff_ScriptCodeChange_Detected()
{
var oldConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Scripts = [new ResolvedScript { CanonicalName = "Script1", Code = "// v1" }]
};
var newConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Scripts = [new ResolvedScript { CanonicalName = "Script1", Code = "// v2" }]
};
var diff = _sut.ComputeDiff(oldConfig, newConfig);
Assert.True(diff.HasChanges);
Assert.Single(diff.ScriptChanges);
Assert.Equal(DiffChangeType.Changed, diff.ScriptChanges[0].ChangeType);
}
[Fact]
public void ComputeDiff_AttributeDescriptionChange_DetectedAsChanged()
{
// TemplateEngine-017: AttributesEqual must compare Description.
var oldConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double", Description = "Original" }
]
};
var newConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double", Description = "Updated" }
]
};
var diff = _sut.ComputeDiff(oldConfig, newConfig);
Assert.True(diff.HasChanges);
Assert.Single(diff.AttributeChanges);
Assert.Equal(DiffChangeType.Changed, diff.AttributeChanges[0].ChangeType);
}
[Fact]
public void ComputeDiff_AlarmDescriptionChange_DetectedAsChanged()
{
// TemplateEngine-017: AlarmsEqual must compare Description.
var oldConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Alarms =
[
new ResolvedAlarm { CanonicalName = "HighTemp", TriggerType = "RangeViolation", Description = "Original" }
]
};
var newConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Alarms =
[
new ResolvedAlarm { CanonicalName = "HighTemp", TriggerType = "RangeViolation", Description = "Updated" }
]
};
var diff = _sut.ComputeDiff(oldConfig, newConfig);
Assert.True(diff.HasChanges);
Assert.Single(diff.AlarmChanges);
Assert.Equal(DiffChangeType.Changed, diff.AlarmChanges[0].ChangeType);
}
[Fact]
public void ConnectionsEqual_IdenticalConfigs_ReturnsTrue()
{
// TemplateEngine-017: ConnectionsEqual is the comparator callers use
// to detect connection-endpoint drift (the diff-view extension that
// surfaces this in the UI is tracked under TemplateEngine-018).
var a = new ConnectionConfig
{
Protocol = "OpcUa",
ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-a\"}",
BackupConfigurationJson = "{\"endpoint\":\"opc.tcp://host-b\"}",
FailoverRetryCount = 3
};
var b = a with { };
Assert.True(DiffService.ConnectionsEqual(a, b));
}
[Fact]
public void ConnectionsEqual_EndpointEdit_ReturnsFalse()
{
// TemplateEngine-017: primary endpoint JSON edit must surface as a
// change. Without this, deployment redeploys ship a different
// ConnectionConfig with no visible drift signal.
var a = new ConnectionConfig
{
Protocol = "OpcUa",
ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-a:4840\"}",
FailoverRetryCount = 3
};
var b = a with { ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-b:4840\"}" };
Assert.False(DiffService.ConnectionsEqual(a, b));
}
[Fact]
public void ConnectionsEqual_BackupConfigurationEdit_ReturnsFalse()
{
var a = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{}", BackupConfigurationJson = null, FailoverRetryCount = 3 };
var b = a with { BackupConfigurationJson = "{\"endpoint\":\"opc.tcp://backup\"}" };
Assert.False(DiffService.ConnectionsEqual(a, b));
}
[Fact]
public void ConnectionsEqual_FailoverRetryCountEdit_ReturnsFalse()
{
var a = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{}", FailoverRetryCount = 3 };
var b = a with { FailoverRetryCount = 5 };
Assert.False(DiffService.ConnectionsEqual(a, b));
}
[Fact]
public void ConnectionsEqual_ProtocolEdit_ReturnsFalse()
{
var a = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{}", FailoverRetryCount = 3 };
var b = a with { Protocol = "Modbus" };
Assert.False(DiffService.ConnectionsEqual(a, b));
}
// ── TemplateEngine-018: ComputeConnectionsDiff produces Added/Removed/Changed entries ──
[Fact]
public void ComputeConnectionsDiff_NewBindingAdded_ReportedAsAdded()
{
// First-time binding (or instance gains its first data-sourced
// attribute) — old config has no Connections map, new config does.
// The pre-018 diff shape silently dropped this so operators saw
// "no changes" when the deployment package was structurally larger.
var oldConfig = new FlattenedConfiguration { InstanceUniqueName = "Instance1" };
var newConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Connections = new Dictionary<string, ConnectionConfig>
{
["plc1"] = new ConnectionConfig
{
Protocol = "OpcUa",
ConfigurationJson = "{\"endpoint\":\"opc.tcp://host\"}",
FailoverRetryCount = 3,
}
}
};
var diff = _sut.ComputeConnectionsDiff(oldConfig, newConfig);
Assert.Single(diff);
Assert.Equal("plc1", diff[0].CanonicalName);
Assert.Equal(DiffChangeType.Added, diff[0].ChangeType);
Assert.Null(diff[0].OldValue);
Assert.NotNull(diff[0].NewValue);
Assert.Equal("OpcUa", diff[0].NewValue!.Protocol);
}
[Fact]
public void ComputeConnectionsDiff_BindingCleared_ReportedAsRemoved()
{
// Last data-sourced attribute removed — old config carried a
// connection, new config does not.
var oldConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Connections = new Dictionary<string, ConnectionConfig>
{
["plc1"] = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{}" }
}
};
var newConfig = new FlattenedConfiguration { InstanceUniqueName = "Instance1" };
var diff = _sut.ComputeConnectionsDiff(oldConfig, newConfig);
Assert.Single(diff);
Assert.Equal("plc1", diff[0].CanonicalName);
Assert.Equal(DiffChangeType.Removed, diff[0].ChangeType);
Assert.NotNull(diff[0].OldValue);
Assert.Null(diff[0].NewValue);
}
[Fact]
public void ComputeConnectionsDiff_EndpointEdit_ReportedAsChanged()
{
// A connection-endpoint edit must surface as a Changed diff entry —
// the deployment package will ship a different ConnectionConfig and
// the operator-facing diff view must say so.
var oldConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Connections = new Dictionary<string, ConnectionConfig>
{
["plc1"] = new ConnectionConfig
{
Protocol = "OpcUa",
ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-a:4840\"}",
FailoverRetryCount = 3,
}
}
};
var newConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Connections = new Dictionary<string, ConnectionConfig>
{
["plc1"] = new ConnectionConfig
{
Protocol = "OpcUa",
ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-b:4840\"}",
FailoverRetryCount = 3,
}
}
};
var diff = _sut.ComputeConnectionsDiff(oldConfig, newConfig);
Assert.Single(diff);
Assert.Equal("plc1", diff[0].CanonicalName);
Assert.Equal(DiffChangeType.Changed, diff[0].ChangeType);
Assert.Contains("host-a", diff[0].OldValue!.ConfigurationJson);
Assert.Contains("host-b", diff[0].NewValue!.ConfigurationJson);
}
[Fact]
public void ComputeConnectionsDiff_IdenticalConnections_NoEntries()
{
// Sanity check: an unchanged connection produces no diff entry, so
// ComputeConnectionsDiff stays quiet when nothing relevant has
// changed.
var connections = new Dictionary<string, ConnectionConfig>
{
["plc1"] = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{}" }
};
var oldConfig = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Connections = connections };
var newConfig = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Connections = connections };
var diff = _sut.ComputeConnectionsDiff(oldConfig, newConfig);
Assert.Empty(diff);
}
}
@@ -0,0 +1,349 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Flattening;
public class FlatteningServiceMergeTests
{
// ── MergeHiLoConfig ────────────────────────────────────────────────────
[Fact]
public void MergeHiLoConfig_DerivedKeysWin_InheritedKeysSurvive()
{
const string inherited = @"{""attributeName"":""Temp"",""loLo"":0,""lo"":10,""hi"":80,""hiHi"":100}";
const string derived = @"{""hi"":90}"; // derived only overrides Hi
var result = FlatteningService.MergeHiLoConfig(inherited, derived);
Assert.NotNull(result);
using var doc = JsonDocument.Parse(result!);
Assert.Equal("Temp", doc.RootElement.GetProperty("attributeName").GetString());
Assert.Equal(0, doc.RootElement.GetProperty("loLo").GetDouble());
Assert.Equal(10, doc.RootElement.GetProperty("lo").GetDouble());
Assert.Equal(90, doc.RootElement.GetProperty("hi").GetDouble()); // overridden
Assert.Equal(100, doc.RootElement.GetProperty("hiHi").GetDouble()); // inherited
}
[Fact]
public void MergeHiLoConfig_DerivedCanOverrideAttribute()
{
const string inherited = @"{""attributeName"":""Temp"",""hi"":80}";
const string derived = @"{""attributeName"":""Pressure"",""hi"":50}";
var result = FlatteningService.MergeHiLoConfig(inherited, derived);
using var doc = JsonDocument.Parse(result!);
Assert.Equal("Pressure", doc.RootElement.GetProperty("attributeName").GetString());
Assert.Equal(50, doc.RootElement.GetProperty("hi").GetDouble());
}
[Fact]
public void MergeHiLoConfig_DerivedNull_ReturnsInherited()
{
const string inherited = @"{""hi"":80}";
var result = FlatteningService.MergeHiLoConfig(inherited, null);
Assert.Equal(inherited, result);
}
[Fact]
public void MergeHiLoConfig_InheritedNull_ReturnsDerived()
{
const string derived = @"{""hi"":80}";
var result = FlatteningService.MergeHiLoConfig(null, derived);
Assert.Equal(derived, result);
}
[Fact]
public void MergeHiLoConfig_BothNull_ReturnsNull()
{
Assert.Null(FlatteningService.MergeHiLoConfig(null, null));
}
[Fact]
public void MergeHiLoConfig_MalformedInherited_FallsBackToDerived()
{
// Safe fallback — never throw on malformed input.
const string derived = @"{""hi"":80}";
var result = FlatteningService.MergeHiLoConfig("{not valid", derived);
Assert.Equal(derived, result);
}
[Fact]
public void MergeHiLoConfig_DerivedAddsNewKey_PreservesInheritedRest()
{
// Derived adds a deadband that the base didn't have.
const string inherited = @"{""hi"":80,""hiHi"":100}";
const string derived = @"{""hiDeadband"":3}";
var result = FlatteningService.MergeHiLoConfig(inherited, derived);
using var doc = JsonDocument.Parse(result!);
Assert.Equal(80, doc.RootElement.GetProperty("hi").GetDouble());
Assert.Equal(100, doc.RootElement.GetProperty("hiHi").GetDouble());
Assert.Equal(3, doc.RootElement.GetProperty("hiDeadband").GetDouble());
}
// ── DiffHiLoConfig ─────────────────────────────────────────────────────
[Fact]
public void DiffHiLoConfig_NoChanges_ReturnsNull()
{
const string both = @"{""attributeName"":""Temp"",""hi"":80}";
Assert.Null(FlatteningService.DiffHiLoConfig(both, both));
}
[Fact]
public void DiffHiLoConfig_ChangedKey_ReturnsOnlyChangedKey()
{
const string inherited = @"{""attributeName"":""Temp"",""loLo"":0,""lo"":10,""hi"":80,""hiHi"":100}";
const string edited = @"{""attributeName"":""Temp"",""loLo"":0,""lo"":10,""hi"":90,""hiHi"":100}";
var diff = FlatteningService.DiffHiLoConfig(inherited, edited);
Assert.NotNull(diff);
using var doc = JsonDocument.Parse(diff!);
var prop = Assert.Single(doc.RootElement.EnumerateObject());
Assert.Equal("hi", prop.Name);
Assert.Equal(90, prop.Value.GetDouble());
}
[Fact]
public void DiffHiLoConfig_NewKey_AddedToDiff()
{
const string inherited = @"{""attributeName"":""Temp"",""hi"":80}";
const string edited = @"{""attributeName"":""Temp"",""hi"":80,""hiDeadband"":3}";
var diff = FlatteningService.DiffHiLoConfig(inherited, edited);
Assert.NotNull(diff);
using var doc = JsonDocument.Parse(diff!);
Assert.Equal(3, doc.RootElement.GetProperty("hiDeadband").GetDouble());
Assert.False(doc.RootElement.TryGetProperty("hi", out _));
}
[Fact]
public void DiffHiLoConfig_NullInherited_ReturnsEditedVerbatim()
{
const string edited = @"{""attributeName"":""Temp"",""hi"":80}";
Assert.Equal(edited, FlatteningService.DiffHiLoConfig(null, edited));
}
[Fact]
public void DiffHiLoConfig_NullEdited_ReturnsNull()
{
Assert.Null(FlatteningService.DiffHiLoConfig(@"{""hi"":80}", null));
}
[Fact]
public void DiffHiLoConfig_IgnoresStringEscapeDifferences()
{
// Inherited has literal em-dash; edited has the unicode-escaped form.
// Decoded values are identical, so the key should NOT be in the diff.
const string inherited = @"{""attributeName"":""Temp"",""hi"":80,""hiMessage"":""High — investigate""}";
const string edited = @"{""attributeName"":""Temp"",""hi"":80,""hiMessage"":""High — investigate""}";
var diff = FlatteningService.DiffHiLoConfig(inherited, edited);
Assert.Null(diff); // no real change once values are decoded
}
[Fact]
public void DiffHiLoConfig_IgnoresNumericFormatDifferences()
{
// 85 vs 85.0 are the same number — should not produce a diff.
const string inherited = @"{""hi"":85}";
const string edited = @"{""hi"":85.0}";
Assert.Null(FlatteningService.DiffHiLoConfig(inherited, edited));
}
[Fact]
public void DiffHiLoConfig_RoundTripsThroughMerge()
{
// Merge(inherited, Diff(inherited, edited)) ≡ edited — when the
// edited config is itself a superset/equivalent of inherited.
const string inherited = @"{""attributeName"":""Temp"",""hi"":80,""hiHi"":100}";
const string edited = @"{""attributeName"":""Temp"",""hi"":90,""hiHi"":100,""hiDeadband"":5}";
var diff = FlatteningService.DiffHiLoConfig(inherited, edited);
var merged = FlatteningService.MergeHiLoConfig(inherited, diff);
using var origDoc = JsonDocument.Parse(edited);
using var mergedDoc = JsonDocument.Parse(merged!);
Assert.Equal(origDoc.RootElement.GetProperty("hi").GetDouble(),
mergedDoc.RootElement.GetProperty("hi").GetDouble());
Assert.Equal(origDoc.RootElement.GetProperty("hiHi").GetDouble(),
mergedDoc.RootElement.GetProperty("hiHi").GetDouble());
Assert.Equal(origDoc.RootElement.GetProperty("hiDeadband").GetDouble(),
mergedDoc.RootElement.GetProperty("hiDeadband").GetDouble());
}
// ── Instance-level alarm override (end-to-end Flatten) ─────────────────
private static (Template, Instance) BuildHiLoFixture(string inheritedJson, InstanceAlarmOverride? ovr = null, bool locked = false)
{
var template = new Template("PumpTpl")
{
Id = 1,
Alarms = new List<TemplateAlarm>
{
new("Temp")
{
Id = 10,
TemplateId = 1,
TriggerType = AlarmTriggerType.HiLo,
TriggerConfiguration = inheritedJson,
PriorityLevel = 500,
IsLocked = locked
}
}
};
var instance = new Instance("Pump-1") { Id = 100, TemplateId = 1, SiteId = 1 };
if (ovr != null) instance.AlarmOverrides.Add(ovr);
return (template, instance);
}
private static FlattenedConfiguration Flatten(Template template, Instance instance)
{
var sut = new FlatteningService();
var result = sut.Flatten(
instance,
new[] { template },
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
new Dictionary<int, DataConnection>());
if (!result.IsSuccess) Assert.Fail(result.Error);
return result.Value!;
}
[Fact]
public void Flatten_InstanceAlarmOverride_HiLo_MergesSetpoints()
{
// Template has {hi=80, hiHi=100, lo=10, loLo=0}. Instance overrides hi=90 only.
var (tpl, inst) = BuildHiLoFixture(
@"{""attributeName"":""Temp"",""loLo"":0,""lo"":10,""hi"":80,""hiHi"":100}",
new InstanceAlarmOverride("Temp")
{
InstanceId = 100,
TriggerConfigurationOverride = @"{""hi"":90}"
});
var flat = Flatten(tpl, inst);
var alarm = flat.Alarms.Single();
using var doc = JsonDocument.Parse(alarm.TriggerConfiguration!);
Assert.Equal(0, doc.RootElement.GetProperty("loLo").GetDouble());
Assert.Equal(10, doc.RootElement.GetProperty("lo").GetDouble());
Assert.Equal(90, doc.RootElement.GetProperty("hi").GetDouble()); // overridden
Assert.Equal(100, doc.RootElement.GetProperty("hiHi").GetDouble()); // inherited
Assert.Equal("Override", alarm.Source);
}
[Fact]
public void Flatten_InstanceAlarmOverride_OverridesPriority()
{
var (tpl, inst) = BuildHiLoFixture(
@"{""attributeName"":""Temp"",""hi"":80}",
new InstanceAlarmOverride("Temp")
{
InstanceId = 100,
PriorityLevelOverride = 950
});
var flat = Flatten(tpl, inst);
var alarm = flat.Alarms.Single();
Assert.Equal(950, alarm.PriorityLevel);
Assert.Equal("Override", alarm.Source);
}
[Fact]
public void Flatten_InstanceAlarmOverride_LockedAlarm_OverrideSilentlyIgnored()
{
// Locked alarm — override should be a no-op at flatten time. (The
// InstanceService.SetAlarmOverrideAsync write-time check is what
// prevents the override from being persisted in the first place;
// this test covers the runtime safety net.)
var (tpl, inst) = BuildHiLoFixture(
@"{""attributeName"":""Temp"",""hi"":80}",
new InstanceAlarmOverride("Temp")
{
InstanceId = 100,
TriggerConfigurationOverride = @"{""hi"":999}"
},
locked: true);
var flat = Flatten(tpl, inst);
var alarm = flat.Alarms.Single();
using var doc = JsonDocument.Parse(alarm.TriggerConfiguration!);
Assert.Equal(80, doc.RootElement.GetProperty("hi").GetDouble()); // not overridden
}
[Fact]
public void Flatten_InstanceAlarmOverride_UnknownName_DoesNothing()
{
// Override targets an alarm name that doesn't exist on the template —
// silently ignored (same behavior as attribute overrides).
var (tpl, inst) = BuildHiLoFixture(
@"{""attributeName"":""Temp"",""hi"":80}",
new InstanceAlarmOverride("DoesNotExist")
{
InstanceId = 100,
TriggerConfigurationOverride = @"{""hi"":999}"
});
var flat = Flatten(tpl, inst);
var alarm = flat.Alarms.Single();
using var doc = JsonDocument.Parse(alarm.TriggerConfiguration!);
Assert.Equal(80, doc.RootElement.GetProperty("hi").GetDouble());
Assert.NotEqual("Override", alarm.Source);
}
[Fact]
public void Flatten_InstanceAlarmOverride_BinaryTrigger_ReplacesWholeConfig()
{
// For non-HiLo trigger types, an instance override replaces the whole
// TriggerConfiguration (no per-key merge).
var template = new Template("PumpTpl")
{
Id = 1,
Alarms = new List<TemplateAlarm>
{
new("Temp")
{
Id = 10,
TemplateId = 1,
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = @"{""attributeName"":""T"",""min"":0,""max"":100}",
PriorityLevel = 500
}
}
};
var instance = new Instance("Pump-1") { Id = 100, TemplateId = 1, SiteId = 1 };
instance.AlarmOverrides.Add(new InstanceAlarmOverride("Temp")
{
InstanceId = 100,
TriggerConfigurationOverride = @"{""attributeName"":""T"",""min"":-50,""max"":50}"
});
var flat = Flatten(template, instance);
var alarm = flat.Alarms.Single();
using var doc = JsonDocument.Parse(alarm.TriggerConfiguration!);
Assert.Equal(-50, doc.RootElement.GetProperty("min").GetDouble());
Assert.Equal(50, doc.RootElement.GetProperty("max").GetDouble());
}
}
@@ -0,0 +1,669 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Flattening;
public class FlatteningServiceTests
{
private readonly FlatteningService _sut = new();
private static Instance CreateInstance(string name = "TestInstance", int templateId = 1, int siteId = 1) =>
new(name) { Id = 1, TemplateId = templateId, SiteId = siteId };
private static Template CreateTemplate(int id, string name, int? parentId = null)
{
var t = new Template(name) { Id = id, ParentTemplateId = parentId };
return t;
}
[Fact]
public void Flatten_EmptyTemplateChain_ReturnsFailure()
{
var instance = CreateInstance();
var result = _sut.Flatten(
instance,
[],
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
new Dictionary<int, DataConnection>());
Assert.True(result.IsFailure);
Assert.Contains("empty", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Flatten_SingleTemplate_ResolvesAttributes()
{
var template = CreateTemplate(1, "Base");
template.Attributes.Add(new TemplateAttribute("Temperature") { DataType = DataType.Double, Value = "25.0" });
template.Attributes.Add(new TemplateAttribute("Status") { DataType = DataType.String, Value = "OK" });
var instance = CreateInstance();
var result = _sut.Flatten(
instance,
[template],
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
new Dictionary<int, DataConnection>());
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.Attributes.Count);
Assert.Equal("Temperature", result.Value.Attributes[1].CanonicalName); // Sorted
Assert.Equal("25.0", result.Value.Attributes[1].Value);
Assert.Equal("Status", result.Value.Attributes[0].CanonicalName);
}
[Fact]
public void Flatten_InheritanceChain_DerivedOverridesBase()
{
var baseTemplate = CreateTemplate(2, "Base");
baseTemplate.Attributes.Add(new TemplateAttribute("Speed") { DataType = DataType.Double, Value = "100.0" });
baseTemplate.Attributes.Add(new TemplateAttribute("BaseOnly") { DataType = DataType.String, Value = "base" });
var childTemplate = CreateTemplate(1, "Child", parentId: 2);
childTemplate.Attributes.Add(new TemplateAttribute("Speed") { DataType = DataType.Double, Value = "200.0" });
childTemplate.Attributes.Add(new TemplateAttribute("ChildOnly") { DataType = DataType.Int32, Value = "42" });
// Chain: [child, base] — most-derived first
var instance = CreateInstance();
var result = _sut.Flatten(
instance,
[childTemplate, baseTemplate],
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
new Dictionary<int, DataConnection>());
Assert.True(result.IsSuccess);
Assert.Equal(3, result.Value.Attributes.Count);
var speed = result.Value.Attributes.First(a => a.CanonicalName == "Speed");
Assert.Equal("200.0", speed.Value); // Child's value wins
}
[Fact]
public void Flatten_LockedAttribute_NotOverriddenByDerived()
{
var baseTemplate = CreateTemplate(2, "Base");
baseTemplate.Attributes.Add(new TemplateAttribute("Locked") { DataType = DataType.String, Value = "locked", IsLocked = true });
var childTemplate = CreateTemplate(1, "Child", parentId: 2);
childTemplate.Attributes.Add(new TemplateAttribute("Locked") { DataType = DataType.String, Value = "overridden" });
var instance = CreateInstance();
var result = _sut.Flatten(
instance,
[childTemplate, baseTemplate],
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
new Dictionary<int, DataConnection>());
Assert.True(result.IsSuccess);
var locked = result.Value.Attributes.First(a => a.CanonicalName == "Locked");
Assert.Equal("locked", locked.Value); // Base locked value preserved
}
[Fact]
public void Flatten_InstanceOverride_AppliedToUnlockedAttribute()
{
var template = CreateTemplate(1, "Base");
template.Attributes.Add(new TemplateAttribute("Threshold") { DataType = DataType.Double, Value = "50.0" });
var instance = CreateInstance();
instance.AttributeOverrides.Add(new InstanceAttributeOverride("Threshold") { OverrideValue = "75.0" });
var result = _sut.Flatten(
instance,
[template],
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
new Dictionary<int, DataConnection>());
Assert.True(result.IsSuccess);
var attr = result.Value.Attributes.First(a => a.CanonicalName == "Threshold");
Assert.Equal("75.0", attr.Value);
Assert.Equal("Override", attr.Source);
}
[Fact]
public void Flatten_InstanceOverride_SkippedForLockedAttribute()
{
var template = CreateTemplate(1, "Base");
template.Attributes.Add(new TemplateAttribute("Locked") { DataType = DataType.String, Value = "original", IsLocked = true });
var instance = CreateInstance();
instance.AttributeOverrides.Add(new InstanceAttributeOverride("Locked") { OverrideValue = "changed" });
var result = _sut.Flatten(
instance,
[template],
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
new Dictionary<int, DataConnection>());
Assert.True(result.IsSuccess);
var attr = result.Value.Attributes.First(a => a.CanonicalName == "Locked");
Assert.Equal("original", attr.Value); // Lock honored
}
[Fact]
public void Flatten_ComposedModule_PathQualifiedNames()
{
var composedTemplate = CreateTemplate(2, "Pump");
composedTemplate.Attributes.Add(new TemplateAttribute("RPM") { DataType = DataType.Double, Value = "1500" });
composedTemplate.Scripts.Add(new TemplateScript("Start", "// start") { Id = 10 });
var parentTemplate = CreateTemplate(1, "Station");
parentTemplate.Attributes.Add(new TemplateAttribute("StationName") { DataType = DataType.String, Value = "S1" });
var compositions = new Dictionary<int, IReadOnlyList<TemplateComposition>>
{
[1] = new List<TemplateComposition>
{
new("MainPump") { ComposedTemplateId = 2 }
}
};
var composedChains = new Dictionary<int, IReadOnlyList<Template>>
{
[2] = [composedTemplate]
};
var instance = CreateInstance();
var result = _sut.Flatten(
instance,
[parentTemplate],
compositions,
composedChains,
new Dictionary<int, DataConnection>());
Assert.True(result.IsSuccess);
Assert.Contains(result.Value.Attributes, a => a.CanonicalName == "MainPump.RPM");
Assert.Contains(result.Value.Attributes, a => a.CanonicalName == "StationName");
Assert.Contains(result.Value.Scripts, s => s.CanonicalName == "MainPump.Start");
}
[Fact]
public void Flatten_ConnectionBindings_ResolvedCorrectly()
{
var template = CreateTemplate(1, "Base");
template.Attributes.Add(new TemplateAttribute("Temp")
{
DataType = DataType.Double,
DataSourceReference = "ns=2;s=Temperature"
});
var instance = CreateInstance();
instance.ConnectionBindings.Add(new InstanceConnectionBinding("Temp") { DataConnectionId = 100 });
var connections = new Dictionary<int, DataConnection>
{
[100] = new("OPC-Server1", "OpcUa", 1) { Id = 100, PrimaryConfiguration = "opc.tcp://localhost:4840" }
};
var result = _sut.Flatten(
instance,
[template],
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
connections);
Assert.True(result.IsSuccess);
var attr = result.Value.Attributes.First(a => a.CanonicalName == "Temp");
Assert.Equal(100, attr.BoundDataConnectionId);
Assert.Equal("OPC-Server1", attr.BoundDataConnectionName);
Assert.Equal("OpcUa", attr.BoundDataConnectionProtocol);
}
[Fact]
public void Flatten_Alarms_ResolvedFromChain()
{
var template = CreateTemplate(1, "Base");
template.Alarms.Add(new TemplateAlarm("HighTemp")
{
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
PriorityLevel = 1
});
var instance = CreateInstance();
var result = _sut.Flatten(
instance,
[template],
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
new Dictionary<int, DataConnection>());
Assert.True(result.IsSuccess);
Assert.Single(result.Value.Alarms);
Assert.Equal("HighTemp", result.Value.Alarms[0].CanonicalName);
Assert.Equal("RangeViolation", result.Value.Alarms[0].TriggerType);
}
[Fact]
public void Flatten_InstanceMetadata_SetCorrectly()
{
var template = CreateTemplate(1, "Base");
var instance = CreateInstance("MyInstance", templateId: 1, siteId: 5);
instance.AreaId = 3;
var result = _sut.Flatten(
instance,
[template],
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
new Dictionary<int, DataConnection>());
Assert.True(result.IsSuccess);
Assert.Equal("MyInstance", result.Value.InstanceUniqueName);
Assert.Equal(1, result.Value.TemplateId);
Assert.Equal(5, result.Value.SiteId);
Assert.Equal(3, result.Value.AreaId);
}
[Fact]
public void Flatten_InheritedAttributeOnDerived_BaseValueWins()
{
var baseTemplate = CreateTemplate(2, "Sensor");
baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { DataType = DataType.Double, Value = "100.0" });
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
derived.Attributes.Add(new TemplateAttribute("SetPoint")
{
DataType = DataType.Double,
Value = "STALE",
IsInherited = true
});
var instance = CreateInstance();
var result = _sut.Flatten(
instance,
[derived, baseTemplate],
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
new Dictionary<int, DataConnection>());
Assert.True(result.IsSuccess);
var setPoint = result.Value.Attributes.First(a => a.CanonicalName == "SetPoint");
Assert.Equal("100.0", setPoint.Value);
}
[Fact]
public void Flatten_OverriddenAttributeOnDerived_DerivedValueWins()
{
var baseTemplate = CreateTemplate(2, "Sensor");
baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { DataType = DataType.Double, Value = "100.0" });
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
derived.Attributes.Add(new TemplateAttribute("SetPoint")
{
DataType = DataType.Double,
Value = "150.0",
IsInherited = false
});
var instance = CreateInstance();
var result = _sut.Flatten(
instance,
[derived, baseTemplate],
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
new Dictionary<int, DataConnection>());
Assert.True(result.IsSuccess);
var setPoint = result.Value.Attributes.First(a => a.CanonicalName == "SetPoint");
Assert.Equal("150.0", setPoint.Value);
}
[Fact]
public void Flatten_LockedInDerivedOverride_Fails()
{
var baseTemplate = CreateTemplate(2, "Sensor");
baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint")
{
DataType = DataType.Double,
Value = "100.0",
LockedInDerived = true
});
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
derived.Attributes.Add(new TemplateAttribute("SetPoint")
{
DataType = DataType.Double,
Value = "150.0",
IsInherited = false
});
var instance = CreateInstance();
var result = _sut.Flatten(
instance,
[derived, baseTemplate],
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
new Dictionary<int, DataConnection>());
Assert.True(result.IsFailure);
Assert.Contains("LockedInDerived", result.Error);
Assert.Contains("SetPoint", result.Error);
}
[Fact]
public void Flatten_InheritedScriptOnDerived_BaseCodeWins()
{
var baseTemplate = CreateTemplate(2, "Sensor");
baseTemplate.Scripts.Add(new TemplateScript("Sample", "return base;"));
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
derived.Scripts.Add(new TemplateScript("Sample", "stale code") { IsInherited = true });
var instance = CreateInstance();
var result = _sut.Flatten(
instance,
[derived, baseTemplate],
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
new Dictionary<int, DataConnection>());
Assert.True(result.IsSuccess);
var script = result.Value.Scripts.First(s => s.CanonicalName == "Sample");
Assert.Equal("return base;", script.Code);
}
// ── TemplateEngine-002: per-slot alarm override ────────────────────────
[Fact]
public void Flatten_InheritedAlarmOnDerived_BaseValueWins()
{
var baseTemplate = CreateTemplate(2, "Sensor");
baseTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
{
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
PriorityLevel = 5
});
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
derived.Alarms.Add(new TemplateAlarm("HighTemp")
{
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":999}",
PriorityLevel = 99,
IsInherited = true
});
var instance = CreateInstance();
var result = _sut.Flatten(
instance,
[derived, baseTemplate],
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
new Dictionary<int, DataConnection>());
Assert.True(result.IsSuccess);
var alarm = result.Value.Alarms.First(a => a.CanonicalName == "HighTemp");
Assert.Equal(5, alarm.PriorityLevel);
Assert.Equal("{\"attributeName\":\"Temp\",\"high\":100}", alarm.TriggerConfiguration);
}
[Fact]
public void Flatten_OverriddenAlarmOnDerived_DerivedValueWins()
{
var baseTemplate = CreateTemplate(2, "Sensor");
baseTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
{
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
PriorityLevel = 5
});
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
derived.Alarms.Add(new TemplateAlarm("HighTemp")
{
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":120}",
PriorityLevel = 42,
IsInherited = false
});
var instance = CreateInstance();
var result = _sut.Flatten(
instance,
[derived, baseTemplate],
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
new Dictionary<int, DataConnection>());
Assert.True(result.IsSuccess);
var alarm = result.Value.Alarms.First(a => a.CanonicalName == "HighTemp");
Assert.Equal(42, alarm.PriorityLevel);
Assert.Equal("{\"attributeName\":\"Temp\",\"high\":120}", alarm.TriggerConfiguration);
}
[Fact]
public void Flatten_LockedInDerivedAlarmOverride_Fails()
{
var baseTemplate = CreateTemplate(2, "Sensor");
baseTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
{
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
PriorityLevel = 5,
LockedInDerived = true
});
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
derived.Alarms.Add(new TemplateAlarm("HighTemp")
{
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":120}",
PriorityLevel = 42,
IsInherited = false
});
var instance = CreateInstance();
var result = _sut.Flatten(
instance,
[derived, baseTemplate],
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
new Dictionary<int, DataConnection>());
Assert.True(result.IsFailure);
Assert.Contains("LockedInDerived", result.Error);
Assert.Contains("HighTemp", result.Error);
}
// ── TemplateEngine-001: deep composition nesting ───────────────────────
[Fact]
public void Flatten_ThreeLevelComposition_AttributesAlarmsScriptsAllResolved()
{
// Station composes Pump (level 1); Pump composes Motor (level 2);
// Motor composes Bearing (level 3).
var bearing = CreateTemplate(4, "Bearing");
bearing.Attributes.Add(new TemplateAttribute("Vibration") { DataType = DataType.Double, Value = "0.1" });
bearing.Alarms.Add(new TemplateAlarm("HighVibration")
{
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = "{\"attributeName\":\"Vibration\",\"high\":5}",
PriorityLevel = 1
});
bearing.Scripts.Add(new TemplateScript("MonitorBearing", "// monitor") { Id = 40 });
var motor = CreateTemplate(3, "Motor");
motor.Attributes.Add(new TemplateAttribute("Current") { DataType = DataType.Double, Value = "10" });
var pump = CreateTemplate(2, "Pump");
pump.Attributes.Add(new TemplateAttribute("RPM") { DataType = DataType.Double, Value = "1500" });
var station = CreateTemplate(1, "Station");
var compositions = new Dictionary<int, IReadOnlyList<TemplateComposition>>
{
[1] = new List<TemplateComposition> { new("MainPump") { ComposedTemplateId = 2 } },
[2] = new List<TemplateComposition> { new("DriveMotor") { ComposedTemplateId = 3 } },
[3] = new List<TemplateComposition> { new("FrontBearing") { ComposedTemplateId = 4 } },
};
var composedChains = new Dictionary<int, IReadOnlyList<Template>>
{
[2] = [pump],
[3] = [motor],
[4] = [bearing],
};
var instance = CreateInstance();
var result = _sut.Flatten(instance, [station], compositions, composedChains,
new Dictionary<int, DataConnection>());
Assert.True(result.IsSuccess);
// Level 3 attribute must be present with the full path-qualified name.
Assert.Contains(result.Value.Attributes,
a => a.CanonicalName == "MainPump.DriveMotor.FrontBearing.Vibration");
// Level 3 alarm must be present (was dropped entirely before).
Assert.Contains(result.Value.Alarms,
a => a.CanonicalName == "MainPump.DriveMotor.FrontBearing.HighVibration");
// Level 3 script must be present (was dropped entirely before).
Assert.Contains(result.Value.Scripts,
s => s.CanonicalName == "MainPump.DriveMotor.FrontBearing.MonitorBearing");
}
[Fact]
public void Flatten_NestedComposedAlarm_TriggerAttributePrefixed()
{
var bearing = CreateTemplate(4, "Bearing");
bearing.Attributes.Add(new TemplateAttribute("Vibration") { DataType = DataType.Double, Value = "0.1" });
bearing.Alarms.Add(new TemplateAlarm("HighVibration")
{
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = "{\"attributeName\":\"Vibration\",\"high\":5}",
PriorityLevel = 1
});
var motor = CreateTemplate(3, "Motor");
var pump = CreateTemplate(2, "Pump");
var station = CreateTemplate(1, "Station");
var compositions = new Dictionary<int, IReadOnlyList<TemplateComposition>>
{
[1] = new List<TemplateComposition> { new("MainPump") { ComposedTemplateId = 2 } },
[2] = new List<TemplateComposition> { new("DriveMotor") { ComposedTemplateId = 3 } },
[3] = new List<TemplateComposition> { new("FrontBearing") { ComposedTemplateId = 4 } },
};
var composedChains = new Dictionary<int, IReadOnlyList<Template>>
{
[2] = [pump], [3] = [motor], [4] = [bearing],
};
var instance = CreateInstance();
var result = _sut.Flatten(instance, [station], compositions, composedChains,
new Dictionary<int, DataConnection>());
Assert.True(result.IsSuccess);
var alarm = result.Value.Alarms.First(a => a.CanonicalName.EndsWith("HighVibration"));
// The trigger's attribute reference must carry the full nested prefix.
Assert.Contains("MainPump.DriveMotor.FrontBearing.Vibration", alarm.TriggerConfiguration);
}
// ── TemplateEngine-004: alarm on-trigger script resolution ─────────────
[Fact]
public void Flatten_AlarmOnTriggerScript_ResolvedToCanonicalName()
{
var template = CreateTemplate(1, "Base");
template.Scripts.Add(new TemplateScript("HandleAlarm", "// handle") { Id = 50 });
template.Alarms.Add(new TemplateAlarm("HighTemp")
{
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
PriorityLevel = 1,
OnTriggerScriptId = 50
});
var instance = CreateInstance();
var result = _sut.Flatten(instance, [template],
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
new Dictionary<int, DataConnection>());
Assert.True(result.IsSuccess);
var alarm = result.Value.Alarms.First(a => a.CanonicalName == "HighTemp");
Assert.Equal("HandleAlarm", alarm.OnTriggerScriptCanonicalName);
}
[Fact]
public void Flatten_ComposedAlarmOnTriggerScript_ResolvedWithPrefix()
{
var composedTemplate = CreateTemplate(2, "Pump");
composedTemplate.Scripts.Add(new TemplateScript("PumpAlarmHandler", "// h") { Id = 60 });
composedTemplate.Alarms.Add(new TemplateAlarm("PumpFault")
{
TriggerType = AlarmTriggerType.ValueMatch,
TriggerConfiguration = "{\"attributeName\":\"State\",\"value\":\"FAULT\"}",
PriorityLevel = 5,
OnTriggerScriptId = 60
});
var station = CreateTemplate(1, "Station");
var compositions = new Dictionary<int, IReadOnlyList<TemplateComposition>>
{
[1] = new List<TemplateComposition> { new("MainPump") { ComposedTemplateId = 2 } }
};
var composedChains = new Dictionary<int, IReadOnlyList<Template>>
{
[2] = [composedTemplate]
};
var instance = CreateInstance();
var result = _sut.Flatten(instance, [station], compositions, composedChains,
new Dictionary<int, DataConnection>());
Assert.True(result.IsSuccess);
var alarm = result.Value.Alarms.First(a => a.CanonicalName == "MainPump.PumpFault");
Assert.Equal("MainPump.PumpAlarmHandler", alarm.OnTriggerScriptCanonicalName);
}
// ── TemplateEngine-016: composed-script ScriptScope.ParentPath ─────────
[Fact]
public void Flatten_NestedComposedScript_ScopeCarriesCorrectParentPath()
{
// Station composes Pump (level 1); Pump composes Motor (level 2).
// The depth-1 script's parent is the root template (ParentPath "");
// the depth-2 script's parent is the Pump module (ParentPath "MainPump").
var motor = CreateTemplate(3, "Motor");
motor.Scripts.Add(new TemplateScript("MonitorMotor", "// m") { Id = 70 });
var pump = CreateTemplate(2, "Pump");
pump.Scripts.Add(new TemplateScript("MonitorPump", "// p") { Id = 71 });
var station = CreateTemplate(1, "Station");
var compositions = new Dictionary<int, IReadOnlyList<TemplateComposition>>
{
[1] = new List<TemplateComposition> { new("MainPump") { ComposedTemplateId = 2 } },
[2] = new List<TemplateComposition> { new("DriveMotor") { ComposedTemplateId = 3 } },
};
var composedChains = new Dictionary<int, IReadOnlyList<Template>>
{
[2] = [pump], [3] = [motor],
};
var instance = CreateInstance();
var result = _sut.Flatten(instance, [station], compositions, composedChains,
new Dictionary<int, DataConnection>());
Assert.True(result.IsSuccess);
var depth1 = result.Value.Scripts.First(s => s.CanonicalName == "MainPump.MonitorPump");
Assert.Equal("MainPump", depth1.Scope.SelfPath);
Assert.Equal("", depth1.Scope.ParentPath);
var depth2 = result.Value.Scripts.First(s => s.CanonicalName == "MainPump.DriveMotor.MonitorMotor");
Assert.Equal("MainPump.DriveMotor", depth2.Scope.SelfPath);
// Parent module of a depth-2 script is the enclosing Pump module.
Assert.Equal("MainPump", depth2.Scope.ParentPath);
}
}
@@ -0,0 +1,295 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Flattening;
public class RevisionHashServiceTests
{
private readonly RevisionHashService _sut = new();
[Fact]
public void ComputeHash_SameContent_SameHash()
{
var config1 = CreateConfig("Instance1", "25.0");
var config2 = CreateConfig("Instance1", "25.0");
var hash1 = _sut.ComputeHash(config1);
var hash2 = _sut.ComputeHash(config2);
Assert.Equal(hash1, hash2);
}
[Fact]
public void ComputeHash_DifferentContent_DifferentHash()
{
var config1 = CreateConfig("Instance1", "25.0");
var config2 = CreateConfig("Instance1", "50.0");
var hash1 = _sut.ComputeHash(config1);
var hash2 = _sut.ComputeHash(config2);
Assert.NotEqual(hash1, hash2);
}
[Fact]
public void ComputeHash_StartsWithSha256Prefix()
{
var config = CreateConfig("Instance1", "25.0");
var hash = _sut.ComputeHash(config);
Assert.StartsWith("sha256:", hash);
}
[Fact]
public void ComputeHash_DeterministicAcrossRuns()
{
// Different GeneratedAtUtc should NOT affect the hash (volatile field excluded)
var config1 = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
TemplateId = 1,
SiteId = 1,
Attributes = [new ResolvedAttribute { CanonicalName = "A", Value = "1", DataType = "Int32" }],
GeneratedAtUtc = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)
};
var config2 = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
TemplateId = 1,
SiteId = 1,
Attributes = [new ResolvedAttribute { CanonicalName = "A", Value = "1", DataType = "Int32" }],
GeneratedAtUtc = new DateTimeOffset(2026, 6, 15, 12, 0, 0, TimeSpan.Zero)
};
Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
}
[Fact]
public void ComputeHash_NullConfig_ThrowsArgumentNull()
{
Assert.Throws<ArgumentNullException>(() => _sut.ComputeHash(null!));
}
[Fact]
public void ComputeHash_AttributeOrder_DoesNotAffectHash()
{
var config1 = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
TemplateId = 1,
SiteId = 1,
Attributes =
[
new ResolvedAttribute { CanonicalName = "A", Value = "1", DataType = "Int32" },
new ResolvedAttribute { CanonicalName = "B", Value = "2", DataType = "Int32" }
]
};
var config2 = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
TemplateId = 1,
SiteId = 1,
Attributes =
[
new ResolvedAttribute { CanonicalName = "B", Value = "2", DataType = "Int32" },
new ResolvedAttribute { CanonicalName = "A", Value = "1", DataType = "Int32" }
]
};
Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
}
[Fact]
public void HashableRecords_PropertiesDeclaredAlphabetically()
{
// TemplateEngine-011: revision-hash determinism depends entirely on the
// private Hashable* records declaring their properties in alphabetical
// order (System.Text.Json emits properties in CLR declaration order and
// does not sort). This guards against a contributor silently changing
// every revision hash by adding a property out of order.
var nested = typeof(RevisionHashService)
.GetNestedTypes(System.Reflection.BindingFlags.NonPublic)
.Where(t => t.Name.StartsWith("Hashable"))
.ToList();
Assert.NotEmpty(nested);
foreach (var type in nested)
{
var propNames = type
.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
.Where(p => p.Name != "EqualityContract")
.Select(p => p.Name)
.ToList();
var sorted = propNames.OrderBy(n => n, StringComparer.Ordinal).ToList();
Assert.True(
propNames.SequenceEqual(sorted),
$"{type.Name} properties must be declared alphabetically. " +
$"Declared: [{string.Join(", ", propNames)}] Expected: [{string.Join(", ", sorted)}]");
}
}
[Fact]
public void ComputeHash_AttributeDescriptionEdit_ChangesHash()
{
// TemplateEngine-017: Description must be folded into the hash so that
// edits to authoring-time documentation (which still travels in the
// deployed payload) flow through the staleness indicator.
var baseAttr = new ResolvedAttribute
{
CanonicalName = "Temperature",
Value = "25",
DataType = "Double",
Description = "Original description"
};
var editedAttr = baseAttr with { Description = "Updated description" };
var configBefore = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
TemplateId = 1,
SiteId = 1,
Attributes = [baseAttr]
};
var configAfter = configBefore with { Attributes = [editedAttr] };
Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
}
[Fact]
public void ComputeHash_AlarmDescriptionEdit_ChangesHash()
{
// TemplateEngine-017: same Description contract applies to alarms.
var baseAlarm = new ResolvedAlarm
{
CanonicalName = "HighTemp",
TriggerType = "RangeViolation",
Description = "Original"
};
var editedAlarm = baseAlarm with { Description = "Updated" };
var configBefore = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
TemplateId = 1,
SiteId = 1,
Alarms = [baseAlarm]
};
var configAfter = configBefore with { Alarms = [editedAlarm] };
Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
}
[Fact]
public void ComputeHash_ConnectionEndpointEdit_ChangesHash()
{
// TemplateEngine-017: a Deployment user editing the primary endpoint
// JSON of a data connection bound to an instance must produce a
// different revision hash. The connection's protocol, primary/backup
// configuration JSON, and failover retry count are all part of the
// deployment package and therefore part of the hash input.
var connectionsBefore = new Dictionary<string, ConnectionConfig>
{
["plc1"] = new ConnectionConfig
{
Protocol = "OpcUa",
ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-a:4840\"}",
BackupConfigurationJson = null,
FailoverRetryCount = 3
}
};
var connectionsAfter = new Dictionary<string, ConnectionConfig>
{
["plc1"] = connectionsBefore["plc1"] with
{
ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-b:4840\"}"
}
};
var configBefore = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
TemplateId = 1,
SiteId = 1,
Connections = connectionsBefore
};
var configAfter = configBefore with { Connections = connectionsAfter };
Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
}
[Fact]
public void ComputeHash_ConnectionProtocolEdit_ChangesHash()
{
// TemplateEngine-017: changing protocol must change the hash.
var connectionsBefore = new Dictionary<string, ConnectionConfig>
{
["plc1"] = new ConnectionConfig
{
Protocol = "OpcUa",
ConfigurationJson = "{}",
FailoverRetryCount = 3
}
};
var connectionsAfter = new Dictionary<string, ConnectionConfig>
{
["plc1"] = connectionsBefore["plc1"] with { Protocol = "Modbus" }
};
var configBefore = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
TemplateId = 1,
SiteId = 1,
Connections = connectionsBefore
};
var configAfter = configBefore with { Connections = connectionsAfter };
Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
}
[Fact]
public void ComputeHash_ConnectionsSameContent_SameHash()
{
// TemplateEngine-017: equal Connections maps must yield the same hash,
// regardless of dictionary iteration order (the SortedDictionary
// projection guards this).
var connections1 = new Dictionary<string, ConnectionConfig>
{
["b"] = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{\"k\":2}", FailoverRetryCount = 3 },
["a"] = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{\"k\":1}", FailoverRetryCount = 3 }
};
var connections2 = new Dictionary<string, ConnectionConfig>
{
["a"] = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{\"k\":1}", FailoverRetryCount = 3 },
["b"] = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{\"k\":2}", FailoverRetryCount = 3 }
};
var config1 = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
TemplateId = 1,
SiteId = 1,
Connections = connections1
};
var config2 = config1 with { Connections = connections2 };
Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
}
private static FlattenedConfiguration CreateConfig(string instanceName, string tempValue)
{
return new FlattenedConfiguration
{
InstanceUniqueName = instanceName,
TemplateId = 1,
SiteId = 1,
Attributes =
[
new ResolvedAttribute { CanonicalName = "Temperature", Value = tempValue, DataType = "Double" }
]
};
}
}
@@ -0,0 +1,256 @@
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 LockEnforcerTests
{
// ========================================================================
// WP-8: Override Granularity
// ========================================================================
[Fact]
public void ValidateAttributeOverride_LockedAttribute_ReturnsError()
{
var original = new TemplateAttribute("Speed")
{
DataType = DataType.Float, IsLocked = true, Value = "0"
};
var proposed = new TemplateAttribute("Speed")
{
DataType = DataType.Float, IsLocked = true, Value = "100"
};
var result = LockEnforcer.ValidateAttributeOverride(original, proposed);
Assert.NotNull(result);
Assert.Contains("locked", result);
}
[Fact]
public void ValidateAttributeOverride_DataTypeChanged_ReturnsError()
{
var original = new TemplateAttribute("Speed")
{
DataType = DataType.Float, IsLocked = false
};
var proposed = new TemplateAttribute("Speed")
{
DataType = DataType.String, IsLocked = false // DataType changed!
};
var result = LockEnforcer.ValidateAttributeOverride(original, proposed);
Assert.NotNull(result);
Assert.Contains("DataType", result);
}
[Fact]
public void ValidateAttributeOverride_DataSourceReferenceChanged_ReturnsError()
{
var original = new TemplateAttribute("Speed")
{
DataType = DataType.Float, IsLocked = false, DataSourceReference = "tag1"
};
var proposed = new TemplateAttribute("Speed")
{
DataType = DataType.Float, IsLocked = false, DataSourceReference = "tag2" // Changed!
};
var result = LockEnforcer.ValidateAttributeOverride(original, proposed);
Assert.NotNull(result);
Assert.Contains("DataSourceReference", result);
}
[Fact]
public void ValidateAttributeOverride_ValueAndDescriptionChanged_ReturnsNull()
{
var original = new TemplateAttribute("Speed")
{
DataType = DataType.Float, IsLocked = false, Value = "0", Description = "old"
};
var proposed = new TemplateAttribute("Speed")
{
DataType = DataType.Float, IsLocked = false, Value = "100", Description = "new"
};
var result = LockEnforcer.ValidateAttributeOverride(original, proposed);
Assert.Null(result);
}
[Fact]
public void ValidateAlarmOverride_LockedAlarm_ReturnsError()
{
var original = new TemplateAlarm("HighTemp")
{
TriggerType = AlarmTriggerType.ValueMatch, IsLocked = true, PriorityLevel = 500
};
var proposed = new TemplateAlarm("HighTemp")
{
TriggerType = AlarmTriggerType.ValueMatch, IsLocked = true, PriorityLevel = 600
};
var result = LockEnforcer.ValidateAlarmOverride(original, proposed);
Assert.NotNull(result);
Assert.Contains("locked", result);
}
[Fact]
public void ValidateAlarmOverride_TriggerTypeChanged_ReturnsError()
{
var original = new TemplateAlarm("HighTemp")
{
TriggerType = AlarmTriggerType.ValueMatch, IsLocked = false
};
var proposed = new TemplateAlarm("HighTemp")
{
TriggerType = AlarmTriggerType.RangeViolation, IsLocked = false // Changed!
};
var result = LockEnforcer.ValidateAlarmOverride(original, proposed);
Assert.NotNull(result);
Assert.Contains("TriggerType", result);
}
[Fact]
public void ValidateAlarmOverride_OverridableFieldsChanged_ReturnsNull()
{
var original = new TemplateAlarm("HighTemp")
{
TriggerType = AlarmTriggerType.ValueMatch, IsLocked = false,
PriorityLevel = 500, Description = "old"
};
var proposed = new TemplateAlarm("HighTemp")
{
TriggerType = AlarmTriggerType.ValueMatch, IsLocked = false,
PriorityLevel = 700, Description = "new", TriggerConfiguration = """{"value": 100}"""
};
var result = LockEnforcer.ValidateAlarmOverride(original, proposed);
Assert.Null(result);
}
[Fact]
public void ValidateScriptOverride_LockedScript_ReturnsError()
{
var original = new TemplateScript("OnStart", "code") { IsLocked = true };
var proposed = new TemplateScript("OnStart", "new code") { IsLocked = true };
var result = LockEnforcer.ValidateScriptOverride(original, proposed);
Assert.NotNull(result);
Assert.Contains("locked", result);
}
[Fact]
public void ValidateScriptOverride_NameChanged_ReturnsError()
{
var original = new TemplateScript("OnStart", "code") { IsLocked = false };
var proposed = new TemplateScript("OnStop", "code") { IsLocked = false }; // Name changed!
var result = LockEnforcer.ValidateScriptOverride(original, proposed);
Assert.NotNull(result);
Assert.Contains("Name", result);
}
[Fact]
public void ValidateScriptOverride_OverridableFieldsChanged_ReturnsNull()
{
var original = new TemplateScript("OnStart", "old code") { IsLocked = false };
var proposed = new TemplateScript("OnStart", "new code")
{
IsLocked = false,
TriggerType = "Timer",
MinTimeBetweenRuns = TimeSpan.FromSeconds(30)
};
var result = LockEnforcer.ValidateScriptOverride(original, proposed);
Assert.Null(result);
}
// ========================================================================
// WP-9: Locking Rules
// ========================================================================
[Fact]
public void ValidateLockChange_UnlockLocked_ReturnsError()
{
var result = LockEnforcer.ValidateLockChange(true, false, "Speed");
Assert.NotNull(result);
Assert.Contains("cannot be unlocked", result);
}
[Fact]
public void ValidateLockChange_LockUnlocked_ReturnsNull()
{
var result = LockEnforcer.ValidateLockChange(false, true, "Speed");
Assert.Null(result);
}
[Fact]
public void ValidateLockChange_KeepLocked_ReturnsNull()
{
var result = LockEnforcer.ValidateLockChange(true, true, "Speed");
Assert.Null(result);
}
[Fact]
public void ValidateLockChange_KeepUnlocked_ReturnsNull()
{
var result = LockEnforcer.ValidateLockChange(false, false, "Speed");
Assert.Null(result);
}
// ========================================================================
// TemplateEngine-022: LockedInDerived one-way ratchet
// ========================================================================
[Fact]
public void ValidateLockedInDerivedChange_ClearLocked_ReturnsError()
{
// Once a base template marks a member LockedInDerived, the flag may
// not be cleared — derived overrides previously blocked would
// otherwise become retroactively legal.
var result = LockEnforcer.ValidateLockedInDerivedChange(true, false, "Speed");
Assert.NotNull(result);
Assert.Contains("locked-in-derived", result);
Assert.Contains("cannot be cleared", result);
}
[Fact]
public void ValidateLockedInDerivedChange_LockUnlocked_ReturnsNull()
{
// Setting the flag from false→true is the normal direction.
var result = LockEnforcer.ValidateLockedInDerivedChange(false, true, "Speed");
Assert.Null(result);
}
[Fact]
public void ValidateLockedInDerivedChange_KeepLocked_ReturnsNull()
{
var result = LockEnforcer.ValidateLockedInDerivedChange(true, true, "Speed");
Assert.Null(result);
}
[Fact]
public void ValidateLockedInDerivedChange_KeepUnlocked_ReturnsNull()
{
var result = LockEnforcer.ValidateLockedInDerivedChange(false, false, "Speed");
Assert.Null(result);
}
}
@@ -0,0 +1,295 @@
using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Services;
public class AreaServiceTests
{
private readonly Mock<ITemplateEngineRepository> _repoMock = new();
private readonly Mock<IAuditService> _auditMock = new();
private readonly AreaService _sut;
public AreaServiceTests()
{
_sut = new AreaService(_repoMock.Object, _auditMock.Object);
}
[Fact]
public async Task CreateArea_ValidInput_ReturnsSuccess()
{
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>());
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var result = await _sut.CreateAreaAsync("Building A", 1, null, "admin");
Assert.True(result.IsSuccess);
Assert.Equal("Building A", result.Value.Name);
Assert.Equal(1, result.Value.SiteId);
}
[Fact]
public async Task CreateArea_DuplicateName_ReturnsFailure()
{
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new("Building A") { Id = 1, SiteId = 1, ParentAreaId = null }
});
var result = await _sut.CreateAreaAsync("Building A", 1, null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("already exists", result.Error);
}
[Fact]
public async Task CreateArea_WithParent_ValidatesParentBelongsToSite()
{
_repoMock.Setup(r => r.GetAreaByIdAsync(5, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Parent") { Id = 5, SiteId = 99 }); // Different site!
var result = await _sut.CreateAreaAsync("Child", 1, 5, "admin");
Assert.True(result.IsFailure);
Assert.Contains("does not belong", result.Error);
}
[Fact]
public async Task DeleteArea_WithAssignedInstances_ReturnsFailure()
{
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Building A") { Id = 1, SiteId = 1 });
_repoMock.Setup(r => r.GetInstancesBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Instance>
{
new("Inst1") { Id = 1, AreaId = 1, SiteId = 1 }
});
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area> { new("Building A") { Id = 1, SiteId = 1 } });
var result = await _sut.DeleteAreaAsync(1, "admin");
Assert.True(result.IsFailure);
Assert.Contains("instance(s) are assigned", result.Error);
}
[Fact]
public async Task DeleteArea_WithChildAreas_ReturnsFailure()
{
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Parent") { Id = 1, SiteId = 1 });
_repoMock.Setup(r => r.GetInstancesBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Instance>());
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new("Parent") { Id = 1, SiteId = 1 },
new("Child") { Id = 2, SiteId = 1, ParentAreaId = 1 }
});
var result = await _sut.DeleteAreaAsync(1, "admin");
Assert.True(result.IsFailure);
Assert.Contains("child areas", result.Error);
}
[Fact]
public async Task DeleteArea_NoConstraints_Success()
{
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Empty") { Id = 1, SiteId = 1 });
_repoMock.Setup(r => r.GetInstancesBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Instance>());
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area> { new("Empty") { Id = 1, SiteId = 1 } });
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var result = await _sut.DeleteAreaAsync(1, "admin");
Assert.True(result.IsSuccess);
_repoMock.Verify(r => r.DeleteAreaAsync(1, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task MoveArea_ToOtherArea_Succeeds()
{
// Move 'Leaf' from under 'A' to under 'B'.
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
_repoMock.Setup(r => r.GetAreaByIdAsync(2, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("B") { Id = 2, SiteId = 1 });
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new("A") { Id = 1, SiteId = 1 },
new("B") { Id = 2, SiteId = 1 },
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 }
});
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
var result = await _sut.MoveAreaAsync(3, 2, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.ParentAreaId);
_repoMock.Verify(r => r.UpdateAreaAsync(It.Is<Area>(a => a.Id == 3 && a.ParentAreaId == 2), It.IsAny<CancellationToken>()), Times.Once);
_auditMock.Verify(a => a.LogAsync("admin", "Move", "Area", "3", "Leaf", It.IsAny<object>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task MoveArea_ToSiteRoot_Succeeds()
{
// Move 'Leaf' from under 'A' to site root (null parent).
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new("A") { Id = 1, SiteId = 1 },
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 }
});
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
var result = await _sut.MoveAreaAsync(3, null, "admin");
Assert.True(result.IsSuccess);
Assert.Null(result.Value.ParentAreaId);
}
[Fact]
public async Task MoveArea_ToSelf_Fails()
{
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("A") { Id = 1, SiteId = 1 });
var result = await _sut.MoveAreaAsync(1, 1, "admin");
Assert.True(result.IsFailure);
Assert.Contains("its own parent", result.Error);
}
[Fact]
public async Task MoveArea_ToDescendant_FailsWithCycleError()
{
// Tree: 1 -> 2 -> 3. Try to move 1 under 3.
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Root") { Id = 1, SiteId = 1 });
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 2 });
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new("Root") { Id = 1, SiteId = 1 },
new("Mid") { Id = 2, SiteId = 1, ParentAreaId = 1 },
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 2 }
});
var result = await _sut.MoveAreaAsync(1, 3, "admin");
Assert.True(result.IsFailure);
Assert.Contains("descendants", result.Error);
}
[Fact]
public async Task MoveArea_DifferentSite_Fails()
{
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("A") { Id = 1, SiteId = 1 });
_repoMock.Setup(r => r.GetAreaByIdAsync(99, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Foreign") { Id = 99, SiteId = 2 });
var result = await _sut.MoveAreaAsync(1, 99, "admin");
Assert.True(result.IsFailure);
Assert.Contains("same site", result.Error);
}
[Fact]
public async Task MoveArea_NameCollidesAtNewParent_Fails()
{
// 'Leaf' under parent 1; a sibling 'Leaf' already exists under parent 2.
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
_repoMock.Setup(r => r.GetAreaByIdAsync(2, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("B") { Id = 2, SiteId = 1 });
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new("A") { Id = 1, SiteId = 1 },
new("B") { Id = 2, SiteId = 1 },
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 },
new("Leaf") { Id = 4, SiteId = 1, ParentAreaId = 2 }
});
var result = await _sut.MoveAreaAsync(3, 2, "admin");
Assert.True(result.IsFailure);
Assert.Contains("already exists", result.Error);
}
[Fact]
public async Task MoveArea_SameParent_NoOpSuccess()
{
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("A") { Id = 1, SiteId = 1 });
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new("A") { Id = 1, SiteId = 1 },
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 }
});
var result = await _sut.MoveAreaAsync(3, 1, "admin");
Assert.True(result.IsSuccess);
_repoMock.Verify(r => r.UpdateAreaAsync(It.IsAny<Area>(), It.IsAny<CancellationToken>()), Times.Never);
_auditMock.Verify(a => a.LogAsync(It.IsAny<string>(), "Move", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<object>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task MoveArea_TargetParentMissing_Fails()
{
_repoMock.Setup(r => r.GetAreaByIdAsync(3, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 1 });
_repoMock.Setup(r => r.GetAreaByIdAsync(999, It.IsAny<CancellationToken>()))
.ReturnsAsync((Area?)null);
var result = await _sut.MoveAreaAsync(3, 999, "admin");
Assert.True(result.IsFailure);
Assert.Contains("not found", result.Error);
}
[Fact]
public async Task DeleteArea_InstancesInDescendants_Blocked()
{
// Area hierarchy: Area1 -> Area2 -> Area3
// Instance assigned to Area3, trying to delete Area1 should be blocked
_repoMock.Setup(r => r.GetAreaByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("Root") { Id = 1, SiteId = 1 });
_repoMock.Setup(r => r.GetAreasBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new("Root") { Id = 1, SiteId = 1 },
new("Mid") { Id = 2, SiteId = 1, ParentAreaId = 1 },
new("Leaf") { Id = 3, SiteId = 1, ParentAreaId = 2 }
});
_repoMock.Setup(r => r.GetInstancesBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Instance>
{
new("DeepInstance") { Id = 10, AreaId = 3, SiteId = 1 }
});
var result = await _sut.DeleteAreaAsync(1, "admin");
Assert.True(result.IsFailure);
Assert.Contains("instance(s) are assigned", result.Error);
}
}
@@ -0,0 +1,286 @@
using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Services;
public class InstanceServiceTests
{
private readonly Mock<ITemplateEngineRepository> _repoMock = new();
private readonly Mock<IAuditService> _auditMock = new();
private readonly InstanceService _sut;
public InstanceServiceTests()
{
_sut = new InstanceService(_repoMock.Object, _auditMock.Object);
}
[Fact]
public async Task CreateInstance_ValidInput_ReturnsSuccess()
{
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Template("TestTemplate") { Id = 1 });
_repoMock.Setup(r => r.GetInstanceByUniqueNameAsync("Inst1", It.IsAny<CancellationToken>()))
.ReturnsAsync((Instance?)null);
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var result = await _sut.CreateInstanceAsync("Inst1", 1, 1, null, "admin");
Assert.True(result.IsSuccess);
Assert.Equal("Inst1", result.Value.UniqueName);
Assert.Equal(InstanceState.Disabled, result.Value.State); // Starts disabled
_repoMock.Verify(r => r.AddInstanceAsync(It.IsAny<Instance>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CreateInstance_DuplicateName_ReturnsFailure()
{
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Template("T") { Id = 1 });
_repoMock.Setup(r => r.GetInstanceByUniqueNameAsync("Inst1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new Instance("Inst1") { Id = 99 });
var result = await _sut.CreateInstanceAsync("Inst1", 1, 1, null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("already exists", result.Error);
}
[Fact]
public async Task CreateInstance_MissingTemplate_ReturnsFailure()
{
_repoMock.Setup(r => r.GetTemplateByIdAsync(999, It.IsAny<CancellationToken>()))
.ReturnsAsync((Template?)null);
var result = await _sut.CreateInstanceAsync("Inst1", 999, 1, null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("not found", result.Error);
}
[Fact]
public async Task SetAttributeOverride_LockedAttribute_ReturnsFailure()
{
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(instance);
_repoMock.Setup(r => r.GetAttributesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateAttribute>
{
new("LockedAttr") { IsLocked = true }
});
var result = await _sut.SetAttributeOverrideAsync(1, "LockedAttr", "new", "admin");
Assert.True(result.IsFailure);
Assert.Contains("locked", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SetAttributeOverride_NonExistentAttribute_ReturnsFailure()
{
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(instance);
_repoMock.Setup(r => r.GetAttributesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateAttribute>());
var result = await _sut.SetAttributeOverrideAsync(1, "Missing", "value", "admin");
Assert.True(result.IsFailure);
Assert.Contains("does not exist", result.Error);
}
[Fact]
public async Task SetAttributeOverride_UnlockedAttribute_ReturnsSuccess()
{
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(instance);
_repoMock.Setup(r => r.GetAttributesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateAttribute>
{
new("Threshold") { IsLocked = false }
});
_repoMock.Setup(r => r.GetOverridesByInstanceIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<InstanceAttributeOverride>());
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var result = await _sut.SetAttributeOverrideAsync(1, "Threshold", "99", "admin");
Assert.True(result.IsSuccess);
Assert.Equal("Threshold", result.Value.AttributeName);
Assert.Equal("99", result.Value.OverrideValue);
}
// --- TemplateEngine-008 regression: SetAlarmOverrideAsync validation ---
private static Template TemplateWithAlarms(int id, params TemplateAlarm[] alarms)
{
var t = new Template($"T{id}") { Id = id };
foreach (var a in alarms)
{
a.TemplateId = id;
t.Alarms.Add(a);
}
return t;
}
[Fact]
public async Task SetAlarmOverride_NonExistentAlarm_ReturnsFailure()
{
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(instance);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template>
{
TemplateWithAlarms(1, new TemplateAlarm("HighTemp") { Id = 10 })
});
var result = await _sut.SetAlarmOverrideAsync(1, "Missing", "{}", null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("does not exist", result.Error);
_repoMock.Verify(r => r.AddInstanceAlarmOverrideAsync(
It.IsAny<InstanceAlarmOverride>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task SetAlarmOverride_ComposedLockedAlarm_ReturnsFailure()
{
// The locked alarm lives in a composed module, so it is NOT a direct
// alarm of the instance's template — the old code skipped the lock check.
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(instance);
var module = TemplateWithAlarms(2, new TemplateAlarm("Fault") { Id = 20, IsLocked = true });
var host = new Template("Host") { Id = 1 };
host.Compositions.Add(new TemplateComposition("Pump") { Id = 1, ComposedTemplateId = 2 });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { host, module });
var result = await _sut.SetAlarmOverrideAsync(1, "Pump.Fault", "{}", null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("locked", result.Error, StringComparison.OrdinalIgnoreCase);
_repoMock.Verify(r => r.AddInstanceAlarmOverrideAsync(
It.IsAny<InstanceAlarmOverride>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task SetAlarmOverride_ComposedUnlockedAlarm_ReturnsSuccess()
{
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(instance);
var module = TemplateWithAlarms(2, new TemplateAlarm("Fault") { Id = 20, IsLocked = false });
var host = new Template("Host") { Id = 1 };
host.Compositions.Add(new TemplateComposition("Pump") { Id = 1, ComposedTemplateId = 2 });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { host, module });
_repoMock.Setup(r => r.GetAlarmOverrideAsync(1, "Pump.Fault", It.IsAny<CancellationToken>()))
.ReturnsAsync((InstanceAlarmOverride?)null);
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var result = await _sut.SetAlarmOverrideAsync(1, "Pump.Fault", "{}", 2, "admin");
Assert.True(result.IsSuccess);
_repoMock.Verify(r => r.AddInstanceAlarmOverrideAsync(
It.IsAny<InstanceAlarmOverride>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task SetAlarmOverride_DirectLockedAlarm_ReturnsFailure()
{
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1 };
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(instance);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template>
{
TemplateWithAlarms(1, new TemplateAlarm("HighTemp") { Id = 10, IsLocked = true })
});
var result = await _sut.SetAlarmOverrideAsync(1, "HighTemp", "{}", null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("locked", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Enable_ExistingInstance_SetsEnabled()
{
var instance = new Instance("Inst1") { Id = 1, State = InstanceState.Disabled };
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(instance);
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var result = await _sut.EnableAsync(1, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(InstanceState.Enabled, result.Value.State);
}
[Fact]
public async Task Disable_ExistingInstance_SetsDisabled()
{
var instance = new Instance("Inst1") { Id = 1, State = InstanceState.Enabled };
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(instance);
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var result = await _sut.DisableAsync(1, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(InstanceState.Disabled, result.Value.State);
}
[Fact]
public async Task SetConnectionBindings_BulkAssignment_Success()
{
var instance = new Instance("Inst1") { Id = 1 };
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(instance);
_repoMock.Setup(r => r.GetBindingsByInstanceIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<InstanceConnectionBinding>());
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var bindings = new List<ConnectionBinding> { new("Temp", 100), new("Pressure", 200) };
var result = await _sut.SetConnectionBindingsAsync(1, bindings, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.Count);
_repoMock.Verify(r => r.AddInstanceConnectionBindingAsync(It.IsAny<InstanceConnectionBinding>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
}
[Fact]
public async Task AssignToArea_AreaInDifferentSite_ReturnsFailure()
{
var instance = new Instance("Inst1") { Id = 1, SiteId = 1 };
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(instance);
_repoMock.Setup(r => r.GetAreaByIdAsync(5, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Area("WrongSiteArea") { Id = 5, SiteId = 99 });
var result = await _sut.AssignToAreaAsync(1, 5, "admin");
Assert.True(result.IsFailure);
Assert.Contains("does not belong", result.Error);
}
}
@@ -0,0 +1,117 @@
using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Services;
public class SiteServiceTests
{
private readonly Mock<ISiteRepository> _repoMock = new();
private readonly Mock<IAuditService> _auditMock = new();
private readonly SiteService _sut;
public SiteServiceTests()
{
_sut = new SiteService(_repoMock.Object, _auditMock.Object);
}
[Fact]
public async Task CreateSite_ValidInput_ReturnsSuccess()
{
_repoMock.Setup(r => r.GetSiteByIdentifierAsync("SITE-001", It.IsAny<CancellationToken>()))
.ReturnsAsync((Site?)null);
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var result = await _sut.CreateSiteAsync("Plant Alpha", "SITE-001", "Main plant", "admin");
Assert.True(result.IsSuccess);
Assert.Equal("Plant Alpha", result.Value.Name);
Assert.Equal("SITE-001", result.Value.SiteIdentifier);
}
[Fact]
public async Task CreateSite_DuplicateIdentifier_ReturnsFailure()
{
_repoMock.Setup(r => r.GetSiteByIdentifierAsync("SITE-001", It.IsAny<CancellationToken>()))
.ReturnsAsync(new Site("Existing", "SITE-001") { Id = 1 });
var result = await _sut.CreateSiteAsync("New", "SITE-001", null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("already exists", result.Error);
}
[Fact]
public async Task CreateSite_EmptyName_ReturnsFailure()
{
var result = await _sut.CreateSiteAsync("", "SITE-001", null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("required", result.Error);
}
[Fact]
public async Task DeleteSite_WithInstances_ReturnsFailure()
{
_repoMock.Setup(r => r.GetSiteByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Site("Plant", "SITE-001") { Id = 1 });
_repoMock.Setup(r => r.GetInstancesBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Instance>
{
new("Inst1") { Id = 1, SiteId = 1 },
new("Inst2") { Id = 2, SiteId = 1 }
});
var result = await _sut.DeleteSiteAsync(1, "admin");
Assert.True(result.IsFailure);
Assert.Contains("2 instance(s)", result.Error);
}
[Fact]
public async Task DeleteSite_NoInstances_Success()
{
_repoMock.Setup(r => r.GetSiteByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Site("Plant", "SITE-001") { Id = 1 });
_repoMock.Setup(r => r.GetInstancesBySiteIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Instance>());
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var result = await _sut.DeleteSiteAsync(1, "admin");
Assert.True(result.IsSuccess);
}
[Fact]
public async Task CreateDataConnection_ValidInput_Success()
{
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var result = await _sut.CreateDataConnectionAsync(1, "OPC-Server1", "OpcUa", "{\"url\":\"opc.tcp://localhost\"}", null, 3, "admin");
Assert.True(result.IsSuccess);
Assert.Equal("OPC-Server1", result.Value.Name);
Assert.Equal("OpcUa", result.Value.Protocol);
Assert.Equal(1, result.Value.SiteId);
}
[Fact]
public async Task UpdateSite_ValidInput_Success()
{
_repoMock.Setup(r => r.GetSiteByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Site("Old", "S1") { Id = 1 });
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var result = await _sut.UpdateSiteAsync(1, "New Name", "New desc", "admin");
Assert.True(result.IsSuccess);
Assert.Equal("New Name", result.Value.Name);
}
}
@@ -0,0 +1,191 @@
using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Services;
public class TemplateDeletionServiceTests
{
private readonly Mock<ITemplateEngineRepository> _repoMock = new();
private readonly TemplateDeletionService _sut;
public TemplateDeletionServiceTests()
{
_sut = new TemplateDeletionService(_repoMock.Object);
}
[Fact]
public async Task CanDeleteTemplate_NoReferences_ReturnsSuccess()
{
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Template("Orphan") { Id = 1 });
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Instance>());
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { new("Orphan") { Id = 1 } });
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateComposition>());
var result = await _sut.CanDeleteTemplateAsync(1);
Assert.True(result.IsSuccess);
}
[Fact]
public async Task CanDeleteTemplate_WithInstances_ReturnsFailure()
{
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Template("Used") { Id = 1 });
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Instance>
{
new("Inst1") { Id = 1 },
new("Inst2") { Id = 2 }
});
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { new("Used") { Id = 1 } });
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateComposition>());
var result = await _sut.CanDeleteTemplateAsync(1);
Assert.True(result.IsFailure);
Assert.Contains("2 instance(s)", result.Error);
Assert.Contains("Inst1", result.Error);
}
[Fact]
public async Task CanDeleteTemplate_WithChildTemplates_ReturnsFailure()
{
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Template("Base") { Id = 1 });
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Instance>());
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template>
{
new("Base") { Id = 1 },
new("Child") { Id = 2, ParentTemplateId = 1 }
});
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateComposition>());
var result = await _sut.CanDeleteTemplateAsync(1);
Assert.True(result.IsFailure);
Assert.Contains("child template(s)", result.Error);
Assert.Contains("Child", result.Error);
}
[Fact]
public async Task CanDeleteTemplate_ComposedByOthers_ReturnsFailure()
{
var composer = new Template("Composer") { Id = 2 };
composer.Compositions.Add(new TemplateComposition("PumpModule") { ComposedTemplateId = 1 });
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Template("Module") { Id = 1 });
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Instance>());
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template>
{
new("Module") { Id = 1 },
composer
});
var result = await _sut.CanDeleteTemplateAsync(1);
Assert.True(result.IsFailure);
Assert.Contains("compose it", result.Error);
Assert.Contains("Composer", result.Error);
}
[Fact]
public async Task CanDeleteTemplate_DoesNotIssuePerTemplateCompositionQuery()
{
// TemplateEngine-009: Check 3 must read the Compositions navigation
// already loaded by GetAllTemplatesAsync rather than issuing one
// GetCompositionsByTemplateIdAsync round-trip per template.
var templates = new List<Template>
{
new("Module") { Id = 1 },
new("A") { Id = 2 },
new("B") { Id = 3 },
new("C") { Id = 4 },
};
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Template("Module") { Id = 1 });
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Instance>());
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(templates);
var result = await _sut.CanDeleteTemplateAsync(1);
Assert.True(result.IsSuccess);
_repoMock.Verify(r => r.GetCompositionsByTemplateIdAsync(
It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task CanDeleteTemplate_NotFound_ReturnsFailure()
{
_repoMock.Setup(r => r.GetTemplateByIdAsync(999, It.IsAny<CancellationToken>()))
.ReturnsAsync((Template?)null);
var result = await _sut.CanDeleteTemplateAsync(999);
Assert.True(result.IsFailure);
Assert.Contains("not found", result.Error);
}
[Fact]
public async Task DeleteTemplate_AllConstraintsMet_Deletes()
{
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Template("Safe") { Id = 1 });
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Instance>());
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { new("Safe") { Id = 1 } });
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateComposition>());
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var result = await _sut.DeleteTemplateAsync(1);
Assert.True(result.IsSuccess);
_repoMock.Verify(r => r.DeleteTemplateAsync(1, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CanDeleteTemplate_MultipleConstraints_AllErrorsReported()
{
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Template("Busy") { Id = 1 });
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Instance> { new("Inst1") { Id = 1 } });
var composer = new Template("Composer") { Id = 3 };
composer.Compositions.Add(new TemplateComposition("Module") { ComposedTemplateId = 1 });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template>
{
new("Busy") { Id = 1 },
new("Child") { Id = 2, ParentTemplateId = 1 },
composer
});
var result = await _sut.CanDeleteTemplateAsync(1);
Assert.True(result.IsFailure);
// All three constraint types should be mentioned
Assert.Contains("instance(s)", result.Error);
Assert.Contains("child template(s)", result.Error);
Assert.Contains("compose it", result.Error);
}
}
@@ -0,0 +1,239 @@
using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Services;
public class TemplateFolderServiceTests
{
private readonly Mock<ITemplateEngineRepository> _repoMock = new();
private readonly Mock<IAuditService> _auditMock = new();
private readonly TemplateFolderService _sut;
public TemplateFolderServiceTests()
{
_sut = new TemplateFolderService(_repoMock.Object, _auditMock.Object);
}
[Fact]
public async Task CreateFolder_ValidInput_ReturnsSuccess()
{
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder>());
var result = await _sut.CreateFolderAsync("Dev", null, "admin");
Assert.True(result.IsSuccess);
Assert.Equal("Dev", result.Value.Name);
Assert.Null(result.Value.ParentFolderId);
_repoMock.Verify(r => r.AddFolderAsync(It.IsAny<TemplateFolder>(), It.IsAny<CancellationToken>()), Times.Once);
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CreateFolder_EmptyName_ReturnsFailure()
{
var result = await _sut.CreateFolderAsync(" ", null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("required", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task CreateFolder_DuplicateSiblingName_CaseInsensitive_ReturnsFailure()
{
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder>
{
new("Dev") { Id = 1, ParentFolderId = null }
});
var result = await _sut.CreateFolderAsync("dev", null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("already exists", result.Error);
}
[Fact]
public async Task CreateFolder_ParentNotFound_ReturnsFailure()
{
_repoMock.Setup(r => r.GetFolderByIdAsync(99, It.IsAny<CancellationToken>()))
.ReturnsAsync((TemplateFolder?)null);
var result = await _sut.CreateFolderAsync("Sub", 99, "admin");
Assert.True(result.IsFailure);
Assert.Contains("not found", result.Error);
}
[Fact]
public async Task RenameFolder_ValidInput_ReturnsSuccess()
{
var folder = new TemplateFolder("Old") { Id = 1, ParentFolderId = null };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { folder });
var result = await _sut.RenameFolderAsync(1, "New", "admin");
Assert.True(result.IsSuccess);
Assert.Equal("New", result.Value.Name);
}
[Fact]
public async Task RenameFolder_NotFound_ReturnsFailure()
{
_repoMock.Setup(r => r.GetFolderByIdAsync(99, It.IsAny<CancellationToken>()))
.ReturnsAsync((TemplateFolder?)null);
var result = await _sut.RenameFolderAsync(99, "New", "admin");
Assert.True(result.IsFailure);
}
[Fact]
public async Task RenameFolder_DuplicateSibling_ReturnsFailure()
{
var folder = new TemplateFolder("Old") { Id = 1, ParentFolderId = null };
var sibling = new TemplateFolder("Other") { Id = 2, ParentFolderId = null };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { folder, sibling });
var result = await _sut.RenameFolderAsync(1, "Other", "admin");
Assert.True(result.IsFailure);
Assert.Contains("already exists", result.Error);
}
[Fact]
public async Task MoveFolder_ValidParent_ReturnsSuccess()
{
var f1 = new TemplateFolder("A") { Id = 1, ParentFolderId = null };
var f2 = new TemplateFolder("B") { Id = 2, ParentFolderId = null };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f1);
_repoMock.Setup(r => r.GetFolderByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(f2);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { f1, f2 });
var result = await _sut.MoveFolderAsync(1, 2, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.ParentFolderId);
}
[Fact]
public async Task MoveFolder_OntoSelf_ReturnsFailure()
{
var f1 = new TemplateFolder("A") { Id = 1 };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f1);
var result = await _sut.MoveFolderAsync(1, 1, "admin");
Assert.True(result.IsFailure);
Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task MoveFolder_OntoDescendant_ReturnsFailure()
{
// A -> B -> C; attempting to move A under C must fail.
var fa = new TemplateFolder("A") { Id = 1, ParentFolderId = null };
var fb = new TemplateFolder("B") { Id = 2, ParentFolderId = 1 };
var fc = new TemplateFolder("C") { Id = 3, ParentFolderId = 2 };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(fa);
_repoMock.Setup(r => r.GetFolderByIdAsync(3, It.IsAny<CancellationToken>())).ReturnsAsync(fc);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { fa, fb, fc });
var result = await _sut.MoveFolderAsync(1, 3, "admin");
Assert.True(result.IsFailure);
Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task MoveFolder_ToRoot_ReturnsSuccess()
{
var f = new TemplateFolder("Sub") { Id = 1, ParentFolderId = 99 };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { f });
var result = await _sut.MoveFolderAsync(1, null, "admin");
Assert.True(result.IsSuccess);
Assert.Null(result.Value.ParentFolderId);
}
[Fact]
public async Task MoveFolder_PreExistingCycleInGraph_ReturnsFailure_DoesNotInfiniteLoop()
{
// Manufactured malformed graph: X.parent=Y, Y.parent=X. We move Z under X.
// The ancestor walk would loop forever without a guard.
var x = new TemplateFolder("X") { Id = 1, ParentFolderId = 2 };
var y = new TemplateFolder("Y") { Id = 2, ParentFolderId = 1 };
var z = new TemplateFolder("Z") { Id = 3, ParentFolderId = null };
_repoMock.Setup(r => r.GetFolderByIdAsync(3, It.IsAny<CancellationToken>())).ReturnsAsync(z);
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(x);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { x, y, z });
var result = await _sut.MoveFolderAsync(3, 1, "admin");
Assert.True(result.IsFailure);
Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task DeleteFolder_Empty_ReturnsSuccess()
{
var f = new TemplateFolder("Empty") { Id = 1 };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { f });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template>());
var result = await _sut.DeleteFolderAsync(1, "admin");
Assert.True(result.IsSuccess);
_repoMock.Verify(r => r.DeleteFolderAsync(1, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task DeleteFolder_HasChildFolders_ReturnsFailure_WithCounts()
{
var parent = new TemplateFolder("P") { Id = 1 };
var child = new TemplateFolder("C") { Id = 2, ParentFolderId = 1 };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(parent);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { parent, child });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template>());
var result = await _sut.DeleteFolderAsync(1, "admin");
Assert.True(result.IsFailure);
Assert.Contains("1 subfolder", result.Error);
}
[Fact]
public async Task DeleteFolder_HasTemplates_ReturnsFailure_WithCounts()
{
var f = new TemplateFolder("P") { Id = 1 };
var t = new Template("X") { Id = 5, FolderId = 1 };
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { f });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { t });
var result = await _sut.DeleteFolderAsync(1, "admin");
Assert.True(result.IsFailure);
Assert.Contains("1 template", result.Error);
}
}
@@ -0,0 +1,203 @@
using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests;
public class SharedScriptServiceTests
{
private readonly Mock<ITemplateEngineRepository> _repoMock;
private readonly Mock<IAuditService> _auditMock;
private readonly SharedScriptService _service;
public SharedScriptServiceTests()
{
_repoMock = new Mock<ITemplateEngineRepository>();
_auditMock = new Mock<IAuditService>();
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
_service = new SharedScriptService(_repoMock.Object, _auditMock.Object);
}
[Fact]
public async Task CreateSharedScript_Success()
{
_repoMock.Setup(r => r.GetSharedScriptByNameAsync("Helpers", It.IsAny<CancellationToken>()))
.ReturnsAsync((SharedScript?)null);
var result = await _service.CreateSharedScriptAsync(
"Helpers", "public static int Add(int a, int b) { return a + b; }", null, null, "admin");
Assert.True(result.IsSuccess);
Assert.Equal("Helpers", result.Value.Name);
_repoMock.Verify(r => r.AddSharedScriptAsync(It.IsAny<SharedScript>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CreateSharedScript_EmptyName_Fails()
{
var result = await _service.CreateSharedScriptAsync("", "code", null, null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("name is required", result.Error);
}
[Fact]
public async Task CreateSharedScript_EmptyCode_Fails()
{
var result = await _service.CreateSharedScriptAsync("Test", "", null, null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("code is required", result.Error);
}
[Fact]
public async Task CreateSharedScript_DuplicateName_Fails()
{
_repoMock.Setup(r => r.GetSharedScriptByNameAsync("Helpers", It.IsAny<CancellationToken>()))
.ReturnsAsync(new SharedScript("Helpers", "existing code"));
var result = await _service.CreateSharedScriptAsync("Helpers", "new code", null, null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("already exists", result.Error);
}
[Fact]
public async Task CreateSharedScript_UnbalancedBraces_Fails()
{
_repoMock.Setup(r => r.GetSharedScriptByNameAsync("Bad", It.IsAny<CancellationToken>()))
.ReturnsAsync((SharedScript?)null);
var result = await _service.CreateSharedScriptAsync("Bad", "public void Run() {", null, null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("Syntax error", result.Error);
}
[Fact]
public async Task UpdateSharedScript_Success()
{
var existing = new SharedScript("Helpers", "old code") { Id = 1 };
_repoMock.Setup(r => r.GetSharedScriptByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
var result = await _service.UpdateSharedScriptAsync(1, "return 42;", null, null, "admin");
Assert.True(result.IsSuccess);
Assert.Equal("return 42;", result.Value.Code);
}
[Fact]
public async Task UpdateSharedScript_NotFound_Fails()
{
_repoMock.Setup(r => r.GetSharedScriptByIdAsync(999, It.IsAny<CancellationToken>())).ReturnsAsync((SharedScript?)null);
var result = await _service.UpdateSharedScriptAsync(999, "code", null, null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("not found", result.Error);
}
[Fact]
public async Task DeleteSharedScript_Success()
{
var existing = new SharedScript("Helpers", "code") { Id = 1 };
_repoMock.Setup(r => r.GetSharedScriptByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
var result = await _service.DeleteSharedScriptAsync(1, "admin");
Assert.True(result.IsSuccess);
_repoMock.Verify(r => r.DeleteSharedScriptAsync(1, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task DeleteSharedScript_NotFound_Fails()
{
_repoMock.Setup(r => r.GetSharedScriptByIdAsync(999, It.IsAny<CancellationToken>())).ReturnsAsync((SharedScript?)null);
var result = await _service.DeleteSharedScriptAsync(999, "admin");
Assert.True(result.IsFailure);
Assert.Contains("not found", result.Error);
}
// Syntax validation unit tests
[Theory]
[InlineData("return 42;", null)]
[InlineData("public void Run() { }", null)]
[InlineData("var x = new int[] { 1, 2, 3 };", null)]
[InlineData("if (a > b) { return a; } else { return b; }", null)]
public void ValidateSyntax_ValidCode_ReturnsNull(string code, string? expected)
{
Assert.Equal(expected, SharedScriptService.ValidateSyntax(code));
}
[Theory]
[InlineData("public void Run() {")]
[InlineData("return a + b);")]
[InlineData("var x = new int[] { 1, 2 ;")]
public void ValidateSyntax_InvalidCode_ReturnsError(string code)
{
var result = SharedScriptService.ValidateSyntax(code);
Assert.NotNull(result);
Assert.Contains("Syntax error", result);
}
// --- TemplateEngine-007 regression: string/comment-literal awareness ---
[Theory]
[InlineData("var s = \"a } brace\"; { }")] // brace inside a normal string
[InlineData("var s = \"a ) paren ] bracket\";")] // paren/bracket inside a string
[InlineData("var s = @\"verbatim } brace\"; { }")] // brace inside a verbatim string
[InlineData("var x = 1; var s = $\"hole {x} literal}}\"; { }")] // interpolated string with braces
[InlineData("var c = '}'; if (true) { }")] // char literal containing a brace
[InlineData("// a stray } here\nvar x = 1;")] // brace inside a line comment
[InlineData("/* a stray ) here */ var x = 1;")] // paren inside a block comment
public void ValidateSyntax_DelimiterInsideStringOrComment_ReturnsNull(string code)
{
Assert.Null(SharedScriptService.ValidateSyntax(code));
}
// --- TemplateEngine-020 regression: audit row carries the real script Id ---
[Fact]
public async Task CreateSharedScript_AuditRowCarriesRealScriptIdNotLiteralZero()
{
// Pre-020: AddSharedScriptAsync → LogAsync("0", ...) → SaveChangesAsync.
// The audit row was queued with EntityId = "0" because EF Core had
// not yet populated the auto-generated key. Post-020: save first,
// then log with the real Id, then save the audit row.
_repoMock.Setup(r => r.GetSharedScriptByNameAsync("Helpers", It.IsAny<CancellationToken>()))
.ReturnsAsync((SharedScript?)null);
SharedScript? added = null;
_repoMock.Setup(r => r.AddSharedScriptAsync(It.IsAny<SharedScript>(), It.IsAny<CancellationToken>()))
.Callback<SharedScript, CancellationToken>((s, _) => added = s)
.Returns(Task.CompletedTask);
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.Callback<CancellationToken>(_ =>
{
if (added != null && added.Id == 0) added.Id = 314;
})
.ReturnsAsync(1);
string? auditedEntityId = null;
_auditMock.Setup(a => a.LogAsync(
It.IsAny<string>(),
"Create",
"SharedScript",
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<object?>(),
It.IsAny<CancellationToken>()))
.Callback<string, string, string, string, string, object?, CancellationToken>(
(_, _, _, entityId, _, _, _) => auditedEntityId = entityId)
.Returns(Task.CompletedTask);
var result = await _service.CreateSharedScriptAsync(
"Helpers", "public static int Add(int a, int b) { return a + b; }", null, null, "admin");
Assert.True(result.IsSuccess);
Assert.Equal("314", auditedEntityId);
Assert.NotEqual("0", auditedEntityId);
}
}
@@ -0,0 +1,87 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests;
/// <summary>
/// Coverage for <see cref="TemplateNaming.QualifiedName"/> — the computed
/// hierarchical name of a composition-derived template. Derived templates store
/// only their contained name (the composition slot's <c>InstanceName</c>); the
/// dotted path is resolved on read by walking the <c>OwnerCompositionId</c> chain.
/// </summary>
public class TemplateNamingTests
{
private static (Dictionary<int, Template> byId, Dictionary<int, TemplateComposition> compById)
BuildGraph(params Template[] templates)
{
var byId = templates.ToDictionary(t => t.Id);
var compById = templates
.SelectMany(t => t.Compositions)
.ToDictionary(c => c.Id);
return (byId, compById);
}
[Fact]
public void QualifiedName_BaseTemplate_IsJustItsName()
{
var motorController = new Template("Motor Controller") { Id = 4 };
var (byId, compById) = BuildGraph(motorController);
Assert.Equal("Motor Controller", TemplateNaming.QualifiedName(motorController, byId, compById));
}
[Fact]
public void QualifiedName_OneLevelDerived_PrefixesTheOwner()
{
// Motor Controller composes the Pump template into a slot named "Pump".
var motorController = new Template("Motor Controller") { Id = 4 };
motorController.Compositions.Add(
new TemplateComposition("Pump") { Id = 1014, TemplateId = 4, ComposedTemplateId = 2018 });
var derivedPump = new Template("Pump")
{
Id = 2018, IsDerived = true, OwnerCompositionId = 1014
};
var (byId, compById) = BuildGraph(motorController, derivedPump);
Assert.Equal("Motor Controller.Pump", TemplateNaming.QualifiedName(derivedPump, byId, compById));
}
[Fact]
public void QualifiedName_NestedDerived_WalksTheWholeChain()
{
// Motor Controller -> Pump slot -> TempSensor slot.
var motorController = new Template("Motor Controller") { Id = 4 };
motorController.Compositions.Add(
new TemplateComposition("Pump") { Id = 1014, TemplateId = 4, ComposedTemplateId = 2018 });
var derivedPump = new Template("Pump")
{
Id = 2018, IsDerived = true, OwnerCompositionId = 1014
};
derivedPump.Compositions.Add(
new TemplateComposition("TempSensor") { Id = 1015, TemplateId = 2018, ComposedTemplateId = 2019 });
var derivedTempSensor = new Template("TempSensor")
{
Id = 2019, IsDerived = true, OwnerCompositionId = 1015
};
var (byId, compById) = BuildGraph(motorController, derivedPump, derivedTempSensor);
Assert.Equal(
"Motor Controller.Pump.TempSensor",
TemplateNaming.QualifiedName(derivedTempSensor, byId, compById));
}
[Fact]
public void QualifiedName_DerivedWithMissingOwnerLink_FallsBackToStoredName()
{
// Defensive: a derived template whose owner composition is not in the
// lookup must not throw — it falls back to the stored contained name.
var orphan = new Template("TempSensor")
{
Id = 2019, IsDerived = true, OwnerCompositionId = 9999
};
var (byId, compById) = BuildGraph(orphan);
Assert.Equal("TempSensor", TemplateNaming.QualifiedName(orphan, byId, compById));
}
}
@@ -0,0 +1,253 @@
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 TemplateResolverTests
{
// ========================================================================
// WP-7: Path-Qualified Canonical Naming
// ========================================================================
[Fact]
public void ResolveAllMembers_DirectMembers_NoPrefix()
{
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 });
template.Scripts.Add(new TemplateScript("OnStart", "code") { Id = 1, TemplateId = 1 });
var all = new List<Template> { template };
var members = TemplateResolver.ResolveAllMembers(1, all);
Assert.Equal(3, members.Count);
Assert.Contains(members, m => m.CanonicalName == "Speed" && m.MemberType == "Attribute");
Assert.Contains(members, m => m.CanonicalName == "HighTemp" && m.MemberType == "Alarm");
Assert.Contains(members, m => m.CanonicalName == "OnStart" && m.MemberType == "Script");
}
[Fact]
public void ResolveAllMembers_ComposedModule_PrefixedNames()
{
var module = new Template("Module") { Id = 2 };
module.Attributes.Add(new TemplateAttribute("Pressure") { Id = 10, TemplateId = 2, DataType = DataType.Float });
var template = new Template("Pump") { Id = 1 };
template.Compositions.Add(new TemplateComposition("sensor1") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
var all = new List<Template> { template, module };
var members = TemplateResolver.ResolveAllMembers(1, all);
Assert.Single(members);
Assert.Equal("sensor1.Pressure", members[0].CanonicalName);
}
[Fact]
public void ResolveAllMembers_NestedComposition_MultiLevelPrefix()
{
var inner = new Template("Inner") { Id = 3 };
inner.Attributes.Add(new TemplateAttribute("Value") { Id = 30, TemplateId = 3, DataType = DataType.Float });
var outer = new Template("Outer") { Id = 2 };
outer.Compositions.Add(new TemplateComposition("innerMod") { Id = 1, TemplateId = 2, ComposedTemplateId = 3 });
var main = new Template("Main") { Id = 1 };
main.Compositions.Add(new TemplateComposition("outerMod") { Id = 2, TemplateId = 1, ComposedTemplateId = 2 });
var all = new List<Template> { main, outer, inner };
var members = TemplateResolver.ResolveAllMembers(1, all);
Assert.Single(members);
Assert.Equal("outerMod.innerMod.Value", members[0].CanonicalName);
}
// ========================================================================
// WP-10: Inheritance Override Scope
// ========================================================================
[Fact]
public void ResolveAllMembers_InheritedMembers_Included()
{
var parent = new Template("Base") { Id = 1 };
parent.Attributes.Add(new TemplateAttribute("Speed") { Id = 10, TemplateId = 1, DataType = DataType.Float });
var child = new Template("Child") { Id = 2, ParentTemplateId = 1 };
child.Attributes.Add(new TemplateAttribute("ExtraAttr") { Id = 20, TemplateId = 2, DataType = DataType.String });
var all = new List<Template> { parent, child };
var members = TemplateResolver.ResolveAllMembers(2, all);
Assert.Equal(2, members.Count);
Assert.Contains(members, m => m.CanonicalName == "Speed");
Assert.Contains(members, m => m.CanonicalName == "ExtraAttr");
}
[Fact]
public void ResolveAllMembers_ChildOverridesParentMember_UsesChildVersion()
{
var parent = new Template("Base") { Id = 1 };
parent.Attributes.Add(new TemplateAttribute("Speed")
{
Id = 10, TemplateId = 1, DataType = DataType.Float, Value = "0"
});
var child = new Template("Child") { Id = 2, ParentTemplateId = 1 };
child.Attributes.Add(new TemplateAttribute("Speed")
{
Id = 20, TemplateId = 2, DataType = DataType.Float, Value = "100"
});
var all = new List<Template> { parent, child };
var members = TemplateResolver.ResolveAllMembers(2, all);
// Should have one Speed member, from the child (override)
var speedMember = Assert.Single(members, m => m.CanonicalName == "Speed");
Assert.Equal(2, speedMember.SourceTemplateId); // Child's version
}
[Fact]
public void ResolveAllMembers_InheritedComposedModules_Included()
{
var module = new Template("Module") { Id = 3 };
module.Attributes.Add(new TemplateAttribute("Pressure") { Id = 30, TemplateId = 3, DataType = DataType.Float });
var parent = new Template("Base") { Id = 1 };
parent.Compositions.Add(new TemplateComposition("sensor1") { Id = 1, TemplateId = 1, ComposedTemplateId = 3 });
var child = new Template("Child") { Id = 2, ParentTemplateId = 1 };
var all = new List<Template> { parent, child, module };
var members = TemplateResolver.ResolveAllMembers(2, all);
Assert.Contains(members, m => m.CanonicalName == "sensor1.Pressure");
}
// ========================================================================
// WP-11: Composition Override Scope
// ========================================================================
[Fact]
public void FindMemberByCanonicalName_ComposedMember_Found()
{
var module = new Template("Module") { Id = 2 };
module.Attributes.Add(new TemplateAttribute("Value") { Id = 10, TemplateId = 2, DataType = DataType.Float, IsLocked = false });
var template = new Template("Parent") { Id = 1 };
template.Compositions.Add(new TemplateComposition("mod1") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
var all = new List<Template> { template, module };
var member = TemplateResolver.FindMemberByCanonicalName("mod1.Value", 1, all);
Assert.NotNull(member);
Assert.Equal("mod1.Value", member.CanonicalName);
Assert.False(member.IsLocked);
}
[Fact]
public void FindMemberByCanonicalName_LockedComposedMember_ReturnsLocked()
{
var module = new Template("Module") { Id = 2 };
module.Attributes.Add(new TemplateAttribute("Value") { Id = 10, TemplateId = 2, DataType = DataType.Float, IsLocked = true });
var template = new Template("Parent") { Id = 1 };
template.Compositions.Add(new TemplateComposition("mod1") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
var all = new List<Template> { template, module };
var member = TemplateResolver.FindMemberByCanonicalName("mod1.Value", 1, all);
Assert.NotNull(member);
Assert.True(member.IsLocked);
}
[Fact]
public void BuildInheritanceChain_ThreeLevel_RootFirst()
{
var grandparent = new Template("GP") { Id = 1 };
var parent = new Template("P") { Id = 2, ParentTemplateId = 1 };
var child = new Template("C") { Id = 3, ParentTemplateId = 2 };
var lookup = new Dictionary<int, Template> { [1] = grandparent, [2] = parent, [3] = child };
var chain = TemplateResolver.BuildInheritanceChain(3, lookup);
Assert.Equal(3, chain.Count);
Assert.Equal("GP", chain[0].Name);
Assert.Equal("P", chain[1].Name);
Assert.Equal("C", chain[2].Name);
}
// ── TemplateEngine-019: a real Id of 0 must walk the inheritance chain ──
[Fact]
public void BuildInheritanceChain_RealIdZero_IsTreatedAsParentReferenceNotAsNoParent()
{
// TemplateEngine-013 removed the 0-as-no-parent sentinel from
// CycleDetector; the same fix had not propagated into the resolver,
// so seeding BuildInheritanceChain with templateId == 0 returned an
// empty chain even when a template with Id 0 existed (e.g. an
// import-staging row, or any in-memory test setup). Post-019: only a
// null ParentTemplateId means "no parent", and an Id of 0 walks the
// chain like any other node.
var orphaned = new Template("OrphanedId0") { Id = 0 };
orphaned.Attributes.Add(new TemplateAttribute("Speed")
{
Id = 1,
TemplateId = 0,
DataType = DataType.Float,
});
var lookup = new Dictionary<int, Template> { [0] = orphaned };
var chain = TemplateResolver.BuildInheritanceChain(0, lookup);
// Pre-019: while (currentId != 0 && ...) was false on the first
// iteration, so the chain was empty and the orphaned template's
// members were silently dropped from every flatten/resolve through
// it. Post-019: the orphan is the chain.
Assert.Single(chain);
Assert.Equal("OrphanedId0", chain[0].Name);
}
[Fact]
public void BuildInheritanceChain_ParentChainThroughIdZero_DoesNotTruncateChainAtZero()
{
// A template whose real parent has Id 0 must include the Id 0 parent
// in the chain. Pre-019: `current.ParentTemplateId ?? 0` paired with
// `currentId != 0` exited the loop as soon as the walk reached a real
// Id of 0 — silently truncating the inheritance contribution from the
// root template.
var parent = new Template("ParentWithIdZero") { Id = 0 };
parent.Attributes.Add(new TemplateAttribute("RootAttr")
{
Id = 1,
TemplateId = 0,
DataType = DataType.String,
});
var child = new Template("Child") { Id = 5, ParentTemplateId = 0 };
var lookup = new Dictionary<int, Template> { [0] = parent, [5] = child };
var chain = TemplateResolver.BuildInheritanceChain(5, lookup);
Assert.Equal(2, chain.Count);
Assert.Equal("ParentWithIdZero", chain[0].Name); // root first
Assert.Equal("Child", chain[1].Name);
}
[Fact]
public void ResolveAllMembers_TemplateWithRealIdZero_StillResolvesItsMembers()
{
// End-to-end: ResolveAllMembers piggybacks on BuildInheritanceChain,
// so the chain truncation regression also dropped every member of an
// Id-0 template from the resolved-member set. Lock this in too.
var orphan = new Template("OrphanedId0") { Id = 0 };
orphan.Attributes.Add(new TemplateAttribute("Speed")
{
Id = 1,
TemplateId = 0,
DataType = DataType.Float,
});
var members = TemplateResolver.ResolveAllMembers(0, new List<Template> { orphan });
Assert.Single(members);
Assert.Equal("Speed", members[0].CanonicalName);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,155 @@
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Validation;
public class ScriptCompilerTests
{
private readonly ScriptCompiler _sut = new();
[Fact]
public void TryCompile_ValidCode_ReturnsSuccess()
{
var result = _sut.TryCompile("var x = 1; if (x > 0) { x++; }", "Test");
Assert.True(result.IsSuccess);
}
[Fact]
public void TryCompile_EmptyCode_ReturnsFailure()
{
var result = _sut.TryCompile("", "Test");
Assert.True(result.IsFailure);
Assert.Contains("empty", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void TryCompile_MismatchedBraces_ReturnsFailure()
{
var result = _sut.TryCompile("if (true) { x = 1;", "Test");
Assert.True(result.IsFailure);
Assert.Contains("braces", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void TryCompile_UnclosedBlockComment_ReturnsFailure()
{
var result = _sut.TryCompile("/* this is never closed", "Test");
Assert.True(result.IsFailure);
Assert.Contains("comment", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData("System.IO.File.ReadAllText(\"x\");")]
[InlineData("System.Diagnostics.Process.Start(\"cmd\");")]
[InlineData("System.Threading.Thread.Sleep(1000);")]
[InlineData("System.Reflection.Assembly.Load(\"x\");")]
[InlineData("System.Net.Sockets.TcpClient c;")]
[InlineData("System.Net.Http.HttpClient c;")]
public void TryCompile_ForbiddenApi_ReturnsFailure(string code)
{
var result = _sut.TryCompile(code, "Test");
Assert.True(result.IsFailure);
Assert.Contains("forbidden", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void TryCompile_BracesInStrings_Ignored()
{
var result = _sut.TryCompile("var s = \"{ not a brace }\";", "Test");
Assert.True(result.IsSuccess);
}
[Fact]
public void TryCompile_BracesInComments_Ignored()
{
var result = _sut.TryCompile("// { not a brace\nvar x = 1;", "Test");
Assert.True(result.IsSuccess);
}
[Fact]
public void TryCompile_BlockCommentWithBraces_Ignored()
{
var result = _sut.TryCompile("/* { } */ var x = 1;", "Test");
Assert.True(result.IsSuccess);
}
// --- TemplateEngine-007 regression: string-literal awareness ---
[Fact]
public void TryCompile_VerbatimStringWithBrace_NotFlaggedAsMismatched()
{
// @"..." — backslash is literal, "" is the escape. The closing brace
// inside the verbatim string must not affect the brace balance.
var result = _sut.TryCompile("var s = @\"a brace } and a \\ slash\"; if (true) { }", "Test");
Assert.True(result.IsSuccess);
}
[Fact]
public void TryCompile_VerbatimStringWithEscapedQuote_NotFlaggedAsMismatched()
{
// The "" inside a verbatim string is an escaped quote, not a string end.
var result = _sut.TryCompile("var s = @\"he said \"\"hi}\"\"\"; { }", "Test");
Assert.True(result.IsSuccess);
}
[Fact]
public void TryCompile_InterpolatedStringWithBraces_NotFlaggedAsMismatched()
{
// The braces in $"{x}" are interpolation holes; the literal "}}" is an
// escaped brace. Neither should unbalance the real braces.
var result = _sut.TryCompile("var x = 1; var s = $\"val={x} literal}}\"; if (x>0) { x++; }", "Test");
Assert.True(result.IsSuccess);
}
[Fact]
public void TryCompile_RawStringLiteralWithBraces_NotFlaggedAsMismatched()
{
// C# 11 raw string literal — the triple quotes delimit, braces inside are text.
var result = _sut.TryCompile("var s = \"\"\"a } brace { in raw\"\"\"; { }", "Test");
Assert.True(result.IsSuccess);
}
[Fact]
public void TryCompile_CharLiteralWithBrace_NotFlaggedAsMismatched()
{
// A '}' char literal must not decrement the brace depth.
var result = _sut.TryCompile("var c = '}'; if (true) { }", "Test");
Assert.True(result.IsSuccess);
}
[Fact]
public void TryCompile_GenuineMismatchedBraces_StillDetected()
{
// Sanity check that the string-aware scan still catches real mismatches.
var result = _sut.TryCompile("var s = \"ok\"; if (true) { x++;", "Test");
Assert.True(result.IsFailure);
Assert.Contains("braces", result.Error, StringComparison.OrdinalIgnoreCase);
}
// --- TemplateEngine-006 regression: forbidden-API scan false positives ---
[Fact]
public void TryCompile_ForbiddenApiTextInsideStringLiteral_NotFlagged()
{
// "System.IO." appears only inside a string literal — it is inert text,
// not a use of the forbidden API, and must not be rejected.
var result = _sut.TryCompile("var msg = \"see System.IO.File docs\"; var x = 1;", "Test");
Assert.True(result.IsSuccess);
}
[Fact]
public void TryCompile_ForbiddenApiTextInsideComment_NotFlagged()
{
// "System.Threading." appears only inside a comment — inert.
var result = _sut.TryCompile("// avoid System.Threading.Thread here\nvar x = 1;", "Test");
Assert.True(result.IsSuccess);
}
[Fact]
public void TryCompile_ForbiddenApiInRealCode_StillFlagged()
{
// Sanity check: a genuine use in code is still rejected.
var result = _sut.TryCompile("var x = System.IO.File.ReadAllText(\"a\");", "Test");
Assert.True(result.IsFailure);
Assert.Contains("forbidden", result.Error, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,408 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Validation;
public class SemanticValidatorTests
{
private readonly SemanticValidator _sut = new();
[Fact]
public void Validate_CallScriptTargetNotFound_ReturnsError()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Scripts =
[
new ResolvedScript
{
CanonicalName = "Caller",
Code = "CallScript(\"NonExistent\");"
}
]
};
var result = _sut.Validate(config);
Assert.Contains(result.Errors, e =>
e.Category == ValidationCategory.CallTargetNotFound &&
e.Message.Contains("NonExistent"));
}
[Fact]
public void Validate_CallScriptTargetExists_NoError()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Scripts =
[
new ResolvedScript { CanonicalName = "Target", Code = "var x = 1;" },
new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"Target\");" }
]
};
var result = _sut.Validate(config);
Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.CallTargetNotFound);
}
[Fact]
public void Validate_CallSharedTargetNotFound_ReturnsError()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Scripts =
[
new ResolvedScript
{
CanonicalName = "Caller",
Code = "CallShared(\"MissingShared\");"
}
]
};
var result = _sut.Validate(config, sharedScripts: []);
Assert.Contains(result.Errors, e =>
e.Category == ValidationCategory.CallTargetNotFound &&
e.Message.Contains("MissingShared"));
}
[Fact]
public void Validate_CallSharedTargetExists_NoError()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Scripts =
[
new ResolvedScript { CanonicalName = "Caller", Code = "CallShared(\"Utility\");" }
]
};
var shared = new List<ResolvedScript>
{
new() { CanonicalName = "Utility", Code = "// shared" }
};
var result = _sut.Validate(config, shared);
Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.CallTargetNotFound);
}
[Fact]
public void Validate_ParameterCountMismatch_ReturnsError()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Scripts =
[
new ResolvedScript
{
CanonicalName = "Target",
Code = "var x = 1;",
ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Int32\"},{\"name\":\"b\",\"type\":\"String\"}]"
},
new ResolvedScript
{
CanonicalName = "Caller",
Code = "CallScript(\"Target\", 42);" // 1 arg but 2 expected
}
]
};
var result = _sut.Validate(config);
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch);
}
[Fact]
public void Validate_RangeViolationOnNonNumeric_ReturnsError()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Status", DataType = "String" }
],
Alarms =
[
new ResolvedAlarm
{
CanonicalName = "BadAlarm",
TriggerType = "RangeViolation",
TriggerConfiguration = "{\"attributeName\":\"Status\"}"
}
]
};
var result = _sut.Validate(config);
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType);
}
[Fact]
public void Validate_RangeViolationOnNumeric_NoError()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Temp", DataType = "Double" }
],
Alarms =
[
new ResolvedAlarm
{
CanonicalName = "HighTemp",
TriggerType = "RangeViolation",
TriggerConfiguration = "{\"attributeName\":\"Temp\"}"
}
]
};
var result = _sut.Validate(config);
Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType);
}
[Fact]
public void Validate_OnTriggerScriptNotFound_ReturnsError()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Scripts = [new ResolvedScript { CanonicalName = "OtherScript", Code = "var x = 1;" }],
Alarms =
[
new ResolvedAlarm
{
CanonicalName = "Alarm1",
TriggerType = "ValueMatch",
OnTriggerScriptCanonicalName = "MissingScript"
}
]
};
var result = _sut.Validate(config);
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.OnTriggerScriptNotFound);
}
[Fact]
public void Validate_InstanceScriptCallsAlarmOnTrigger_ReturnsError()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Scripts =
[
new ResolvedScript { CanonicalName = "AlarmHandler", Code = "// alarm handler" },
new ResolvedScript
{
CanonicalName = "RegularScript",
Code = "CallScript(\"AlarmHandler\");"
}
],
Alarms =
[
new ResolvedAlarm
{
CanonicalName = "Alarm1",
TriggerType = "ValueMatch",
OnTriggerScriptCanonicalName = "AlarmHandler"
}
]
};
var result = _sut.Validate(config);
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.CrossCallViolation);
}
[Fact]
public void ExtractCallTargets_MultipleCallTypes()
{
var code = @"
var x = CallScript(""Script1"", arg1, arg2);
CallShared(""Shared1"");
CallScript(""Script2"");
";
var targets = SemanticValidator.ExtractCallTargets(code);
Assert.Equal(3, targets.Count);
Assert.Contains(targets, t => t.TargetName == "Script1" && !t.IsShared && t.ArgumentCount == 2);
Assert.Contains(targets, t => t.TargetName == "Shared1" && t.IsShared && t.ArgumentCount == 0);
Assert.Contains(targets, t => t.TargetName == "Script2" && !t.IsShared && t.ArgumentCount == 0);
}
[Fact]
public void ParseParameterDefinitions_ValidJson_ReturnsList()
{
var json = "[{\"name\":\"a\",\"type\":\"Int32\"},{\"name\":\"b\",\"type\":\"String\"}]";
var result = SemanticValidator.ParseParameterDefinitions(json);
Assert.Equal(2, result.Count);
Assert.Equal("Int32", result[0]);
Assert.Equal("String", result[1]);
}
[Fact]
public void ParseParameterDefinitions_NullOrEmpty_ReturnsEmpty()
{
Assert.Empty(SemanticValidator.ParseParameterDefinitions(null));
Assert.Empty(SemanticValidator.ParseParameterDefinitions(""));
}
// ── HiLo validation ─────────────────────────────────────────────────────
private static FlattenedConfiguration HiLoConfig(string attrName, string dataType, string triggerJson) =>
new()
{
InstanceUniqueName = "Instance1",
Attributes = [new ResolvedAttribute { CanonicalName = attrName, DataType = dataType }],
Alarms =
[
new ResolvedAlarm
{
CanonicalName = "Hi/Lo Alarm",
TriggerType = "HiLo",
TriggerConfiguration = triggerJson
}
]
};
[Fact]
public void Validate_HiLoOnNonNumericAttribute_ReturnsError()
{
var config = HiLoConfig("Status", "String",
"{\"attributeName\":\"Status\",\"hi\":80}");
var result = _sut.Validate(config);
Assert.Contains(result.Errors,
e => e.Category == ValidationCategory.TriggerOperandType
&& e.Message.Contains("HiLo")
&& e.Message.Contains("non-numeric"));
}
[Fact]
public void Validate_HiLoOnNumericAttribute_NoOperandTypeError()
{
var config = HiLoConfig("Temp", "Double",
"{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100}");
var result = _sut.Validate(config);
Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType);
}
[Fact]
public void Validate_HiLoNoSetpoints_ReturnsWarning()
{
// No setpoints means the alarm can never fire — design-time warning.
var config = HiLoConfig("Temp", "Double",
"{\"attributeName\":\"Temp\"}");
var result = _sut.Validate(config);
Assert.Contains(result.Warnings,
w => w.Category == ValidationCategory.TriggerOperandType
&& w.Message.Contains("no setpoints"));
}
[Fact]
public void Validate_HiLoLoLoGreaterThanLo_ReturnsError()
{
var config = HiLoConfig("Temp", "Double",
"{\"attributeName\":\"Temp\",\"loLo\":20,\"lo\":10}");
var result = _sut.Validate(config);
Assert.Contains(result.Errors,
e => e.Category == ValidationCategory.TriggerOperandType
&& e.Message.Contains("LoLo")
&& e.Message.Contains("Lo"));
}
[Fact]
public void Validate_HiLoHiGreaterThanHiHi_ReturnsError()
{
var config = HiLoConfig("Temp", "Double",
"{\"attributeName\":\"Temp\",\"hi\":120,\"hiHi\":100}");
var result = _sut.Validate(config);
Assert.Contains(result.Errors,
e => e.Category == ValidationCategory.TriggerOperandType
&& e.Message.Contains("Hi")
&& e.Message.Contains("HiHi"));
}
[Fact]
public void Validate_HiLoLowSideOverlapsHighSide_ReturnsError()
{
// Lo (50) >= Hi (40) — bands overlap.
var config = HiLoConfig("Temp", "Double",
"{\"attributeName\":\"Temp\",\"lo\":50,\"hi\":40}");
var result = _sut.Validate(config);
Assert.Contains(result.Errors,
e => e.Category == ValidationCategory.TriggerOperandType
&& e.Message.Contains("overlap"));
}
[Fact]
public void Validate_HiLoOnlyHighSideConfigured_NoOrderingError()
{
// Only Hi/HiHi configured — no low-side comparison needed.
var config = HiLoConfig("Temp", "Double",
"{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100}");
var result = _sut.Validate(config);
Assert.DoesNotContain(result.Errors,
e => e.Category == ValidationCategory.TriggerOperandType);
}
[Fact]
public void Validate_HiLoNegativeDeadband_ReturnsError()
{
var config = HiLoConfig("Temp", "Double",
"{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100,\"hiDeadband\":-1}");
var result = _sut.Validate(config);
Assert.Contains(result.Errors,
e => e.Category == ValidationCategory.TriggerOperandType
&& e.Message.Contains("Hi deadband")
&& e.Message.Contains("non-negative"));
}
[Fact]
public void Validate_HiLoZeroDeadband_NoError()
{
// Zero deadband is the default (no hysteresis) and must be accepted.
var config = HiLoConfig("Temp", "Double",
"{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100,\"hiDeadband\":0,\"hiHiDeadband\":0}");
var result = _sut.Validate(config);
Assert.DoesNotContain(result.Errors,
e => e.Category == ValidationCategory.TriggerOperandType);
}
[Fact]
public void Validate_HiLoValidOrdering_NoErrors()
{
// LoLo (-10) < Lo (0) < Hi (90) < HiHi (100) — fully valid.
var config = HiLoConfig("Temp", "Double",
"{\"attributeName\":\"Temp\",\"loLo\":-10,\"lo\":0,\"hi\":90,\"hiHi\":100}");
var result = _sut.Validate(config);
Assert.DoesNotContain(result.Errors,
e => e.Category == ValidationCategory.TriggerOperandType);
Assert.DoesNotContain(result.Warnings,
w => w.Category == ValidationCategory.TriggerOperandType);
}
}
@@ -0,0 +1,193 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Validation;
public class ValidationServiceTests
{
private readonly ValidationService _sut = new();
[Fact]
public void Validate_ValidConfig_ReturnsSuccess()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double" }],
Scripts = [new ResolvedScript { CanonicalName = "Monitor", Code = "var x = 1;" }]
};
var result = _sut.Validate(config);
Assert.True(result.IsValid);
}
[Fact]
public void Validate_EmptyInstanceName_ReturnsError()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "",
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", DataType = "Double" }]
};
var result = _sut.Validate(config);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.FlatteningFailure);
}
[Fact]
public void Validate_NamingCollision_ReturnsError()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Temp", DataType = "Double" },
new ResolvedAttribute { CanonicalName = "Temp", DataType = "Int32" } // Duplicate!
]
};
var result = _sut.Validate(config);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.NamingCollision);
}
[Fact]
public void Validate_ForbiddenApi_ReturnsCompilationError()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Scripts =
[
new ResolvedScript
{
CanonicalName = "BadScript",
Code = "System.IO.File.ReadAllText(\"secret.txt\");"
}
]
};
var result = _sut.Validate(config);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ScriptCompilation);
}
[Fact]
public void Validate_MismatchedBraces_ReturnsCompilationError()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Scripts =
[
new ResolvedScript { CanonicalName = "Bad", Code = "if (true) {" }
]
};
var result = _sut.Validate(config);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ScriptCompilation);
}
[Fact]
public void Validate_AlarmReferencesMissingAttribute_ReturnsError()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", DataType = "Double" }],
Alarms =
[
new ResolvedAlarm
{
CanonicalName = "HighPressure",
TriggerType = "RangeViolation",
TriggerConfiguration = "{\"attributeName\":\"Pressure\"}" // Pressure doesn't exist
}
]
};
var result = _sut.Validate(config);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.AlarmTriggerReference);
}
[Fact]
public void Validate_AlarmReferencesExistingAttribute_NoError()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", DataType = "Double" }],
Alarms =
[
new ResolvedAlarm
{
CanonicalName = "HighTemp",
TriggerType = "RangeViolation",
TriggerConfiguration = "{\"attributeName\":\"Temp\"}"
}
]
};
var result = _sut.Validate(config);
// Should not have alarm trigger reference errors
Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.AlarmTriggerReference);
}
[Fact]
public void Validate_ScriptTriggerReferencesMissingAttribute_ReturnsError()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", DataType = "Double" }],
Scripts =
[
new ResolvedScript
{
CanonicalName = "OnChange",
Code = "var x = 1;",
TriggerConfiguration = "{\"attributeName\":\"Missing\"}"
}
]
};
var result = _sut.Validate(config);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ScriptTriggerReference);
}
[Fact]
public void Validate_UnboundDataSourceAttribute_ReturnsWarning()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Temp",
DataType = "Double",
DataSourceReference = "ns=2;s=Temp",
BoundDataConnectionId = null // No binding!
}
]
};
var result = _sut.Validate(config);
Assert.Contains(result.Warnings, w => w.Category == ValidationCategory.ConnectionBinding);
}
[Fact]
public void Validate_EmptyConfig_ReturnsWarning()
{
var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1" };
var result = _sut.Validate(config);
Assert.Contains(result.Warnings, w => w.Category == ValidationCategory.FlatteningFailure);
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.TemplateEngine/ZB.MOM.WW.ScadaBridge.TemplateEngine.csproj" />
</ItemGroup>
</Project>