feat(composer): admit GalaxyMxGateway-backed equipment alias tags (+byte-parity)

This commit is contained in:
Joseph Doherty
2026-06-11 21:10:21 -04:00
parent 4b4738a891
commit bc9e83ed9f
4 changed files with 211 additions and 3 deletions
@@ -362,11 +362,16 @@ public static class Phase7Composer
// Equipment tags = the inverse filter: a Tag bound to an Equipment (non-null EquipmentId)
// whose driver's namespace is Equipment-kind. FullName is the driver-side wire reference
// pulled from TagConfig — it becomes the variable's NodeId + read/write routing key.
// A Galaxy alias is the one exception to the Equipment-kind namespace rule: a GalaxyMxGateway
// driver lives in a SystemPlatform-kind namespace, yet an equipment-scoped alias Tag bound to
// it must still surface as an equipment tag — so admit those by DriverType. The galaxyTags
// producer keeps its `t.EquipmentId is null` guard, so an alias (EquipmentId set) never
// double-counts there.
var equipmentTags = tags
.Where(t => t.EquipmentId is not null)
.Where(t => driversById.TryGetValue(t.DriverInstanceId, out var di)
&& namespacesById.TryGetValue(di.NamespaceId, out var ns)
&& ns.Kind == NamespaceKind.Equipment)
&& (ns.Kind == NamespaceKind.Equipment || di.DriverType == "GalaxyMxGateway"))
.OrderBy(t => t.EquipmentId, StringComparer.Ordinal)
.ThenBy(t => t.FolderPath ?? string.Empty, StringComparer.Ordinal) // coalesce so the sort matches the artifact-decode side exactly
.ThenBy(t => t.Name, StringComparer.Ordinal)
@@ -471,8 +471,11 @@ public static class DeploymentArtifact
if (isEquipment) equipmentNamespaces.Add(id!);
}
// driverInstanceId → namespaceId
// driverInstanceId → namespaceId, and driverInstanceId → DriverType. The DriverType map admits
// a Galaxy alias (a GalaxyMxGateway-backed equipment-scoped tag) that lives in a SystemPlatform
// namespace — byte-parity with the composer's `di.DriverType == "GalaxyMxGateway"` clause.
var driverToNamespace = new Dictionary<string, string>(StringComparer.Ordinal);
var driverToType = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var el in diArr.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
@@ -480,6 +483,9 @@ public static class DeploymentArtifact
var ns = el.TryGetProperty("NamespaceId", out var nsEl) ? nsEl.GetString() : null;
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(ns))
driverToNamespace[id!] = ns!;
var dtype = el.TryGetProperty("DriverType", out var dtEl) ? dtEl.GetString() : null;
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(dtype))
driverToType[id!] = dtype!;
}
var result = new List<EquipmentTagPlan>(tagsArr.GetArrayLength());
@@ -502,7 +508,10 @@ public static class DeploymentArtifact
if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name)) continue;
if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue;
if (!equipmentNamespaces.Contains(nsId)) continue;
// A GalaxyMxGateway-backed alias qualifies even though its namespace is SystemPlatform-kind
// (not Equipment) — byte-parity with the composer's broadened equipment-tag filter.
var isGalaxyAlias = driverToType.TryGetValue(di!, out var dtype2) && dtype2 == "GalaxyMxGateway";
if (!equipmentNamespaces.Contains(nsId) && !isGalaxyAlias) continue;
result.Add(new EquipmentTagPlan(
TagId: tagId!,
@@ -0,0 +1,98 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Verifies the live-edit compose seam (<see cref="Phase7Composer.Compose"/>) admits a Galaxy
/// alias tag — an equipment-scoped <see cref="Tag"/> (non-null <see cref="Tag.EquipmentId"/>)
/// bound to a <c>GalaxyMxGateway</c> driver that lives in a <c>SystemPlatform</c>-kind namespace.
/// The broadened equipment-tag filter must surface the alias under
/// <see cref="Phase7CompositionResult.EquipmentTags"/> (carrying its driver-side FullName) while a
/// sibling SystemPlatform mirror tag (null EquipmentId) on the same driver stays in
/// <see cref="Phase7CompositionResult.GalaxyTags"/> and never double-counts.
/// </summary>
public sealed class Phase7ComposerAliasTagTests
{
/// <summary>A <c>GalaxyMxGateway</c> driver in a SystemPlatform namespace carries two tags: an
/// equipment-scoped alias (EquipmentId set, FolderPath null, TagConfig FullName = the Galaxy ref)
/// and a SystemPlatform mirror (EquipmentId null, FolderPath set). Compose must put the alias in
/// EquipmentTags with its FullName and keep the mirror in GalaxyTags only.</summary>
[Fact]
public void Compose_admits_galaxy_alias_tag_in_equipment_tags()
{
var ns = new Namespace
{
NamespaceId = "ns-sp",
ClusterId = "c1",
Kind = NamespaceKind.SystemPlatform,
NamespaceUri = "urn:sp",
};
var driver = new DriverInstance
{
DriverInstanceId = "drv-galaxy",
ClusterId = "c1",
NamespaceId = "ns-sp",
Name = "Galaxy1",
DriverType = "GalaxyMxGateway",
DriverConfig = "{}",
};
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
var equip = new Equipment
{
EquipmentId = "eq-1",
DriverInstanceId = "drv-galaxy",
UnsLineId = "line-1",
Name = "TestMachine_020",
MachineCode = "TESTMACHINE_020",
};
// (a) Equipment-scoped alias: EquipmentId set, FolderPath null, FullName = Galaxy ref.
var aliasTag = new Tag
{
TagId = "tag-alias",
DriverInstanceId = "drv-galaxy",
EquipmentId = "eq-1",
FolderPath = null,
Name = "TestChangingInt",
DataType = "Int32",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"FullName\":\"TestMachine_020.TestChangingInt\"}",
};
// (b) SystemPlatform mirror: EquipmentId null, FolderPath set → stays a Galaxy tag.
var mirrorTag = new Tag
{
TagId = "tag-mirror",
DriverInstanceId = "drv-galaxy",
EquipmentId = null,
FolderPath = "TestMachine_020",
Name = "Speed",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"FullName\":\"TestMachine_020.Speed\"}",
};
var result = Phase7Composer.Compose(
new[] { area }, new[] { line }, new[] { equip },
new[] { driver }, Array.Empty<ScriptedAlarm>(),
new[] { aliasTag, mirrorTag }, new[] { ns });
// (a) the alias survives composition as an equipment tag, carrying its Galaxy FullName.
var alias = result.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.FullName.ShouldBe("TestMachine_020.TestChangingInt");
// (b) the SystemPlatform mirror stays in GalaxyTags and is NOT double-counted as equipment.
result.GalaxyTags.ShouldContain(g => g.TagId == "tag-mirror");
result.GalaxyTags.ShouldNotContain(g => g.TagId == "tag-alias");
result.EquipmentTags.ShouldNotContain(t => t.TagId == "tag-mirror");
}
}
@@ -0,0 +1,96 @@
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
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.
/// </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>
[Fact]
public void ParseComposition_admits_galaxy_alias_tag_in_equipment_tags()
{
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { NamespaceId = "ns-sp", Kind = 1 }, // NamespaceKind.SystemPlatform
},
DriverInstances = new[]
{
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",
EquipmentId = "eq-1",
Name = "Source",
FolderPath = (string?)null,
DataType = "Int32",
TagConfig = "{\"FullName\":\"TestMachine_020.Source\"}",
},
},
});
var c = DeploymentArtifact.ParseComposition(blob);
c.EquipmentTags.ShouldBeEmpty();
}
}