diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/DiffService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/DiffService.cs index 1182af78..d17bcfe5 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/DiffService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/DiffService.cs @@ -119,6 +119,11 @@ public class DiffService a.CanonicalName == b.CanonicalName && a.Value == b.Value && a.DataType == b.DataType && + // #290: ElementDataType is part of the revision hash, so the diff must + // treat a List element-type change (e.g. Int32 → Double) with identical + // JSON-encoded values as a Changed attribute — keeping the structured + // diff in parity with the staleness hash. + a.ElementDataType == b.ElementDataType && a.Description == b.Description && a.IsLocked == b.IsLocked && a.DataSourceReference == b.DataSourceReference && diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs index 19337841..8bdc978d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs @@ -53,6 +53,7 @@ public class RevisionHashService CanonicalName = a.CanonicalName, Value = a.Value, DataType = a.DataType, + ElementDataType = a.ElementDataType, Description = a.Description, IsLocked = a.IsLocked, DataSourceReference = a.DataSourceReference, @@ -174,6 +175,16 @@ public class RevisionHashService /// public string? Description { get; init; } /// + /// For List attributes: the element scalar type name; null otherwise. + /// Folded into the hash (#290) so a List element-type change (e.g. + /// Int32 → Double) with identical JSON-encoded values is detected as a + /// staleness/revision change. Null for scalar attributes — and because + /// the serializer ignores null properties + /// (), scalar-only + /// configurations hash identically to before this field was added. + /// + public string? ElementDataType { get; init; } + /// /// Whether the attribute is locked. /// public bool IsLocked { get; init; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/DiffServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/DiffServiceTests.cs index fb5fa89f..ef83f798 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/DiffServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/DiffServiceTests.cs @@ -162,6 +162,37 @@ public class DiffServiceTests Assert.Equal(DiffChangeType.Changed, diff.AttributeChanges[0].ChangeType); } + [Fact] + public void ComputeDiff_AttributeElementDataTypeChange_DetectedAsChanged() + { + // #290: ElementDataType is part of the revision hash, so AttributesEqual + // must also compare it — a List element-type change (Int32 → Double) with + // identical JSON-encoded values must surface as a Changed attribute, + // keeping the structured diff in parity with the staleness hash. + var oldConfig = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Attributes = + [ + new ResolvedAttribute { CanonicalName = "Readings", Value = "[1,2]", DataType = "List", ElementDataType = "Int32" } + ] + }; + var newConfig = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Attributes = + [ + new ResolvedAttribute { CanonicalName = "Readings", Value = "[1,2]", DataType = "List", ElementDataType = "Double" } + ] + }; + + var diff = _sut.ComputeDiff(oldConfig, newConfig); + + Assert.True(diff.HasChanges); + Assert.Single(diff.AttributeChanges); + Assert.Equal(DiffChangeType.Changed, diff.AttributeChanges[0].ChangeType); + } + [Fact] public void ComputeDiff_AlarmDescriptionChange_DetectedAsChanged() { 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 0c13f8ff..93fd1323 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/RevisionHashServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/RevisionHashServiceTests.cs @@ -279,6 +279,94 @@ public class RevisionHashServiceTests Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2)); } + [Fact] + public void ComputeHash_ListElementDataTypeChange_ChangesHash() + { + // #290: a List attribute whose element type changes (Int32 → Double) + // while the JSON-encoded values stay identical MUST produce a different + // revision hash, so the deployment is flagged stale. ElementDataType is + // folded into the hashable projection precisely for this case. + var baseAttr = new ResolvedAttribute + { + CanonicalName = "Readings", + Value = "[1,2]", + DataType = "List", + ElementDataType = "Int32" + }; + var editedAttr = baseAttr with { ElementDataType = "Double" }; + + var configBefore = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + TemplateId = 1, + SiteId = 1, + Attributes = [baseAttr] + }; + var configAfter = configBefore with { Attributes = [editedAttr] }; + + Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter)); + } + + [Fact] + public void ComputeHash_SameListElementDataType_SameHash() + { + // #290: two List attributes identical in every field — including + // ElementDataType — must hash the same. + var attr = new ResolvedAttribute + { + CanonicalName = "Readings", + Value = "[1,2]", + DataType = "List", + ElementDataType = "Int32" + }; + + var config1 = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + TemplateId = 1, + SiteId = 1, + Attributes = [attr] + }; + var config2 = config1 with { Attributes = [attr with { }] }; + + Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2)); + } + + [Fact] + public void ComputeHash_ScalarAttribute_NullVsUnsetElementDataType_SameHash() + { + // #290 no-op guard: a scalar attribute has a null ElementDataType, and + // the canonical serializer omits null properties + // (DefaultIgnoreCondition = WhenWritingNull). Explicitly setting + // ElementDataType = null must therefore hash identically to leaving it + // at its default — i.e. adding the field is a no-op for scalars and does + // not retroactively change their hashes. + var withExplicitNull = new ResolvedAttribute + { + CanonicalName = "Temperature", + Value = "25", + DataType = "Double", + ElementDataType = null + }; + var withDefault = new ResolvedAttribute + { + CanonicalName = "Temperature", + Value = "25", + DataType = "Double" + }; + + var config1 = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + TemplateId = 1, + SiteId = 1, + Attributes = [withExplicitNull] + }; + var config2 = config1 with { Attributes = [withDefault] }; + + Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2)); + } + private static FlattenedConfiguration CreateConfig(string instanceName, string tempValue) { return new FlattenedConfiguration