feat(opcua): remove SystemPlatform-mirror GalaxyTags contract end-to-end (composer+applier+artifact, byte-parity)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+56
-53
@@ -7,19 +7,65 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the artifact-decode mirror (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>)
|
||||
/// admits a Galaxy alias tag — an equipment-scoped tag (non-null <c>EquipmentId</c>) bound to a
|
||||
/// <c>GalaxyMxGateway</c> driver in a <c>SystemPlatform</c>-kind namespace — into the decoded
|
||||
/// <c>EquipmentTags</c> with byte-parity to the live-edit composer path: same FullName, EquipmentId,
|
||||
/// DriverInstanceId, Name, DataType. The composer broadens the same filter by DriverType, so both
|
||||
/// data-contract sites must agree on which tags qualify.
|
||||
/// treats a Galaxy point as an ordinary equipment tag — an equipment-scoped tag (non-null
|
||||
/// <c>EquipmentId</c>) bound to a <c>GalaxyMxGateway</c> driver in an <c>Equipment</c>-kind namespace —
|
||||
/// into the decoded <c>EquipmentTags</c> with byte-parity to the live-edit composer path: same FullName,
|
||||
/// EquipmentId, DriverInstanceId, Name, DataType. Both data-contract sites gate purely on the namespace
|
||||
/// Kind being <c>Equipment</c> (no Galaxy/DriverType exception — the SystemPlatform-mirror contract is
|
||||
/// retired), so they agree on which tags qualify.
|
||||
/// </summary>
|
||||
public sealed class DeploymentArtifactAliasParityTests
|
||||
{
|
||||
/// <summary>An artifact JSON blob with a GalaxyMxGateway driver in a SystemPlatform (Kind=1)
|
||||
/// namespace and one equipment-scoped alias tag (EquipmentId set, FolderPath null, FullName = the
|
||||
/// Galaxy ref). Decode must surface the alias in EquipmentTags carrying its driver-side FullName.</summary>
|
||||
/// <summary>An artifact JSON blob with a GalaxyMxGateway driver in an Equipment (Kind=0) namespace and
|
||||
/// one equipment-scoped tag (EquipmentId set, FolderPath null, FullName = the Galaxy ref). Decode must
|
||||
/// surface the tag in EquipmentTags carrying its driver-side FullName, coalescing the null FolderPath to
|
||||
/// <c>string.Empty</c>.</summary>
|
||||
[Fact]
|
||||
public void ParseComposition_admits_galaxy_alias_tag_in_equipment_tags()
|
||||
public void ParseComposition_admits_galaxy_equipment_tag_in_equipment_tags()
|
||||
{
|
||||
var blob = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
Namespaces = new[]
|
||||
{
|
||||
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment
|
||||
},
|
||||
DriverInstances = new[]
|
||||
{
|
||||
new { DriverInstanceId = "drv-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", NamespaceId = "ns-eq" },
|
||||
},
|
||||
Tags = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
TagId = "tag-galaxy",
|
||||
DriverInstanceId = "drv-galaxy",
|
||||
EquipmentId = "eq-1",
|
||||
Name = "TestChangingInt",
|
||||
FolderPath = (string?)null,
|
||||
DataType = "Int32",
|
||||
TagConfig = "{\"FullName\":\"TestMachine_020.TestChangingInt\"}",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
var c = DeploymentArtifact.ParseComposition(blob);
|
||||
|
||||
var tag = c.EquipmentTags.ShouldHaveSingleItem();
|
||||
tag.TagId.ShouldBe("tag-galaxy");
|
||||
tag.EquipmentId.ShouldBe("eq-1");
|
||||
tag.DriverInstanceId.ShouldBe("drv-galaxy");
|
||||
tag.Name.ShouldBe("TestChangingInt");
|
||||
tag.DataType.ShouldBe("Int32");
|
||||
tag.FolderPath.ShouldBe(string.Empty);
|
||||
tag.FullName.ShouldBe("TestMachine_020.TestChangingInt");
|
||||
}
|
||||
|
||||
/// <summary>An equipment-scoped GalaxyMxGateway tag in a SystemPlatform-kind namespace must NOT surface
|
||||
/// in EquipmentTags — byte-parity with the composer's pure <c>ns.Kind == NamespaceKind.Equipment</c>
|
||||
/// predicate. The retired SystemPlatform-mirror contract no longer carried a DriverType exception, so a
|
||||
/// non-Equipment namespace excludes the tag regardless of driver type.</summary>
|
||||
[Fact]
|
||||
public void ParseComposition_excludes_galaxy_tag_in_non_equipment_namespace()
|
||||
{
|
||||
var blob = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
@@ -32,54 +78,11 @@ public sealed class DeploymentArtifactAliasParityTests
|
||||
new { DriverInstanceId = "drv-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", NamespaceId = "ns-sp" },
|
||||
},
|
||||
Tags = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
TagId = "tag-alias",
|
||||
DriverInstanceId = "drv-galaxy",
|
||||
EquipmentId = "eq-1",
|
||||
Name = "TestChangingInt",
|
||||
FolderPath = (string?)null,
|
||||
DataType = "Int32",
|
||||
TagConfig = "{\"FullName\":\"TestMachine_020.TestChangingInt\"}",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
var c = DeploymentArtifact.ParseComposition(blob);
|
||||
|
||||
var alias = c.EquipmentTags.ShouldHaveSingleItem();
|
||||
alias.TagId.ShouldBe("tag-alias");
|
||||
alias.EquipmentId.ShouldBe("eq-1");
|
||||
alias.DriverInstanceId.ShouldBe("drv-galaxy");
|
||||
alias.Name.ShouldBe("TestChangingInt");
|
||||
alias.DataType.ShouldBe("Int32");
|
||||
alias.FolderPath.ShouldBe(string.Empty);
|
||||
alias.FullName.ShouldBe("TestMachine_020.TestChangingInt");
|
||||
}
|
||||
|
||||
/// <summary>An equipment-scoped tag bound to a non-Galaxy driver in a SystemPlatform namespace is
|
||||
/// NOT a Galaxy alias and must stay excluded from EquipmentTags — the broadened clause keys on the
|
||||
/// GalaxyMxGateway DriverType, not on the namespace kind, so the contract narrows correctly.</summary>
|
||||
[Fact]
|
||||
public void ParseComposition_excludes_non_galaxy_systemplatform_equipment_tag()
|
||||
{
|
||||
var blob = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
Namespaces = new[]
|
||||
{
|
||||
new { NamespaceId = "ns-sp", Kind = 1 }, // NamespaceKind.SystemPlatform
|
||||
},
|
||||
DriverInstances = new[]
|
||||
{
|
||||
new { DriverInstanceId = "drv-modbus", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-sp" },
|
||||
},
|
||||
Tags = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
TagId = "tag-x",
|
||||
DriverInstanceId = "drv-modbus",
|
||||
DriverInstanceId = "drv-galaxy",
|
||||
EquipmentId = "eq-1",
|
||||
Name = "Source",
|
||||
FolderPath = (string?)null,
|
||||
|
||||
@@ -193,9 +193,10 @@ public sealed class DeploymentArtifactTests
|
||||
/// <summary>
|
||||
/// Verifies ParseComposition surfaces Equipment-namespace tags (non-null EquipmentId in an
|
||||
/// <c>Equipment</c>-kind namespace) as <c>EquipmentTags</c>, with <c>FullName</c> extracted
|
||||
/// from the tag's TagConfig blob — the equipment-signal mirror of the Galaxy-tag path. A
|
||||
/// SystemPlatform (Galaxy) tag in the same blob must NOT leak into EquipmentTags and must
|
||||
/// still route to GalaxyTags.
|
||||
/// from the tag's TagConfig blob. A tag in a non-Equipment (SystemPlatform) namespace with a
|
||||
/// null EquipmentId must NOT surface in EquipmentTags — byte-parity with the composer's pure
|
||||
/// <c>ns.Kind == NamespaceKind.Equipment</c> predicate (the SystemPlatform-mirror contract is
|
||||
/// retired, so such a tag routes nowhere).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParseComposition_reads_EquipmentTags_from_equipment_namespace()
|
||||
@@ -247,8 +248,9 @@ public sealed class DeploymentArtifactTests
|
||||
tag.DataType.ShouldBe("Float");
|
||||
tag.FullName.ShouldBe("40001"); // extracted from TagConfig, not the raw blob
|
||||
|
||||
// The Galaxy tag still routes to GalaxyTags and does NOT leak into EquipmentTags.
|
||||
c.GalaxyTags.ShouldContain(g => g.TagId == "tag-gx");
|
||||
// The SystemPlatform tag (null EquipmentId, non-Equipment namespace) does NOT leak into
|
||||
// EquipmentTags — byte-parity with the composer's pure ns.Kind == Equipment predicate.
|
||||
c.EquipmentTags.ShouldNotContain(t => t.TagId == "tag-gx");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -387,15 +389,18 @@ public sealed class DeploymentArtifactTests
|
||||
new { DriverInstanceId = "main-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" },
|
||||
new { DriverInstanceId = "sa-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" },
|
||||
},
|
||||
// Galaxy points are ordinary equipment tags now — Equipment-kind namespaces with non-null
|
||||
// EquipmentId, so the cluster-scoped decode filters them via EquipmentTags (by their driver's
|
||||
// cluster), exactly as it filtered the retired GalaxyTags.
|
||||
Namespaces = new[]
|
||||
{
|
||||
new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 1 },
|
||||
new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 1 },
|
||||
new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 0 },
|
||||
new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 0 },
|
||||
},
|
||||
Tags = new[]
|
||||
{
|
||||
new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)null, Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
|
||||
new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)null, Name = "S1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
|
||||
new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)"eq-main", Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
|
||||
new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)"eq-sa", Name = "S1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -406,18 +411,18 @@ public sealed class DeploymentArtifactTests
|
||||
|
||||
var main = DeploymentArtifact.ParseComposition(blob, "central-1:4053");
|
||||
main.DriverInstancePlans.Select(d => d.DriverInstanceId).ShouldBe(new[] { "main-galaxy" });
|
||||
main.GalaxyTags.Select(t => t.TagId).ShouldBe(new[] { "t-main" });
|
||||
main.EquipmentTags.Select(t => t.TagId).ShouldBe(new[] { "t-main" });
|
||||
|
||||
var siteA = DeploymentArtifact.ParseComposition(blob, "site-a-1:4053");
|
||||
siteA.DriverInstancePlans.Select(d => d.DriverInstanceId).ShouldBe(new[] { "sa-galaxy" });
|
||||
siteA.GalaxyTags.Select(t => t.TagId).ShouldBe(new[] { "t-sa" });
|
||||
siteA.EquipmentTags.Select(t => t.TagId).ShouldBe(new[] { "t-sa" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseComposition_scoped_unknown_node_is_empty()
|
||||
{
|
||||
var comp = DeploymentArtifact.ParseComposition(BlobOf(MultiClusterSnapshotWithTags()), "ghost-9:4053");
|
||||
comp.GalaxyTags.ShouldBeEmpty();
|
||||
comp.EquipmentTags.ShouldBeEmpty();
|
||||
comp.DriverInstancePlans.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
|
||||
+32
-16
@@ -102,10 +102,11 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
/// Wiring proof for per-ClusterId scoping (Task 4): a multi-cluster artifact must
|
||||
/// materialise ONLY the local node's cluster slice. Mirrors the multi-cluster artifact
|
||||
/// shape exercised in <c>DeploymentArtifactTests</c> (MAIN + SITE-A, one Galaxy driver +
|
||||
/// one SystemPlatform tag each). The scoped rebuild for the SITE-A node must surface the
|
||||
/// SITE-A tag (<c>t-sa</c> → variable <c>F.S1</c>) and NOT MAIN's (<c>t-main</c> →
|
||||
/// <c>F.M1</c>); the mirror holds for the MAIN node. Without the production scoping edit,
|
||||
/// the unscoped parse would materialise BOTH variables on every node.
|
||||
/// one equipment tag each — Galaxy points are ordinary equipment tags now). The scoped
|
||||
/// rebuild for the SITE-A node must surface the SITE-A tag (<c>t-sa</c> → folder-scoped
|
||||
/// variable <c>eq-sa/F/S1</c>) and NOT MAIN's (<c>t-main</c> → <c>eq-main/F/M1</c>); the
|
||||
/// mirror holds for the MAIN node. Without the production scoping edit, the unscoped parse
|
||||
/// would materialise BOTH variables on every node.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rebuild_materialises_only_the_nodes_cluster()
|
||||
@@ -125,10 +126,10 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
siteActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
|
||||
AwaitAssert(() => sinkA.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2));
|
||||
// t-sa (Name "S1", FolderPath "F") → MxAccessRef "F.S1" → variable node "F.S1".
|
||||
sinkA.Calls.ShouldContain("EV:F.S1");
|
||||
// t-sa (EquipmentId "eq-sa", FolderPath "F", Name "S1") → folder-scoped variable "eq-sa/F/S1".
|
||||
sinkA.Calls.ShouldContain("EV:eq-sa/F/S1");
|
||||
// t-main (MAIN cluster) must NOT leak onto the SITE-A node.
|
||||
sinkA.Calls.ShouldNotContain("EV:F.M1");
|
||||
sinkA.Calls.ShouldNotContain("EV:eq-main/F/M1");
|
||||
|
||||
// --- MAIN node: the mirror — only MAIN's tag's variable, never SITE-A's. ---
|
||||
var dbM = NewInMemoryDbFactory();
|
||||
@@ -145,15 +146,15 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
mainActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
|
||||
AwaitAssert(() => sinkM.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2));
|
||||
sinkM.Calls.ShouldContain("EV:F.M1");
|
||||
sinkM.Calls.ShouldNotContain("EV:F.S1");
|
||||
sinkM.Calls.ShouldContain("EV:eq-main/F/M1");
|
||||
sinkM.Calls.ShouldNotContain("EV:eq-sa/F/S1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seal a 2-cluster deployment (MAIN + SITE-A) whose artifact mirrors the multi-cluster
|
||||
/// shape the composer emits: a <c>Clusters</c> + <c>Nodes</c> map, one SystemPlatform
|
||||
/// namespace + Galaxy driver + Galaxy tag per cluster. Used by
|
||||
/// <see cref="Rebuild_materialises_only_the_nodes_cluster"/>.
|
||||
/// shape the composer emits: a <c>Clusters</c> + <c>Nodes</c> map, one Equipment namespace +
|
||||
/// Galaxy driver + equipment tag per cluster (Galaxy points are ordinary equipment tags now).
|
||||
/// Used by <see cref="Rebuild_materialises_only_the_nodes_cluster"/>.
|
||||
/// </summary>
|
||||
private static void SeedMultiClusterDeployment(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
|
||||
{
|
||||
@@ -165,6 +166,21 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
new { NodeId = "central-1:4053", ClusterId = "MAIN" },
|
||||
new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" },
|
||||
},
|
||||
UnsAreas = new[]
|
||||
{
|
||||
new { UnsAreaId = "area-main", ClusterId = "MAIN", Name = "main-area" },
|
||||
new { UnsAreaId = "area-sa", ClusterId = "SITE-A", Name = "sa-area" },
|
||||
},
|
||||
UnsLines = new[]
|
||||
{
|
||||
new { UnsLineId = "line-main", UnsAreaId = "area-main", Name = "main-line" },
|
||||
new { UnsLineId = "line-sa", UnsAreaId = "area-sa", Name = "sa-line" },
|
||||
},
|
||||
Equipment = new[]
|
||||
{
|
||||
new { EquipmentId = "eq-main", DriverInstanceId = "main-galaxy", UnsLineId = "line-main", Name = "eq-main", MachineCode = "EQ-MAIN" },
|
||||
new { EquipmentId = "eq-sa", DriverInstanceId = "sa-galaxy", UnsLineId = "line-sa", Name = "eq-sa", MachineCode = "EQ-SA" },
|
||||
},
|
||||
DriverInstances = new[]
|
||||
{
|
||||
new { DriverInstanceId = "main-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" },
|
||||
@@ -172,13 +188,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
},
|
||||
Namespaces = new[]
|
||||
{
|
||||
new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 1 }, // NamespaceKind.SystemPlatform
|
||||
new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 1 },
|
||||
new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 0 }, // NamespaceKind.Equipment
|
||||
new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 0 },
|
||||
},
|
||||
Tags = new[]
|
||||
{
|
||||
new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)null, Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
|
||||
new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)null, Name = "S1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
|
||||
new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)"eq-main", Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
|
||||
new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)"eq-sa", Name = "S1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
|
||||
},
|
||||
ScriptedAlarms = Array.Empty<object>(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user