test(opcua): pin F10b surgical guard — alarm-bearing tag rebuilds + multi-delta surgical success
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user