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; /// /// v3 WP4 byte-parity: the artifact-decode seam (DeploymentArtifact.ParseComposition) and the /// live compose seam (AddressSpaceComposer.Compose) must resolve {{equip}}/<RefName> /// script paths to the SAME backing RawPath — both build the per-equipment reference map (effective name /// → RawPath) from UnsTagReferences + Tags + the raw topology via the shared /// EquipmentReferenceMap + RawPathResolver. Exercises both a VirtualTag script and a /// scripted-alarm predicate, plus an override-named reference. /// public sealed class DeploymentArtifactEquipRefParityTests { // Reference effective name "MotorSpeed" (a DisplayNameOverride) backing raw tag leaf "Speed" → // RawPath "Modbus/Dev1/Speed" (driver at cluster root, no folder / group). private const string VtScript = "return System.Convert.ToInt32(ctx.GetTag(\"{{equip}}/MotorSpeed\").Value) > 50;"; private const string AlarmScript = "return System.Convert.ToDouble(ctx.GetTag(\"{{equip}}/MotorSpeed\").Value) > 90;"; private const string ExpectedRawPath = "Modbus/Dev1/Speed"; private static DriverInstance Driver() => new() { DriverInstanceId = "drv-1", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", RawFolderId = null, }; private static Device Dev() => new() { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}", }; private static Tag RawTag() => new() { TagId = "tag-1", DeviceId = "dev-1", Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}", }; private static UnsTagReference Ref() => new() { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "tag-1", DisplayNameOverride = "MotorSpeed", }; [Fact] public void VirtualTag_equip_ref_resolves_byte_parity() { var script = new Script { ScriptId = "s-1", Name = "n", SourceCode = VtScript, SourceHash = "h" }; var vt = new VirtualTag { VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Over", DataType = "Boolean", ScriptId = "s-1" }; var composed = AddressSpaceComposer.Compose( Array.Empty(), Array.Empty(), Array.Empty(), new[] { Driver() }, Array.Empty(), virtualTags: new[] { vt }, scripts: new[] { script }, unsTagReferences: new[] { Ref() }, tags: new[] { RawTag() }, rawFolders: Array.Empty(), devices: new[] { Dev() }, tagGroups: Array.Empty()); var blob = JsonSerializer.SerializeToUtf8Bytes(new { DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = (string?)null } }, Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" } }, Tags = new[] { new { TagId = "tag-1", DeviceId = "dev-1", Name = "Speed", DataType = "Float", TagConfig = "{}" } }, UnsTagReferences = new[] { new { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "tag-1", DisplayNameOverride = "MotorSpeed" } }, Scripts = new[] { new { ScriptId = "s-1", SourceCode = VtScript } }, VirtualTags = new[] { new { VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Over", DataType = "Boolean", ScriptId = "s-1" } }, }); var decoded = DeploymentArtifact.ParseComposition(blob); // The token resolved to the backing RawPath on BOTH sides, and the plans are byte-identical. var cPlan = composed.EquipmentVirtualTags.ShouldHaveSingleItem(); cPlan.Expression.ShouldContain($"ctx.GetTag(\"{ExpectedRawPath}\")"); cPlan.Expression.ShouldNotContain("{{equip}}"); cPlan.DependencyRefs.ShouldBe(new[] { ExpectedRawPath }); var dPlan = decoded.EquipmentVirtualTags.ShouldHaveSingleItem(); dPlan.ShouldBe(cPlan); } [Fact] public void ScriptedAlarm_predicate_equip_ref_resolves_byte_parity() { var script = new Script { ScriptId = "s-1", Name = "n", SourceCode = AlarmScript, SourceHash = "h" }; var alarm = new ScriptedAlarm { ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "Hot", AlarmType = "LimitAlarm", Severity = 700, MessageTemplate = "hot", PredicateScriptId = "s-1", HistorizeToAveva = true, Retain = true, Enabled = true, }; var composed = AddressSpaceComposer.Compose( Array.Empty(), Array.Empty(), Array.Empty(), new[] { Driver() }, new[] { alarm }, virtualTags: Array.Empty(), scripts: new[] { script }, unsTagReferences: new[] { Ref() }, tags: new[] { RawTag() }, rawFolders: Array.Empty(), devices: new[] { Dev() }, tagGroups: Array.Empty()); var blob = JsonSerializer.SerializeToUtf8Bytes(new { DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = (string?)null } }, Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" } }, Tags = new[] { new { TagId = "tag-1", DeviceId = "dev-1", Name = "Speed", DataType = "Float", TagConfig = "{}" } }, UnsTagReferences = new[] { new { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "tag-1", DisplayNameOverride = "MotorSpeed" } }, Scripts = new[] { new { ScriptId = "s-1", SourceCode = AlarmScript } }, ScriptedAlarms = new[] { new { ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "Hot", AlarmType = "LimitAlarm", Severity = 700, MessageTemplate = "hot", PredicateScriptId = "s-1", HistorizeToAveva = true, Retain = true, Enabled = true }, }, }); var decoded = DeploymentArtifact.ParseComposition(blob); var cPlan = composed.EquipmentScriptedAlarms.ShouldHaveSingleItem(); cPlan.PredicateSource.ShouldContain($"ctx.GetTag(\"{ExpectedRawPath}\")"); cPlan.PredicateSource.ShouldNotContain("{{equip}}"); cPlan.DependencyRefs.ShouldBe(new[] { ExpectedRawPath }); var dPlan = decoded.EquipmentScriptedAlarms.ShouldHaveSingleItem(); dPlan.ShouldBe(cPlan); } /// Wave-B review LOW-3 edge: an UNRESOLVED {{equip}}/<RefName> (no matching reference) /// must be left literally intact — identically — on both seams (the deploy gate rejects it separately; the /// compose seams must not diverge). Here the only reference is "MotorSpeed", so "Nope" cannot resolve. [Fact] public void VirtualTag_equip_ref_unresolved_left_intact_byte_parity() { const string unresolvedScript = "return ctx.GetTag(\"{{equip}}/Nope\").Value;"; var script = new Script { ScriptId = "s-1", Name = "n", SourceCode = unresolvedScript, SourceHash = "h" }; var vt = new VirtualTag { VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Over", DataType = "Double", ScriptId = "s-1" }; var composed = AddressSpaceComposer.Compose( Array.Empty(), Array.Empty(), Array.Empty(), new[] { Driver() }, Array.Empty(), virtualTags: new[] { vt }, scripts: new[] { script }, unsTagReferences: new[] { Ref() }, tags: new[] { RawTag() }, rawFolders: Array.Empty(), devices: new[] { Dev() }, tagGroups: Array.Empty()); var blob = JsonSerializer.SerializeToUtf8Bytes(new { DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = (string?)null } }, Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" } }, Tags = new[] { new { TagId = "tag-1", DeviceId = "dev-1", Name = "Speed", DataType = "Float", TagConfig = "{}" } }, UnsTagReferences = new[] { new { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "tag-1", DisplayNameOverride = "MotorSpeed" } }, Scripts = new[] { new { ScriptId = "s-1", SourceCode = unresolvedScript } }, VirtualTags = new[] { new { VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Over", DataType = "Double", ScriptId = "s-1" } }, }); var decoded = DeploymentArtifact.ParseComposition(blob); var cPlan = composed.EquipmentVirtualTags.ShouldHaveSingleItem(); cPlan.Expression.ShouldContain("{{equip}}/Nope"); // left intact — not substituted var dPlan = decoded.EquipmentVirtualTags.ShouldHaveSingleItem(); dPlan.ShouldBe(cPlan); } /// Wave-B review LOW-3 edge: RawPath ancestry (folder + tag-group) must resolve identically on both /// seams. Driver under folder "Cell1", tag under group "Fast" → RawPath "Cell1/Modbus/Dev1/Fast/Speed". [Fact] public void VirtualTag_equip_ref_resolves_through_folder_and_group_byte_parity() { const string deepRawPath = "Cell1/Modbus/Dev1/Fast/Speed"; var driver = new DriverInstance { DriverInstanceId = "drv-1", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "fld-1" }; var folder = new RawFolder { RawFolderId = "fld-1", ClusterId = "c1", Name = "Cell1", ParentRawFolderId = null }; var group = new TagGroup { TagGroupId = "grp-1", DeviceId = "dev-1", Name = "Fast", ParentTagGroupId = null }; var tag = new Tag { TagId = "tag-1", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}" }; var script = new Script { ScriptId = "s-1", Name = "n", SourceCode = VtScript, SourceHash = "h" }; var vt = new VirtualTag { VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Over", DataType = "Boolean", ScriptId = "s-1" }; var composed = AddressSpaceComposer.Compose( Array.Empty(), Array.Empty(), Array.Empty(), new[] { driver }, Array.Empty(), virtualTags: new[] { vt }, scripts: new[] { script }, unsTagReferences: new[] { Ref() }, tags: new[] { tag }, rawFolders: new[] { folder }, devices: new[] { Dev() }, tagGroups: new[] { group }); var blob = JsonSerializer.SerializeToUtf8Bytes(new { RawFolders = new[] { new { RawFolderId = "fld-1", ParentRawFolderId = (string?)null, Name = "Cell1" } }, DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "fld-1" } }, Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" } }, TagGroups = new[] { new { TagGroupId = "grp-1", ParentTagGroupId = (string?)null, Name = "Fast" } }, Tags = new[] { new { TagId = "tag-1", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float", TagConfig = "{}" } }, UnsTagReferences = new[] { new { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "tag-1", DisplayNameOverride = "MotorSpeed" } }, Scripts = new[] { new { ScriptId = "s-1", SourceCode = VtScript } }, VirtualTags = new[] { new { VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Over", DataType = "Boolean", ScriptId = "s-1" } }, }); var decoded = DeploymentArtifact.ParseComposition(blob); var cPlan = composed.EquipmentVirtualTags.ShouldHaveSingleItem(); cPlan.Expression.ShouldContain($"ctx.GetTag(\"{deepRawPath}\")"); cPlan.DependencyRefs.ShouldBe(new[] { deepRawPath }); var dPlan = decoded.EquipmentVirtualTags.ShouldHaveSingleItem(); dPlan.ShouldBe(cPlan); } }