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:
@@ -253,7 +253,8 @@ public sealed class AddressSpaceApplierRawUnsTests
|
||||
UnsReferenceVariables = new[]
|
||||
{
|
||||
new UnsReferenceVariable("r1", "EQ-1", "filling/line1/station1/speed", "speed",
|
||||
"Plant/A/dev/speed", "Float", Writable: true),
|
||||
"Plant/A/dev/speed", "Float", Writable: true,
|
||||
BackingIsHistorized: true, BackingHistorianTagname: "WW.Speed"),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -276,11 +277,79 @@ public sealed class AddressSpaceApplierRawUnsTests
|
||||
r.ReferenceType.ShouldBe("Organizes");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-15 review — the inherited historian tagname + array shape come from the
|
||||
/// <see cref="UnsReferenceVariable"/> RECORD, never from a <c>RawTags</c> back-lookup by RawPath. The
|
||||
/// fixture makes the two disagree DELIBERATELY (the raw tag says de-historized + scalar; the reference
|
||||
/// record says historized "WW.Record" + array-of-4) so the assertion can only pass on the record. That
|
||||
/// matters because the record is what the planner diffed to produce the repair delta: materialising
|
||||
/// from anything else would recreate the node with values nobody diffed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MaterialiseUnsReferences_inherits_shape_and_historian_from_the_record_not_the_raw_tag()
|
||||
{
|
||||
var sink = new RealmRecordingSink();
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
RawTags = new[]
|
||||
{
|
||||
new RawTagPlan("t1", "Plant/A/dev/speed", "Plant/A/dev", "drv", "speed", "Float",
|
||||
Writable: true, Alarm: null, ReferencingEquipmentPaths: new[] { "filling/line1/station1" },
|
||||
IsHistorized: false, HistorianTagname: "WW.RawTag", IsArray: false, ArrayLength: null),
|
||||
},
|
||||
UnsReferenceVariables = new[]
|
||||
{
|
||||
new UnsReferenceVariable("r1", "EQ-1", "filling/line1/station1/speed", "speed",
|
||||
"Plant/A/dev/speed", "Float", Writable: true,
|
||||
BackingIsHistorized: true, BackingHistorianTagname: "WW.Record",
|
||||
BackingIsArray: true, BackingArrayLength: 4),
|
||||
},
|
||||
};
|
||||
|
||||
NewApplier(sink).MaterialiseUnsReferences(composition);
|
||||
|
||||
var v = sink.Variables.ShouldHaveSingleItem();
|
||||
v.HistorianTagname.ShouldBe("WW.Record"); // the record wins; the raw tag says not historized at all
|
||||
v.IsArray.ShouldBeTrue();
|
||||
v.ArrayLength.ShouldBe(4u);
|
||||
v.Writable.ShouldBeFalse(); // Writable && !IsArray ⇒ an array mirror is read-only
|
||||
}
|
||||
|
||||
/// <summary>The historian-resolution semantics carried over verbatim from the old back-lookup: a BLANK
|
||||
/// <c>BackingHistorianTagname</c> on a historized reference falls back to the <c>BackingRawPath</c> (which
|
||||
/// IS the backing raw tag's NodeId, the raw side's own default), and a non-historized reference registers
|
||||
/// no tagname at all even when an override string is present.</summary>
|
||||
[Theory]
|
||||
[InlineData(true, null, "Plant/A/dev/speed")]
|
||||
[InlineData(true, " ", "Plant/A/dev/speed")]
|
||||
[InlineData(true, "WW.Override", "WW.Override")]
|
||||
[InlineData(false, "WW.Override", null)]
|
||||
public void MaterialiseUnsReferences_resolves_the_historian_tagname_like_the_raw_side(
|
||||
bool historized, string? tagname, string? expected)
|
||||
{
|
||||
var sink = new RealmRecordingSink();
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
UnsReferenceVariables = new[]
|
||||
{
|
||||
new UnsReferenceVariable("r1", "EQ-1", "filling/line1/station1/speed", "speed",
|
||||
"Plant/A/dev/speed", "Float", Writable: true,
|
||||
BackingIsHistorized: historized, BackingHistorianTagname: tagname),
|
||||
},
|
||||
};
|
||||
|
||||
NewApplier(sink).MaterialiseUnsReferences(composition);
|
||||
|
||||
sink.Variables.ShouldHaveSingleItem().HistorianTagname.ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>A capturing sink that records (nodeId, realm) for every materialise + the Organizes edges.</summary>
|
||||
private sealed class RealmRecordingSink : IOpcUaAddressSpaceSink
|
||||
{
|
||||
public List<(string NodeId, string? Parent, AddressSpaceRealm Realm)> Folders { get; } = new();
|
||||
public List<(string NodeId, string? Parent, bool Writable, string? HistorianTagname, AddressSpaceRealm Realm)> Variables { get; } = new();
|
||||
public List<(string NodeId, string? Parent, bool Writable, string? HistorianTagname, AddressSpaceRealm Realm, bool IsArray, uint? ArrayLength)> Variables { get; } = new();
|
||||
public List<(string NodeId, string? Parent, bool IsNative, AddressSpaceRealm Realm)> Conditions { get; } = new();
|
||||
public List<(string Source, AddressSpaceRealm SourceRealm, string Target, AddressSpaceRealm TargetRealm, string ReferenceType)> References { get; } = new();
|
||||
public List<(string AlarmNodeId, AddressSpaceRealm AlarmRealm, IReadOnlyList<string> FolderNodeIds, AddressSpaceRealm FolderRealm)> NotifierWirings { get; } = new();
|
||||
@@ -289,7 +358,7 @@ public sealed class AddressSpaceApplierRawUnsTests
|
||||
=> Folders.Add((folderNodeId, parentNodeId, realm));
|
||||
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
=> Variables.Add((variableNodeId, parentFolderNodeId, writable, historianTagname, realm));
|
||||
=> Variables.Add((variableNodeId, parentFolderNodeId, writable, historianTagname, realm, isArray, arrayLength));
|
||||
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||||
=> Conditions.Add((alarmNodeId, equipmentNodeId, isNative, realm));
|
||||
|
||||
@@ -1553,6 +1553,81 @@ public sealed class AddressSpaceApplierTests
|
||||
outcome.ChangedNodes.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-15 review — the combined plan a backing-tag HISTORIAN edit produces: a surgical raw-tag
|
||||
/// delta AND the coincident UNS-reference delta (the reference record carries the backing historian
|
||||
/// intent, so it goes unequal too). Both are non-rebuild kinds, so ONE apply must update the raw node
|
||||
/// in place AND tear the stale UNS mirror down for the materialise pass to recreate — with NO rebuild.
|
||||
/// Without the coincident delta the UNS mirror kept the OLD historian tagname and HistoryRead through
|
||||
/// the UNS NodeId served a different series than through the raw NodeId.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Backing_historian_edit_updates_raw_in_place_and_removes_the_stale_uns_mirror_without_rebuilding()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var rawBefore = RawTag("Plant/Modbus/dev1/speed") with { IsHistorized = true, HistorianTagname = "WW.Old" };
|
||||
var rawAfter = rawBefore with { HistorianTagname = "WW.New" };
|
||||
var unsBefore = UnsRef("filling/line-1/station-1/speed", "speed") with
|
||||
{
|
||||
BackingIsHistorized = true, BackingHistorianTagname = "WW.Old",
|
||||
};
|
||||
var unsAfter = unsBefore with { BackingHistorianTagname = "WW.New" };
|
||||
|
||||
var previous = CompositionWith(rawBefore, unsBefore);
|
||||
var current = CompositionWith(rawAfter, unsAfter);
|
||||
var plan = AddressSpacePlanner.Compute(previous, current);
|
||||
|
||||
// The plan shape the fix produces: one delta in EACH realm, nothing structural.
|
||||
plan.ChangedRawTags.Count.ShouldBe(1);
|
||||
plan.ChangedUnsReferenceVariables.Count.ShouldBe(1);
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
|
||||
// Raw node: updated in place with the NEW tagname, in the Raw realm.
|
||||
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
|
||||
call.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
|
||||
call.Historian.ShouldBe("WW.New");
|
||||
call.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
|
||||
// UNS mirror: terminal Bad + in-place remove of the PREVIOUS node (the recreate rides the materialise
|
||||
// pass after Apply returns, which reads the CURRENT record's Backing* values).
|
||||
sink.ValueWrites.ShouldContain((unsBefore.NodeId, OpcUaStatus.Bad, AddressSpaceRealm.Uns));
|
||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", unsBefore.NodeId, AddressSpaceRealm.Uns));
|
||||
}
|
||||
|
||||
/// <summary>The same combined-plan repair for a scalar→ARRAY flip on the backing tag: without it the UNS
|
||||
/// mirror stays scalar AND writable, bypassing the array read-only write gate through the UNS NodeId.</summary>
|
||||
[Fact]
|
||||
public void Backing_shape_flip_removes_the_stale_uns_mirror_without_rebuilding()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var rawBefore = RawTag("Plant/Modbus/dev1/block");
|
||||
var rawAfter = rawBefore with { IsArray = true, ArrayLength = 8 };
|
||||
var unsBefore = UnsRef("filling/line-1/station-1/block", "block");
|
||||
var unsAfter = unsBefore with { BackingIsArray = true, BackingArrayLength = 8 };
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(
|
||||
CompositionWith(rawBefore, unsBefore), CompositionWith(rawAfter, unsAfter));
|
||||
|
||||
plan.ChangedRawTags.Count.ShouldBe(1);
|
||||
plan.ChangedUnsReferenceVariables.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
sink.SurgicalCalls.ShouldHaveSingleItem().IsArray.ShouldBeTrue();
|
||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", unsBefore.NodeId, AddressSpaceRealm.Uns));
|
||||
}
|
||||
|
||||
/// <summary>The one-way rebuild ratchet holds for the new raw-tag path: a surgical update that reports the
|
||||
/// node missing (false) falls back to a full rebuild.</summary>
|
||||
[Fact]
|
||||
@@ -1625,6 +1700,16 @@ public sealed class AddressSpaceApplierTests
|
||||
RawTags = tags,
|
||||
};
|
||||
|
||||
// Both realms in one composition — the shape a backing-tag edit produces (a raw delta AND its coincident
|
||||
// UNS-reference delta) needs the raw tag and the reference to it side by side.
|
||||
private static AddressSpaceComposition CompositionWith(RawTagPlan tag, UnsReferenceVariable reference) =>
|
||||
new(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
RawTags = new[] { tag },
|
||||
UnsReferenceVariables = new[] { reference },
|
||||
};
|
||||
|
||||
private static AddressSpaceComposition CompositionWithUnsRefs(params UnsReferenceVariable[] refs) =>
|
||||
new(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
|
||||
+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]
|
||||
|
||||
+64
@@ -178,6 +178,70 @@ public sealed class AddressSpaceComposerDualNamespaceTests
|
||||
statusRef.Writable.ShouldBeFalse(); // inherited from the Read raw tag
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-15 surgical-deploys review — a UNS reference also carries its backing tag's HISTORIAN
|
||||
/// intent + ARRAY shape (<c>Backing{IsHistorized,HistorianTagname,IsArray,ArrayLength}</c>). They are
|
||||
/// on the record so a raw-side edit to any of them makes the reference record unequal, producing the
|
||||
/// coincident <c>UnsReferenceDelta</c> that repairs the mirror node; without them the UNS node kept the
|
||||
/// old historian tagname / scalar shape after a surgical raw edit.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Uns_reference_variables_carry_backing_historian_intent_and_array_shape()
|
||||
{
|
||||
var c = ComposeFullFixture();
|
||||
|
||||
// Backing tag historized WITH an override → both fields ride the reference verbatim.
|
||||
var speedRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-speed");
|
||||
speedRef.BackingIsHistorized.ShouldBeTrue();
|
||||
speedRef.BackingHistorianTagname.ShouldBe("speed_hist");
|
||||
speedRef.BackingIsArray.ShouldBeFalse();
|
||||
speedRef.BackingArrayLength.ShouldBeNull();
|
||||
|
||||
// Backing tag neither historized nor array → the defaults.
|
||||
var statusRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-status");
|
||||
statusRef.BackingIsHistorized.ShouldBeFalse();
|
||||
statusRef.BackingHistorianTagname.ShouldBeNull();
|
||||
statusRef.BackingIsArray.ShouldBeFalse();
|
||||
statusRef.BackingArrayLength.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>The array + historize-without-override branches of the same inheritance: an ARRAY backing tag
|
||||
/// projects its shape onto the reference (so the UNS mirror is created as an array, and therefore
|
||||
/// read-only), and a historized tag with a BLANK override leaves <c>BackingHistorianTagname</c> null — the
|
||||
/// applier resolves that to the BackingRawPath, exactly as the raw side defaults to its NodeId.</summary>
|
||||
[Fact]
|
||||
public void Uns_reference_variables_carry_array_shape_and_blank_historian_override()
|
||||
{
|
||||
var driver = new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = null,
|
||||
Name = "Modbus1", DriverType = "Modbus", DriverConfig = "{}",
|
||||
};
|
||||
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "PLC-A", DeviceConfig = "{}" };
|
||||
var levels = new Tag
|
||||
{
|
||||
TagId = "t-levels", DeviceId = "dev-1", Name = "Levels", DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.ReadWrite,
|
||||
TagConfig = "{\"isArray\":true,\"arrayLength\":8,\"isHistorized\":true}",
|
||||
};
|
||||
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
|
||||
var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "station-1", MachineCode = "STATION_001" };
|
||||
var reference = new UnsTagReference { UnsTagReferenceId = "ref-levels", EquipmentId = "eq-1", TagId = "t-levels" };
|
||||
|
||||
var c = AddressSpaceComposer.Compose(
|
||||
new[] { area }, new[] { line }, new[] { equip },
|
||||
new[] { driver }, Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: new[] { reference }, tags: new[] { levels },
|
||||
rawFolders: Array.Empty<RawFolder>(), devices: new[] { device });
|
||||
|
||||
var levelsRef = c.UnsReferenceVariables.ShouldHaveSingleItem();
|
||||
levelsRef.BackingIsArray.ShouldBeTrue();
|
||||
levelsRef.BackingArrayLength.ShouldBe(8u);
|
||||
levelsRef.BackingIsHistorized.ShouldBeTrue();
|
||||
levelsRef.BackingHistorianTagname.ShouldBeNull(); // blank override ⇒ resolved to BackingRawPath later
|
||||
}
|
||||
|
||||
/// <summary>A composition with no raw/UNS inputs leaves all three new subtree sets empty (the legacy
|
||||
/// convenience overloads + earlier callers keep working).</summary>
|
||||
[Fact]
|
||||
|
||||
+51
-2
@@ -20,7 +20,7 @@ public sealed class AddressSpacePlannerDualNamespaceTests
|
||||
|
||||
// Compose a fixture with one raw tag (given name) under Plant/Modbus1/PLC-A and one UNS reference to it
|
||||
// (with an optional display-name override so the effective name can be held stable across a raw rename).
|
||||
private static AddressSpaceComposition Compose(string tagName, string? displayOverride)
|
||||
private static AddressSpaceComposition Compose(string tagName, string? displayOverride, string tagConfig = "{}")
|
||||
{
|
||||
var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = RawFolderName };
|
||||
var driver = new DriverInstance
|
||||
@@ -32,7 +32,7 @@ public sealed class AddressSpacePlannerDualNamespaceTests
|
||||
var tag = new Tag
|
||||
{
|
||||
TagId = "t-speed", DeviceId = "dev-1", Name = tagName,
|
||||
DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{}",
|
||||
DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = tagConfig,
|
||||
};
|
||||
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
|
||||
@@ -219,6 +219,55 @@ public sealed class AddressSpacePlannerDualNamespaceTests
|
||||
_ = prev;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>THE PIN (2026-08-15 surgical-deploys review):</b> a RAW-side edit to the backing tag's historian
|
||||
/// intent or array shape must produce a <c>ChangedRawTags</c> delta AND a <b>coincident</b>
|
||||
/// <c>ChangedUnsReferenceVariables</c> delta on every reference to it. The coincident delta is the ONLY
|
||||
/// repair trigger for the UNS mirror node: the raw node is updated surgically in place and
|
||||
/// <c>EnsureVariable</c> early-returns on the existing UNS node, so without this delta the mirror keeps
|
||||
/// the OLD historian tagname (HistoryRead via the UNS NodeId serves a different series than via the raw
|
||||
/// NodeId) and the OLD shape (a scalar→array flip leaves it scalar AND writable). Nothing about the
|
||||
/// reference ROW changed here — only the backing tag's TagConfig — so the delta exists purely because
|
||||
/// the four <c>Backing*</c> fields are on <see cref="UnsReferenceVariable"/>.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("{\"isHistorized\":true,\"historianTagname\":\"WW.Old\"}", "{\"isHistorized\":true,\"historianTagname\":\"WW.New\"}")]
|
||||
[InlineData("{\"isHistorized\":true}", "{}")]
|
||||
[InlineData("{}", "{\"isArray\":true,\"arrayLength\":8}")]
|
||||
[InlineData("{\"isArray\":true,\"arrayLength\":8}", "{\"isArray\":true,\"arrayLength\":16}")]
|
||||
public void Backing_tag_historian_or_shape_edit_produces_a_coincident_uns_reference_delta(string before, string after)
|
||||
{
|
||||
var prev = Compose("Speed", displayOverride: null, tagConfig: before);
|
||||
var next = Compose("Speed", displayOverride: null, tagConfig: after);
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(prev, next);
|
||||
|
||||
// Raw side: an attribute edit on a stable RawPath ⇒ a Changed delta (not remove+add).
|
||||
var rawDelta = plan.ChangedRawTags.ShouldHaveSingleItem();
|
||||
rawDelta.Previous.NodeId.ShouldBe(rawDelta.Current.NodeId);
|
||||
|
||||
// UNS side: the SAME reference row, same NodeId + BackingRawPath — flagged Changed ONLY because the
|
||||
// inherited backing historian/shape moved. This is what drives the applier's remove+recreate repair.
|
||||
var unsDelta = plan.ChangedUnsReferenceVariables.ShouldHaveSingleItem();
|
||||
unsDelta.Previous.UnsTagReferenceId.ShouldBe("ref-1");
|
||||
unsDelta.Previous.NodeId.ShouldBe(unsDelta.Current.NodeId);
|
||||
unsDelta.Previous.BackingRawPath.ShouldBe(unsDelta.Current.BackingRawPath);
|
||||
unsDelta.Previous.ShouldNotBe(unsDelta.Current);
|
||||
|
||||
// And the inherited values on the CURRENT side match the backing tag's new intent, so the recreate
|
||||
// materialises the mirror from the same truth the raw node was updated with.
|
||||
unsDelta.Current.BackingIsHistorized.ShouldBe(rawDelta.Current.IsHistorized);
|
||||
unsDelta.Current.BackingHistorianTagname.ShouldBe(rawDelta.Current.HistorianTagname);
|
||||
unsDelta.Current.BackingIsArray.ShouldBe(rawDelta.Current.IsArray);
|
||||
unsDelta.Current.BackingArrayLength.ShouldBe(rawDelta.Current.ArrayLength);
|
||||
|
||||
// Neither realm sees a structural add/remove — this stays a surgical, subscription-preserving deploy.
|
||||
plan.AddedRawTags.ShouldBeEmpty();
|
||||
plan.RemovedRawTags.ShouldBeEmpty();
|
||||
plan.AddedUnsReferenceVariables.ShouldBeEmpty();
|
||||
plan.RemovedUnsReferenceVariables.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Identical compositions diff to an empty plan across both realms (fresh list instances must
|
||||
/// not spuriously flag RawTags/UnsReferenceVariables as changed).</summary>
|
||||
[Fact]
|
||||
|
||||
+23
-5
@@ -61,11 +61,14 @@ public sealed class DeploymentArtifactRawUnsParityTests
|
||||
var line = new UnsLine { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" };
|
||||
var equip = new Equipment { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" };
|
||||
var reference = new UnsTagReference { UnsTagReferenceId = "ref-1", EquipmentId = "EQ-1", TagId = "tag-speed", DisplayNameOverride = "MotorSpeed" };
|
||||
// A second reference onto the ARRAY tag: the UNS reference record carries the backing tag's array
|
||||
// shape + historian intent (2026-08-15 review), so both branches must survive the artifact round-trip.
|
||||
var arrayReference = new UnsTagReference { UnsTagReferenceId = "ref-2", EquipmentId = "EQ-1", TagId = "tag-profile", DisplayNameOverride = null };
|
||||
|
||||
var composed = AddressSpaceComposer.Compose(
|
||||
new[] { area }, new[] { line }, new[] { equip },
|
||||
new[] { driver }, Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: new[] { reference }, tags: new[] { speed, run, profile, tempHi },
|
||||
unsTagReferences: new[] { reference, arrayReference }, tags: new[] { speed, run, profile, tempHi },
|
||||
rawFolders: new[] { folder }, devices: new[] { device }, tagGroups: new[] { group });
|
||||
|
||||
// Artifact JSON — the same shape ConfigComposer serializes (enums numeric: ReadWrite = 1, Read = 0).
|
||||
@@ -85,7 +88,11 @@ public sealed class DeploymentArtifactRawUnsParityTests
|
||||
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } },
|
||||
UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" } },
|
||||
Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" } },
|
||||
UnsTagReferences = new[] { new { UnsTagReferenceId = "ref-1", EquipmentId = "EQ-1", TagId = "tag-speed", DisplayNameOverride = "MotorSpeed" } },
|
||||
UnsTagReferences = new[]
|
||||
{
|
||||
new { UnsTagReferenceId = "ref-1", EquipmentId = "EQ-1", TagId = "tag-speed", DisplayNameOverride = (string?)"MotorSpeed" },
|
||||
new { UnsTagReferenceId = "ref-2", EquipmentId = "EQ-1", TagId = "tag-profile", DisplayNameOverride = (string?)null },
|
||||
},
|
||||
});
|
||||
|
||||
var decoded = DeploymentArtifact.ParseComposition(blob);
|
||||
@@ -103,9 +110,20 @@ public sealed class DeploymentArtifactRawUnsParityTests
|
||||
composedAlarm.Alarm.ShouldNotBeNull();
|
||||
composedAlarm.Alarm!.AlarmType.ShouldBe("OffNormalAlarm");
|
||||
composedAlarm.Writable.ShouldBeTrue();
|
||||
composed.UnsReferenceVariables.Count.ShouldBe(1);
|
||||
composed.UnsReferenceVariables[0].NodeId.ShouldBe("filling/line1/station1/MotorSpeed");
|
||||
composed.UnsReferenceVariables[0].BackingRawPath.ShouldBe("Cell1/Modbus/Dev1/Fast/Speed");
|
||||
composed.UnsReferenceVariables.Count.ShouldBe(2);
|
||||
var composedSpeedRef = composed.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-1");
|
||||
composedSpeedRef.NodeId.ShouldBe("filling/line1/station1/MotorSpeed");
|
||||
composedSpeedRef.BackingRawPath.ShouldBe("Cell1/Modbus/Dev1/Fast/Speed");
|
||||
|
||||
// Same "both right, not both blind" sanity for the backing historian intent + array shape the UNS
|
||||
// reference inherits — a composer that dropped them entirely would parity-match a decode that did too.
|
||||
composedSpeedRef.BackingIsHistorized.ShouldBeTrue();
|
||||
composedSpeedRef.BackingHistorianTagname.ShouldBe("WW.Speed");
|
||||
composedSpeedRef.BackingIsArray.ShouldBeFalse();
|
||||
var composedProfileRef = composed.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-2");
|
||||
composedProfileRef.BackingIsArray.ShouldBeTrue();
|
||||
composedProfileRef.BackingArrayLength.ShouldBe(16u);
|
||||
composedProfileRef.BackingIsHistorized.ShouldBeFalse();
|
||||
|
||||
// Byte-parity: the three dual-namespace node sets are record-equal between the two seams.
|
||||
decoded.RawContainers.ShouldBe(composed.RawContainers);
|
||||
|
||||
Reference in New Issue
Block a user