fix(deploy): include ElementDataType in RevisionHashService.HashableAttribute (#290)
Fold a List attribute's ElementDataType into the hashable projection so a change to the element type (e.g. Int32 -> Double) with identical JSON-encoded values is detected as a staleness/revision change. Inserted in alphabetical position; null ElementDataType (scalars) is omitted by the canonical serializer (WhenWritingNull), so scalar-only configs hash identically to before. DiffService.AttributesEqual gains the same comparison to keep the structured diff in parity with the staleness hash. Adds tests for differing vs. equal ElementDataType (hash + diff) and the scalar no-op guard.
This commit is contained in:
@@ -119,6 +119,11 @@ public class DiffService
|
|||||||
a.CanonicalName == b.CanonicalName &&
|
a.CanonicalName == b.CanonicalName &&
|
||||||
a.Value == b.Value &&
|
a.Value == b.Value &&
|
||||||
a.DataType == b.DataType &&
|
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.Description == b.Description &&
|
||||||
a.IsLocked == b.IsLocked &&
|
a.IsLocked == b.IsLocked &&
|
||||||
a.DataSourceReference == b.DataSourceReference &&
|
a.DataSourceReference == b.DataSourceReference &&
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ public class RevisionHashService
|
|||||||
CanonicalName = a.CanonicalName,
|
CanonicalName = a.CanonicalName,
|
||||||
Value = a.Value,
|
Value = a.Value,
|
||||||
DataType = a.DataType,
|
DataType = a.DataType,
|
||||||
|
ElementDataType = a.ElementDataType,
|
||||||
Description = a.Description,
|
Description = a.Description,
|
||||||
IsLocked = a.IsLocked,
|
IsLocked = a.IsLocked,
|
||||||
DataSourceReference = a.DataSourceReference,
|
DataSourceReference = a.DataSourceReference,
|
||||||
@@ -174,6 +175,16 @@ public class RevisionHashService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Description { get; init; }
|
public string? Description { get; init; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// (<see cref="JsonIgnoreCondition.WhenWritingNull"/>), scalar-only
|
||||||
|
/// configurations hash identically to before this field was added.
|
||||||
|
/// </summary>
|
||||||
|
public string? ElementDataType { get; init; }
|
||||||
|
/// <summary>
|
||||||
/// Whether the attribute is locked.
|
/// Whether the attribute is locked.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsLocked { get; init; }
|
public bool IsLocked { get; init; }
|
||||||
|
|||||||
@@ -162,6 +162,37 @@ public class DiffServiceTests
|
|||||||
Assert.Equal(DiffChangeType.Changed, diff.AttributeChanges[0].ChangeType);
|
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]
|
[Fact]
|
||||||
public void ComputeDiff_AlarmDescriptionChange_DetectedAsChanged()
|
public void ComputeDiff_AlarmDescriptionChange_DetectedAsChanged()
|
||||||
{
|
{
|
||||||
|
|||||||
+88
@@ -279,6 +279,94 @@ public class RevisionHashServiceTests
|
|||||||
Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
|
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)
|
private static FlattenedConfiguration CreateConfig(string instanceName, string tempValue)
|
||||||
{
|
{
|
||||||
return new FlattenedConfiguration
|
return new FlattenedConfiguration
|
||||||
|
|||||||
Reference in New Issue
Block a user