fix(opcua): structural equality for EquipmentVirtualTagPlan so no-op redeploys diff empty
IReadOnlyList<string> DependencyRefs compared by reference in the auto-generated record equality, causing every VirtualTag with dependencies to be flagged "Changed" on every parse (fresh list instances from composer and artifact-decoder). Add Equals/GetHashCode overrides with element-wise ordinal comparison so Phase7Plan.IsEmpty short-circuits a no-op redeploy. Add regression test Identical_virtualtag_snapshots_diff_to_empty_plan (separate list instances, same contents → IsEmpty true). Add TODO comment in Phase7Applier near needsRebuild predicate.
This commit is contained in:
@@ -81,6 +81,7 @@ public sealed class Phase7Applier
|
|||||||
// VirtualTag topology requires a real address-space rebuild. Driver-instance changes don't
|
// VirtualTag topology requires a real address-space rebuild. Driver-instance changes don't
|
||||||
// touch the address-space topology directly — they go through DriverHostActor's spawn-plan
|
// touch the address-space topology directly — they go through DriverHostActor's spawn-plan
|
||||||
// in Runtime.
|
// in Runtime.
|
||||||
|
// TODO(equipment-virtualtags): when MaterialiseEquipmentVirtualTags drives per-delta sink work, revisit whether ChangedEquipmentVirtualTags should also force needsRebuild.
|
||||||
var needsRebuild =
|
var needsRebuild =
|
||||||
plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 ||
|
plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 ||
|
||||||
plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 ||
|
plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 ||
|
||||||
|
|||||||
@@ -122,7 +122,31 @@ public sealed record EquipmentVirtualTagPlan(
|
|||||||
string Name,
|
string Name,
|
||||||
string DataType,
|
string DataType,
|
||||||
string Expression,
|
string Expression,
|
||||||
IReadOnlyList<string> DependencyRefs);
|
IReadOnlyList<string> DependencyRefs)
|
||||||
|
{
|
||||||
|
/// <summary>Structural equality: the auto-generated record equality would compare
|
||||||
|
/// <see cref="DependencyRefs"/> (an interface-typed list) BY REFERENCE, flagging every
|
||||||
|
/// VirtualTag as "changed" on every parse (fresh list instances). Compare it element-wise
|
||||||
|
/// so a no-op redeploy diffs empty.</summary>
|
||||||
|
public bool Equals(EquipmentVirtualTagPlan? other) =>
|
||||||
|
other is not null &&
|
||||||
|
VirtualTagId == other.VirtualTagId &&
|
||||||
|
EquipmentId == other.EquipmentId &&
|
||||||
|
FolderPath == other.FolderPath &&
|
||||||
|
Name == other.Name &&
|
||||||
|
DataType == other.DataType &&
|
||||||
|
Expression == other.Expression &&
|
||||||
|
DependencyRefs.SequenceEqual(other.DependencyRefs, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public override int GetHashCode()
|
||||||
|
{
|
||||||
|
var hash = new HashCode();
|
||||||
|
hash.Add(VirtualTagId); hash.Add(EquipmentId); hash.Add(FolderPath);
|
||||||
|
hash.Add(Name); hash.Add(DataType); hash.Add(Expression);
|
||||||
|
foreach (var r in DependencyRefs) hash.Add(r, StringComparer.Ordinal);
|
||||||
|
return hash.ToHashCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pure composer that flattens the live-edit DB tables into the address-space build plan a
|
/// Pure composer that flattens the live-edit DB tables into the address-space build plan a
|
||||||
|
|||||||
@@ -139,6 +139,44 @@ public sealed class Phase7PlannerTests
|
|||||||
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
|
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Regression guard for structural equality on <see cref="EquipmentVirtualTagPlan.DependencyRefs"/>:
|
||||||
|
/// two snapshots containing the SAME VirtualTag built from SEPARATE list instances must diff to an empty plan
|
||||||
|
/// (IReadOnlyList equality is BY REFERENCE without the custom Equals override, so every VirtualTag with
|
||||||
|
/// dependencies would be wrongly flagged "Changed" on every parse, preventing IsEmpty short-circuits).</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Identical_virtualtag_snapshots_diff_to_empty_plan()
|
||||||
|
{
|
||||||
|
// Two separate list instances with identical contents — proves structural (not reference) equality.
|
||||||
|
var refsA = new[] { "EQ1.Speed", "EQ1.Torque" };
|
||||||
|
var refsB = new[] { "EQ1.Speed", "EQ1.Torque" };
|
||||||
|
|
||||||
|
var prev = new Phase7CompositionResult(
|
||||||
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||||
|
{
|
||||||
|
EquipmentVirtualTags = new[]
|
||||||
|
{
|
||||||
|
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
|
||||||
|
Expression: "ctx.GetTag(\"EQ1.Speed\") / ctx.GetTag(\"EQ1.Torque\")", DependencyRefs: refsA),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var next = new Phase7CompositionResult(
|
||||||
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||||
|
{
|
||||||
|
EquipmentVirtualTags = new[]
|
||||||
|
{
|
||||||
|
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
|
||||||
|
Expression: "ctx.GetTag(\"EQ1.Speed\") / ctx.GetTag(\"EQ1.Torque\")", DependencyRefs: refsB),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var plan = Phase7Planner.Compute(prev, next);
|
||||||
|
|
||||||
|
plan.IsEmpty.ShouldBeTrue();
|
||||||
|
plan.ChangedEquipmentVirtualTags.ShouldBeEmpty();
|
||||||
|
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
|
||||||
|
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that new equipment goes to the AddedEquipment list.</summary>
|
/// <summary>Verifies that new equipment goes to the AddedEquipment list.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void New_equipment_goes_to_AddedEquipment()
|
public void New_equipment_goes_to_AddedEquipment()
|
||||||
|
|||||||
Reference in New Issue
Block a user