Phase 2 WP-1–13+23: Template Engine CRUD, composition, overrides, locking, collision detection, acyclicity
- WP-23: ITemplateEngineRepository full EF Core implementation - WP-1: Template CRUD with deletion constraints (instances, children, compositions) - WP-2–4: Attribute, alarm, script definitions with lock flags and override granularity - WP-5: Shared script CRUD with syntax validation - WP-6–7: Composition with recursive nesting and canonical naming - WP-8–11: Override granularity, locking rules, inheritance/composition scope - WP-12: Naming collision detection on canonical names (recursive) - WP-13: Graph acyclicity (inheritance + composition cycles) Core services: TemplateService, SharedScriptService, TemplateResolver, LockEnforcer, CollisionDetector, CycleDetector. 358 tests pass.
This commit is contained in:
114
tests/ScadaLink.TemplateEngine.Tests/CollisionDetectorTests.cs
Normal file
114
tests/ScadaLink.TemplateEngine.Tests/CollisionDetectorTests.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.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"));
|
||||
}
|
||||
}
|
||||
156
tests/ScadaLink.TemplateEngine.Tests/CycleDetectorTests.cs
Normal file
156
tests/ScadaLink.TemplateEngine.Tests/CycleDetectorTests.cs
Normal file
@@ -0,0 +1,156 @@
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
|
||||
namespace ScadaLink.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Text.Json;
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
|
||||
namespace ScadaLink.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,136 @@
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
using ScadaLink.TemplateEngine.Flattening;
|
||||
|
||||
namespace ScadaLink.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.TemplateEngine.Flattening;
|
||||
|
||||
namespace ScadaLink.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") { Id = 100, Configuration = "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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
using ScadaLink.TemplateEngine.Flattening;
|
||||
|
||||
namespace ScadaLink.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));
|
||||
}
|
||||
|
||||
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" }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
214
tests/ScadaLink.TemplateEngine.Tests/LockEnforcerTests.cs
Normal file
214
tests/ScadaLink.TemplateEngine.Tests/LockEnforcerTests.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.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);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using Moq;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.TemplateEngine.Services;
|
||||
|
||||
namespace ScadaLink.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 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,185 @@
|
||||
using Moq;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.TemplateEngine.Services;
|
||||
|
||||
namespace ScadaLink.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);
|
||||
}
|
||||
|
||||
[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<(string, int)> { ("Temp", 100), ("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,149 @@
|
||||
using Moq;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.TemplateEngine.Services;
|
||||
|
||||
namespace ScadaLink.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("OPC-Server1", "OpcUa", "{\"url\":\"opc.tcp://localhost\"}", "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("OPC-Server1", result.Value.Name);
|
||||
Assert.Equal("OpcUa", result.Value.Protocol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignConnectionToSite_AlreadyAssigned_ReturnsFailure()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetSiteByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Site("S", "S1") { Id = 1 });
|
||||
_repoMock.Setup(r => r.GetDataConnectionByIdAsync(100, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DataConnection("Conn", "OpcUa") { Id = 100 });
|
||||
_repoMock.Setup(r => r.GetSiteDataConnectionAssignmentAsync(1, 100, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SiteDataConnectionAssignment { Id = 1, SiteId = 1, DataConnectionId = 100 });
|
||||
|
||||
var result = await _sut.AssignConnectionToSiteAsync(1, 100, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("already assigned", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignConnectionToSite_Valid_Success()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetSiteByIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Site("S", "S1") { Id = 1 });
|
||||
_repoMock.Setup(r => r.GetDataConnectionByIdAsync(100, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DataConnection("Conn", "OpcUa") { Id = 100 });
|
||||
_repoMock.Setup(r => r.GetSiteDataConnectionAssignmentAsync(1, 100, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SiteDataConnectionAssignment?)null);
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
var result = await _sut.AssignConnectionToSiteAsync(1, 100, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
[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,174 @@
|
||||
using Moq;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.TemplateEngine.Services;
|
||||
|
||||
namespace ScadaLink.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()
|
||||
{
|
||||
_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 },
|
||||
new("Composer") { Id = 2 }
|
||||
});
|
||||
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(2, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TemplateComposition>
|
||||
{
|
||||
new("PumpModule") { ComposedTemplateId = 1 }
|
||||
});
|
||||
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TemplateComposition>());
|
||||
|
||||
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_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 } });
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template>
|
||||
{
|
||||
new("Busy") { Id = 1 },
|
||||
new("Child") { Id = 2, ParentTemplateId = 1 },
|
||||
new("Composer") { Id = 3 }
|
||||
});
|
||||
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(3, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TemplateComposition>
|
||||
{
|
||||
new("Module") { ComposedTemplateId = 1 }
|
||||
});
|
||||
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TemplateComposition>());
|
||||
_repoMock.Setup(r => r.GetCompositionsByTemplateIdAsync(2, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<TemplateComposition>());
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
144
tests/ScadaLink.TemplateEngine.Tests/SharedScriptServiceTests.cs
Normal file
144
tests/ScadaLink.TemplateEngine.Tests/SharedScriptServiceTests.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using Moq;
|
||||
using ScadaLink.Commons.Entities.Scripts;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
|
||||
namespace ScadaLink.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);
|
||||
}
|
||||
}
|
||||
177
tests/ScadaLink.TemplateEngine.Tests/TemplateResolverTests.cs
Normal file
177
tests/ScadaLink.TemplateEngine.Tests/TemplateResolverTests.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.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);
|
||||
}
|
||||
}
|
||||
510
tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs
Normal file
510
tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs
Normal file
@@ -0,0 +1,510 @@
|
||||
using Moq;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Scripts;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests;
|
||||
|
||||
public class TemplateServiceTests
|
||||
{
|
||||
private readonly Mock<ITemplateEngineRepository> _repoMock;
|
||||
private readonly Mock<IAuditService> _auditMock;
|
||||
private readonly TemplateService _service;
|
||||
|
||||
public TemplateServiceTests()
|
||||
{
|
||||
_repoMock = new Mock<ITemplateEngineRepository>();
|
||||
_auditMock = new Mock<IAuditService>();
|
||||
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
|
||||
_service = new TemplateService(_repoMock.Object, _auditMock.Object);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-1: Template CRUD with Inheritance
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTemplate_Success()
|
||||
{
|
||||
var result = await _service.CreateTemplateAsync("Pump", "A pump template", null, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("Pump", result.Value.Name);
|
||||
_repoMock.Verify(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
_auditMock.Verify(a => a.LogAsync("admin", "Create", "Template", It.IsAny<string>(), "Pump", It.IsAny<object?>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTemplate_EmptyName_Fails()
|
||||
{
|
||||
var result = await _service.CreateTemplateAsync("", null, null, "admin");
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("required", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTemplate_WithParent_Success()
|
||||
{
|
||||
var parent = new Template("Base") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(parent);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { parent });
|
||||
|
||||
var result = await _service.CreateTemplateAsync("Child", null, 1, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(1, result.Value.ParentTemplateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTemplate_NonexistentParent_Fails()
|
||||
{
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(999, It.IsAny<CancellationToken>())).ReturnsAsync((Template?)null);
|
||||
|
||||
var result = await _service.CreateTemplateAsync("Child", null, 999, "admin");
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("not found", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTemplate_Success()
|
||||
{
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
_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> { template });
|
||||
|
||||
var result = await _service.DeleteTemplateAsync(1, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
_repoMock.Verify(r => r.DeleteTemplateAsync(1, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTemplate_ReferencedByInstances_Fails()
|
||||
{
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Instance> { new Instance("Pump1") { Id = 1, TemplateId = 1, SiteId = 1 } });
|
||||
|
||||
var result = await _service.DeleteTemplateAsync(1, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("referenced by", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTemplate_HasChildren_Fails()
|
||||
{
|
||||
var parent = new Template("Base") { Id = 1 };
|
||||
var child = new Template("Child") { Id = 2, ParentTemplateId = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(parent);
|
||||
_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> { parent, child });
|
||||
|
||||
var result = await _service.DeleteTemplateAsync(1, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("inherited by", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTemplate_ComposedByOther_Fails()
|
||||
{
|
||||
var moduleTemplate = new Template("Module") { Id = 1 };
|
||||
var composingTemplate = new Template("Composing") { Id = 2 };
|
||||
composingTemplate.Compositions.Add(new TemplateComposition("mod1") { Id = 1, TemplateId = 2, ComposedTemplateId = 1 });
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
|
||||
_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> { moduleTemplate, composingTemplate });
|
||||
|
||||
var result = await _service.DeleteTemplateAsync(1, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("composed by", result.Error);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-2: Attribute Definitions with Lock Flags
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task AddAttribute_Success()
|
||||
{
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { template });
|
||||
|
||||
var attr = new TemplateAttribute("Temperature") { DataType = DataType.Float, Value = "0.0" };
|
||||
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("Temperature", result.Value.Name);
|
||||
Assert.Equal(1, result.Value.TemplateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAttribute_DuplicateName_Fails()
|
||||
{
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
template.Attributes.Add(new TemplateAttribute("Temperature") { Id = 1, TemplateId = 1, DataType = DataType.Float });
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
|
||||
var attr = new TemplateAttribute("Temperature") { DataType = DataType.Float };
|
||||
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("already exists", result.Error);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-3: Alarm Definitions
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task AddAlarm_Success()
|
||||
{
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { template });
|
||||
|
||||
var alarm = new TemplateAlarm("HighTemp")
|
||||
{
|
||||
PriorityLevel = 500,
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = """{"Max": 100}"""
|
||||
};
|
||||
var result = await _service.AddAlarmAsync(1, alarm, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("HighTemp", result.Value.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAlarm_InvalidPriority_Fails()
|
||||
{
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
|
||||
var alarm = new TemplateAlarm("HighTemp") { PriorityLevel = 1001, TriggerType = AlarmTriggerType.ValueMatch };
|
||||
var result = await _service.AddAlarmAsync(1, alarm, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("priority", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAlarm_TriggerTypeFixed_Fails()
|
||||
{
|
||||
var existing = new TemplateAlarm("HighTemp")
|
||||
{
|
||||
Id = 1,
|
||||
TemplateId = 1,
|
||||
TriggerType = AlarmTriggerType.ValueMatch,
|
||||
PriorityLevel = 500
|
||||
};
|
||||
_repoMock.Setup(r => r.GetTemplateAlarmByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
||||
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
|
||||
var proposed = new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation, // Changed!
|
||||
PriorityLevel = 600
|
||||
};
|
||||
var result = await _service.UpdateAlarmAsync(1, proposed, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("TriggerType", result.Error);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-4: Script Definitions
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task AddScript_Success()
|
||||
{
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { template });
|
||||
|
||||
var script = new TemplateScript("OnStart", "return true;") { TriggerType = "Startup" };
|
||||
var result = await _service.AddScriptAsync(1, script, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("OnStart", result.Value.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateScript_NameFixed_Fails()
|
||||
{
|
||||
var existing = new TemplateScript("OnStart", "return true;") { Id = 1, TemplateId = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateScriptByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
|
||||
var proposed = new TemplateScript("OnStop", "return false;"); // Name changed!
|
||||
var result = await _service.UpdateScriptAsync(1, proposed, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("Name", result.Error);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-5: Shared Script CRUD (see SharedScriptServiceTests)
|
||||
// ========================================================================
|
||||
|
||||
// ========================================================================
|
||||
// WP-6: Composition with Recursive Nesting
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task AddComposition_Success()
|
||||
{
|
||||
var moduleTemplate = new Template("Module") { Id = 2 };
|
||||
var template = new Template("Parent") { Id = 1 };
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { template, moduleTemplate });
|
||||
|
||||
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("myModule", result.Value.InstanceName);
|
||||
Assert.Equal(2, result.Value.ComposedTemplateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddComposition_DuplicateInstanceName_Fails()
|
||||
{
|
||||
var moduleTemplate = new Template("Module") { Id = 2 };
|
||||
var template = new Template("Parent") { Id = 1 };
|
||||
template.Compositions.Add(new TemplateComposition("myModule") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
|
||||
|
||||
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("already exists", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddComposition_SelfComposition_Fails()
|
||||
{
|
||||
var template = new Template("Self") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { template });
|
||||
|
||||
var result = await _service.AddCompositionAsync(1, 1, "self", "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("compose itself", result.Error);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-9: Locking Rules
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAttribute_LockedMember_CannotUnlock()
|
||||
{
|
||||
var existing = new TemplateAttribute("Temperature")
|
||||
{
|
||||
Id = 1, TemplateId = 1, DataType = DataType.Float, IsLocked = true
|
||||
};
|
||||
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
|
||||
var proposed = new TemplateAttribute("Temperature")
|
||||
{
|
||||
DataType = DataType.Float, IsLocked = false, Value = "42"
|
||||
};
|
||||
var result = await _service.UpdateAttributeAsync(1, proposed, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("cannot be unlocked", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAttribute_LockUnlockedMember_Succeeds()
|
||||
{
|
||||
var existing = new TemplateAttribute("Temperature")
|
||||
{
|
||||
Id = 1, TemplateId = 1, DataType = DataType.Float, IsLocked = false
|
||||
};
|
||||
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
|
||||
var proposed = new TemplateAttribute("Temperature")
|
||||
{
|
||||
DataType = DataType.Float, IsLocked = true, Value = "42"
|
||||
};
|
||||
var result = await _service.UpdateAttributeAsync(1, proposed, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(result.Value.IsLocked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAttribute_ParentLocked_CannotOverride()
|
||||
{
|
||||
// Parent template with locked attribute
|
||||
var parentTemplate = new Template("Base") { Id = 1 };
|
||||
parentTemplate.Attributes.Add(new TemplateAttribute("Speed")
|
||||
{
|
||||
Id = 10, TemplateId = 1, DataType = DataType.Float, IsLocked = true
|
||||
});
|
||||
|
||||
// Child template overriding same attribute
|
||||
var childTemplate = new Template("Child") { Id = 2, ParentTemplateId = 1 };
|
||||
var childAttr = new TemplateAttribute("Speed")
|
||||
{
|
||||
Id = 20, TemplateId = 2, DataType = DataType.Float, IsLocked = false
|
||||
};
|
||||
childTemplate.Attributes.Add(childAttr);
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(20, It.IsAny<CancellationToken>())).ReturnsAsync(childAttr);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(childTemplate);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { parentTemplate, childTemplate });
|
||||
|
||||
var proposed = new TemplateAttribute("Speed")
|
||||
{
|
||||
DataType = DataType.Float, IsLocked = false, Value = "100"
|
||||
};
|
||||
var result = await _service.UpdateAttributeAsync(20, proposed, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("locked in parent", result.Error);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-10: Inheritance Override Scope — Cannot remove parent members
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAttribute_InheritedFromParent_Fails()
|
||||
{
|
||||
var parentTemplate = new Template("Base") { Id = 1 };
|
||||
parentTemplate.Attributes.Add(new TemplateAttribute("Speed")
|
||||
{
|
||||
Id = 10, TemplateId = 1, DataType = DataType.Float
|
||||
});
|
||||
|
||||
var childTemplate = new Template("Child") { Id = 2, ParentTemplateId = 1 };
|
||||
var childAttr = new TemplateAttribute("Speed")
|
||||
{
|
||||
Id = 20, TemplateId = 2, DataType = DataType.Float
|
||||
};
|
||||
childTemplate.Attributes.Add(childAttr);
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(20, It.IsAny<CancellationToken>())).ReturnsAsync(childAttr);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(childTemplate);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { parentTemplate, childTemplate });
|
||||
|
||||
var result = await _service.DeleteAttributeAsync(20, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("inherited from parent", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAttribute_OwnMember_Succeeds()
|
||||
{
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
var attr = new TemplateAttribute("CustomAttr")
|
||||
{
|
||||
Id = 1, TemplateId = 1, DataType = DataType.String
|
||||
};
|
||||
template.Attributes.Add(attr);
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(attr);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
|
||||
var result = await _service.DeleteAttributeAsync(1, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-13: Graph Acyclicity
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTemplate_InheritanceCycle_Fails()
|
||||
{
|
||||
var templateA = new Template("A") { Id = 1, ParentTemplateId = null };
|
||||
var templateB = new Template("B") { Id = 2, ParentTemplateId = 1 };
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(templateA);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(templateB);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { templateA, templateB });
|
||||
|
||||
// Try to make A inherit from B (B already inherits from A) => cycle
|
||||
var result = await _service.UpdateTemplateAsync(1, "A", null, 2, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTemplate_SelfInheritance_Fails()
|
||||
{
|
||||
var template = new Template("Self") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { template });
|
||||
|
||||
var result = await _service.UpdateTemplateAsync(1, "Self", null, 1, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("itself", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddComposition_CircularChain_Fails()
|
||||
{
|
||||
// A composes B, B composes C, try to make C compose A => cycle
|
||||
var templateC = new Template("C") { Id = 3 };
|
||||
var templateB = new Template("B") { Id = 2 };
|
||||
templateB.Compositions.Add(new TemplateComposition("c1") { Id = 2, TemplateId = 2, ComposedTemplateId = 3 });
|
||||
var templateA = new Template("A") { Id = 1 };
|
||||
templateA.Compositions.Add(new TemplateComposition("b1") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(3, It.IsAny<CancellationToken>())).ReturnsAsync(templateC);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(templateA);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { templateA, templateB, templateC });
|
||||
|
||||
var result = await _service.AddCompositionAsync(3, 1, "a1", "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace ScadaLink.TemplateEngine.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using ScadaLink.TemplateEngine.Validation;
|
||||
|
||||
namespace ScadaLink.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
using ScadaLink.TemplateEngine.Validation;
|
||||
|
||||
namespace ScadaLink.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(""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
using ScadaLink.TemplateEngine.Validation;
|
||||
|
||||
namespace ScadaLink.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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user