feat(opcua): route AddressSpaceApplier through the classifier — pure-add deploys no longer rebuild (R2-07 T2)
This commit is contained in:
+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