perf(opcuaserver): non-rebuild applies materialise the delta, not the composition

This commit is contained in:
Joseph Doherty
2026-08-15 17:26:53 -04:00
parent 14c31941c7
commit bfbdce32e4
4 changed files with 626 additions and 65 deletions
@@ -345,6 +345,100 @@ public sealed class AddressSpaceApplierRawUnsTests
sink.Variables.ShouldHaveSingleItem().HistorianTagname.ShouldBe(expected);
}
/// <summary>
/// 2026-08-15 surgical-deploys (Task 6) — the subset overload materialises ONLY the tags/containers it
/// is handed, while still resolving native-alarm notifier targets from the FULL composition's equipment
/// map. Both halves matter: narrowing the iteration is the whole point (a one-tag deploy used to re-walk
/// every node on the server), and the map has to stay full because the equipment a raw alarm fans to is
/// not part of any raw-tag delta — building it from the subset would silently drop the fan-out.
/// </summary>
[Fact]
public void MaterialiseRawSubtree_with_a_subset_ensures_only_the_subset_but_resolves_notifiers_from_the_full_composition()
{
var sink = new RealmRecordingSink();
var untouched = new RawTagPlan("t0", "Plant/A/dev/speed", "Plant/A/dev", "drv", "speed", "Float",
Writable: true, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>());
var added = new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean",
Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700),
ReferencingEquipmentPaths: new[] { "filling/line1/station1" });
var composition = new AddressSpaceComposition(
new[] { new UnsAreaProjection("a1", "filling") },
new[] { new UnsLineProjection("l1", "a1", "line1") },
new[] { new EquipmentNode("EQ-1", "station1", "l1") },
Array.Empty<DriverInstancePlan>(),
Array.Empty<ScriptedAlarmPlan>())
{
RawContainers = new[]
{
new RawContainerNode("Plant", null, "Plant", RawNodeKind.Folder),
new RawContainerNode("Plant/A", "Plant", "A", RawNodeKind.Driver),
new RawContainerNode("Plant/A/dev", "Plant/A", "dev", RawNodeKind.Device),
},
RawTags = new[] { untouched, added },
};
// The delta of a one-tag add: no new containers, exactly one new tag.
var failed = NewApplier(sink).MaterialiseRawSubtree(
composition,
onlyContainers: Array.Empty<RawContainerNode>(),
onlyTags: new[] { added });
failed.ShouldBe(0);
sink.Folders.ShouldBeEmpty(); // the three existing containers were NOT re-ensured
sink.Variables.ShouldBeEmpty(); // the untouched value tag was NOT re-ensured
sink.Conditions.ShouldHaveSingleItem().NodeId.ShouldBe("Plant/A/dev/OverTemp");
// …and the notifier still resolved through the FULL composition's Area/Line/Equipment map.
sink.NotifierWirings.ShouldHaveSingleItem().FolderNodeIds.ShouldBe(new[] { "EQ-1" });
}
/// <summary>A null subset (the rebuild path) still means "the whole composition" — the overload is additive
/// and every pre-existing call site keeps its behaviour.</summary>
[Fact]
public void MaterialiseRawSubtree_with_no_subset_still_materialises_the_whole_composition()
{
var sink = new RealmRecordingSink();
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
RawContainers = new[] { new RawContainerNode("Plant", null, "Plant", RawNodeKind.Folder) },
RawTags = new[]
{
new RawTagPlan("t0", "Plant/a", "Plant", "drv", "a", "Float", Writable: true, Alarm: null,
ReferencingEquipmentPaths: Array.Empty<string>()),
new RawTagPlan("t1", "Plant/b", "Plant", "drv", "b", "Float", Writable: true, Alarm: null,
ReferencingEquipmentPaths: Array.Empty<string>()),
},
};
NewApplier(sink).MaterialiseRawSubtree(composition);
sink.Folders.Select(f => f.NodeId).ShouldBe(new[] { "Plant" });
sink.Variables.Select(v => v.NodeId).ShouldBe(new[] { "Plant/a", "Plant/b" });
}
/// <summary>2026-08-15 surgical-deploys (Task 6) — the UNS-reference subset overload materialises only the
/// references it is handed (with their Organizes edges), leaving every other reference in the composition
/// untouched.</summary>
[Fact]
public void MaterialiseUnsReferences_with_a_subset_ensures_only_the_subset()
{
var sink = new RealmRecordingSink();
var untouched = new UnsReferenceVariable("r0", "EQ-1", "filling/line1/station1/speed", "speed",
"Plant/A/dev/speed", "Float", Writable: true);
var added = new UnsReferenceVariable("r1", "EQ-1", "filling/line1/station1/temp", "temp",
"Plant/A/dev/temp", "Float", Writable: false);
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
UnsReferenceVariables = new[] { untouched, added },
};
NewApplier(sink).MaterialiseUnsReferences(composition, only: new[] { added });
sink.Variables.ShouldHaveSingleItem().NodeId.ShouldBe("filling/line1/station1/temp");
sink.References.ShouldHaveSingleItem().Target.ShouldBe("Plant/A/dev/temp");
}
/// <summary>A capturing sink that records (nodeId, realm) for every materialise + the Organizes edges.</summary>
private sealed class RealmRecordingSink : IOpcUaAddressSpaceSink
{