feat(opcua): route AddressSpaceApplier through the classifier — pure-add deploys no longer rebuild (R2-07 T2)

This commit is contained in:
Joseph Doherty
2026-07-13 11:46:24 -04:00
parent bb226f45ed
commit 0d717e4785
5 changed files with 210 additions and 85 deletions
@@ -11,7 +11,7 @@
{ {
"id": "T2", "id": "T2",
"subject": "Phase 1: route AddressSpaceApplier.Apply through the classifier \u2014 PureAdd skips rebuild (high-risk)", "subject": "Phase 1: route AddressSpaceApplier.Apply through the classifier \u2014 PureAdd skips rebuild (high-risk)",
"status": "pending", "status": "completed",
"blockedBy": [ "blockedBy": [
"T1" "T1"
] ]
@@ -7,22 +7,31 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary> /// <summary>
/// Side-effecting orchestrator over <see cref="AddressSpacePlan"/>. Drives an /// Side-effecting orchestrator over <see cref="AddressSpacePlan"/>. Drives an
/// <see cref="IOpcUaAddressSpaceSink"/> to materialise the diff between two /// <see cref="IOpcUaAddressSpaceSink"/> to materialise the diff between two
/// <see cref="AddressSpaceComposition"/> snapshots: /// <see cref="AddressSpaceComposition"/> snapshots, routing each delta class to its MINIMAL mutation
/// (R2-07 03/P1) via <see cref="AddressSpaceChangeClassifier"/> instead of rebuilding the whole
/// address space for any topology change (which would kill every client's server-wide subscriptions):
/// ///
/// <list type="bullet"> /// <list type="bullet">
/// <item>RemovedEquipment / RemovedAlarms — write Bad-quality on every removed /// <item><b>PureAdd</b> — no rebuild. <c>OpcUaPublishActor</c>'s idempotent Materialise passes
/// node id then call <c>RebuildAddressSpace</c> at the end so the sink can /// create exactly the added folders/variables/conditions (no-op'ing existing nodes), then
/// actually tear down the OPC UA folders + variables.</item> /// <c>AnnounceAddedNodes</c> raises a Part 3 <c>NodeAdded</c> per affected parent so
/// <item>AddedEquipment / AddedAlarms — same Rebuild trigger (real SDK NodeManager /// model-aware clients re-browse. Existing MonitoredItems are untouched.</item>
/// will repopulate from the persisted artifact). For now we record the work.</item> /// <item><b>AttributeOnly</b> — no rebuild. Surgical-eligible changed tags + UNS folder renames
/// <item>ChangedEquipment / ChangedAlarms — record what changed; the SDK adapter /// are applied IN PLACE via <see cref="ISurgicalAddressSpaceSink"/> (F10b). Any <c>false</c>
/// that lands in F10b will decide between in-place property writes and /// or throw from a surgical call falls back to a full rebuild (safe default).</item>
/// tear-down + rebuild.</item> /// <item><b>PureRemove</b> — scoped in-place teardown of only the removed subtree (writes a
/// terminal Bad / <c>RemovedConditionState</c> first so in-flight MonitoredItems observe the
/// removal). Maps to a full rebuild until R2-07 Phase 2 ships.</item>
/// <item><b>AddRemoveMix</b> — removes-then-adds within one apply. Maps to a full rebuild until
/// R2-07 Phase 3 ships.</item>
/// <item><b>Rebuild</b> — the default-closed safety valve for any node-affecting change
/// (ChangedEquipment / ChangedAlarms / non-surgical ChangedEquipmentTags / node-relevant
/// ChangedEquipmentVirtualTags), so the worst outcome of any misclassification is today's
/// full-rebuild behaviour.</item>
/// </list> /// </list>
/// ///
/// This is the side-effecting layer deferred to F14. It stays pure-of-SDK so /// Stays pure-of-SDK so production binds a real SDK sink, dev/Mac binds
/// production binds a real SDK sink, dev/Mac binds <see cref="NullOpcUaAddressSpaceSink"/>, /// <see cref="NullOpcUaAddressSpaceSink"/>, and tests can capture every call.
/// and tests can capture every call.
/// </summary> /// </summary>
public sealed class AddressSpaceApplier public sealed class AddressSpaceApplier
{ {
@@ -111,36 +120,26 @@ public sealed class AddressSpaceApplier
plan.AddedEquipmentTags.Count + plan.AddedEquipmentTags.Count +
plan.AddedEquipmentVirtualTags.Count; plan.AddedEquipmentVirtualTags.Count;
// Any add / remove / in-place CHANGE of Equipment, ScriptedAlarm, Equipment tag, or Equipment // R2-07 03/P1 — classify the plan and route each delta class to its MINIMAL mutation instead of
// VirtualTag topology requires a real address-space rebuild — the materialise passes re-derive // rebuilding the world for any topology change. AddressSpaceChangeClassifier is a pure policy over
// every node from the composition, so a changed-only deploy (e.g. a renamed equipment, a // the planner's diff (the planner stays a pure diff). The routing here:
// re-severitied alarm, a flipped tag dataType) must still rebuild or the running server keeps // • PureAdd / AttributeOnly ⇒ NO rebuild. The idempotent Materialise passes in
// the stale node. // OpcUaPublishActor.HandleRebuild create exactly the added nodes (no-op'ing existing ones), so
// ChangedDrivers is deliberately EXCLUDED: a driver-instance config change doesn't touch the // every client subscription server-wide survives; coincident surgical tag deltas + folder
// address-space topology — it routes through DriverHostActor's spawn-plan in Runtime, which // renames are applied IN PLACE via ISurgicalAddressSpaceSink below (any false/throw there falls
// re-spawns the affected driver actor without re-materialising any nodes. // back to a full rebuild — the F10b contract).
// F10b (vtag skip): a CHANGED VirtualTag whose ONLY differences are Expression/DependencyRefs/ // • PureRemove / AddRemoveMix ⇒ full rebuild UNTIL their phases ship (R2-07 T11 / T13); mapping
// Historize is node-IRRELEVANT (see VtagDeltaIsNodeIrrelevant) — its materialised node is // them here keeps each phase independently shippable with today's behaviour as the floor.
// byte-identical and the vtag engine adopts those edits via VirtualTagHostActor's INDEPENDENT // • Rebuild ⇒ the default-closed safety valve for any node-affecting change (ChangedEquipment /
// respawn (DriverHostActor → ApplyVirtualTags), so it skips the rebuild and PRESERVES every // ChangedAlarms / non-surgical ChangedEquipmentTags / node-relevant ChangedEquipmentVirtualTags)
// client's server-wide subscriptions. Any structural / node-affecting vtag change (Name/ // — the classifier's rule 2, which also catches any future plan field that makes a changed
// FolderPath/DataType) — or any non-vtag change anywhere — still forces a full rebuild. // record unequal, so the worst outcome of a misclassification is today's full rebuild.
// F10b (surgical tag write): a CHANGED equipment tag whose ONLY differences are Writable / // ChangedDrivers is node-inert (routes through DriverHostActor's spawn plan) — the classifier leaves
// IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength (a plain value variable — no // a driver-only plan AttributeOnly, so it never rebuilds here.
// alarm condition node) can be updated IN PLACE on the existing node via var kind = AddressSpaceChangeClassifier.Classify(plan);
// ISurgicalAddressSpaceSink.UpdateTagAttributes (see TagDeltaIsSurgicalEligible), avoiding the full var mustRebuild = kind is AddressSpaceChangeKind.Rebuild
// rebuild and preserving subscriptions. The shape fields (DataType/IsArray/ArrayLength) are now or AddressSpaceChangeKind.PureRemove
// surgical because the sink swaps DataType + ValueRank + ArrayDimensions in place and raises a or AddressSpaceChangeKind.AddRemoveMix;
// GeneralModelChangeEvent. Any other tag difference (FullName/Name/DriverInstanceId/identity/alarm) —
// or a sink that lacks the surgical capability, or a node that turns out missing — falls back to a
// full rebuild (safe default).
var structuralRebuild =
plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 || plan.ChangedEquipment.Count > 0 ||
plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 || plan.ChangedAlarms.Count > 0 ||
plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 ||
plan.ChangedEquipmentTags.Any(d => !AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible(d)) ||
plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0 ||
plan.ChangedEquipmentVirtualTags.Any(d => !AddressSpaceChangeClassifier.VtagDeltaIsNodeIrrelevant(d));
var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible).ToList(); var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible).ToList();
// UNS Area / Line renames are surgically applicable (in-place DisplayName swap) // UNS Area / Line renames are surgically applicable (in-place DisplayName swap)
@@ -151,7 +150,7 @@ public sealed class AddressSpaceApplier
var rebuilt = false; var rebuilt = false;
var rebuildFailed = false; var rebuildFailed = false;
if (structuralRebuild) if (mustRebuild)
{ {
rebuildFailed = !SafeRebuild(); rebuildFailed = !SafeRebuild();
rebuilt = true; rebuilt = true;
@@ -203,8 +202,8 @@ public sealed class AddressSpaceApplier
} }
_logger.LogInformation( _logger.LogInformation(
"AddressSpaceApplier: applied plan (added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, renamedFolders={Renamed}, rebuild={Rebuild})", "AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, renamedFolders={Renamed}, rebuild={Rebuild})",
addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count, rebuilt ? 0 : renamedFolders.Count, rebuilt); kind, addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count, rebuilt ? 0 : renamedFolders.Count, rebuilt);
// After the address-space work has completed, auto-provision the historian for the added // After the address-space work has completed, auto-provision the historian for the added
// historized tags. This is fully detached (fire-and-forget) and wrapped so it can NEVER block // historized tags. This is fully detached (fire-and-forget) and wrapped so it can NEVER block
@@ -23,7 +23,7 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
var sink = new ConfigurableThrowingSink { ThrowOnRebuild = true }; var sink = new ConfigurableThrowingSink { ThrowOnRebuild = true };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance); var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var outcome = applier.Apply(AddedEquipmentPlan("new")); var outcome = applier.Apply(RebuildingPlan("eq-1"));
outcome.RebuildCalled.ShouldBeTrue(); outcome.RebuildCalled.ShouldBeTrue();
outcome.RebuildFailed.ShouldBeTrue(); outcome.RebuildFailed.ShouldBeTrue();
@@ -36,7 +36,7 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
var sink = new ConfigurableThrowingSink(); var sink = new ConfigurableThrowingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance); var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var outcome = applier.Apply(AddedEquipmentPlan("new")); var outcome = applier.Apply(RebuildingPlan("eq-1"));
outcome.RebuildCalled.ShouldBeTrue(); outcome.RebuildCalled.ShouldBeTrue();
outcome.RebuildFailed.ShouldBeFalse(); outcome.RebuildFailed.ShouldBeFalse();
@@ -121,10 +121,18 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
EquipmentScriptedAlarms = alarms, EquipmentScriptedAlarms = alarms,
}; };
private static AddressSpacePlan AddedEquipmentPlan(string id) => new( // A rebuild-forcing plan: a node-affecting ChangedEquipment delta classifies as Rebuild (R2-07), so
AddedEquipment: new[] { new EquipmentNode(id, id, "line-1") }, // these tests exercise the SafeRebuild path (RebuildCalled/RebuildFailed) regardless of the pure-add
// routing. A pure-add plan would no longer rebuild, so the failure-surface tests must use a change.
private static AddressSpacePlan RebuildingPlan(string id) => new(
AddedEquipment: Array.Empty<EquipmentNode>(),
RemovedEquipment: Array.Empty<EquipmentNode>(), RemovedEquipment: Array.Empty<EquipmentNode>(),
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(), ChangedEquipment: new[]
{
new AddressSpacePlan.EquipmentDelta(
new EquipmentNode(id, id, "line-1"),
new EquipmentNode(id, id + "-renamed", "line-1")),
},
AddedDrivers: Array.Empty<DriverInstancePlan>(), AddedDrivers: Array.Empty<DriverInstancePlan>(),
RemovedDrivers: Array.Empty<DriverInstancePlan>(), RemovedDrivers: Array.Empty<DriverInstancePlan>(),
ChangedDrivers: Array.Empty<AddressSpacePlan.DriverDelta>(), ChangedDrivers: Array.Empty<AddressSpacePlan.DriverDelta>(),
@@ -64,7 +64,8 @@ public sealed class AddressSpaceApplierProvisioningTests
var outcome = applier.Apply(plan); var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue(); outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no rebuild; provisioning still fires
outcome.AddedNodes.ShouldBeGreaterThan(0);
// Fire-and-forget: await the capturing double's signal so the assertion is deterministic. // Fire-and-forget: await the capturing double's signal so the assertion is deterministic.
await prov.Called.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); await prov.Called.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
@@ -108,7 +109,7 @@ public sealed class AddressSpaceApplierProvisioningTests
var outcome = applier.Apply(PlanWithAddedTags( var outcome = applier.Apply(PlanWithAddedTags(
HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32"))); HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32")));
outcome.RebuildCalled.ShouldBeTrue(); // address-space work still completed outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no rebuild; Apply still completed + provisioned
} }
/// <summary>The default ctor (no provisioner) binds the no-op <see cref="NullHistorianProvisioning"/> /// <summary>The default ctor (no provisioner) binds the no-op <see cref="NullHistorianProvisioning"/>
@@ -121,7 +122,8 @@ public sealed class AddressSpaceApplierProvisioningTests
var outcome = applier.Apply(PlanWithAddedTags( var outcome = applier.Apply(PlanWithAddedTags(
HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32"))); HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32")));
outcome.RebuildCalled.ShouldBeTrue(); outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no rebuild; default no-op provisioning still safe
outcome.AddedNodes.ShouldBeGreaterThan(0);
} }
/// <summary>An added historized tag whose DataType string is not a <see cref="DriverDataType"/> is /// <summary>An added historized tag whose DataType string is not a <see cref="DriverDataType"/> is
@@ -319,7 +321,7 @@ public sealed class AddressSpaceApplierProvisioningTests
var outcome = applier.Apply(PlanWithAddedTags( var outcome = applier.Apply(PlanWithAddedTags(
HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32"))); HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32")));
outcome.RebuildCalled.ShouldBeTrue(); // address-space work still completed outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no rebuild; Apply still completed + provisioned
} }
private static EquipmentTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref") private static EquipmentTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref")
@@ -43,9 +43,11 @@ public sealed class AddressSpaceApplierTests
sink.RebuildCalls.ShouldBe(1); sink.RebuildCalls.ShouldBe(1);
} }
/// <summary>Verifies that added equipment triggers rebuild without writing alarm state.</summary> /// <summary>R2-07 T2 — added equipment is a PureAdd: the applier SKIPS the rebuild (the idempotent
/// Materialise passes create the new folder; existing client subscriptions survive) and writes no alarm
/// state. (Supersedes the pre-R2-07 "added equipment ⇒ rebuild" pin.)</summary>
[Fact] [Fact]
public void Added_equipment_triggers_rebuild_without_alarm_writes() public void Added_equipment_is_pure_add_and_skips_rebuild()
{ {
var sink = new RecordingSink(); var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance); var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
@@ -63,10 +65,10 @@ public sealed class AddressSpaceApplierTests
var outcome = applier.Apply(plan); var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue(); outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no teardown, subscriptions preserved
outcome.AddedNodes.ShouldBe(1); outcome.AddedNodes.ShouldBe(1);
sink.AlarmWrites.ShouldBeEmpty(); sink.AlarmWrites.ShouldBeEmpty();
sink.RebuildCalls.ShouldBe(1); sink.RebuildCalls.ShouldBe(0);
} }
/// <summary>Verifies that driver-only changes do not trigger address space rebuild.</summary> /// <summary>Verifies that driver-only changes do not trigger address space rebuild.</summary>
@@ -675,11 +677,11 @@ public sealed class AddressSpaceApplierTests
sink.ModelChangeCalls.ShouldBeEmpty(); sink.ModelChangeCalls.ShouldBeEmpty();
} }
/// <summary>Verifies that added equipment tags in an otherwise-empty plan trigger an /// <summary>R2-07 T2 — added equipment tags in an otherwise-empty plan are a PureAdd: the applier
/// address-space rebuild (the planner now diffs equipment tags, so a tags-only deploy is no /// SKIPS the rebuild (the idempotent Materialise passes create the new variables; existing subscriptions
/// longer a silent no-op).</summary> /// survive). (Supersedes the pre-R2-07 "added tags ⇒ rebuild" pin.)</summary>
[Fact] [Fact]
public void Added_equipment_tags_trigger_rebuild() public void Added_equipment_tags_are_pure_add_and_skip_rebuild()
{ {
var sink = new RecordingSink(); var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance); var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
@@ -694,16 +696,16 @@ public sealed class AddressSpaceApplierTests
var outcome = applier.Apply(plan); var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue(); outcome.RebuildCalled.ShouldBeFalse();
outcome.AddedNodes.ShouldBe(1); outcome.AddedNodes.ShouldBe(1);
sink.RebuildCalls.ShouldBe(1); sink.RebuildCalls.ShouldBe(0);
} }
/// <summary>Verifies that added Equipment VirtualTags in an otherwise-empty plan trigger an /// <summary>R2-07 T2 — added Equipment VirtualTags in an otherwise-empty plan are a PureAdd: the applier
/// address-space rebuild (parity with the equipment-tag path — the planner now diffs VirtualTags, /// SKIPS the rebuild (parity with the equipment-tag path). (Supersedes the pre-R2-07 "added vtags
/// so a VirtualTag-only deploy is no longer a silent no-op).</summary> /// rebuild" pin.)</summary>
[Fact] [Fact]
public void Added_equipment_virtual_tags_trigger_rebuild() public void Added_equipment_virtual_tags_are_pure_add_and_skip_rebuild()
{ {
var sink = new RecordingSink(); var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance); var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
@@ -719,8 +721,119 @@ public sealed class AddressSpaceApplierTests
var outcome = applier.Apply(plan); var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue(); outcome.RebuildCalled.ShouldBeFalse();
outcome.AddedNodes.ShouldBe(1); outcome.AddedNodes.ShouldBe(1);
sink.RebuildCalls.ShouldBe(0);
}
/// <summary>R2-07 T2 — a PureAdd plan carrying a coincident surgical tag delta (only Writable flips on
/// an existing tag) SKIPS the rebuild AND applies the in-place tag update via the surgical sink. The
/// classifier routes adds+surgical-change to PureAdd (rule 2 clears because the change is
/// surgical-eligible); the applier still runs the F10b surgical pass.</summary>
[Fact]
public void Pure_add_with_coincident_surgical_tag_delta_skips_rebuild_and_updates_in_place()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
},
ChangedEquipmentTags = new[]
{
new AddressSpacePlan.EquipmentTagDelta(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
call.Writable.ShouldBeTrue();
}
/// <summary>R2-07 T2 — during a PureAdd apply, if a coincident surgical tag update reports the node
/// missing (returns false), the applier falls back to a full rebuild (the F10b safety valve is
/// preserved through the classifier routing).</summary>
[Fact]
public void Pure_add_with_surgical_returns_false_falls_back_to_rebuild()
{
var sink = new RecordingSink { SurgicalReturns = false };
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
},
ChangedEquipmentTags = new[]
{
new AddressSpacePlan.EquipmentTagDelta(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue(); // fell back to a full rebuild
sink.RebuildCalls.ShouldBe(1);
sink.SurgicalCalls.ShouldHaveSingleItem(); // the surgical update was attempted first
}
/// <summary>R2-07 T2 phase pin — a remove-only plan still triggers a full rebuild until Phase 2
/// (PureRemove) ships. Flipped in T11.</summary>
[Fact]
public void Removed_equipment_tags_only_still_rebuild_phase_pin()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
RemovedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>R2-07 T2 phase pin — an add+remove plan still triggers a full rebuild until Phase 3
/// (AddRemoveMix) ships. Flipped in T13.</summary>
[Fact]
public void Add_and_remove_still_rebuild_phase_pin()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var plan = EmptyPlan with
{
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
},
RemovedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Old", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1); sink.RebuildCalls.ShouldBe(1);
} }
@@ -1496,11 +1609,12 @@ public sealed class AddressSpaceApplierTests
outcome.ChangedNodes.ShouldBe(2); outcome.ChangedNodes.ShouldBe(2);
} }
/// <summary>F10b — a surgical-eligible tag delta MIXED with another change (here an added equipment) /// <summary>R2-07 T2 — a surgical-eligible tag delta MIXED with an added equipment is now a PureAdd:
/// must still rebuild: the rebuild is forced by the OTHER change. The surgical path is taken ONLY when /// the added equipment is created by the Materialise passes and the surgical tag change is applied IN
/// the tag deltas are the sole change. No surgical call is made (the rebuild materialises everything).</summary> /// PLACE, so NO rebuild fires and exactly one surgical call lands. (Supersedes the pre-R2-07
/// "surgical-mixed-with-add ⇒ rebuild" pin — an add no longer forces a rebuild.)</summary>
[Fact] [Fact]
public void Surgical_eligible_tag_delta_mixed_with_added_equipment_rebuilds() public void Surgical_eligible_tag_delta_mixed_with_added_equipment_is_pure_add_and_skips_rebuild()
{ {
var sink = new RecordingSink(); var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance); var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
@@ -1529,9 +1643,9 @@ public sealed class AddressSpaceApplierTests
var outcome = applier.Apply(plan); var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue(); outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(1); sink.RebuildCalls.ShouldBe(0);
sink.SurgicalCalls.ShouldBeEmpty(); sink.SurgicalCalls.ShouldHaveSingleItem().Writable.ShouldBeTrue();
} }
/// <summary>F10b fallback — a sink that does NOT implement <see cref="ISurgicalAddressSpaceSink"/> cannot /// <summary>F10b fallback — a sink that does NOT implement <see cref="ISurgicalAddressSpaceSink"/> cannot
@@ -1632,11 +1746,12 @@ public sealed class AddressSpaceApplierTests
call.DisplayName.ShouldBe("Cell B"); call.DisplayName.ShouldBe("Cell B");
} }
/// <summary>OpcUaServer-001 — a folder rename MIXED with a structural change (here an added equipment) /// <summary>R2-07 T2 — a folder rename MIXED with an added equipment is now a PureAdd: the added
/// must rebuild: the rebuild + MaterialiseHierarchy re-create every folder with the new names, so no /// equipment is created by the Materialise passes and the rename is applied IN PLACE via the surgical
/// separate surgical folder call is made. The rename is covered by the rebuild for free.</summary> /// sink, so NO rebuild fires and exactly one folder-rename call lands. (Supersedes the pre-R2-07
/// "rename-mixed-with-add ⇒ rebuild" pin.)</summary>
[Fact] [Fact]
public void Folder_rename_mixed_with_added_equipment_rebuilds() public void Folder_rename_mixed_with_added_equipment_is_pure_add_and_skips_rebuild()
{ {
var sink = new RecordingSink(); var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance); var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
@@ -1647,7 +1762,8 @@ public sealed class AddressSpaceApplierTests
EquipmentNodes: Array.Empty<EquipmentNode>(), EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(), DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>()); ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
// Area renamed AND a brand-new equipment node — the structural add forces a rebuild. // Area renamed AND a brand-new equipment node — an add no longer forces a rebuild; the rename
// rides the in-place surgical path alongside the PureAdd.
var next = new AddressSpaceComposition( var next = new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-1", "South") }, UnsAreas: new[] { new UnsAreaProjection("area-1", "South") },
UnsLines: Array.Empty<UnsLineProjection>(), UnsLines: Array.Empty<UnsLineProjection>(),
@@ -1661,9 +1777,9 @@ public sealed class AddressSpaceApplierTests
var outcome = applier.Apply(plan); var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue(); outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(1); sink.RebuildCalls.ShouldBe(0);
sink.FolderRenameCalls.ShouldBeEmpty(); // no surgical folder call — rebuild covers it sink.FolderRenameCalls.ShouldHaveSingleItem().ShouldBe(("area-1", "South"));
} }
/// <summary>OpcUaServer-001 fallback — a rename-only plan on a sink that does NOT implement /// <summary>OpcUaServer-001 fallback — a rename-only plan on a sink that does NOT implement