test(opcua): composer↔artifact byte-parity for a Galaxy equipment tag

This commit is contained in:
Joseph Doherty
2026-06-12 22:05:15 -04:00
parent 5edea52bd7
commit 056bfbda1b
@@ -1,6 +1,10 @@
using System.Linq;
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
@@ -96,4 +100,164 @@ public sealed class DeploymentArtifactAliasParityTests
c.EquipmentTags.ShouldBeEmpty();
}
/// <summary>
/// The load-bearing direct byte-parity proof for the two equipment-tag producers: for the SAME
/// input draft, the live-edit composer (<see cref="Phase7Composer.Compose"/>) and the
/// artifact decoder (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>)
/// must emit IDENTICAL <c>EquipmentTags</c> — element-wise equal on every field
/// (TagId, EquipmentId, DriverInstanceId, FolderPath, Name, DataType, FullName) AND in the same
/// ORDER. The draft holds a Galaxy equipment tag (EquipmentId set, GalaxyMxGateway driver in an
/// Equipment-kind namespace, TagConfig <c>{"FullName":"DelmiaReceiver_001.DownloadPath"}</c>)
/// PLUS a Modbus equipment tag, so the parity is proven both for Galaxy specifically and across
/// drivers — Galaxy is now an ordinary Equipment-kind driver with no exception clause on either
/// side. Both sides sort by EquipmentId → FolderPath → Name (Ordinal), so identical input yields
/// identical order. <c>EquipmentTagPlan</c> is a plain positional record (all string members), so
/// <c>SequenceEqual</c> is full value-and-order equality.
/// </summary>
[Fact]
public void Composer_and_artifact_agree_on_galaxy_equipment_tag()
{
// ---- The ONE input draft both producers consume ----
// Equipment-kind namespace shared by both drivers (the qualifying gate on both sides).
var ns = new Namespace
{
NamespaceId = "ns-eq",
ClusterId = "c1",
Kind = NamespaceKind.Equipment,
NamespaceUri = "urn:eq",
};
// Galaxy is a standard Equipment-kind driver now — no driver-type exception on either side.
var galaxyDriver = new DriverInstance
{
DriverInstanceId = "drv-galaxy",
ClusterId = "c1",
NamespaceId = "ns-eq",
Name = "Galaxy1",
DriverType = "GalaxyMxGateway",
DriverConfig = "{}",
};
var modbusDriver = new DriverInstance
{
DriverInstanceId = "drv-modbus",
ClusterId = "c1",
NamespaceId = "ns-eq",
Name = "Modbus1",
DriverType = "Modbus",
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 galaxyEquip = new Equipment
{
EquipmentId = "eq-galaxy",
DriverInstanceId = "drv-galaxy",
UnsLineId = "line-1",
Name = "DelmiaReceiver",
MachineCode = "DELMIARECEIVER",
};
var modbusEquip = new Equipment
{
EquipmentId = "eq-modbus",
DriverInstanceId = "drv-modbus",
UnsLineId = "line-1",
Name = "FillingPump",
MachineCode = "FILLINGPUMP",
};
// The Galaxy equipment tag — FullName is the Galaxy ref "tag_name.AttributeName".
var galaxyTag = new Tag
{
TagId = "tag-galaxy",
DriverInstanceId = "drv-galaxy",
EquipmentId = "eq-galaxy",
FolderPath = null, // coalesces to "" on both sides
Name = "DownloadPath",
DataType = "String",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"FullName\":\"DelmiaReceiver_001.DownloadPath\"}",
};
// A non-Galaxy (Modbus) equipment tag — proves the parity holds across drivers, not just Galaxy.
var modbusTag = new Tag
{
TagId = "tag-modbus",
DriverInstanceId = "drv-modbus",
EquipmentId = "eq-modbus",
FolderPath = "registers",
Name = "Speed",
DataType = "Float",
AccessLevel = TagAccessLevel.ReadWrite,
TagConfig = "{\"FullName\":\"40001\"}",
};
var areas = new[] { area };
var lines = new[] { line };
var equipment = new[] { galaxyEquip, modbusEquip };
var drivers = new[] { galaxyDriver, modbusDriver };
var tags = new[] { galaxyTag, modbusTag };
var namespaces = new[] { ns };
// ---- Side 1: the live-edit composer ----
var composed = Phase7Composer.Compose(
areas, lines, equipment, drivers, Array.Empty<ScriptedAlarm>(), tags, namespaces);
// ---- Side 2: serialise the SAME draft to the artifact blob shape ConfigComposer emits
// (Pascal-case off the EF entities), then decode it. ----
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { ns.NamespaceId, ns.ClusterId, Kind = (int)ns.Kind },
},
DriverInstances = new[]
{
new { galaxyDriver.DriverInstanceId, galaxyDriver.DriverType, galaxyDriver.DriverConfig, galaxyDriver.NamespaceId, galaxyDriver.ClusterId },
new { modbusDriver.DriverInstanceId, modbusDriver.DriverType, modbusDriver.DriverConfig, modbusDriver.NamespaceId, modbusDriver.ClusterId },
},
Tags = new[]
{
ToSnapshot(galaxyTag),
ToSnapshot(modbusTag),
},
});
var decoded = DeploymentArtifact.ParseComposition(blob);
// ---- The equality contract: element-wise equal on ALL fields + same order ----
decoded.EquipmentTags.Count.ShouldBe(2);
// SequenceEqual = full value-and-order equality (EquipmentTagPlan is a positional record).
decoded.EquipmentTags.SequenceEqual(composed.EquipmentTags).ShouldBeTrue();
// Spell the per-field contract out too (so a future divergence names the offending field
// rather than just "sequences differ"), and pin the Galaxy tag's wire-level FullName.
foreach (var (d, x) in decoded.EquipmentTags.Zip(composed.EquipmentTags))
{
d.TagId.ShouldBe(x.TagId);
d.EquipmentId.ShouldBe(x.EquipmentId);
d.DriverInstanceId.ShouldBe(x.DriverInstanceId);
d.FolderPath.ShouldBe(x.FolderPath);
d.Name.ShouldBe(x.Name);
d.DataType.ShouldBe(x.DataType);
d.FullName.ShouldBe(x.FullName);
}
var galaxyPlan = decoded.EquipmentTags.Single(t => t.TagId == "tag-galaxy");
galaxyPlan.FullName.ShouldBe("DelmiaReceiver_001.DownloadPath");
galaxyPlan.EquipmentId.ShouldBe("eq-galaxy");
galaxyPlan.DriverInstanceId.ShouldBe("drv-galaxy");
galaxyPlan.FolderPath.ShouldBe(string.Empty); // null FolderPath coalesced identically on both sides
}
/// <summary>The full Pascal-case snapshot a <see cref="Tag"/> EF entity serialises to in the
/// artifact (matches ConfigComposer): the equipment-tag decoder reads exactly these fields.</summary>
private static object ToSnapshot(Tag t) => new
{
t.TagId,
t.DriverInstanceId,
t.EquipmentId,
t.Name,
t.FolderPath,
t.DataType,
t.TagConfig,
};
}