fix(opcuaserver): UNS reference nodes repair on backing-tag historian/shape edits
Adversarial review of 9f68f272 (classifier: non-alarm raw-tag deltas became
surgical) found stale UNS inheritance after a surgical raw edit. Editing a raw
tag's IsHistorized / HistorianTagname / IsArray / ArrayLength left every
referencing UNS node STALE: none of those fields were on UnsReferenceVariable,
so no coincident UnsReferenceDelta fired; the applier updated the raw node in
place; and EnsureVariable early-returns on an existing node. Consequences:
HistoryRead through the UNS NodeId served a different series than through the
raw NodeId (breaking the documented dual-registration invariant), a
de-historized tag kept serving history via UNS, and a scalar->array flip left
the UNS mirror scalar AND writable (bypassing the array read-only write gate).
UnsReferenceVariable now carries Backing{IsHistorized,HistorianTagname,IsArray,
ArrayLength}, copied from the backing tag's TagConfigIntent at compose time, so
record equality makes the reference Changed in the SAME plan. Both deltas are
non-rebuild kinds, so the existing machinery repairs the mirror surgically:
remove-previous in Apply, recreate in MaterialiseUnsReferences. That pass now
reads the four values from the RECORD (the single truth the planner diffed)
instead of a rawByPath back-lookup, preserving historian resolution exactly
(blank override => BackingRawPath, which is the backing tag's NodeId).
The artifact-decode seam needs no change: DeploymentArtifact.BuildRawAndUnsSubtrees
drives AddressSpaceComposer.Compose verbatim, which is what guarantees byte-parity.
The classifier needs no change either (a UnsReferenceDelta never forces Rebuild);
its comment now records why.
Also from the same review: a three-place-contract comment on RawTagPlan's
hand-written Equals naming its two downstream consumers, plus classifier pins for
the .Any(!eligible) quantifier, an ArrayLength-only delta, and an
alarm-content-only delta.
This commit is contained in:
+42
-1
@@ -104,9 +104,13 @@ public sealed class AddressSpaceChangeClassifierDualNamespaceTests
|
||||
[InlineData("historianTagname")]
|
||||
[InlineData("dataType")]
|
||||
[InlineData("array")]
|
||||
[InlineData("arrayLength")]
|
||||
public void Attribute_raw_tag_delta_is_not_a_rebuild(string field)
|
||||
{
|
||||
var prev = RawTag("Plant/Modbus1/PLC-A/Speed");
|
||||
var baseline = RawTag("Plant/Modbus1/PLC-A/Speed");
|
||||
// ArrayLength moving ALONE (a resize of an already-array tag) — the whitelist must carry ArrayLength
|
||||
// in its own right, not only as a passenger of an IsArray flip. Its "previous" is already an array.
|
||||
var prev = field == "arrayLength" ? baseline with { IsArray = true, ArrayLength = 8 } : baseline;
|
||||
var cur = field switch
|
||||
{
|
||||
"writable" => prev with { Writable = true },
|
||||
@@ -114,6 +118,7 @@ public sealed class AddressSpaceChangeClassifierDualNamespaceTests
|
||||
"historianTagname" => prev with { IsHistorized = true, HistorianTagname = "Plant.PLC_A.Speed" },
|
||||
"dataType" => prev with { DataType = "Double" },
|
||||
"array" => prev with { IsArray = true, ArrayLength = 8 },
|
||||
"arrayLength" => prev with { ArrayLength = 16 },
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(field)),
|
||||
};
|
||||
|
||||
@@ -141,6 +146,42 @@ public sealed class AddressSpaceChangeClassifierDualNamespaceTests
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
|
||||
}
|
||||
|
||||
/// <summary>WP4 binding guard, CONTENT edition: an alarm present on BOTH sides whose severity (or type)
|
||||
/// moved still rebuilds. The null-check pair alone does not decide it — the with-equality whitelist has to
|
||||
/// leave <c>Alarm</c> out, so the two <see cref="EquipmentTagAlarmInfo"/> values compare unequal.</summary>
|
||||
[Fact]
|
||||
public void Alarm_content_only_raw_tag_delta_still_rebuilds()
|
||||
{
|
||||
var prev = RawTag("Plant/Modbus1/PLC-A/Speed") with { Alarm = new EquipmentTagAlarmInfo("OffNormalAlarm", 500) };
|
||||
var cur = prev with { Alarm = new EquipmentTagAlarmInfo("OffNormalAlarm", 900) };
|
||||
|
||||
var plan = Empty() with { ChangedRawTags = new[] { new AddressSpacePlan.RawTagDelta(prev, cur) } };
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
|
||||
}
|
||||
|
||||
/// <summary>The QUANTIFIER: the rebuild gate is <c>.Any(d => !eligible(d))</c>, so ONE ineligible raw-tag
|
||||
/// delta must rebuild even when every other delta in the same plan is surgical. Pins against a regression
|
||||
/// to <c>.All(eligible)</c> on the wrong side, or to inspecting only <c>First()</c> — either of which would
|
||||
/// apply an identity/alarm change as an in-place attribute update and leave the node mis-wired.</summary>
|
||||
[Fact]
|
||||
public void One_ineligible_raw_tag_delta_among_eligible_ones_still_rebuilds()
|
||||
{
|
||||
var eligiblePrev = RawTag("Plant/Modbus1/PLC-A/Speed");
|
||||
var ineligiblePrev = RawTag("Plant/Modbus1/PLC-A/Temp") with { TagId = "t-2" };
|
||||
|
||||
var plan = Empty() with
|
||||
{
|
||||
ChangedRawTags = new[]
|
||||
{
|
||||
// Surgical: a plain historize toggle. Listed FIRST so a First()-only check would pass it.
|
||||
new AddressSpacePlan.RawTagDelta(eligiblePrev, eligiblePrev with { IsHistorized = true }),
|
||||
// Ineligible: DriverInstanceId re-routes the node to a different driver point.
|
||||
new AddressSpacePlan.RawTagDelta(ineligiblePrev, ineligiblePrev with { DriverInstanceId = "drv-2" }),
|
||||
},
|
||||
};
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
|
||||
}
|
||||
|
||||
/// <summary>Default-closed: an identity field outside the whitelist (DriverInstanceId re-routes the node to
|
||||
/// another driver point; Name/TagId/ParentNodeId re-shape identity) still forces a full rebuild.</summary>
|
||||
[Theory]
|
||||
|
||||
Reference in New Issue
Block a user