feat(opcua): remove SystemPlatform-mirror GalaxyTags contract end-to-end (composer+applier+artifact, byte-parity)

This commit is contained in:
Joseph Doherty
2026-06-12 21:45:19 -04:00
parent 5dfb797817
commit 95be607a07
13 changed files with 167 additions and 431 deletions
@@ -36,8 +36,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") },
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") },
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
GalaxyTags: Array.Empty<GalaxyTagPlan>());
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
applier.MaterialiseHierarchy(composition);
@@ -60,8 +59,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: new[] { new EquipmentNode("eq-orphan", "Orphan", UnsLineId: "") },
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
GalaxyTags: Array.Empty<GalaxyTagPlan>());
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
applier.MaterialiseHierarchy(composition);
@@ -95,8 +93,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") },
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") },
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
GalaxyTags: Array.Empty<GalaxyTagPlan>()));
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>()));
sdkServer.NodeManager!.FolderCount.ShouldBe(5); // 2 areas + 1 line + 2 equipment
@@ -106,8 +103,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") },
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") },
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
GalaxyTags: Array.Empty<GalaxyTagPlan>()));
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>()));
sdkServer.NodeManager!.FolderCount.ShouldBe(5);
}
@@ -143,8 +139,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: new[] { new EquipmentNode("eq-1", "filling-eq", UnsLineId: "") },
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
GalaxyTags: Array.Empty<GalaxyTagPlan>())
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
@@ -201,7 +196,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
new[] { area }, new[] { line }, new[] { equipment }, new[] { driver },
Array.Empty<ScriptedAlarm>(), new[] { tag }, new[] { ns });
// Compose-side EquipmentTags extraction (the inverse of the Galaxy filter).
// Compose-side EquipmentTags extraction.
var planned = composition.EquipmentTags.ShouldHaveSingleItem();
planned.EquipmentId.ShouldBe("eq-1");
planned.FullName.ShouldBe("40001");
@@ -59,10 +59,7 @@ public sealed class Phase7ApplierTests
ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(),
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>(),
AddedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
RemovedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
ChangedGalaxyTags: Array.Empty<Phase7Plan.GalaxyTagDelta>());
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>());
var outcome = applier.Apply(plan);
@@ -93,10 +90,7 @@ public sealed class Phase7ApplierTests
},
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>(),
AddedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
RemovedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
ChangedGalaxyTags: Array.Empty<Phase7Plan.GalaxyTagDelta>());
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>());
var outcome = applier.Apply(plan);
@@ -118,66 +112,6 @@ public sealed class Phase7ApplierTests
outcome.RebuildCalled.ShouldBeTrue();
}
/// <summary>Verifies MaterialiseGalaxyTags creates one folder per distinct FolderPath and one
/// variable per tag, with root-level tags hung directly under the namespace root.</summary>
[Fact]
public void MaterialiseGalaxyTags_creates_folder_per_distinct_path_and_variable_per_tag()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var composition = new Phase7CompositionResult(
UnsAreas: Array.Empty<UnsAreaProjection>(),
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
GalaxyTags: new[]
{
new GalaxyTagPlan("t1", "drv", "section.area", "Temperature", "Float", "section.area.Temperature"),
new GalaxyTagPlan("t2", "drv", "", "Pressure", "Int32", "Pressure"),
});
applier.MaterialiseGalaxyTags(composition);
// One folder for the single distinct non-empty FolderPath; the root-level tag adds none.
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("section.area", null, "section.area"));
// Foldered tag → NodeId is its MxAccessRef under the FolderPath parent.
// Root-level tag → NodeId is its DisplayName under the root (null parent).
sink.VariableCalls.ShouldContain(("section.area.Temperature", "section.area", "Temperature", "Float"));
sink.VariableCalls.ShouldContain(("Pressure", (string?)null, "Pressure", "Int32"));
sink.VariableCalls.Count.ShouldBe(2);
}
/// <summary>Verifies that two tags sharing a FolderPath produce a single EnsureFolder call
/// (deduped) but one EnsureVariable per tag.</summary>
[Fact]
public void MaterialiseGalaxyTags_dedupes_folders_for_tags_sharing_a_path()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var composition = new Phase7CompositionResult(
UnsAreas: Array.Empty<UnsAreaProjection>(),
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
GalaxyTags: new[]
{
new GalaxyTagPlan("t1", "drv", "line.cell", "Speed", "Float", "line.cell.Speed"),
new GalaxyTagPlan("t2", "drv", "line.cell", "Torque", "Float", "line.cell.Torque"),
});
applier.MaterialiseGalaxyTags(composition);
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("line.cell", null, "line.cell"));
sink.VariableCalls.Count.ShouldBe(2);
sink.VariableCalls.ShouldContain(("line.cell.Speed", "line.cell", "Speed", "Float"));
sink.VariableCalls.ShouldContain(("line.cell.Torque", "line.cell", "Torque", "Float"));
}
/// <summary>Verifies MaterialiseEquipmentTags creates one Variable per equipment tag directly
/// under its existing equipment folder, with a folder-scoped NodeId (parent/Name — NOT the raw
/// FullName), parent == EquipmentId, displayName == Name, and does NOT re-create the equipment
@@ -193,8 +127,7 @@ public sealed class Phase7ApplierTests
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
GalaxyTags: Array.Empty<GalaxyTagPlan>())
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
@@ -345,8 +278,8 @@ public sealed class Phase7ApplierTests
}
/// <summary>Verifies that added equipment tags in an otherwise-empty plan trigger an
/// address-space rebuild (parity with the Galaxy-tag path — the planner now diffs equipment
/// tags, so a tags-only deploy is no longer a silent no-op).</summary>
/// address-space rebuild (the planner now diffs equipment tags, so a tags-only deploy is no
/// longer a silent no-op).</summary>
[Fact]
public void Added_equipment_tags_trigger_rebuild()
{
@@ -393,42 +326,10 @@ public sealed class Phase7ApplierTests
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>Verifies that added Galaxy tags in an otherwise-empty plan trigger an address-space rebuild.</summary>
[Fact]
public void Added_galaxy_tags_trigger_rebuild()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var plan = new Phase7Plan(
AddedEquipment: Array.Empty<EquipmentNode>(),
RemovedEquipment: Array.Empty<EquipmentNode>(),
ChangedEquipment: Array.Empty<Phase7Plan.EquipmentDelta>(),
AddedDrivers: Array.Empty<DriverInstancePlan>(),
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(),
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>(),
AddedGalaxyTags: new[]
{
new GalaxyTagPlan("t1", "drv", "section.area", "Temperature", "Float", "section.area.Temperature"),
},
RemovedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
ChangedGalaxyTags: Array.Empty<Phase7Plan.GalaxyTagDelta>());
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
outcome.AddedNodes.ShouldBe(1);
sink.RebuildCalls.ShouldBe(1);
}
private static Phase7Plan EmptyPlan => new(
Array.Empty<EquipmentNode>(), Array.Empty<EquipmentNode>(), Array.Empty<Phase7Plan.EquipmentDelta>(),
Array.Empty<DriverInstancePlan>(), Array.Empty<DriverInstancePlan>(), Array.Empty<Phase7Plan.DriverDelta>(),
Array.Empty<ScriptedAlarmPlan>(), Array.Empty<ScriptedAlarmPlan>(), Array.Empty<Phase7Plan.AlarmDelta>(),
Array.Empty<GalaxyTagPlan>(), Array.Empty<GalaxyTagPlan>(), Array.Empty<Phase7Plan.GalaxyTagDelta>());
Array.Empty<ScriptedAlarmPlan>(), Array.Empty<ScriptedAlarmPlan>(), Array.Empty<Phase7Plan.AlarmDelta>());
private static Phase7Plan WithEquipmentRemoval(params string[] ids) => new(
AddedEquipment: Array.Empty<EquipmentNode>(),
@@ -439,10 +340,7 @@ public sealed class Phase7ApplierTests
ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(),
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>(),
AddedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
RemovedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
ChangedGalaxyTags: Array.Empty<Phase7Plan.GalaxyTagDelta>());
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>());
private sealed class RecordingSink : IOpcUaAddressSpaceSink
{
@@ -11,16 +11,15 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// Equipment-kind driver. An equipment-scoped <see cref="Tag"/> (non-null
/// <see cref="Tag.EquipmentId"/>) bound to a <c>GalaxyMxGateway</c> driver living in an
/// <c>Equipment</c>-kind namespace must surface under
/// <see cref="Phase7CompositionResult.EquipmentTags"/> (carrying its driver-side FullName), and
/// the retired SystemPlatform-mirror producer means <see cref="Phase7CompositionResult.GalaxyTags"/>
/// is always empty.
/// <see cref="Phase7CompositionResult.EquipmentTags"/> (carrying its driver-side FullName). The
/// SystemPlatform-mirror <c>GalaxyTags</c> contract is retired entirely.
/// </summary>
public sealed class Phase7ComposerAliasTagTests
{
/// <summary>A <c>GalaxyMxGateway</c> driver in an Equipment-kind namespace carries an
/// equipment-scoped Galaxy tag (EquipmentId set, FolderPath null, TagConfig FullName = the Galaxy
/// ref). Compose must put it in EquipmentTags with its FullName, and GalaxyTags must be empty
/// (the SystemPlatform mirror producer is gone).</summary>
/// ref). Compose must put it in EquipmentTags with its FullName, coalescing the null FolderPath to
/// <c>string.Empty</c> (the SystemPlatform mirror producer is gone entirely).</summary>
[Fact]
public void Compose_admits_galaxy_equipment_tag_in_equipment_tags()
{
@@ -77,8 +76,8 @@ public sealed class Phase7ComposerAliasTagTests
tag.Name.ShouldBe("TestChangingInt");
tag.DataType.ShouldBe("Int32");
tag.FullName.ShouldBe("TestMachine_020.TestChangingInt");
// The SystemPlatform-mirror producer is retired → GalaxyTags is always empty.
result.GalaxyTags.ShouldBeEmpty();
// The input Tag.FolderPath is null; the composer coalesces it to string.Empty (the explicit
// byte-parity null-coalesce the artifact-decode side mirrors).
tag.FolderPath.ShouldBe(string.Empty);
}
}