364 lines
16 KiB
C#
364 lines
16 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Verifies the artifact-decode mirror (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>)
|
|
/// 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 — Galaxy is an ordinary Equipment-kind
|
|
/// driver), so they agree on which tags qualify.
|
|
/// </summary>
|
|
public sealed class DeploymentArtifactAliasParityTests
|
|
{
|
|
/// <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_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");
|
|
// No AccessLevel in the blob → defaults to non-writable (read-only node).
|
|
tag.Writable.ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>The artifact decoder reads <c>AccessLevel</c> into <c>EquipmentTagPlan.Writable</c>:
|
|
/// ReadWrite → true, Read → false. ConfigComposer emits the enum numerically (no string converter),
|
|
/// so the numeric form (1 = ReadWrite) is the canonical wire shape, but the decoder also tolerates
|
|
/// the string form ("ReadWrite") defensively — mirroring how the Kind gate accepts both.</summary>
|
|
[Theory]
|
|
[InlineData(1, true)] // numeric ReadWrite
|
|
[InlineData(0, false)] // numeric Read
|
|
[InlineData(2, false)] // future/unknown AccessLevel maps fail-safe to read-only (only ReadWrite==1 ⇒ writable)
|
|
public void ParseComposition_maps_numeric_AccessLevel_to_Writable(int accessLevel, bool expectedWritable)
|
|
{
|
|
var blob = JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 } },
|
|
DriverInstances = new[]
|
|
{
|
|
new { DriverInstanceId = "drv", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" },
|
|
},
|
|
Tags = new object[]
|
|
{
|
|
new
|
|
{
|
|
TagId = "tag-1",
|
|
DriverInstanceId = "drv",
|
|
EquipmentId = "eq-1",
|
|
Name = "Speed",
|
|
FolderPath = (string?)null,
|
|
DataType = "Float",
|
|
AccessLevel = accessLevel,
|
|
TagConfig = "{\"FullName\":\"40001\"}",
|
|
},
|
|
},
|
|
});
|
|
|
|
var c = DeploymentArtifact.ParseComposition(blob);
|
|
|
|
c.EquipmentTags.ShouldHaveSingleItem().Writable.ShouldBe(expectedWritable);
|
|
}
|
|
|
|
/// <summary>The decoder also tolerates the string enum form ("ReadWrite"/"Read") in case a future
|
|
/// serializer registers a string converter — byte-parity safety, mirroring the Kind gate.</summary>
|
|
[Theory]
|
|
[InlineData("ReadWrite", true)]
|
|
[InlineData("Read", false)]
|
|
public void ParseComposition_maps_string_AccessLevel_to_Writable(string accessLevel, bool expectedWritable)
|
|
{
|
|
var blob = JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 } },
|
|
DriverInstances = new[]
|
|
{
|
|
new { DriverInstanceId = "drv", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" },
|
|
},
|
|
Tags = new object[]
|
|
{
|
|
new
|
|
{
|
|
TagId = "tag-1",
|
|
DriverInstanceId = "drv",
|
|
EquipmentId = "eq-1",
|
|
Name = "Speed",
|
|
FolderPath = (string?)null,
|
|
DataType = "Float",
|
|
AccessLevel = accessLevel,
|
|
TagConfig = "{\"FullName\":\"40001\"}",
|
|
},
|
|
},
|
|
});
|
|
|
|
var c = DeploymentArtifact.ParseComposition(blob);
|
|
|
|
c.EquipmentTags.ShouldHaveSingleItem().Writable.ShouldBe(expectedWritable);
|
|
}
|
|
|
|
/// <summary>An equipment-scoped tag in a non-Equipment (Simulated) namespace must NOT surface in
|
|
/// EquipmentTags — byte-parity with the composer's pure <c>ns.Kind == NamespaceKind.Equipment</c>
|
|
/// predicate. The gate keys off the namespace Kind alone, with no DriverType exception, so a
|
|
/// non-Equipment namespace excludes the tag regardless of driver type.</summary>
|
|
[Fact]
|
|
public void ParseComposition_excludes_tag_in_non_equipment_namespace()
|
|
{
|
|
var blob = JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
Namespaces = new[]
|
|
{
|
|
new { NamespaceId = "ns-sim", Kind = 1 }, // NamespaceKind.Simulated (non-Equipment)
|
|
},
|
|
DriverInstances = new[]
|
|
{
|
|
new { DriverInstanceId = "drv-sim", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-sim" },
|
|
},
|
|
Tags = new object[]
|
|
{
|
|
new
|
|
{
|
|
TagId = "tag-x",
|
|
DriverInstanceId = "drv-sim",
|
|
EquipmentId = "eq-1",
|
|
Name = "Source",
|
|
FolderPath = (string?)null,
|
|
DataType = "Int32",
|
|
TagConfig = "{\"FullName\":\"40001\"}",
|
|
},
|
|
},
|
|
});
|
|
|
|
var c = DeploymentArtifact.ParseComposition(blob);
|
|
|
|
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".
|
|
// It also carries a native-alarm intent in TagConfig.alarm, so this draft proves the
|
|
// optional Alarm field is parsed byte-identically on both producers (Phase B WS-2).
|
|
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\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}",
|
|
};
|
|
// 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);
|
|
d.Writable.ShouldBe(x.Writable);
|
|
d.Alarm.ShouldBe(x.Alarm); // EquipmentTagAlarmInfo is a positional record ⇒ value equality
|
|
}
|
|
|
|
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
|
|
|
|
// The native-alarm intent in the Galaxy tag's TagConfig.alarm is parsed byte-identically on both
|
|
// producers (Phase B WS-2). The Modbus tag has no alarm object ⇒ null Alarm on both sides.
|
|
galaxyPlan.Alarm.ShouldNotBeNull();
|
|
galaxyPlan.Alarm!.AlarmType.ShouldBe("OffNormalAlarm");
|
|
galaxyPlan.Alarm.Severity.ShouldBe(700);
|
|
composed.EquipmentTags.Single(t => t.TagId == "tag-galaxy").Alarm.ShouldBe(galaxyPlan.Alarm);
|
|
decoded.EquipmentTags.Single(t => t.TagId == "tag-modbus").Alarm.ShouldBeNull();
|
|
composed.EquipmentTags.Single(t => t.TagId == "tag-modbus").Alarm.ShouldBeNull();
|
|
|
|
// Writability flows from Tag.AccessLevel: the Galaxy tag is Read (read-only node), the Modbus
|
|
// tag is ReadWrite (writable node). Both producers must derive the same Writable flag, and the
|
|
// SequenceEqual above already proves they agree element-wise.
|
|
galaxyPlan.Writable.ShouldBeFalse(); // AccessLevel = Read
|
|
var modbusPlan = decoded.EquipmentTags.Single(t => t.TagId == "tag-modbus");
|
|
modbusPlan.Writable.ShouldBeTrue(); // AccessLevel = ReadWrite
|
|
composed.EquipmentTags.Single(t => t.TagId == "tag-galaxy").Writable.ShouldBeFalse();
|
|
composed.EquipmentTags.Single(t => t.TagId == "tag-modbus").Writable.ShouldBeTrue();
|
|
}
|
|
|
|
/// <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,
|
|
// ConfigComposer serialises with no JsonStringEnumConverter, so the TagAccessLevel enum lands
|
|
// as its numeric value (Read = 0, ReadWrite = 1) — exactly like Kind = (int)ns.Kind above.
|
|
AccessLevel = (int)t.AccessLevel,
|
|
t.TagConfig,
|
|
};
|
|
}
|