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:
@@ -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" }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user