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:
Joseph Doherty
2026-06-07 05:19:47 -04:00
parent 9464c91546
commit 9818d0cba8
3 changed files with 64 additions and 1 deletions
@@ -139,6 +139,44 @@ public sealed class Phase7PlannerTests
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>
[Fact]
public void New_equipment_goes_to_AddedEquipment()