diff --git a/docs/requirements/Component-TemplateEngine.md b/docs/requirements/Component-TemplateEngine.md index 9bacc5b2..261dc630 100644 --- a/docs/requirements/Component-TemplateEngine.md +++ b/docs/requirements/Component-TemplateEngine.md @@ -214,6 +214,8 @@ Each flattened configuration output includes a **revision hash** (computed from The override flows into the flattened attribute's `DataSourceReference` and therefore participates in the revision hash — changes to an instance's binding overrides re-deploy as expected. +The revision hash covers the resolved attributes, alarms, scripts, connections, **and native alarm source bindings** (`ResolvedNativeAlarmSource`: canonical name, connection name, source reference, condition filter, and locked state). Native alarm sources were folded into the hash in migration TemplateEngine-011; the hashable projection is populated null-when-empty and the canonical serializer omits null properties, so every native-source-free configuration keeps its byte-identical pre-migration hash. **Migration note:** native-source-bearing instances will report stale once after upgrade; redeploy clears it — deliberate. + ### On-Demand Validation The same validation logic is available to Design users in the Central UI without triggering a deployment. This allows template authors to check their work for errors during authoring. diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs index e863a123..53fd2427 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs @@ -18,6 +18,16 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening; /// by RevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically. /// Collections are explicitly sorted by CanonicalName before hashing. /// +/// +/// MIGRATION (TemplateEngine-011): native alarm source bindings +/// () now participate in the hash so +/// that binding edits flag the instance stale. The hashable property is +/// populated NULL-WHEN-EMPTY and the serializer omits null properties +/// (), so every +/// native-source-free configuration keeps its byte-identical pre-migration +/// hash — no global staleness storm. Native-source-bearing instances will +/// report stale once after upgrade; redeploy clears it — deliberate. +/// /// public class RevisionHashService { @@ -90,6 +100,19 @@ public class RevisionHashService ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds }) .ToList(), + NativeAlarmSources = configuration.NativeAlarmSources is { Count: > 0 } + ? configuration.NativeAlarmSources + .OrderBy(n => n.CanonicalName, StringComparer.Ordinal) + .Select(n => new HashableNativeAlarmSource + { + CanonicalName = n.CanonicalName, + ConditionFilter = n.ConditionFilter, + ConnectionName = n.ConnectionName, + IsLocked = n.IsLocked, + SourceReference = n.SourceReference + }) + .ToList() + : null, Connections = configuration.Connections is { Count: > 0 } ? new SortedDictionary( configuration.Connections.ToDictionary( @@ -138,6 +161,13 @@ public class RevisionHashService /// public string InstanceUniqueName { get; init; } = string.Empty; /// + /// Collection of native alarm source bindings in the configuration. + /// Null when the configuration carries no native alarm sources so that + /// native-source-free configs hash identically to before this field was + /// added (see the MIGRATION note on the class summary). + /// + public List? NativeAlarmSources { get; init; } + /// /// Collection of scripts in the configuration. /// public List Scripts { get; init; } = []; @@ -247,6 +277,30 @@ public class RevisionHashService public string Protocol { get; init; } = string.Empty; } + private sealed record HashableNativeAlarmSource + { + /// + /// The path-qualified canonical name of the native alarm source binding. + /// + public string CanonicalName { get; init; } = string.Empty; + /// + /// Optional condition filter; null = mirror all conditions under the source. + /// + public string? ConditionFilter { get; init; } + /// + /// The data connection name that owns the alarm feed. + /// + public string ConnectionName { get; init; } = string.Empty; + /// + /// Whether the native alarm source binding is locked. + /// + public bool IsLocked { get; init; } + /// + /// The source reference (OPC UA SourceNode/notifier nodeId, or MxAccess object/area). + /// + public string SourceReference { get; init; } = string.Empty; + } + private sealed record HashableScript { /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/RevisionHashServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/RevisionHashServiceTests.cs index 93fd1323..98e2da22 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/RevisionHashServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/RevisionHashServiceTests.cs @@ -367,6 +367,103 @@ public class RevisionHashServiceTests Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2)); } + [Fact] + public void ComputeHash_NativeAlarmSourceChange_ChangesHash() + { + // TemplateEngine-011: native alarm source bindings are part of the + // deployment package, so an edit to a source's binding (here the + // SourceReference) MUST change the revision hash and flag the instance + // stale. Before this migration native sources were absent from the hash + // and this edit went undetected. + var baseSource = new ResolvedNativeAlarmSource + { + CanonicalName = "Boiler.Alarms", + ConnectionName = "plc1", + SourceReference = "ns=2;s=Boiler", + ConditionFilter = null, + IsLocked = false + }; + var editedSource = baseSource with { SourceReference = "ns=2;s=Boiler2" }; + + var configBefore = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + TemplateId = 1, + SiteId = 1, + NativeAlarmSources = [baseSource] + }; + var configAfter = configBefore with { NativeAlarmSources = [editedSource] }; + + Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter)); + } + + [Fact] + public void ComputeHash_NativeAlarmSourceIsLockedChange_ChangesHash() + { + // TemplateEngine-011: IsLocked participates in the hash too. + var baseSource = new ResolvedNativeAlarmSource + { + CanonicalName = "Boiler.Alarms", + ConnectionName = "plc1", + SourceReference = "ns=2;s=Boiler", + IsLocked = false + }; + var editedSource = baseSource with { IsLocked = true }; + + var configBefore = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + TemplateId = 1, + SiteId = 1, + NativeAlarmSources = [baseSource] + }; + var configAfter = configBefore with { NativeAlarmSources = [editedSource] }; + + Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter)); + } + + [Fact] + public void ComputeHash_NativeAlarmSourceOrder_DoesNotAffectHash() + { + // TemplateEngine-011: sources are sorted by CanonicalName before hashing, + // mirroring attributes/alarms/scripts — authoring order is irrelevant. + var a = new ResolvedNativeAlarmSource + { + CanonicalName = "A", ConnectionName = "plc1", SourceReference = "ns=2;s=A" + }; + var b = new ResolvedNativeAlarmSource + { + CanonicalName = "B", ConnectionName = "plc1", SourceReference = "ns=2;s=B" + }; + + var config1 = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + TemplateId = 1, + SiteId = 1, + NativeAlarmSources = [a, b] + }; + var config2 = config1 with { NativeAlarmSources = [b, a] }; + + Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2)); + } + + [Fact] + public void ComputeHash_NoNativeAlarmSources_HashUnchangedFromBaseline() + { + // TemplateEngine-011 migration surgicality: adding the (null-when-empty) + // NativeAlarmSources property to the hashable projection must NOT change + // the hash of any native-source-free configuration. The literal below is + // the pre-migration hash of CreateConfig("Instance1", "25.0"); pinning it + // proves that only instances that HAVE native sources flip stale — there + // is no global staleness storm. + var config = CreateConfig("Instance1", "25.0"); + + Assert.Equal( + "sha256:e937bcfa34a146ca6bdb9bda27bdceb204e440a4a6110ca646f7750f37b166cb", + _sut.ComputeHash(config)); + } + private static FlattenedConfiguration CreateConfig(string instanceName, string tempValue) { return new FlattenedConfiguration