test(opcua): pin F10b surgical guard — alarm-bearing tag rebuilds + multi-delta surgical success

This commit is contained in:
Joseph Doherty
2026-06-18 13:44:26 -04:00
parent f6618144a4
commit b472bba384
@@ -1277,6 +1277,102 @@ public sealed class Phase7ApplierTests
sink.SurgicalCalls.ShouldBeEmpty();
}
/// <summary>F10b guard — a tag that ALREADY carries an alarm (<c>Alarm is not null</c> on BOTH previous
/// and current, identical alarm info) is NOT surgical-eligible even when only <c>Writable</c> changes.
/// The explicit <c>Alarm is null</c> guard in <c>TagDeltaIsSurgicalEligible</c> prevents a false
/// positive: without it the <c>with { Writable = … }</c> override (which does NOT touch <c>Alarm</c>)
/// would leave both sides with equal <c>Alarm</c> values and the delta would WRONGLY look surgical.
/// An alarm-bearing tag is a Part 9 condition node, not a plain value variable, so any change to it
/// must go through a full rebuild.</summary>
[Fact]
public void Changed_alarm_bearing_tag_writable_only_still_rebuilds()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
// Both previous and current carry an identical, non-null alarm — the tag is an alarm-bearing node.
var alarm = new EquipmentTagAlarmInfo("OffNormalAlarm", 700);
var previous = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean",
FullName: "00001", Writable: false, Alarm: alarm));
// Only Writable flips; alarm is unchanged (and still non-null on both sides).
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean",
FullName: "00001", Writable: true, Alarm: alarm));
var plan = Phase7Planner.Compute(previous, next);
// Guard the arrange: the planner sees a changed tag (Writable differs) and nothing else.
plan.ChangedEquipmentTags.Count.ShouldBe(1);
plan.AddedEquipmentTags.ShouldBeEmpty();
plan.RemovedEquipmentTags.ShouldBeEmpty();
var outcome = applier.Apply(plan);
// The alarm-bearing tag is NOT surgical-eligible — it must rebuild and make NO surgical call.
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
sink.SurgicalCalls.ShouldBeEmpty(); // NO surgical write on an alarm-bearing tag
}
/// <summary>F10b multi-delta — when a plan contains TWO distinct surgical-eligible tag deltas (plain
/// value variables, no alarm, both changing only <c>Writable</c>), the applier must apply BOTH in
/// place (two <c>UpdateTagAttributes</c> calls) without triggering any rebuild. Proves the surgical
/// path iterates all eligible deltas rather than stopping after the first.</summary>
[Fact]
public void Two_surgical_eligible_tag_deltas_both_apply_in_place_no_rebuild()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
// Two distinct plain (no-alarm) tags, both will flip Writable — both are surgical-eligible.
var previous = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "Pressure", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
},
};
// Only Writable flips on both tags; everything else is identical.
var next = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null),
new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "Pressure", DataType: "Float", FullName: "40002", Writable: true, Alarm: null),
},
};
var plan = Phase7Planner.Compute(previous, next);
// Guard the arrange: exactly two changed tags, nothing else.
plan.ChangedEquipmentTags.Count.ShouldBe(2);
plan.AddedEquipmentTags.ShouldBeEmpty();
plan.RemovedEquipmentTags.ShouldBeEmpty();
plan.AddedEquipment.ShouldBeEmpty();
plan.RemovedEquipment.ShouldBeEmpty();
var outcome = applier.Apply(plan);
// No rebuild — both deltas are surgical-eligible.
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
// Exactly two surgical calls — one per eligible delta.
sink.SurgicalCalls.Count.ShouldBe(2);
// Both expected node ids must appear (order is not guaranteed).
var nodeId1 = EquipmentNodeIds.Variable("eq-1", "", "Speed");
var nodeId2 = EquipmentNodeIds.Variable("eq-1", "", "Pressure");
sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId1 && c.Writable);
sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId2 && c.Writable);
outcome.ChangedNodes.ShouldBe(2);
}
/// <summary>F10b — a surgical-eligible tag delta MIXED with another change (here an added equipment)
/// must still rebuild: the rebuild is forced by the OTHER change. The surgical path is taken ONLY when
/// the tag deltas are the sole change. No surgical call is made (the rebuild materialises everything).</summary>