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",
|
||||
"subject": "Phase 1: route AddressSpaceApplier.Apply through the classifier \u2014 PureAdd skips rebuild (high-risk)",
|
||||
"status": "pending",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T1"
|
||||
]
|
||||
|
||||
@@ -7,22 +7,31 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
/// <summary>
|
||||
/// Side-effecting orchestrator over <see cref="AddressSpacePlan"/>. Drives an
|
||||
/// <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">
|
||||
/// <item>RemovedEquipment / RemovedAlarms — write Bad-quality on every removed
|
||||
/// node id then call <c>RebuildAddressSpace</c> at the end so the sink can
|
||||
/// actually tear down the OPC UA folders + variables.</item>
|
||||
/// <item>AddedEquipment / AddedAlarms — same Rebuild trigger (real SDK NodeManager
|
||||
/// will repopulate from the persisted artifact). For now we record the work.</item>
|
||||
/// <item>ChangedEquipment / ChangedAlarms — record what changed; the SDK adapter
|
||||
/// that lands in F10b will decide between in-place property writes and
|
||||
/// tear-down + rebuild.</item>
|
||||
/// <item><b>PureAdd</b> — no rebuild. <c>OpcUaPublishActor</c>'s idempotent Materialise passes
|
||||
/// create exactly the added folders/variables/conditions (no-op'ing existing nodes), then
|
||||
/// <c>AnnounceAddedNodes</c> raises a Part 3 <c>NodeAdded</c> per affected parent so
|
||||
/// model-aware clients re-browse. Existing MonitoredItems are untouched.</item>
|
||||
/// <item><b>AttributeOnly</b> — no rebuild. Surgical-eligible changed tags + UNS folder renames
|
||||
/// are applied IN PLACE via <see cref="ISurgicalAddressSpaceSink"/> (F10b). Any <c>false</c>
|
||||
/// or throw from a surgical call falls back to a full rebuild (safe default).</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>
|
||||
///
|
||||
/// This is the side-effecting layer deferred to F14. It stays pure-of-SDK so
|
||||
/// production binds a real SDK sink, dev/Mac binds <see cref="NullOpcUaAddressSpaceSink"/>,
|
||||
/// and tests can capture every call.
|
||||
/// Stays pure-of-SDK so production binds a real SDK sink, dev/Mac binds
|
||||
/// <see cref="NullOpcUaAddressSpaceSink"/>, and tests can capture every call.
|
||||
/// </summary>
|
||||
public sealed class AddressSpaceApplier
|
||||
{
|
||||
@@ -111,36 +120,26 @@ public sealed class AddressSpaceApplier
|
||||
plan.AddedEquipmentTags.Count +
|
||||
plan.AddedEquipmentVirtualTags.Count;
|
||||
|
||||
// Any add / remove / in-place CHANGE of Equipment, ScriptedAlarm, Equipment tag, or Equipment
|
||||
// VirtualTag topology requires a real address-space rebuild — the materialise passes re-derive
|
||||
// every node from the composition, so a changed-only deploy (e.g. a renamed equipment, a
|
||||
// re-severitied alarm, a flipped tag dataType) must still rebuild or the running server keeps
|
||||
// the stale node.
|
||||
// ChangedDrivers is deliberately EXCLUDED: a driver-instance config change doesn't touch the
|
||||
// address-space topology — it routes through DriverHostActor's spawn-plan in Runtime, which
|
||||
// re-spawns the affected driver actor without re-materialising any nodes.
|
||||
// F10b (vtag skip): a CHANGED VirtualTag whose ONLY differences are Expression/DependencyRefs/
|
||||
// Historize is node-IRRELEVANT (see VtagDeltaIsNodeIrrelevant) — its materialised node is
|
||||
// byte-identical and the vtag engine adopts those edits via VirtualTagHostActor's INDEPENDENT
|
||||
// respawn (DriverHostActor → ApplyVirtualTags), so it skips the rebuild and PRESERVES every
|
||||
// client's server-wide subscriptions. Any structural / node-affecting vtag change (Name/
|
||||
// FolderPath/DataType) — or any non-vtag change anywhere — still forces a full rebuild.
|
||||
// F10b (surgical tag write): a CHANGED equipment tag whose ONLY differences are Writable /
|
||||
// IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength (a plain value variable — no
|
||||
// alarm condition node) can be updated IN PLACE on the existing node via
|
||||
// ISurgicalAddressSpaceSink.UpdateTagAttributes (see TagDeltaIsSurgicalEligible), avoiding the full
|
||||
// rebuild and preserving subscriptions. The shape fields (DataType/IsArray/ArrayLength) are now
|
||||
// surgical because the sink swaps DataType + ValueRank + ArrayDimensions in place and raises a
|
||||
// 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));
|
||||
// R2-07 03/P1 — classify the plan and route each delta class to its MINIMAL mutation instead of
|
||||
// rebuilding the world for any topology change. AddressSpaceChangeClassifier is a pure policy over
|
||||
// the planner's diff (the planner stays a pure diff). The routing here:
|
||||
// • PureAdd / AttributeOnly ⇒ NO rebuild. The idempotent Materialise passes in
|
||||
// OpcUaPublishActor.HandleRebuild create exactly the added nodes (no-op'ing existing ones), so
|
||||
// every client subscription server-wide survives; coincident surgical tag deltas + folder
|
||||
// renames are applied IN PLACE via ISurgicalAddressSpaceSink below (any false/throw there falls
|
||||
// back to a full rebuild — the F10b contract).
|
||||
// • PureRemove / AddRemoveMix ⇒ full rebuild UNTIL their phases ship (R2-07 T11 / T13); mapping
|
||||
// them here keeps each phase independently shippable with today's behaviour as the floor.
|
||||
// • Rebuild ⇒ the default-closed safety valve for any node-affecting change (ChangedEquipment /
|
||||
// ChangedAlarms / non-surgical ChangedEquipmentTags / node-relevant ChangedEquipmentVirtualTags)
|
||||
// — the classifier's rule 2, which also catches any future plan field that makes a changed
|
||||
// record unequal, so the worst outcome of a misclassification is today's full rebuild.
|
||||
// ChangedDrivers is node-inert (routes through DriverHostActor's spawn plan) — the classifier leaves
|
||||
// a driver-only plan AttributeOnly, so it never rebuilds here.
|
||||
var kind = AddressSpaceChangeClassifier.Classify(plan);
|
||||
var mustRebuild = kind is AddressSpaceChangeKind.Rebuild
|
||||
or AddressSpaceChangeKind.PureRemove
|
||||
or AddressSpaceChangeKind.AddRemoveMix;
|
||||
|
||||
var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible).ToList();
|
||||
// UNS Area / Line renames are surgically applicable (in-place DisplayName swap)
|
||||
@@ -151,7 +150,7 @@ public sealed class AddressSpaceApplier
|
||||
var rebuilt = false;
|
||||
var rebuildFailed = false;
|
||||
|
||||
if (structuralRebuild)
|
||||
if (mustRebuild)
|
||||
{
|
||||
rebuildFailed = !SafeRebuild();
|
||||
rebuilt = true;
|
||||
@@ -203,8 +202,8 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"AddressSpaceApplier: applied plan (added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, renamedFolders={Renamed}, rebuild={Rebuild})",
|
||||
addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count, rebuilt ? 0 : renamedFolders.Count, rebuilt);
|
||||
"AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, renamedFolders={Renamed}, rebuild={Rebuild})",
|
||||
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
|
||||
// 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 applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var outcome = applier.Apply(AddedEquipmentPlan("new"));
|
||||
var outcome = applier.Apply(RebuildingPlan("eq-1"));
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
outcome.RebuildFailed.ShouldBeTrue();
|
||||
@@ -36,7 +36,7 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
|
||||
var sink = new ConfigurableThrowingSink();
|
||||
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.RebuildFailed.ShouldBeFalse();
|
||||
@@ -121,10 +121,18 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
|
||||
EquipmentScriptedAlarms = alarms,
|
||||
};
|
||||
|
||||
private static AddressSpacePlan AddedEquipmentPlan(string id) => new(
|
||||
AddedEquipment: new[] { new EquipmentNode(id, id, "line-1") },
|
||||
// A rebuild-forcing plan: a node-affecting ChangedEquipment delta classifies as Rebuild (R2-07), so
|
||||
// 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>(),
|
||||
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>(),
|
||||
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||
ChangedDrivers: Array.Empty<AddressSpacePlan.DriverDelta>(),
|
||||
|
||||
+6
-4
@@ -64,7 +64,8 @@ public sealed class AddressSpaceApplierProvisioningTests
|
||||
|
||||
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.
|
||||
await prov.Called.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
@@ -108,7 +109,7 @@ public sealed class AddressSpaceApplierProvisioningTests
|
||||
var outcome = applier.Apply(PlanWithAddedTags(
|
||||
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"/>
|
||||
@@ -121,7 +122,8 @@ public sealed class AddressSpaceApplierProvisioningTests
|
||||
var outcome = applier.Apply(PlanWithAddedTags(
|
||||
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
|
||||
@@ -319,7 +321,7 @@ public sealed class AddressSpaceApplierProvisioningTests
|
||||
var outcome = applier.Apply(PlanWithAddedTags(
|
||||
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")
|
||||
|
||||
@@ -43,9 +43,11 @@ public sealed class AddressSpaceApplierTests
|
||||
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]
|
||||
public void Added_equipment_triggers_rebuild_without_alarm_writes()
|
||||
public void Added_equipment_is_pure_add_and_skips_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
@@ -63,10 +65,10 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no teardown, subscriptions preserved
|
||||
outcome.AddedNodes.ShouldBe(1);
|
||||
sink.AlarmWrites.ShouldBeEmpty();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that driver-only changes do not trigger address space rebuild.</summary>
|
||||
@@ -675,11 +677,11 @@ public sealed class AddressSpaceApplierTests
|
||||
sink.ModelChangeCalls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that added equipment tags in an otherwise-empty plan trigger an
|
||||
/// address-space rebuild (the planner now diffs equipment tags, so a tags-only deploy is no
|
||||
/// longer a silent no-op).</summary>
|
||||
/// <summary>R2-07 T2 — added equipment tags in an otherwise-empty plan are a PureAdd: the applier
|
||||
/// SKIPS the rebuild (the idempotent Materialise passes create the new variables; existing subscriptions
|
||||
/// survive). (Supersedes the pre-R2-07 "added tags ⇒ rebuild" pin.)</summary>
|
||||
[Fact]
|
||||
public void Added_equipment_tags_trigger_rebuild()
|
||||
public void Added_equipment_tags_are_pure_add_and_skip_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
@@ -694,16 +696,16 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
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
|
||||
/// address-space rebuild (parity with the equipment-tag path — the planner now diffs VirtualTags,
|
||||
/// so a VirtualTag-only deploy is no longer a silent no-op).</summary>
|
||||
/// <summary>R2-07 T2 — added Equipment VirtualTags in an otherwise-empty plan are a PureAdd: the applier
|
||||
/// SKIPS the rebuild (parity with the equipment-tag path). (Supersedes the pre-R2-07 "added vtags ⇒
|
||||
/// rebuild" pin.)</summary>
|
||||
[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 applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
@@ -719,8 +721,119 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1496,11 +1609,12 @@ public sealed class AddressSpaceApplierTests
|
||||
outcome.ChangedNodes.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>F10b — a surgical-eligible tag delta MIXED with another change (here an added equipment)
|
||||
/// must still rebuild: the rebuild is forced by the OTHER change. The surgical path is taken ONLY when
|
||||
/// the tag deltas are the sole change. No surgical call is made (the rebuild materialises everything).</summary>
|
||||
/// <summary>R2-07 T2 — a surgical-eligible tag delta MIXED with an added equipment is now a PureAdd:
|
||||
/// the added equipment is created by the Materialise passes and the surgical tag change is applied IN
|
||||
/// 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]
|
||||
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 applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
@@ -1529,9 +1643,9 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
sink.SurgicalCalls.ShouldBeEmpty();
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
sink.SurgicalCalls.ShouldHaveSingleItem().Writable.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <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");
|
||||
}
|
||||
|
||||
/// <summary>OpcUaServer-001 — a folder rename MIXED with a structural change (here an added equipment)
|
||||
/// must rebuild: the rebuild + MaterialiseHierarchy re-create every folder with the new names, so no
|
||||
/// separate surgical folder call is made. The rename is covered by the rebuild for free.</summary>
|
||||
/// <summary>R2-07 T2 — a folder rename MIXED with an added equipment is now a PureAdd: the added
|
||||
/// equipment is created by the Materialise passes and the rename is applied IN PLACE via the surgical
|
||||
/// 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]
|
||||
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 applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
@@ -1647,7 +1762,8 @@ public sealed class AddressSpaceApplierTests
|
||||
EquipmentNodes: Array.Empty<EquipmentNode>(),
|
||||
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||
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(
|
||||
UnsAreas: new[] { new UnsAreaProjection("area-1", "South") },
|
||||
UnsLines: Array.Empty<UnsLineProjection>(),
|
||||
@@ -1661,9 +1777,9 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
sink.FolderRenameCalls.ShouldBeEmpty(); // no surgical folder call — rebuild covers it
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
sink.FolderRenameCalls.ShouldHaveSingleItem().ShouldBe(("area-1", "South"));
|
||||
}
|
||||
|
||||
/// <summary>OpcUaServer-001 fallback — a rename-only plan on a sink that does NOT implement
|
||||
|
||||
Reference in New Issue
Block a user