feat(opcua): route AddressSpaceApplier through the classifier — pure-add deploys no longer rebuild (R2-07 T2)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+13
-5
@@ -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>(),
|
||||||
|
|||||||
+6
-4
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user