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
@@ -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>(),
@@ -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