From 329144b1aab1ac1f006cee6096a2b4dc171352c1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 21:26:00 -0400 Subject: [PATCH] v3 B1-WP6: migrate Core + Server test projects to greenfield schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix TESTS only (production already green) so the four in-scope test projects compile + pass under the v3 dark address space: - Core.Abstractions.Tests: drop AllowedNamespaceKinds/NamespaceKindCompatibility from DriverTypeMetadata; rewrite EquipmentTagRefResolver tests to the single-func RawPath-lookup contract (no parseRef/transient cache). - Core.Tests: rewrite EquipmentNodeWalkerTests to EquipmentNamespaceContent(Areas, Lines,Equipment,VirtualTags?,ScriptedAlarms?) — folders + VirtualTags + ScriptedAlarms only; raw-tag variables dark (skipped Batch-4 placeholder). Drop retired Equipment.DriverInstanceId. - Runtime.Tests: rewrite golden corpus + TagConfigCorpusParityTests to the v3 RawPath/RawTagEntry round-trip (byte + TagConfigIntent parity; EquipmentTags dark). Salvage VirtualTag + ScriptedAlarm artifact parity to the new Compose signature. Retire the equipment-tag-materialization parity files (Array/Historize/Alias) and the equipment-device-binding DeviceHost parity to documented skipped placeholders. Skip 34 dark equipment-tag routing/value/alarm/write/discovery actor tests with a shared DarkAddressSpaceReasons constant. Re-key cluster-scoped tests to v3 UNS line->area attribution. - ControlPlane.Tests: ConfigComposer round-trips re-keyed to RawFolder/Device (no Namespaces); tag-config gate resolves driver via Device; deploy-gate collision test re-keyed to UnsEffectiveNameCollision; drop retired BadCrossClusterNamespaceBinding case. --- .../DriverTypeRegistryTests.cs | 21 +- .../EquipmentTagRefResolverTests.cs | 67 +-- .../OpcUa/EquipmentNodeWalkerTests.cs | 169 ++----- .../OpcUa/IdentificationFolderBuilderTests.cs | 2 - .../AdminOperationsActorTagConfigGateTests.cs | 22 +- .../AdminOperationsActorTests.cs | 86 ++-- .../ConfigComposerTests.cs | 59 +-- .../Drivers/DarkAddressSpaceReasons.cs | 27 ++ .../DeploymentArtifactAliasParityTests.cs | 450 +----------------- .../DeploymentArtifactArrayParityTests.cs | 249 +--------- ...DeploymentArtifactDeviceHostParityTests.cs | 133 +----- .../DeploymentArtifactEquipTokenTests.cs | 5 +- .../DeploymentArtifactHistorizeParityTests.cs | 200 +------- ...loymentArtifactScriptedAlarmParityTests.cs | 17 +- .../Drivers/DeploymentArtifactTests.cs | 37 +- ...tArtifactVirtualTagHistorizeParityTests.cs | 21 +- .../DiscoveryInjectionEndToEndTests.cs | 4 +- .../Drivers/DriverHostActorDiscoveryTests.cs | 32 +- .../Drivers/DriverHostActorLiveValueTests.cs | 4 +- ...iverHostActorNativeAlarmAckRoutingTests.cs | 2 +- .../DriverHostActorNativeAlarmTests.cs | 12 +- .../DriverHostActorPrimaryGateTests.cs | 8 +- .../DriverHostActorWriteRoutingTests.cs | 4 +- .../Drivers/TagConfigCorpusParityTests.cs | 155 +++--- .../Drivers/TagConfigGoldenCorpus.cs | 82 ++-- .../OpcUa/OpcUaPublishActorRebuildTests.cs | 2 +- 26 files changed, 425 insertions(+), 1445 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeRegistryTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeRegistryTests.cs index bbd9990f..61e401a0 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeRegistryTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeRegistryTests.cs @@ -8,9 +8,8 @@ public sealed class DriverTypeRegistryTests { private static DriverTypeMetadata SampleMetadata( string typeName = "Modbus", - NamespaceKindCompatibility allowed = NamespaceKindCompatibility.Equipment, DriverTier tier = DriverTier.B) => - new(typeName, allowed, + new(typeName, DriverConfigJsonSchema: "{\"type\": \"object\"}", DeviceConfigJsonSchema: "{\"type\": \"object\"}", TagConfigJsonSchema: "{\"type\": \"object\"}", @@ -118,7 +117,7 @@ public sealed class DriverTypeRegistryTests var registry = new DriverTypeRegistry(); registry.Register(SampleMetadata("Modbus")); registry.Register(SampleMetadata("S7")); - registry.Register(SampleMetadata("Galaxy", NamespaceKindCompatibility.Equipment)); + registry.Register(SampleMetadata("Galaxy")); var all = registry.All(); @@ -126,22 +125,6 @@ public sealed class DriverTypeRegistryTests all.Select(m => m.TypeName).ShouldBe(new[] { "Modbus", "S7", "Galaxy" }, ignoreOrder: true); } - /// - /// Verifies that NamespaceKindCompatibility flags are implemented as a bitmask. - /// - [Fact] - public void NamespaceKindCompatibility_FlagsAreBitmask() - { - // Per decision #111 — driver types may be valid for multiple namespace kinds. - var both = NamespaceKindCompatibility.Equipment | NamespaceKindCompatibility.Simulated; - - both.HasFlag(NamespaceKindCompatibility.Equipment).ShouldBeTrue(); - both.HasFlag(NamespaceKindCompatibility.Simulated).ShouldBeTrue(); - - // A single-flag value does not carry the other flag — proves these are real bitmask bits. - NamespaceKindCompatibility.Equipment.HasFlag(NamespaceKindCompatibility.Simulated).ShouldBeFalse(); - } - /// /// Verifies that Get rejects empty or null type names. /// diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/EquipmentTagRefResolverTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/EquipmentTagRefResolverTests.cs index 55b92f8f..c049b289 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/EquipmentTagRefResolverTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/EquipmentTagRefResolverTests.cs @@ -4,54 +4,65 @@ using Xunit; namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests; +/// +/// v3: is now a thin wrapper over the driver's +/// authored RawPath → TDef lookup. The pre-v3 two-arg ctor (byName + a JSON-blob parse +/// fallback with a transient cache) is retired — a reference is always a RawPath, and a miss is a +/// miss (no parse attempt). These tests pin the single-func RawPath-lookup contract. +/// public class EquipmentTagRefResolverTests { private sealed record Def(string Id); - private static EquipmentTagRefResolver Make( - Dictionary byName, Func parse) - => new(r => byName.TryGetValue(r, out var d) ? d : null, parse); + private static EquipmentTagRefResolver Make(Dictionary byRawPath) + => new(r => byRawPath.TryGetValue(r, out var d) ? d : null); [Fact] - public void Legacy_name_resolves_via_byName() + public void RawPath_resolves_via_lookup() { - var r = Make(new() { ["Temp"] = new Def("Temp") }, _ => null); - r.TryResolve("Temp", out var def).ShouldBeTrue(); + var r = Make(new() { ["Area/Modbus/Dev/Temp"] = new Def("Temp") }); + r.TryResolve("Area/Modbus/Dev/Temp", out var def).ShouldBeTrue(); def!.Id.ShouldBe("Temp"); } [Fact] - public void Equipment_ref_resolves_via_parse_and_is_cached() + public void Unknown_rawPath_returns_false() { - var calls = 0; - var r = Make(new(), reference => { calls++; return reference.StartsWith("{") ? new Def(reference) : null; }); - r.TryResolve("{\"a\":1}", out var d1).ShouldBeTrue(); - r.TryResolve("{\"a\":1}", out var d2).ShouldBeTrue(); - d1!.Id.ShouldBe("{\"a\":1}"); - calls.ShouldBe(1); + var r = Make(new() { ["Area/Modbus/Dev/Temp"] = new Def("Temp") }); + r.TryResolve("Area/Modbus/Dev/Missing", out _).ShouldBeFalse(); } [Fact] - public void Garbage_ref_returns_false_and_caches_negative() + public void Lookup_is_consulted_each_call() { + // v3 holds no transient parse cache — the resolver reads the driver's live table by closure, + // so every TryResolve consults the underlying lookup (a table rebuild is immediately visible). var calls = 0; - var r = Make(new(), _ => { calls++; return null; }); - r.TryResolve("nope", out _).ShouldBeFalse(); - r.TryResolve("nope", out _).ShouldBeFalse(); - calls.ShouldBe(1); - } + var table = new Dictionary(); + var r = new EquipmentTagRefResolver(reference => + { + calls++; + return table.TryGetValue(reference, out var d) ? d : null; + }); - [Fact] - public void Clear_drops_transient_cache() - { - var calls = 0; - var r = Make(new(), reference => { calls++; return new Def(reference); }); - r.TryResolve("{\"a\":1}", out _).ShouldBeTrue(); - r.Clear(); - r.TryResolve("{\"a\":1}", out _).ShouldBeTrue(); + r.TryResolve("Area/Dev/Temp", out _).ShouldBeFalse(); + table["Area/Dev/Temp"] = new Def("Temp"); + r.TryResolve("Area/Dev/Temp", out var def).ShouldBeTrue(); + def!.Id.ShouldBe("Temp"); calls.ShouldBe(2); } + [Fact] + public void Clear_is_a_noop_and_lookup_still_works() + { + // Clear() is retained for call-site compatibility only — there is no transient cache to drop. + var r = Make(new() { ["ref"] = new Def("ref") }); + r.TryResolve("ref", out _).ShouldBeTrue(); + r.Clear(); + r.TryResolve("ref", out var def).ShouldBeTrue(); + def!.Id.ShouldBe("ref"); + } + /// /// Core.Abstractions-009: carries /// [MaybeNullWhen(false)] on its out parameter so NRT-enabled callers do @@ -62,7 +73,7 @@ public class EquipmentTagRefResolverTests [Fact] public void TryResolve_false_path_sets_def_to_default() { - var r = Make(new(), _ => null); + var r = Make(new()); var returned = r.TryResolve("no-such-ref", out var def); returned.ShouldBeFalse(); // The [MaybeNullWhen(false)] attribute means callers must treat def as potentially null diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs index 1b22a9d7..da277e21 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs @@ -9,12 +9,23 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa; [Trait("Category", "Unit")] public sealed class EquipmentNodeWalkerTests { + /// + /// v3: raw tags no longer flow into and the walker + /// emits no raw-tag variable nodes. Raw tags reach the UNS namespace via + /// UnsTagReference as the dual-namespace fan-out of their backing raw nodes, which lands + /// with the Batch-4 address space. Tests that asserted the old equipment-tag variable + /// materialization are skipped with this reason until that projection is built. + /// + internal const string DarkBatch4 = + "v3 dark address space: raw-tag → UNS variable materialization lands in Batch 4 " + + "(UnsTagReference fan-out), not in EquipmentNodeWalker."; + /// Verifies that walking empty content emits no nodes. [Fact] public void Walk_EmptyContent_EmitsNothing() { var rec = new RecordingBuilder("root"); - EquipmentNodeWalker.Walk(rec, new EquipmentNamespaceContent([], [], [], [])); + EquipmentNodeWalker.Walk(rec, new EquipmentNamespaceContent([], [], [])); rec.Children.ShouldBeEmpty(); } @@ -26,8 +37,7 @@ public sealed class EquipmentNodeWalkerTests var content = new EquipmentNamespaceContent( Areas: [Area("area-1", "warsaw"), Area("area-2", "berlin")], Lines: [Line("line-1", "area-1", "oven-line"), Line("line-2", "area-2", "press-line")], - Equipment: [Eq("eq-1", "line-1", "oven-3"), Eq("eq-2", "line-2", "press-7")], - Tags: []); + Equipment: [Eq("eq-1", "line-1", "oven-3"), Eq("eq-2", "line-2", "press-7")]); var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, content); @@ -49,7 +59,7 @@ public sealed class EquipmentNodeWalkerTests eq.ZTag = null; eq.SAPID = null; var content = new EquipmentNamespaceContent( - [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []); + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq]); var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, content); @@ -72,7 +82,7 @@ public sealed class EquipmentNodeWalkerTests eq.ZTag = "ZT-0042"; eq.SAPID = "10000042"; var content = new EquipmentNamespaceContent( - [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []); + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq]); var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, content); @@ -90,7 +100,7 @@ public sealed class EquipmentNodeWalkerTests eq.Manufacturer = "Trumpf"; eq.Model = "TruLaser-3030"; var content = new EquipmentNamespaceContent( - [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []); + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq]); var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, content); @@ -108,7 +118,7 @@ public sealed class EquipmentNodeWalkerTests { var eq = Eq("eq-1", "line-1", "oven-3"); // no identification fields var content = new EquipmentNamespaceContent( - [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []); + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq]); var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, content); @@ -117,42 +127,18 @@ public sealed class EquipmentNodeWalkerTests equipmentNode.Children.ShouldNotContain(c => c.BrowseName == "Identification"); } - /// Verifies that walking emits a variable for each bound tag under equipment. - [Fact] - public void Walk_Emits_Variable_Per_BoundTag_Under_Equipment() + /// + /// Batch-4 pending: the walker must emit a raw-tag variable per UnsTagReference under + /// equipment (dual-namespace fan-out). Preserved as a skipped placeholder — the pre-v3 test + /// asserted equipment-tag variable materialization directly off an equipment-bound Tag, + /// which no longer exists (tags bind to Devices, not equipment). Intent retained; unskip when + /// the Batch-4 UNS projection lands. + /// + [Fact(Skip = DarkBatch4)] + public void Walk_Emits_RawTag_Variables_Under_Equipment_DARK_Batch4() { - var eq = Eq("eq-1", "line-1", "oven-3"); - var tag1 = NewTag("tag-1", "Temperature", "Int32", "plcaddr-01", equipmentId: "eq-1"); - var tag2 = NewTag("tag-2", "Setpoint", "Float32", "plcaddr-02", equipmentId: "eq-1"); - var unboundTag = NewTag("tag-3", "Orphan", "Int32", "plcaddr-03", equipmentId: null); // SystemPlatform-style, walker skips - var content = new EquipmentNamespaceContent( - [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], - [eq], [tag1, tag2, unboundTag]); - - var rec = new RecordingBuilder("root"); - EquipmentNodeWalker.Walk(rec, content); - - var equipmentNode = rec.Children[0].Children[0].Children[0]; - equipmentNode.Variables.Count.ShouldBe(2); - equipmentNode.Variables.Select(v => v.BrowseName).ShouldBe(["Setpoint", "Temperature"]); - equipmentNode.Variables.First(v => v.BrowseName == "Temperature").AttributeInfo.FullName.ShouldBe("plcaddr-01"); - equipmentNode.Variables.First(v => v.BrowseName == "Setpoint").AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32); - } - - /// Verifies that walking falls back to String type for unparseable data types. - [Fact] - public void Walk_FallsBack_To_String_For_Unparseable_DataType() - { - var eq = Eq("eq-1", "line-1", "oven-3"); - var tag = NewTag("tag-1", "Mystery", "NotARealType", "plcaddr-42", equipmentId: "eq-1"); - var content = new EquipmentNamespaceContent( - [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], [tag]); - - var rec = new RecordingBuilder("root"); - EquipmentNodeWalker.Walk(rec, content); - - var variable = rec.Children[0].Children[0].Children[0].Variables.Single(); - variable.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.String); + // When Batch 4 lands, EquipmentNamespaceContent carries UnsTagReference rows and the walker + // emits one Variable per referenced raw node (correct DriverDataType, address, source kind). } /// Verifies that walking emits virtual tag variables with Virtual source discriminator. @@ -168,7 +154,7 @@ public sealed class EquipmentNodeWalkerTests }; var content = new EquipmentNamespaceContent( [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], - [eq], [], VirtualTags: [vtag]); + [eq], VirtualTags: [vtag]); var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, content); @@ -196,7 +182,7 @@ public sealed class EquipmentNodeWalkerTests }; var content = new EquipmentNamespaceContent( [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], - [eq], [], ScriptedAlarms: [alarm]); + [eq], ScriptedAlarms: [alarm]); var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, content); @@ -229,7 +215,7 @@ public sealed class EquipmentNodeWalkerTests }; var content = new EquipmentNamespaceContent( [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], - [eq], [], VirtualTags: [vtag], ScriptedAlarms: [alarm]); + [eq], VirtualTags: [vtag], ScriptedAlarms: [alarm]); var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, content); @@ -241,10 +227,10 @@ public sealed class EquipmentNodeWalkerTests [Fact] public void Walk_Null_VirtualTags_And_ScriptedAlarms_Is_Safe() { - // Backwards-compat — callers that don't populate the new collections still work. + // Backwards-compat — callers that don't populate the optional collections still work. var eq = Eq("eq-1", "line-1", "oven-3"); var content = new EquipmentNamespaceContent( - [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []); + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq]); var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, content); // must not throw @@ -252,81 +238,6 @@ public sealed class EquipmentNodeWalkerTests rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty(); } - /// Verifies that driver tag default NodeSourceKind is Driver. - [Fact] - public void Driver_tag_default_NodeSourceKind_is_Driver() - { - var eq = Eq("eq-1", "line-1", "oven-3"); - var tag = NewTag("t-1", "Temp", "Int32", "plc-01", "eq-1"); - var content = new EquipmentNamespaceContent( - [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], - [eq], [tag]); - - var rec = new RecordingBuilder("root"); - EquipmentNodeWalker.Walk(rec, content); - - var v = rec.Children[0].Children[0].Children[0].Variables.Single(); - v.AttributeInfo.Source.ShouldBe(NodeSourceKind.Driver); - v.AttributeInfo.VirtualTagId.ShouldBeNull(); - v.AttributeInfo.ScriptedAlarmId.ShouldBeNull(); - } - - /// Verifies that ExtractFullName unwraps a JSON object with FullName field. - [Fact] - public void ExtractFullName_unwraps_json_object_with_FullName_field() - { - EquipmentNodeWalker.ExtractFullName( - "{\"FullName\":\"MESReceiver_001.MoveInBatchID\",\"DataType\":\"Int32\"}") - .ShouldBe("MESReceiver_001.MoveInBatchID"); - } - - /// Verifies that ExtractFullName handles S7-style extra fields. - [Fact] - public void ExtractFullName_handles_S7_style_extra_fields() - { - EquipmentNodeWalker.ExtractFullName( - "{\"FullName\":\"DB1_DBW0\",\"Address\":\"DB1.DBW0\",\"DataType\":\"Int16\"}") - .ShouldBe("DB1_DBW0"); - } - - /// Verifies that ExtractFullName returns raw string when input is not JSON. - [Fact] - public void ExtractFullName_returns_raw_when_not_json() - { - // Drivers that opt out of JSON TagConfig still work — fallback preserves the literal - // string so the driver's IReadable sees whatever the row author stored. - EquipmentNodeWalker.ExtractFullName("raw-tag-ref").ShouldBe("raw-tag-ref"); - } - - /// Verifies that ExtractFullName returns raw string when JSON is missing FullName field. - [Fact] - public void ExtractFullName_returns_raw_when_json_missing_FullName_field() - { - EquipmentNodeWalker.ExtractFullName("{\"Address\":\"DB1.DBW0\"}") - .ShouldBe("{\"Address\":\"DB1.DBW0\"}"); - } - - /// Verifies that driver tag FullName passes through from TagConfig JSON. - [Fact] - public void Driver_tag_FullName_passes_through_from_TagConfig_json() - { - // The walker hands the driver the unwrapped FullName string so IReadable.ReadAsync - // sees the plain address, not the raw TagConfig JSON. Verifies the dispatch contract - // the path-based NodeId refactor relies on. - var eq = Eq("eq-1", "line-1", "oven-3"); - var tag = NewTag("t-1", "Temp", "Int32", "plc-01", "eq-1", - tagConfig: "{\"FullName\":\"plc-01/HR200\",\"DataType\":\"Int32\"}"); - var content = new EquipmentNamespaceContent( - [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], - [eq], [tag]); - - var rec = new RecordingBuilder("root"); - EquipmentNodeWalker.Walk(rec, content); - - var v = rec.Children[0].Children[0].Children[0].Variables.Single(); - v.AttributeInfo.FullName.ShouldBe("plc-01/HR200"); - } - // ----- builders for test seed rows ----- private static UnsArea Area(string id, string name) => new() @@ -344,25 +255,11 @@ public sealed class EquipmentNodeWalkerTests EquipmentRowId = Guid.NewGuid(), EquipmentId = equipmentId, EquipmentUuid = Guid.NewGuid(), - DriverInstanceId = "drv", UnsLineId = lineId, Name = name, MachineCode = "MC-" + name, }; - private static Tag NewTag(string tagId, string name, string dataType, string address, - string? equipmentId, string? tagConfig = null) => new() - { - TagRowId = Guid.NewGuid(), - TagId = tagId, - DriverInstanceId = "drv", - EquipmentId = equipmentId, - Name = name, - DataType = dataType, - AccessLevel = ZB.MOM.WW.OtOpcUa.Configuration.Enums.TagAccessLevel.ReadWrite, - TagConfig = tagConfig ?? address, - }; - // ----- recording IAddressSpaceBuilder ----- /// Test implementation of IAddressSpaceBuilder that records calls. diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/IdentificationFolderBuilderTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/IdentificationFolderBuilderTests.cs index a997d70d..33e836ef 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/IdentificationFolderBuilderTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/IdentificationFolderBuilderTests.cs @@ -45,7 +45,6 @@ public sealed class IdentificationFolderBuilderTests private static Equipment EmptyEquipment() => new() { EquipmentId = "EQ-000000000001", - DriverInstanceId = "drv-1", UnsLineId = "line-1", Name = "eq-1", MachineCode = "machine_001", @@ -54,7 +53,6 @@ public sealed class IdentificationFolderBuilderTests private static Equipment FullyPopulatedEquipment() => new() { EquipmentId = "EQ-000000000001", - DriverInstanceId = "drv-1", UnsLineId = "line-1", Name = "eq-1", MachineCode = "machine_001", diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTagConfigGateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTagConfigGateTests.cs index a501ef29..d4e291e6 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTagConfigGateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTagConfigGateTests.cs @@ -23,29 +23,25 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; /// public sealed class AdminOperationsActorTagConfigGateTests : ControlPlaneActorTestBase { + // v3: a raw Tag binds to a Device (Tag.DeviceId), and its driver type is resolved + // Tag → Device → DriverInstance.DriverType. The tag-config gate inspects every raw tag against that + // resolved driver type. All entity Names must be legal RawPath segments (DraftValidator RawNameInvalid). private static void Seed(IDbContextFactory dbFactory, string driverType, string tagConfig, string tagName = "flow") { - var uuid = Guid.NewGuid(); - var equipmentId = DraftValidator.DeriveEquipmentId(uuid); using var db = dbFactory.CreateDbContext(); - db.Namespaces.Add(new Namespace - { - NamespaceId = "ns-1", ClusterId = "", Kind = NamespaceKind.Equipment, NamespaceUri = "urn:eq", - }); db.DriverInstances.Add(new DriverInstance { - DriverInstanceId = "d-1", ClusterId = "", NamespaceId = "ns-1", + DriverInstanceId = "d-1", ClusterId = "", Name = "drv", DriverType = driverType, DriverConfig = "{}", }); - db.Equipment.Add(new Equipment + db.Devices.Add(new Device { - EquipmentUuid = uuid, EquipmentId = equipmentId, Name = "pump-01", - DriverInstanceId = "d-1", UnsLineId = "line-a", MachineCode = "PUMP01", + DeviceId = "dev-1", DriverInstanceId = "d-1", Name = "dev", DeviceConfig = "{}", }); db.Tags.Add(new Tag { - TagId = "tag-1", DriverInstanceId = "d-1", EquipmentId = equipmentId, + TagId = "tag-1", DeviceId = "dev-1", Name = tagName, DataType = "Int16", AccessLevel = TagAccessLevel.Read, TagConfig = tagConfig, }); db.SaveChanges(); @@ -116,8 +112,8 @@ public sealed class AdminOperationsActorTagConfigGateTests : ControlPlaneActorTe public void Unmapped_galaxy_tag_is_skipped_even_in_error_mode() { var dbFactory = NewInMemoryDbFactory(); - // Galaxy tag carrying a FullName (passes the DraftValidator Galaxy rule); the inspector has no - // Galaxy mapping, so it is skipped regardless of mode. + // A Galaxy raw tag (v3: an ordinary Device-bound tag); the inspector has no Galaxy mapping, so it + // is skipped regardless of mode — even with a nonsense dataType in its TagConfig. Seed(dbFactory, "GalaxyMxGateway", "{\"FullName\":\"Obj.Attr\",\"dataType\":\"nonsense\"}"); var coordinator = CreateTestProbe("coord"); var actor = Sys.ActorOf(AdminOperationsActor.Props( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs index d5190b72..819d5618 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs @@ -175,13 +175,14 @@ public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase db.ConfigEdits.Single().EntityType.ShouldBe("Deployment"); } - /// Verifies the full DraftValidator gate (reject on ANY error): a Tag↔VirtualTag - /// NodeId collision in the live config rejects the deploy (422-mapped - /// ) before any coordinator dispatch — and inserts - /// no Deployment row. The colliding equipment uses a canonical EquipmentId so the rejection is + /// Verifies the full DraftValidator gate (reject on ANY error): a v3 UNS effective-name + /// collision — a UnsTagReference (surfacing a raw Tag under an equipment) and a VirtualTag sharing the + /// same effective leaf name in that equipment's UNS NodeId space — rejects the deploy (422-mapped + /// ) before any coordinator dispatch, inserting no + /// Deployment row. The colliding equipment uses a canonical EquipmentId so the rejection is /// attributable to the collision rule, not to EquipmentIdNotDerived. [Fact] - public void StartDeployment_rejects_on_Tag_VirtualTag_NodeId_collision() + public void StartDeployment_rejects_on_UNS_effective_name_collision() { var uuid = Guid.NewGuid(); var equipmentId = Configuration.Validation.DraftValidator.DeriveEquipmentId(uuid); @@ -194,20 +195,32 @@ public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase EquipmentUuid = uuid, EquipmentId = equipmentId, Name = "eq", - DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m", }); + // v3: a raw Tag binds to a Device; it reaches the equipment's UNS space via a UnsTagReference. + db.DriverInstances.Add(new Configuration.Entities.DriverInstance + { + DriverInstanceId = "d", ClusterId = "", Name = "drv", DriverType = "Modbus", DriverConfig = "{}", + }); + db.Devices.Add(new Configuration.Entities.Device + { + DeviceId = "dev", DriverInstanceId = "d", Name = "dev", DeviceConfig = "{}", + }); db.Tags.Add(new Configuration.Entities.Tag { TagId = "tag-speed", - DriverInstanceId = "d", - EquipmentId = equipmentId, + DeviceId = "dev", Name = "speed", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}", }); + db.UnsTagReferences.Add(new Configuration.Entities.UnsTagReference + { + UnsTagReferenceId = "ref-speed", EquipmentId = equipmentId, TagId = "tag-speed", + }); + // A VirtualTag whose Name collides with the reference's effective name "speed". db.VirtualTags.Add(new Configuration.Entities.VirtualTag { VirtualTagId = "vtag-speed", @@ -228,8 +241,8 @@ public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase var reply = ExpectMsg(TimeSpan.FromSeconds(3)); reply.Outcome.ShouldBe(StartDeploymentOutcome.Rejected); reply.Message.ShouldNotBeNull(); - reply.Message.ShouldContain("EquipmentSignalNameCollision"); // the rule's error code - reply.Message.ShouldContain("collide"); // the rule's message text + reply.Message.ShouldContain("UnsEffectiveNameCollision"); // the v3 rule's error code + reply.Message.ShouldContain("collide"); // the rule's message text using var verify = dbFactory.CreateDbContext(); verify.Deployments.Count().ShouldBe(0); @@ -251,7 +264,6 @@ public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase EquipmentUuid = Guid.NewGuid(), EquipmentId = "EQ-operator-typed", // NOT derived from the UUID Name = "rinser-01", - DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m", }); @@ -273,53 +285,11 @@ public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase verify.Deployments.Count().ShouldBe(0); } - /// Verifies that a DriverInstance whose NamespaceId lives in a different cluster - /// triggers BadCrossClusterNamespaceBinding and routes to the Rejected branch through - /// the actor — no coordinator dispatch, no Deployment row. - /// Seeded: Namespace in cluster "MAIN" + DriverInstance in cluster "SITE-A" referencing that - /// namespace. NamespaceKind.Equipment + DriverType "Modbus" satisfies the compat rule so - /// only the cross-cluster rule fires. - [Fact] - public void StartDeployment_rejects_on_cross_cluster_namespace_binding() - { - const string nsId = "ns-main-equipment"; - - var dbFactory = NewInMemoryDbFactory(); - using (var db = dbFactory.CreateDbContext()) - { - db.Namespaces.Add(new Configuration.Entities.Namespace - { - NamespaceId = nsId, - ClusterId = "MAIN", - Kind = Configuration.Enums.NamespaceKind.Equipment, - NamespaceUri = "urn:zb:main:equipment", - }); - db.DriverInstances.Add(new Configuration.Entities.DriverInstance - { - DriverInstanceId = "drv-site-a-01", - ClusterId = "SITE-A", - NamespaceId = nsId, // cross-cluster: drv is SITE-A, ns is MAIN - Name = "site-a-modbus", - DriverType = "Modbus", // compatible with Equipment ns — no compat error - DriverConfig = "{}", - }); - db.SaveChanges(); - } - - var coordinator = CreateTestProbe("coord"); - var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty())); - - actor.Tell(new StartDeployment("joe", CorrelationId.NewId())); - - coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - var reply = ExpectMsg(TimeSpan.FromSeconds(3)); - reply.Outcome.ShouldBe(StartDeploymentOutcome.Rejected); - reply.Message.ShouldNotBeNull(); - reply.Message.ShouldContain("BadCrossClusterNamespaceBinding"); - - using var verify = dbFactory.CreateDbContext(); - verify.Deployments.Count().ShouldBe(0); - } + // RETIRED (v3): the Namespace entity and its cluster-binding rule (BadCrossClusterNamespaceBinding) + // are deleted — the two OPC UA namespaces (Raw/UNS) are implicit and driver instances no longer bind a + // Namespace. There is no v3 analog for a cross-cluster namespace binding, so this deploy-gate case is + // dropped (per the v3 schema migration). Cluster scoping is now exercised via the UNS line→area→cluster + // attribution covered in DeploymentArtifactTests. /// Verifies the warn-only compile-cost advisory: seeding N distinct non-passthrough /// (genuinely-compiled) Script rows yields an diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ConfigComposerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ConfigComposerTests.cs index f2d2c9c9..ac747ee3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ConfigComposerTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ConfigComposerTests.cs @@ -87,29 +87,27 @@ public sealed class ConfigComposerTests : ControlPlaneActorTestBase } /// - /// Verifies that serialises a - /// 's HostAddress into the artifact blob and that - /// decodes it back - /// as the equipment's - /// (follow-up E). Guards the real serialize→decode seam: if ConfigComposer's Device - /// serialisation ever drifted, DeviceHost would silently become null in production - /// (feature E degrades to a warn-skip) while hand-rolled artifact tests stayed green. + /// v3: verifies that serialises a + /// 's endpoint into the artifact blob and that + /// folds the sole + /// device's DeviceConfig up into the driver's merged DriverConfig (single-endpoint + /// driver). Guards the real serialize→decode seam: if ConfigComposer's Device serialisation ever + /// drifted, the endpoint would silently vanish from the driver config in production (the driver + /// wouldn't bind) while hand-rolled artifact tests stayed green. (Pre-v3 this rode + /// EquipmentNode.DeviceHost; v3 retired that binding — equipment references raw tags via + /// UnsTagReference, and the endpoint reaches the driver via the RawPath device merge.) /// [Fact] - public async Task DeviceHost_survives_ConfigComposer_to_ParseComposition_round_trip() + public async Task Device_endpoint_survives_ConfigComposer_to_ParseDriverInstances_round_trip() { var f = NewInMemoryDbFactory(); await using (var db = f.CreateDbContext()) { db.ServerClusters.Add(NewCluster("c1")); - db.Namespaces.Add(new Namespace - { - NamespaceId = "ns-eq", ClusterId = "c1", - Kind = NamespaceKind.Equipment, NamespaceUri = "urn:eq", - }); + db.RawFolders.Add(new RawFolder { RawFolderId = "f1", ClusterId = "c1", Name = "Plant" }); db.DriverInstances.Add(new DriverInstance { - DriverInstanceId = "drv-1", ClusterId = "c1", NamespaceId = "ns-eq", + DriverInstanceId = "drv-1", ClusterId = "c1", RawFolderId = "f1", Name = "Focas", DriverType = "Focas", DriverConfig = "{}", }); db.Devices.Add(new Device @@ -117,25 +115,23 @@ public sealed class ConfigComposerTests : ControlPlaneActorTestBase DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev-1", DeviceConfig = "{\"HostAddress\":\"10.9.9.9:8193\"}", }); - db.UnsAreas.Add(new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "area-1" }); - db.UnsLines.Add(new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" }); - db.Equipment.Add(new Equipment - { - EquipmentId = "eq-1", DriverInstanceId = "drv-1", DeviceId = "dev-1", - UnsLineId = "line-1", Name = "machine-1", MachineCode = "MACHINE_001", - }); await db.SaveChangesAsync(); } await using var readDb = f.CreateDbContext(); var artifact = await ConfigComposer.SnapshotAndFlattenAsync(readDb); - var composition = DeploymentArtifact.ParseComposition(artifact.Blob); + var specs = DeploymentArtifact.ParseDriverInstances(artifact.Blob); - var node = composition.EquipmentNodes.ShouldHaveSingleItem(); - node.EquipmentId.ShouldBe("eq-1"); - node.DriverInstanceId.ShouldBe("drv-1"); - node.DeviceId.ShouldBe("dev-1"); - node.DeviceHost.ShouldBe("10.9.9.9:8193"); + var spec = specs.ShouldHaveSingleItem(); + spec.DriverInstanceId.ShouldBe("drv-1"); + + // The sole device's DeviceConfig endpoint is folded up to the top level of the merged DriverConfig. + using var doc = System.Text.Json.JsonDocument.Parse(spec.DriverConfig); + doc.RootElement.GetProperty("HostAddress").GetString().ShouldBe("10.9.9.9:8193"); + + // And the device is still discoverable by its routing-key name in the reconstructed Devices array. + var devices = doc.RootElement.GetProperty("Devices").EnumerateArray().ToList(); + devices.ShouldHaveSingleItem().GetProperty("DeviceName").GetString().ShouldBe("dev-1"); } /// @@ -160,21 +156,16 @@ public sealed class ConfigComposerTests : ControlPlaneActorTestBase await using (var db = f.CreateDbContext()) { db.ServerClusters.Add(NewCluster("c1")); - db.Namespaces.Add(new Namespace - { - NamespaceId = "ns-eq", ClusterId = "c1", - Kind = NamespaceKind.Equipment, NamespaceUri = "urn:eq", - }); db.DriverInstances.Add(new DriverInstance { - DriverInstanceId = "drv-1", ClusterId = "c1", NamespaceId = "ns-eq", + DriverInstanceId = "drv-1", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ResilienceConfig = resilienceJson, }); // A second instance with no override proves the null case rides the same real composer path. db.DriverInstances.Add(new DriverInstance { - DriverInstanceId = "drv-2", ClusterId = "c1", NamespaceId = "ns-eq", + DriverInstanceId = "drv-2", ClusterId = "c1", Name = "Modbus2", DriverType = "Modbus", DriverConfig = "{}", }); await db.SaveChangesAsync(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs new file mode 100644 index 00000000..cd44ab42 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs @@ -0,0 +1,27 @@ +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// Shared xUnit Skip reason constants for tests whose intent is retained but whose subject is +/// not materialized in the v3 Batch-1 DARK address space. Equipment-tag variable nodes (and their +/// per-field intent: FullName / array / historize / native-alarm) are lit up by the Batch-4 dual +/// namespace + UNS↔Raw fan-out; until then the composer/artifact both emit an EMPTY equipment-tag +/// plan set, so any parity assertion over those plans has nothing to compare. The raw-tag parse +/// intent these tests characterized lives on in (RawTagEntry +/// round-trip) and TagConfigIntentTests (the parse unit). +/// +internal static class DarkAddressSpaceReasons +{ + /// Equipment-tag variable materialization + its per-field intent is dark until Batch 4. + public const string EquipmentTagsDarkBatch4 = + "v3 dark address space: equipment-tag variable plans (FullName/array/historize/alarm) do not " + + "materialize until Batch 4 (dual-namespace UNS↔Raw fan-out). The composer + artifact both emit an " + + "empty equipment-tag plan set this batch, so this parity has nothing to compare. Raw-tag parse " + + "intent is covered by TagConfigCorpusParityTests (RawTagEntry round-trip) + TagConfigIntentTests."; + + /// Equipment↔device host binding is architecturally retired in v3. + public const string EquipmentDeviceBindingRetired = + "v3: equipment no longer binds a driver/device (EquipmentNode has no DriverInstanceId/DeviceId/" + + "DeviceHost) — equipment references raw tags via UnsTagReference. The device-endpoint merge this " + + "test characterized is now covered by DeploymentArtifactRawPathTests " + + "(ParseDriverInstances_computes_nested_RawPath_and_merges_device_endpoint)."; +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs index b0362164..86446705 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs @@ -1,442 +1,32 @@ -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; /// -/// Verifies the artifact-decode mirror () -/// treats a Galaxy point as an ordinary equipment tag — an equipment-scoped tag (non-null -/// EquipmentId) bound to a GalaxyMxGateway driver in an Equipment-kind namespace — -/// into the decoded EquipmentTags 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 Equipment (no Galaxy/DriverType exception — Galaxy is an ordinary Equipment-kind -/// driver), so they agree on which tags qualify. +/// Galaxy-as-ordinary-equipment-tag byte-parity: a Galaxy point (equipment-scoped tag bound to a +/// GalaxyMxGateway driver) surfaced into EquipmentTags with the same FullName / +/// EquipmentId / DriverInstanceId / AccessLevel→Writable / native-alarm intent on both the composer +/// and the artifact-decode seam. +/// +/// v3 DARK (Batch 1): equipment-tag variable plans do not materialize — both producers emit an +/// EMPTY EquipmentTags set until the Batch-4 dual-namespace UNS↔Raw fan-out. Additionally, the +/// v3 schema retired the Namespace/NamespaceKind entities and the Galaxy +/// ExplicitFullName / namespace-Kind gate this test keyed off; Galaxy is an ordinary +/// Device-bound driver whose raw tags reach the UNS via UnsTagReference. The AccessLevel→Writable +/// and native-alarm parse this asserted are covered at the parse unit (TagConfigIntentTests) and +/// the RawTagEntry round-trip (). Unskip + rewrite to the +/// Batch-4 UNS-projection shape when equipment-tag plans light up. +/// /// public sealed class DeploymentArtifactAliasParityTests { - /// 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 - /// string.Empty. - [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(); - } - - /// The artifact decoder reads AccessLevel into EquipmentTagPlan.Writable: - /// 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. - [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); - } - - /// 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. - [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); - } - - /// An equipment-scoped tag in a non-Equipment (Simulated) namespace must NOT surface in - /// EquipmentTags — byte-parity with the composer's pure ns.Kind == NamespaceKind.Equipment - /// 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. - [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(); - } - - /// - /// The load-bearing direct byte-parity proof for the two equipment-tag producers: for the SAME - /// input draft, the live-edit composer () and the - /// artifact decoder () - /// must emit IDENTICAL EquipmentTags — 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 {"FullName":"DelmiaReceiver_001.DownloadPath"}) - /// 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. EquipmentTagPlan is a plain positional record (all string members), so - /// SequenceEqual is full value-and-order equality. - /// - [Fact] + /// Batch-4 pending: composer and artifact agree on a Galaxy equipment tag surfaced in + /// EquipmentTags (FullName + Writable + native alarm). See class remarks — dark this batch. + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] 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 = AddressSpaceComposer.Compose( - areas, lines, equipment, drivers, Array.Empty(), 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(); + // Restored + rewritten to the Batch-4 UNS-projection shape when equipment-tag variable plans + // materialize: assert a Galaxy point + a Modbus point round-trip identically (FullName, Writable + // from AccessLevel, native-alarm intent incl. historizeToAveva) across composer and artifact. } - - /// - /// The native-alarm historizeToAveva opt-out (bool?, the per-condition durable-AVEVA-write - /// gate) is parsed by BOTH equipment-tag producers' ExtractTagAlarm and MUST stay byte-parity: - /// for the SAME tag TagConfig carrying alarm.historizeToAveva: true (and : false), the - /// live-edit composer () and the artifact decoder - /// () must derive the - /// identical EquipmentTagAlarmInfo.HistorizeToAveva. The galaxy-tag parity test above already - /// covers the absent ⇒ null case; this pins the explicit-bool branch on both sides. - /// - [Theory] - [InlineData(true)] - [InlineData(false)] - public void Composer_and_artifact_agree_on_alarm_historizeToAveva(bool historizeToAveva) - { - var ns = new Namespace - { - NamespaceId = "ns-eq", - ClusterId = "c1", - Kind = NamespaceKind.Equipment, - NamespaceUri = "urn:eq", - }; - var driver = 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 equip = new Equipment - { - EquipmentId = "eq-1", - DriverInstanceId = "drv-modbus", - UnsLineId = "line-1", - Name = "FillingPump", - MachineCode = "FILLINGPUMP", - }; - var alarmJson = historizeToAveva ? "true" : "false"; - var alarmTag = new Tag - { - TagId = "tag-alarm", - DriverInstanceId = "drv-modbus", - EquipmentId = "eq-1", - FolderPath = null, - Name = "Temp", - DataType = "Float", - AccessLevel = TagAccessLevel.Read, - TagConfig = $"{{\"FullName\":\"40001\",\"alarm\":{{\"alarmType\":\"OffNormalAlarm\",\"severity\":700,\"historizeToAveva\":{alarmJson}}}}}", - }; - - // ---- Side 1: the live-edit composer ---- - var composed = AddressSpaceComposer.Compose( - new[] { area }, new[] { line }, new[] { equip }, new[] { driver }, - Array.Empty(), new[] { alarmTag }, new[] { ns }); - - // ---- Side 2: serialise the SAME draft to the artifact blob shape, then decode it ---- - var blob = JsonSerializer.SerializeToUtf8Bytes(new - { - Namespaces = new[] { new { ns.NamespaceId, ns.ClusterId, Kind = (int)ns.Kind } }, - DriverInstances = new[] - { - new { driver.DriverInstanceId, driver.DriverType, driver.DriverConfig, driver.NamespaceId, driver.ClusterId }, - }, - Tags = new[] { ToSnapshot(alarmTag) }, - }); - - var decoded = DeploymentArtifact.ParseComposition(blob); - - // ---- Byte-parity: HistorizeToAveva matches between producers and equals the seeded bool. ---- - var decodedAlarm = decoded.EquipmentTags.ShouldHaveSingleItem().Alarm.ShouldNotBeNull(); - var composedAlarm = composed.EquipmentTags.ShouldHaveSingleItem().Alarm.ShouldNotBeNull(); - decodedAlarm.HistorizeToAveva.ShouldBe(historizeToAveva); - composedAlarm.HistorizeToAveva.ShouldBe(historizeToAveva); - decodedAlarm.ShouldBe(composedAlarm); // EquipmentTagAlarmInfo is a positional record ⇒ value equality - } - - /// The full Pascal-case snapshot a EF entity serialises to in the - /// artifact (matches ConfigComposer): the equipment-tag decoder reads exactly these fields. - 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, - }; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactArrayParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactArrayParityTests.cs index 5ff6975b..b0f5b6f1 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactArrayParityTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactArrayParityTests.cs @@ -1,245 +1,28 @@ -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; /// -/// Proves the Phase 4c array intent (isArray + optional arrayLength), which rides -/// inside the raw TagConfig JSON blob, round-trips with byte-parity through both -/// equipment-tag producers: the live-edit composer () and the -/// artifact decoder (). -/// A secondary/follower node decoding a serialized deployment artifact MUST materialise array tags -/// identically to the primary, so the artifact side must derive IsArray / ArrayLength -/// from the same blob the composer parses. The composer's ExtractTagArray is internal and not -/// visible to this test assembly (InternalsVisibleTo only names the OpcUaServer.Tests -/// project), so byte-parity is asserted via the public-surface round-trip — exactly as the sibling -/// does. +/// Phase 4c array intent (isArray + optional arrayLength) byte-parity between the two +/// equipment-tag producers. +/// +/// v3 DARK (Batch 1): equipment-tag variable plans do not materialize — the composer and the +/// artifact both emit an EMPTY EquipmentTags set until the Batch-4 dual-namespace UNS↔Raw +/// fan-out, so there is nothing to compare. The underlying parse of isArray / arrayLength +/// from a raw TagConfig blob is characterized today by TagConfigIntentTests and +/// round-tripped through the artifact by (RawTagEntry +/// byte-preservation). Unskip when Batch 4 lights the equipment-tag plans. +/// /// public sealed class DeploymentArtifactArrayParityTests { - /// - /// One draft consumed by both producers, exercising every ExtractTagArray branch: - /// isArray:true,arrayLength:16(true, 16u); absent ⇒ (false, null); - /// isArray:true with no length ⇒ (true, null); a non-number (string) length while - /// isArray:true(true, null). The decoded EquipmentTags must equal the - /// composer's element-wise (positional-record value equality) and in the same order, proving - /// IsArray / ArrayLength are derived byte-identically on both seams. - /// - [Fact] + /// Batch-4 pending: composer and artifact agree on array equipment-tag plans + /// (IsArray + ArrayLength). See class remarks — equipment-tag plans are dark this batch. + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Composer_and_artifact_agree_on_array_equipment_tags() { - var ns = new Namespace - { - NamespaceId = "ns-eq", - ClusterId = "c1", - Kind = NamespaceKind.Equipment, - NamespaceUri = "urn:eq", - }; - var driver = 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 equip = new Equipment - { - EquipmentId = "eq-1", - DriverInstanceId = "drv-modbus", - UnsLineId = "line-1", - Name = "FillingPump", - MachineCode = "FILLINGPUMP", - }; - - // Array WITH an explicit bounded length → (true, 16u). - var arrayBoundedTag = new Tag - { - TagId = "tag-array-bounded", - DriverInstanceId = "drv-modbus", - EquipmentId = "eq-1", - FolderPath = null, - Name = "Recipe", - DataType = "Int32", - AccessLevel = TagAccessLevel.Read, - TagConfig = "{\"FullName\":\"40001\",\"isArray\":true,\"arrayLength\":16}", - }; - // Plain scalar — no isArray flag → (false, null). - var scalarTag = new Tag - { - TagId = "tag-scalar", - DriverInstanceId = "drv-modbus", - EquipmentId = "eq-1", - FolderPath = null, - Name = "Speed", - DataType = "Float", - AccessLevel = TagAccessLevel.ReadWrite, - TagConfig = "{\"FullName\":\"40002\"}", - }; - // Array WITHOUT a length → (true, null) ⇒ unbounded 1-D array at materialisation. - var arrayUnboundedTag = new Tag - { - TagId = "tag-array-unbounded", - DriverInstanceId = "drv-modbus", - EquipmentId = "eq-1", - FolderPath = null, - Name = "Trace", - DataType = "Float", - AccessLevel = TagAccessLevel.Read, - TagConfig = "{\"FullName\":\"40003\",\"isArray\":true}", - }; - // Array with a NON-number (string) length token → length ignored ⇒ (true, null). This well-formed - // blob exercises the artifact-side private ExtractTagArray's number-guard branch through the real - // round-trip (it can't be unit-tested directly because it's private). A truly malformed TagConfig - // string would cause ExtractTagFullName to return "" and break the SequenceEqual on other fields, - // so the throws/malformed path is covered by the composer unit test, not here. - // NOTE (review M-1): only the string-type bad-length case is round-tripped here. The - // negative/float/overflow arrayLength reject paths are covered on the composer side - // (ExtractTagArrayTests) and do not need duplication in this artifact parity test. - var arrayBadLengthTag = new Tag - { - TagId = "tag-array-badlen", - DriverInstanceId = "drv-modbus", - EquipmentId = "eq-1", - FolderPath = null, - Name = "Buffer", - DataType = "Int32", - AccessLevel = TagAccessLevel.Read, - TagConfig = "{\"FullName\":\"40004\",\"isArray\":true,\"arrayLength\":\"sixteen\"}", - }; - // I-1: isArray:false with a non-zero arrayLength — the artifact-side guard must gate arrayLength - // under isArray, so the length is discarded and IsArray==false, ArrayLength==null on both sides. - // Pins that a future removal of the isArray gate would be caught here. - var arrayDisabledTag = new Tag - { - TagId = "tag-array-disabled", - DriverInstanceId = "drv-modbus", - EquipmentId = "eq-1", - FolderPath = null, - Name = "Setpoint", - DataType = "Float", - AccessLevel = TagAccessLevel.ReadWrite, - TagConfig = "{\"FullName\":\"40005\",\"isArray\":false,\"arrayLength\":8}", - }; - // I-2: isArray:true with arrayLength:0 — zero is a legal explicit dimension bound (e.g. dynamic - // array with declared-but-unset size). Must decode to IsArray==true, ArrayLength==0u on both sides. - // Pins that a future change treating 0 as absent would be caught here. - var arrayZeroLengthTag = new Tag - { - TagId = "tag-array-zerolen", - DriverInstanceId = "drv-modbus", - EquipmentId = "eq-1", - FolderPath = null, - Name = "Empty", - DataType = "Int32", - AccessLevel = TagAccessLevel.Read, - TagConfig = "{\"FullName\":\"40006\",\"isArray\":true,\"arrayLength\":0}", - }; - - var areas = new[] { area }; - var lines = new[] { line }; - var equipment = new[] { equip }; - var drivers = new[] { driver }; - var tags = new[] { arrayBoundedTag, scalarTag, arrayUnboundedTag, arrayBadLengthTag, arrayDisabledTag, arrayZeroLengthTag }; - var namespaces = new[] { ns }; - - // ---- Side 1: the live-edit composer ---- - var composed = AddressSpaceComposer.Compose( - areas, lines, equipment, drivers, Array.Empty(), tags, namespaces); - - // ---- Side 2: serialise the SAME draft to the artifact blob shape, then decode it ---- - var blob = JsonSerializer.SerializeToUtf8Bytes(new - { - Namespaces = new[] - { - new { ns.NamespaceId, ns.ClusterId, Kind = (int)ns.Kind }, - }, - DriverInstances = new[] - { - new { driver.DriverInstanceId, driver.DriverType, driver.DriverConfig, driver.NamespaceId, driver.ClusterId }, - }, - Tags = new[] - { - ToSnapshot(arrayBoundedTag), - ToSnapshot(scalarTag), - ToSnapshot(arrayUnboundedTag), - ToSnapshot(arrayBadLengthTag), - ToSnapshot(arrayDisabledTag), - ToSnapshot(arrayZeroLengthTag), - }, - }); - - var decoded = DeploymentArtifact.ParseComposition(blob); - - // ---- Full byte-parity: every field, same order (positional-record value equality) ---- - decoded.EquipmentTags.Count.ShouldBe(6); - decoded.EquipmentTags.SequenceEqual(composed.EquipmentTags).ShouldBeTrue(); - - // Spell out the array fields per-tag so a divergence names the offending tag. - var arrayBounded = decoded.EquipmentTags.Single(t => t.TagId == "tag-array-bounded"); - arrayBounded.IsArray.ShouldBeTrue(); - arrayBounded.ArrayLength.ShouldBe(16u); - composed.EquipmentTags.Single(t => t.TagId == "tag-array-bounded").IsArray.ShouldBeTrue(); - composed.EquipmentTags.Single(t => t.TagId == "tag-array-bounded").ArrayLength.ShouldBe(16u); - - var scalar = decoded.EquipmentTags.Single(t => t.TagId == "tag-scalar"); - scalar.IsArray.ShouldBeFalse(); - scalar.ArrayLength.ShouldBeNull(); - composed.EquipmentTags.Single(t => t.TagId == "tag-scalar").IsArray.ShouldBeFalse(); - composed.EquipmentTags.Single(t => t.TagId == "tag-scalar").ArrayLength.ShouldBeNull(); - - var arrayUnbounded = decoded.EquipmentTags.Single(t => t.TagId == "tag-array-unbounded"); - arrayUnbounded.IsArray.ShouldBeTrue(); - arrayUnbounded.ArrayLength.ShouldBeNull(); - composed.EquipmentTags.Single(t => t.TagId == "tag-array-unbounded").IsArray.ShouldBeTrue(); - composed.EquipmentTags.Single(t => t.TagId == "tag-array-unbounded").ArrayLength.ShouldBeNull(); - - // 4th tag: isArray:true with a string arrayLength ⇒ (true, null) on both sides. Exercises the - // artifact-side private ExtractTagArray's number-guard branch. - var arrayBadLength = decoded.EquipmentTags.Single(t => t.TagId == "tag-array-badlen"); - arrayBadLength.IsArray.ShouldBeTrue(); - arrayBadLength.ArrayLength.ShouldBeNull(); - composed.EquipmentTags.Single(t => t.TagId == "tag-array-badlen").IsArray.ShouldBeTrue(); - composed.EquipmentTags.Single(t => t.TagId == "tag-array-badlen").ArrayLength.ShouldBeNull(); - - // I-1: isArray:false with a non-zero arrayLength ⇒ (false, null) on both sides. - // The artifact-side isArray gate must suppress arrayLength even when it is present in the blob. - var arrayDisabled = decoded.EquipmentTags.Single(t => t.TagId == "tag-array-disabled"); - arrayDisabled.IsArray.ShouldBeFalse(); - arrayDisabled.ArrayLength.ShouldBeNull(); - composed.EquipmentTags.Single(t => t.TagId == "tag-array-disabled").IsArray.ShouldBeFalse(); - composed.EquipmentTags.Single(t => t.TagId == "tag-array-disabled").ArrayLength.ShouldBeNull(); - - // I-2: isArray:true with arrayLength:0 ⇒ (true, 0u) on both sides. - // Zero is a legal explicit dimension bound; it must not be treated as absent. - var arrayZeroLength = decoded.EquipmentTags.Single(t => t.TagId == "tag-array-zerolen"); - arrayZeroLength.IsArray.ShouldBeTrue(); - arrayZeroLength.ArrayLength.ShouldBe(0u); - composed.EquipmentTags.Single(t => t.TagId == "tag-array-zerolen").IsArray.ShouldBeTrue(); - composed.EquipmentTags.Single(t => t.TagId == "tag-array-zerolen").ArrayLength.ShouldBe(0u); + // Restored when Batch 4 materializes equipment-tag variable plans: compose a draft exercising every + // array branch (isArray:true+length, absent, isArray:true no length, non-number length) and assert + // decoded EquipmentTags == composed element-wise. } - - /// The Pascal-case snapshot a EF entity serialises to in the artifact - /// (matches ConfigComposer); the equipment-tag decoder re-parses these fields — including the raw - /// TagConfig blob the array flags ride inside. - private static object ToSnapshot(Tag t) => new - { - t.TagId, - t.DriverInstanceId, - t.EquipmentId, - t.Name, - t.FolderPath, - t.DataType, - AccessLevel = (int)t.AccessLevel, - t.TagConfig, - }; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactDeviceHostParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactDeviceHostParityTests.cs index 23f5023d..b714eda3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactDeviceHostParityTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactDeviceHostParityTests.cs @@ -1,130 +1,27 @@ -using System.Linq; -using System.Text.Json; -using Shouldly; using Xunit; -using ZB.MOM.WW.OtOpcUa.Configuration.Entities; -using ZB.MOM.WW.OtOpcUa.OpcUaServer; -using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// -/// Proves follow-up E: the equipment's DriverInstanceId / DeviceId bindings and the -/// resolved DeviceHost (parsed from the bound 's schemaless -/// DeviceConfig JSON) round-trip with byte-parity through both -/// producers: the live-edit composer () -/// and the artifact decoder (). -/// A secondary/follower node decoding a serialized artifact MUST see the same DeviceHost as the -/// primary so it grafts FixedTree / partitions multi-device drivers identically. Both sides resolve -/// the host through the shared DeviceConfigIntent.TryExtractHost (single source -/// of truth + identical trim + lower-case normalization). +/// Equipment DriverInstanceId / DeviceId binding + resolved DeviceHost byte-parity +/// across the two EquipmentNode producers (composer + artifact decode). +/// +/// v3 RETIRED: equipment no longer binds a driver/device — EquipmentNode is +/// (EquipmentId, DisplayName, UnsLineId) only, Equipment dropped +/// DriverInstanceId/DeviceId, and devices bind to drivers (tags bind to devices) while +/// equipment references raw tags via UnsTagReference. The device-endpoint resolution this test +/// characterized (fold the sole device's DeviceConfig host into the driver config so a follower +/// node dials the same endpoint) is now proven by +/// . +/// Kept as a skipped marker so the retired coverage is discoverable, not silently deleted. +/// /// public sealed class DeploymentArtifactDeviceHostParityTests { - /// - /// One draft exercising every branch: driver + device + host (with a mixed-case/whitespace host - /// that must normalize identically on both sides); a driver-bound equipment with NO device - /// (DeviceId null ⇒ DeviceHost null); a driver-less, device-less equipment (all three null); and a - /// device whose config carries no HostAddress (DeviceId carried, DeviceHost null). The decoded - /// EquipmentNodes must equal the composer's element-wise (positional-record value equality) - /// and in the same order. - /// - [Fact] + /// Retired: equipment↔device host binding removed in v3; superseded by + /// DeploymentArtifactRawPathTests device-endpoint merge. See class remarks. + [Fact(Skip = DarkAddressSpaceReasons.EquipmentDeviceBindingRetired)] public void Composer_and_artifact_agree_on_equipment_node_device_host() { - // eq-1: driver d1 + device dev1 (host needs trim + lower-case) - var eq1 = NewEquipment("eq-1", driver: "d1", device: "dev1"); - // eq-2: driver d1, NO device → DeviceHost null - var eq2 = NewEquipment("eq-2", driver: "d1", device: null); - // eq-3: driver-less + device-less → all three null - var eq3 = NewEquipment("eq-3", driver: null, device: null); - // eq-4: device dev-nohost whose config has no HostAddress → DeviceHost null - var eq4 = NewEquipment("eq-4", driver: "d1", device: "dev-nohost"); - - var dev1 = NewDevice("dev1", "d1", "{\"HostAddress\":\" HOST-A:8193 \"}"); // → host-a:8193 - var devNoHost = NewDevice("dev-nohost", "d1", "{\"Port\":502}"); // → null - - var equipment = new[] { eq1, eq2, eq3, eq4 }; - var devices = new[] { dev1, devNoHost }; - - // ---- Side 1: the live-edit composer ---- - var composed = AddressSpaceComposer.Compose( - Array.Empty(), Array.Empty(), equipment, - Array.Empty(), Array.Empty(), devices: devices); - - // ---- Side 2: serialise the SAME draft to the artifact blob shape, then decode it ---- - var blob = JsonSerializer.SerializeToUtf8Bytes(new - { - Equipment = equipment.Select(ToEquipmentSnapshot).ToArray(), - Devices = devices.Select(ToDeviceSnapshot).ToArray(), - }); - - var decoded = DeploymentArtifact.ParseComposition(blob); - - // ---- Full byte-parity: every field, same order (positional-record value equality) ---- - decoded.EquipmentNodes.Count.ShouldBe(4); - decoded.EquipmentNodes.SequenceEqual(composed.EquipmentNodes).ShouldBeTrue(); - - // Spell out per-equipment so a divergence names the offending node. - var d1Node = decoded.EquipmentNodes.Single(e => e.EquipmentId == "eq-1"); - d1Node.DriverInstanceId.ShouldBe("d1"); - d1Node.DeviceId.ShouldBe("dev1"); - d1Node.DeviceHost.ShouldBe("host-a:8193"); // trimmed + lower-cased on both sides - - var d2Node = decoded.EquipmentNodes.Single(e => e.EquipmentId == "eq-2"); - d2Node.DriverInstanceId.ShouldBe("d1"); - d2Node.DeviceId.ShouldBeNull(); - d2Node.DeviceHost.ShouldBeNull(); - - var d3Node = decoded.EquipmentNodes.Single(e => e.EquipmentId == "eq-3"); - d3Node.DriverInstanceId.ShouldBeNull(); - d3Node.DeviceId.ShouldBeNull(); - d3Node.DeviceHost.ShouldBeNull(); - - var d4Node = decoded.EquipmentNodes.Single(e => e.EquipmentId == "eq-4"); - d4Node.DriverInstanceId.ShouldBe("d1"); - d4Node.DeviceId.ShouldBe("dev-nohost"); - d4Node.DeviceHost.ShouldBeNull(); } - - private static Equipment NewEquipment(string id, string? driver, string? device) => new() - { - EquipmentId = id, - DriverInstanceId = driver, - DeviceId = device, - UnsLineId = "line-1", - Name = id, - MachineCode = id.ToUpperInvariant(), - }; - - private static Device NewDevice(string deviceId, string driverInstanceId, string deviceConfig) => new() - { - DeviceId = deviceId, - DriverInstanceId = driverInstanceId, - Name = deviceId, - DeviceConfig = deviceConfig, - }; - - /// The Pascal-case snapshot an EF entity serialises to in the - /// artifact (matches ConfigComposer) — including the nullable DriverInstanceId / DeviceId - /// the equipment-node decoder re-reads. - private static object ToEquipmentSnapshot(Equipment e) => new - { - e.EquipmentId, - e.Name, - e.MachineCode, - e.UnsLineId, - e.DriverInstanceId, - e.DeviceId, - }; - - /// The Pascal-case snapshot a EF entity serialises to in the artifact — - /// the decoder re-reads DeviceId + the raw DeviceConfig blob the host rides inside. - private static object ToDeviceSnapshot(Device d) => new - { - d.DeviceId, - d.DriverInstanceId, - d.Name, - d.DeviceConfig, - }; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactEquipTokenTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactEquipTokenTests.cs index 98ce4523..2759d519 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactEquipTokenTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactEquipTokenTests.cs @@ -14,7 +14,10 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// public sealed class DeploymentArtifactEquipTokenTests { - [Fact] + // v3 dark: the {{equip}} tag base is DERIVED from the owning equipment's child equipment-tag FullNames, + // and equipment-tag plans do not materialize until Batch 4 — so baseByEquip is empty and {{equip}} has + // no per-equipment base to substitute. Unskip when Batch 4 lights the equipment-tag plans. + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void ParseComposition_substitutes_equip_token_in_virtual_tag_expression() { var blob = JsonSerializer.SerializeToUtf8Bytes(new diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactHistorizeParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactHistorizeParityTests.cs index 3783aa63..02c11f0e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactHistorizeParityTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactHistorizeParityTests.cs @@ -1,194 +1,30 @@ -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; /// -/// Proves the Phase C HistoryRead intent (isHistorized + optional historianTagname), -/// which rides inside the raw TagConfig JSON blob, round-trips with byte-parity through both -/// equipment-tag producers: the live-edit composer () and the -/// artifact decoder (). -/// The artifact serializer re-parses the SAME TagConfig string both sides emit, so no -/// ConfigComposer change is needed — the flags are already carried in the blob. +/// Phase C HistoryRead intent (isHistorized + optional historianTagname) byte-parity +/// between the two equipment-tag producers. +/// +/// v3 DARK (Batch 1): equipment-tag variable plans do not materialize — +/// AddressSpaceComposer.Compose and DeploymentArtifact.ParseComposition both emit an +/// EMPTY EquipmentTags set until the Batch-4 dual-namespace UNS↔Raw fan-out. There is nothing +/// to compare, so the parity assertion is parked. The underlying parse of isHistorized / +/// historianTagname from a raw TagConfig blob is characterized today by +/// TagConfigIntentTests (the parse unit) and round-tripped through the artifact by +/// (RawTagEntry byte-preservation). Unskip + restore the +/// composer↔artifact equipment-tag comparison when Batch 4 lights the equipment-tag plans. +/// /// public sealed class DeploymentArtifactHistorizeParityTests { - /// - /// One draft consumed by both producers: a historized equipment tag with no explicit historian - /// tagname (defaults to FullName later, so HistorianTagname is null on both sides), plus a - /// historized equipment tag WITH an explicit historianTagname override, plus a plain - /// non-historized tag. The decoded EquipmentTags must equal the composer's element-wise - /// (positional-record value equality) and in the same order, proving IsHistorized / - /// HistorianTagname are derived byte-identically on both seams. - /// - [Fact] + /// Batch-4 pending: composer and artifact agree on historized equipment-tag plans + /// (IsHistorized + HistorianTagname). See class remarks — equipment-tag plans are dark this batch. + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Composer_and_artifact_agree_on_historized_equipment_tags() { - var ns = new Namespace - { - NamespaceId = "ns-eq", - ClusterId = "c1", - Kind = NamespaceKind.Equipment, - NamespaceUri = "urn:eq", - }; - var driver = 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 equip = new Equipment - { - EquipmentId = "eq-1", - DriverInstanceId = "drv-modbus", - UnsLineId = "line-1", - Name = "FillingPump", - MachineCode = "FILLINGPUMP", - }; - - // Historized, no explicit tagname → HistorianTagname null (defaults to FullName later). - var histDefaultTag = new Tag - { - TagId = "tag-hist-default", - DriverInstanceId = "drv-modbus", - EquipmentId = "eq-1", - FolderPath = null, - Name = "Flow", - DataType = "Float", - AccessLevel = TagAccessLevel.Read, - TagConfig = "{\"FullName\":\"40001\",\"isHistorized\":true}", - }; - // Historized WITH an explicit historian-tagname override. - var histOverrideTag = new Tag - { - TagId = "tag-hist-override", - DriverInstanceId = "drv-modbus", - EquipmentId = "eq-1", - FolderPath = null, - Name = "Pressure", - DataType = "Float", - AccessLevel = TagAccessLevel.Read, - TagConfig = "{\"FullName\":\"40002\",\"isHistorized\":true,\"historianTagname\":\"Plant.Line1.Pressure\"}", - }; - // Plain tag — not historized. - var plainTag = new Tag - { - TagId = "tag-plain", - DriverInstanceId = "drv-modbus", - EquipmentId = "eq-1", - FolderPath = null, - Name = "Speed", - DataType = "Float", - AccessLevel = TagAccessLevel.ReadWrite, - TagConfig = "{\"FullName\":\"40003\"}", - }; - // Well-formed but non-historized: explicit isHistorized:false + a JSON-null historianTagname - // (not a string token → falls through the ValueKind==String guard → tagname null). - // This exercises the artifact-side private ExtractTagHistorize through the real round-trip - // (it can't be unit-tested directly because it's private). A truly malformed TagConfig string - // would cause ExtractTagFullName to return "" and break the SequenceEqual on other fields, so - // we use a well-formed blob and note that the malformed/throws path is already covered by - // the composer unit test in ExtractTagHistorizeTests. - var notHistorizedNullNameTag = new Tag - { - TagId = "tag-not-hist-nullname", - DriverInstanceId = "drv-modbus", - EquipmentId = "eq-1", - FolderPath = null, - Name = "Temp", - DataType = "Float", - AccessLevel = TagAccessLevel.Read, - TagConfig = "{\"FullName\":\"40004\",\"isHistorized\":false,\"historianTagname\":null}", - }; - - var areas = new[] { area }; - var lines = new[] { line }; - var equipment = new[] { equip }; - var drivers = new[] { driver }; - var tags = new[] { histDefaultTag, histOverrideTag, plainTag, notHistorizedNullNameTag }; - var namespaces = new[] { ns }; - - // ---- Side 1: the live-edit composer ---- - var composed = AddressSpaceComposer.Compose( - areas, lines, equipment, drivers, Array.Empty(), tags, namespaces); - - // ---- Side 2: serialise the SAME draft to the artifact blob shape, then decode it ---- - var blob = JsonSerializer.SerializeToUtf8Bytes(new - { - Namespaces = new[] - { - new { ns.NamespaceId, ns.ClusterId, Kind = (int)ns.Kind }, - }, - DriverInstances = new[] - { - new { driver.DriverInstanceId, driver.DriverType, driver.DriverConfig, driver.NamespaceId, driver.ClusterId }, - }, - Tags = new[] - { - ToSnapshot(histDefaultTag), - ToSnapshot(histOverrideTag), - ToSnapshot(plainTag), - ToSnapshot(notHistorizedNullNameTag), - }, - }); - - var decoded = DeploymentArtifact.ParseComposition(blob); - - // ---- Full byte-parity: every field, same order (positional-record value equality) ---- - decoded.EquipmentTags.Count.ShouldBe(4); - decoded.EquipmentTags.SequenceEqual(composed.EquipmentTags).ShouldBeTrue(); - - // Spell out the Phase C fields per-tag so a divergence names the offending tag. - var histDefault = decoded.EquipmentTags.Single(t => t.TagId == "tag-hist-default"); - histDefault.IsHistorized.ShouldBeTrue(); - histDefault.HistorianTagname.ShouldBeNull(); - composed.EquipmentTags.Single(t => t.TagId == "tag-hist-default").IsHistorized.ShouldBeTrue(); - composed.EquipmentTags.Single(t => t.TagId == "tag-hist-default").HistorianTagname.ShouldBeNull(); - - var histOverride = decoded.EquipmentTags.Single(t => t.TagId == "tag-hist-override"); - histOverride.IsHistorized.ShouldBeTrue(); - histOverride.HistorianTagname.ShouldBe("Plant.Line1.Pressure"); - composed.EquipmentTags.Single(t => t.TagId == "tag-hist-override").IsHistorized.ShouldBeTrue(); - composed.EquipmentTags.Single(t => t.TagId == "tag-hist-override").HistorianTagname.ShouldBe("Plant.Line1.Pressure"); - - var plain = decoded.EquipmentTags.Single(t => t.TagId == "tag-plain"); - plain.IsHistorized.ShouldBeFalse(); - plain.HistorianTagname.ShouldBeNull(); - composed.EquipmentTags.Single(t => t.TagId == "tag-plain").IsHistorized.ShouldBeFalse(); - composed.EquipmentTags.Single(t => t.TagId == "tag-plain").HistorianTagname.ShouldBeNull(); - - // 4th tag: explicit isHistorized:false + JSON-null historianTagname ⇒ (false, null) on both sides. - // Exercises the artifact-side private ExtractTagHistorize's null-JSON-token guard path. - var notHistorizedNullName = decoded.EquipmentTags.Single(t => t.TagId == "tag-not-hist-nullname"); - notHistorizedNullName.IsHistorized.ShouldBeFalse(); - notHistorizedNullName.HistorianTagname.ShouldBeNull(); - composed.EquipmentTags.Single(t => t.TagId == "tag-not-hist-nullname").IsHistorized.ShouldBeFalse(); - composed.EquipmentTags.Single(t => t.TagId == "tag-not-hist-nullname").HistorianTagname.ShouldBeNull(); + // Restored when Batch 4 materializes equipment-tag variable plans: compose a draft with a + // historized tag (no explicit tagname → HistorianTagname null), a historized tag WITH an explicit + // historianTagname override, and a plain tag; assert decoded EquipmentTags == composed element-wise. } - - /// The Pascal-case snapshot a EF entity serialises to in the artifact - /// (matches ConfigComposer); the equipment-tag decoder re-parses these fields — including the raw - /// TagConfig blob the Phase C flags ride inside. - private static object ToSnapshot(Tag t) => new - { - t.TagId, - t.DriverInstanceId, - t.EquipmentId, - t.Name, - t.FolderPath, - t.DataType, - AccessLevel = (int)t.AccessLevel, - t.TagConfig, - }; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScriptedAlarmParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScriptedAlarmParityTests.cs index 721d5269..1265ac85 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScriptedAlarmParityTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactScriptedAlarmParityTests.cs @@ -73,7 +73,6 @@ public sealed class DeploymentArtifactScriptedAlarmParityTests var composed = AddressSpaceComposer.Compose( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), new[] { alarm1, alarm2 }, - Array.Empty(), Array.Empty(), virtualTags: Array.Empty(), scripts: new[] { script1, script2 }); @@ -127,10 +126,21 @@ public sealed class DeploymentArtifactScriptedAlarmParityTests new { DriverInstanceId = "main-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" }, new { DriverInstanceId = "sa-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" }, }, + // v3: equipment is attributed to a cluster via its UNS line's area cluster, not via a driver. + UnsAreas = new[] + { + new { UnsAreaId = "a-main", Name = "AreaMain", ClusterId = "MAIN" }, + new { UnsAreaId = "a-sa", Name = "AreaSa", ClusterId = "SITE-A" }, + }, + UnsLines = new[] + { + new { UnsLineId = "l1", UnsAreaId = "a-main", Name = "L1" }, + new { UnsLineId = "l2", UnsAreaId = "a-sa", Name = "L2" }, + }, Equipment = new[] { - new { EquipmentId = "eq-main", Name = "eqm", UnsLineId = "l1", DriverInstanceId = "main-modbus" }, - new { EquipmentId = "eq-sa", Name = "eqs", UnsLineId = "l2", DriverInstanceId = "sa-modbus" }, + new { EquipmentId = "eq-main", Name = "eqm", UnsLineId = "l1" }, + new { EquipmentId = "eq-sa", Name = "eqs", UnsLineId = "l2" }, }, Scripts = new[] { @@ -192,7 +202,6 @@ public sealed class DeploymentArtifactScriptedAlarmParityTests var composed = AddressSpaceComposer.Compose( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), new[] { goodAlarm, orphanAlarm }, - Array.Empty(), Array.Empty(), scripts: new[] { goodScript }); var blob = BlobOf(new diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs index f7a35041..fe046209 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs @@ -198,7 +198,7 @@ public sealed class DeploymentArtifactTests /// ns.Kind == NamespaceKind.Equipment predicate (only Equipment-kind namespaces route /// into EquipmentTags, so such a tag routes nowhere). /// - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void ParseComposition_reads_EquipmentTags_from_equipment_namespace() { var blob = JsonSerializer.SerializeToUtf8Bytes(new @@ -405,17 +405,20 @@ public sealed class DeploymentArtifactTests }; [Fact] - public void ParseComposition_scoped_keeps_only_my_clusters_drivers_and_tags() + public void ParseComposition_scoped_keeps_only_my_clusters_drivers() { + // v3: DriverInstance cluster scoping still holds. The EquipmentTags leg of the original assertion + // is dark this batch (equipment-tag plans don't materialize until Batch 4) — see + // TagConfigCorpusParityTests + DarkAddressSpaceReasons. var blob = BlobOf(MultiClusterSnapshotWithTags()); var main = DeploymentArtifact.ParseComposition(blob, "central-1:4053"); main.DriverInstancePlans.Select(d => d.DriverInstanceId).ShouldBe(new[] { "main-galaxy" }); - main.EquipmentTags.Select(t => t.TagId).ShouldBe(new[] { "t-main" }); + main.EquipmentTags.ShouldBeEmpty(); // dark this batch var siteA = DeploymentArtifact.ParseComposition(blob, "site-a-1:4053"); siteA.DriverInstancePlans.Select(d => d.DriverInstanceId).ShouldBe(new[] { "sa-galaxy" }); - siteA.EquipmentTags.Select(t => t.TagId).ShouldBe(new[] { "t-sa" }); + siteA.EquipmentTags.ShouldBeEmpty(); // dark this batch } [Fact] @@ -444,10 +447,22 @@ public sealed class DeploymentArtifactTests new { DriverInstanceId = "main-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" }, new { DriverInstanceId = "sa-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" }, }, + // v3: equipment carries no driver binding — it is attributed to a cluster solely via its UNS + // line's area cluster (Equipment -> UnsLine.UnsAreaId -> UnsArea.ClusterId). + UnsAreas = new[] + { + new { UnsAreaId = "a-main", Name = "AreaMain", ClusterId = "MAIN" }, + new { UnsAreaId = "a-sa", Name = "AreaSa", ClusterId = "SITE-A" }, + }, + UnsLines = new[] + { + new { UnsLineId = "l1", UnsAreaId = "a-main", Name = "L1" }, + new { UnsLineId = "l2", UnsAreaId = "a-sa", Name = "L2" }, + }, Equipment = new[] { - new { EquipmentId = "eq-main", Name = "eqm", UnsLineId = "l1", DriverInstanceId = "main-modbus" }, - new { EquipmentId = "eq-sa", Name = "eqs", UnsLineId = "l2", DriverInstanceId = "sa-modbus" }, + new { EquipmentId = "eq-main", Name = "eqm", UnsLineId = "l1" }, + new { EquipmentId = "eq-sa", Name = "eqs", UnsLineId = "l2" }, }, Scripts = new[] { @@ -500,7 +515,7 @@ public sealed class DeploymentArtifactTests /// Verifies the inconsistency callback fires when a kept equipment's UNS line belongs to /// another cluster (a cross-cluster orphan binding). - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentDeviceBindingRetired)] public void ParseComposition_scoped_flags_cross_cluster_orphan_equipment() { var warnings = new List(); @@ -622,9 +637,11 @@ public sealed class DeploymentArtifactTests comp.EquipmentVirtualTags.ShouldBeEmpty(); } - /// Verifies the unchanged driver-bound path: equipment with an in-cluster DriverInstanceId is - /// kept even when (as here) no UNS line/area rows describe it — attribution is still by driver. - [Fact] + /// RETIRED (v3): equipment no longer carries a DriverInstanceId, so there is no driver-bound + /// attribution path — equipment is attributed to a cluster solely via its UNS line's area + /// (Driverless_equipment_kept_when_its_line_area_is_in_cluster is the v3 replacement, and passes). + /// The original premise ("attribution is still by driver") no longer exists. + [Fact(Skip = DarkAddressSpaceReasons.EquipmentDeviceBindingRetired)] public void Driverbound_equipment_kept_when_driver_in_cluster() { var blob = BlobOf(new diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactVirtualTagHistorizeParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactVirtualTagHistorizeParityTests.cs index b1d34622..a84da7c0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactVirtualTagHistorizeParityTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactVirtualTagHistorizeParityTests.cs @@ -26,35 +26,17 @@ public sealed class DeploymentArtifactVirtualTagHistorizeParityTests [Fact] public void ParseComposition_virtual_tag_historize_is_byte_parity_with_composer() { - var ns = new Namespace - { - NamespaceId = "ns-eq", - ClusterId = "c1", - Kind = NamespaceKind.Equipment, - NamespaceUri = "urn:eq", - }; var driver = new DriverInstance { DriverInstanceId = "drv-1", 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 equip = new Equipment { EquipmentId = "eq-1", DriverInstanceId = "drv-1", UnsLineId = "line-1", Name = "TestMachine_001", MachineCode = "TESTMACHINE_001" }; - var tag = new Tag - { - TagId = "tag-1", - DriverInstanceId = "drv-1", - EquipmentId = "eq-1", - Name = "Source", - DataType = "Int32", - AccessLevel = TagAccessLevel.Read, - TagConfig = "{\"FullName\":\"TestMachine_001.Source\",\"DataType\":\"Int32\"}", - }; + var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "TestMachine_001", MachineCode = "TESTMACHINE_001" }; var script = new Script { ScriptId = "s-1", @@ -68,7 +50,6 @@ public sealed class DeploymentArtifactVirtualTagHistorizeParityTests var composed = AddressSpaceComposer.Compose( new[] { area }, new[] { line }, new[] { equip }, new[] { driver }, Array.Empty(), - new[] { tag }, new[] { ns }, virtualTags: new[] { vtHist, vtPlain }, scripts: new[] { script }); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs index 82a378ce..94088347 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs @@ -108,7 +108,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase /// — proving the live value routed end-to-end and (in production) /// overwrote the BadWaitingForInitialData seed. /// - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Discovered_node_materialises_at_equipment_and_polled_value_flows_Good() { var db = NewInMemoryDbFactory(); @@ -154,7 +154,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase /// (re-materialised after the rebuild) at the same NodeId, and a subsequent published value STILL /// WriteValues Good there (the routing map was rebuilt, not left empty by the Clear()). /// - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Discovered_node_and_value_survive_a_redeploy() { var db = NewInMemoryDbFactory(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs index 2c111462..64bcb14a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs @@ -54,7 +54,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// rooted at the equipment NodeId; (b) the driver re-subscribes the UNION of the authored ref + the /// FixedTree refs; (c) a value published for a FixedTree ref now routes to its mapped NodeId (proving the /// live-value routing map was extended). - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void DiscoveredNodes_materialise_extend_routing_and_merge_subscription() { var db = NewInMemoryDbFactory(); @@ -121,7 +121,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// rooted at its NodeId and the driver /// subscribes the discovered refs (no authored ref exists to union). Previously this was skipped with /// "no equipment/authored tags". - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Tag_less_equipment_resolved_via_EquipmentNode_grafts_discovered_nodes() { var db = NewInMemoryDbFactory(); @@ -198,7 +198,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// equipment), (b) the union subscription carries BOTH devices' refs, and (c) a value for each device's ref /// routes to the right equipment's node (proving BOTH inner-map entries cached + keyed correctly). The "H1" /// vs stored "h1" wrinkle proves the SHARED DeviceConfigIntent.NormalizeHost match. - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Multi_device_driver_partitions_fixed_tree_by_device_host_under_matching_equipment() { var db = NewInMemoryDbFactory(); @@ -264,7 +264,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// NO matching equipment is warn-skipped (NOT mis-grafted), while the matched partitions still graft. Here /// d1 fans out to EQ-A("h1") + EQ-B("h2"), but a third discovered partition "h3" matches no equipment: only /// EQ-A + EQ-B materialise (no third), and the unmatched ref routes nowhere (no crash). - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Multi_device_unmatched_device_host_is_warn_skipped_matched_still_graft() { var db = NewInMemoryDbFactory(); @@ -305,7 +305,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// logged at Debug, which the suite's loglevel = WARNING HOCON suppresses at source, so EventFilter /// observes the dedup as "zero further matching warnings". (The matched EQ-A/EQ-B partitions still graft on /// pass 1; pass 2's matched routing is short-circuited by PlansRoutingEqual.) - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Repeated_unmatched_device_host_partition_warns_once_then_is_quiet() { var db = NewInMemoryDbFactory(); @@ -378,7 +378,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// Dedup: a discovered node whose FullReference equals an authored equipment tag's /// FullName is NOT injected (it would shadow the authored node) — only the genuinely-new FixedTree refs /// are materialised. - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Discovered_node_shadowing_an_authored_ref_is_not_injected() { var db = NewInMemoryDbFactory(); @@ -413,7 +413,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// re-discovers each ~2s pass) is a no-op — it materialises ONCE and does not force the child to /// re-subscribe. A GROWN set, however, DOES re-apply (materialise again + re-subscribe), so a stabilising /// FixedTree still converges. - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Repeated_identical_discovery_does_not_reapply_but_a_grown_set_does() { var db = NewInMemoryDbFactory(); @@ -469,7 +469,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// FixedTree routes + materialised nodes would be lost until the driver reconnects. Asserts the cached /// plan is (a) re-materialised under EQ-1 after the rebuild, (b) still present in the child's /// post-redeploy subscription, and (c) still routes a published value to its mapped NodeId. - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Discovered_nodes_survive_a_redeploy_rebuild() { var db = NewInMemoryDbFactory(); @@ -536,7 +536,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// (its authored tags were removed) must DROP the cached discovered plan rather than re-graft it onto a /// stale equipment. After such a redeploy the host re-applies the authored rebuild but does NOT re-tell /// for the now-unresolved driver. - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Discovered_nodes_dropped_when_equipment_no_longer_resolves() { var db = NewInMemoryDbFactory(); @@ -578,7 +578,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// drops the entry. After the redeploy NO is /// re-told. (Complements , which /// covers the Count==0 branch; this covers the rebind/StartsWith branch.) - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Discovered_nodes_dropped_when_driver_rebound_to_a_different_equipment() { var db = NewInMemoryDbFactory(); @@ -627,7 +627,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// single Once pass — impossible without the trigger, since the child stays Connected so nothing else re-kicks /// discovery) AND a fresh re-grafts the FixedTree /// under the new equipment EQ-2. - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Config_unchanged_rebind_re_triggers_discovery_on_the_child() { var db = NewInMemoryDbFactory(); @@ -706,7 +706,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// guard — so they would NOT catch a future regression that sets droppedAny on a non-drop path). The /// regression is observable here: a spurious trigger would advance DiscoverCount past the single Once /// pass. - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void No_drop_redeploy_does_not_re_trigger_discovery() { var db = NewInMemoryDbFactory(); @@ -757,7 +757,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// SubscribeAsync — no de-dup in ): the count rises by EXACTLY 1 across the /// redeploy and the final subscribed set is the union. (Pre-task: the bulk loop ALSO sent the authored-only /// set first ⇒ the count rose by 2 and the set transiently dropped "ft-ref-1".) - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Cached_driver_survivor_redeploy_sends_exactly_one_union_subscription() { var db = NewInMemoryDbFactory(); @@ -823,7 +823,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// via SubscribeCount (+1) and the subscribed set ("40001" only, NOT the dropped "ft-ref-1"). (It also /// gets a — a different message type the non-discovery /// child no-ops, so it adds no subscribe.) Guards that the bulk-skip didn't reduce this path to ZERO sends. - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Cached_driver_fully_dropped_redeploy_sends_exactly_one_authored_only_fallback() { var db = NewInMemoryDbFactory(); @@ -885,7 +885,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// authored-only per redeploy (the re-inject tail /// never runs for it). Guards that the skip didn't accidentally suppress (or double) a non-cached driver's /// send. Observed via SubscribeCount (+1) and the subscribed set ("40001" only). - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Non_cached_driver_redeploy_sends_exactly_one_authored_only_subscription() { var db = NewInMemoryDbFactory(); @@ -934,7 +934,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// the Connected handler routes to Unsubscribe (dropping the stale FixedTree handle) — NOT a subscribe. /// Proven by SubscribeCount staying FLAT across the redeploy (no spurious subscribe), closing the /// SubscribeCount-proxy blind spot for the empty-set fallback. - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Cached_tag_less_driver_fully_dropped_redeploy_sends_empty_fallback_without_subscribing() { var db = NewInMemoryDbFactory(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs index 919e0313..75000f7f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs @@ -42,7 +42,7 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase /// A driver value published by FullName lands on the equipment variable's folder-scoped /// NodeId (here eq-1/speed, no sub-folder), carrying the value/quality/timestamp. - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Driver_value_routes_to_folder_scoped_equipment_NodeId() { var db = NewInMemoryDbFactory(); @@ -68,7 +68,7 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase /// The same driver ref backing two equipments fans out: a single publish produces one /// per equipment variable NodeId. - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Same_ref_on_two_equipments_fans_out_to_both_NodeIds() { var db = NewInMemoryDbFactory(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs index a55fe10a..55464a3e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs @@ -46,7 +46,7 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest /// forwards exactly one to the owning driver's /// , with ConditionId == FullName, the operator /// principal, and the comment. - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void RouteNativeAlarmAck_routes_to_driver_AcknowledgeAsync_with_principal() { var db = NewInMemoryDbFactory(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs index 6f38c06d..f46f70f3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs @@ -54,7 +54,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase /// with State.Active == true. The event carries a /// production-shaped SourceNodeId (the bare owning object, distinct from ConditionId) so the /// lookup is proven to key on ConditionId, not SourceNodeId. - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Native_alarm_raise_routes_to_folder_scoped_condition_NodeId_active() { var db = NewInMemoryDbFactory(); @@ -110,7 +110,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase /// publishes exactly one to the cluster alerts topic with /// AlarmId = the folder-scoped condition NodeId, alongside the (ungated) OPC UA condition /// update. No is sent, so the cached role is unknown ⇒ emit. - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Native_alarm_publishes_AlarmTransitionEvent_to_alerts_when_primary() { var db = NewInMemoryDbFactory(); @@ -159,7 +159,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase /// (historizeToAveva is not false) suppresses the durable AVEVA write — the same opt-out the /// scripted-alarm plan flag drives. The live /alerts fan-out is unaffected (the transition still /// publishes; only the durable row is gated downstream). - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Native_alarm_historizeToAveva_false_threads_through() { var db = NewInMemoryDbFactory(); @@ -191,7 +191,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase /// Native-alarm HistorizeToAveva opt-IN (Task 3): an explicit historizeToAveva: true /// rides through as true (distinct from the absent ⇒ null default-on case) so an operator who /// deliberately opts in is recorded as such on the transition. - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Native_alarm_historizeToAveva_true_threads_through() { var db = NewInMemoryDbFactory(); @@ -222,7 +222,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase /// MUST still write the local OPC UA condition node (ungated — keeps the standby's address space warm /// for failover) but MUST NOT publish the cluster-wide alerts transition (the Primary publishes /// the single fleet-wide copy). - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Secondary_node_suppresses_alerts_publish_but_still_updates_condition() { var db = NewInMemoryDbFactory(); @@ -261,7 +261,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase /// Comment carries the operator string and User is "device" (a non-null comment /// signals the upstream alarm system provided an operator origin, but without a specific user identity). /// - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Native_alarm_operator_comment_flows_to_transition_event() { var db = NewInMemoryDbFactory(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs index 036a9007..cce53094 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs @@ -61,7 +61,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase /// Role unknown + no driver peer (count 1) ⇒ the write is SERVICED (single-node / boot-window /// posture preserved). - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Unknown_role_single_driver_services_write() { var db = NewInMemoryDbFactory(); @@ -86,7 +86,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase /// A Primary snapshot services even at count 2; a Secondary snapshot denies with the steady-state /// reason (distinct from the boot-window reason). - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Known_role_wins_over_member_count() { var db = NewInMemoryDbFactory(); @@ -162,7 +162,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase /// Role unknown + count 2 ⇒ ForwardNativeAlarm still writes the (ungated) OPC UA condition update /// but publishes NO cluster-wide alerts transition. - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Unknown_role_multi_driver_suppresses_alerts_emit_but_updates_condition() { var db = NewInMemoryDbFactory(); @@ -181,7 +181,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase /// Role unknown + count 1 ⇒ ForwardNativeAlarm publishes the alerts transition (single-node /// posture preserved). - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Unknown_role_single_driver_publishes_alerts_emit() { var db = NewInMemoryDbFactory(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs index 70c79197..af733d2a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs @@ -46,7 +46,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase /// On the PRIMARY, a RouteNodeWrite for a mapped NodeId forwards the driver-side FullName to /// the right driver child (observed via the recording driver) and replies NodeWriteResult(true). - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Primary_routes_write_to_driver_by_full_name_and_replies_success() { var db = NewInMemoryDbFactory(); @@ -130,7 +130,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase /// blob string when no top-level FullName property is present, so the reverse map keys on /// (DriverInstanceId, <raw-blob>) and the driver receives that exact string as its /// WriteRequest.FullReference — not a FullName value extracted from the blob. - [Fact] + [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Primary_routes_write_for_raw_protocol_blob_tag() { var db = NewInMemoryDbFactory(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigCorpusParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigCorpusParityTests.cs index 562c27a4..9913c64f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigCorpusParityTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigCorpusParityTests.cs @@ -1,111 +1,100 @@ -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.Commons.Types; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// -/// The cross-seam characterization net for R2-11 (01/C-1 + 01/P-1). Composes one equipment tag per -/// blob, serialises the SAME draft to the artifact blob shape, -/// decodes it, and asserts the decoded list equals the composer's -/// element-wise (positional-record value equality) over the WHOLE corpus. Because both producers parse -/// the identical raw TagConfig string, this proves the four byte-parity parse sites agree TODAY, -/// and is the permanent regression guard held green through every subsequent seam swap -/// (TagConfigIntent.Parse consolidation). +/// v3 cross-seam characterization net for the raw-tag path. Injects every +/// blob as a raw Tag's TagConfig under a +/// RawFolder → Driver → Device chain, flattens the snapshot through +/// into the driver's +/// merged RawTags array (keyed by RawPath), and asserts over the WHOLE corpus that: +/// +/// every corpus tag survives (none dropped) and lands at its expected RawPath; +/// the decoded is byte-identical to the authored blob +/// (the artifact carries the raw address blob verbatim — no PascalCase/FullName rewrite); and +/// over the decoded blob equals the parse over the original +/// blob (record value equality incl. the nested alarm intent) — so any downstream intent +/// consumer sees identical platform intents whether it reads the authored Tag or the deploy +/// artifact. The permanent regression guard for the compose→encode→decode raw-tag seam. +/// +/// Dark address space (Batch 1): the same snapshot yields an EMPTY equipment-tag plan set — +/// equipment-tag variable materialization lands in Batch 4 (asserted here so the darkness is pinned). /// public sealed class TagConfigCorpusParityTests { - [Fact] - public void Composer_and_artifact_agree_over_the_whole_TagConfig_corpus() - { - var ns = new Namespace - { - NamespaceId = "ns-eq", - ClusterId = "c1", - Kind = NamespaceKind.Equipment, - NamespaceUri = "urn:eq", - }; - var driver = new DriverInstance - { - DriverInstanceId = "drv-1", - 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 equip = new Equipment - { - EquipmentId = "eq-1", - DriverInstanceId = "drv-1", - UnsLineId = "line-1", - Name = "Machine_001", - MachineCode = "MACHINE_001", - }; + private const string Folder = "Plant"; + private const string DriverName = "Modbus1"; + private const string DeviceName = "PLC-A"; - var tags = TagConfigGoldenCorpus.Blobs - .Select((blob, i) => new Tag + private static byte[] Snapshot() => JsonSerializer.SerializeToUtf8Bytes(new + { + Clusters = new[] { new { ClusterId = "MAIN" } }, + RawFolders = new[] + { + new { RawFolderId = "f1", ParentRawFolderId = (string?)null, Name = Folder, ClusterId = "MAIN" }, + }, + DriverInstances = new[] + { + new { DriverInstanceId = "drv1", Name = DriverName, DriverType = "Modbus", Enabled = true, DriverConfig = "{\"UnitId\":1}", ClusterId = "MAIN", RawFolderId = "f1" }, + }, + Devices = new[] + { + new { DeviceId = "dev1", DriverInstanceId = "drv1", Name = DeviceName, DeviceConfig = "{\"Host\":\"10.0.0.5\",\"Port\":502}" }, + }, + Tags = TagConfigGoldenCorpus.Blobs + .Select((blob, i) => new { TagId = $"tag-{i:D2}", - DriverInstanceId = "drv-1", - EquipmentId = "eq-1", - FolderPath = null, + DeviceId = "dev1", + TagGroupId = (string?)null, Name = $"Sig{i:D2}", DataType = "Float", - AccessLevel = TagAccessLevel.Read, TagConfig = blob, + WriteIdempotent = i % 2 == 0, }) - .ToArray(); + .ToArray(), + }); - // ---- Side 1: the live-edit composer ---- - var composed = AddressSpaceComposer.Compose( - new[] { area }, new[] { line }, new[] { equip }, - new[] { driver }, Array.Empty(), tags, new[] { ns }); + /// Byte-parity + intent-parity of every corpus blob round-tripped through the deploy artifact + /// as a raw tag's TagConfig (keyed by RawPath). + [Fact] + public void Artifact_round_trips_every_TagConfig_blob_byte_and_intent_identical() + { + var blob = Snapshot(); - // ---- Side 2: serialise the SAME draft to the artifact blob shape, then decode it ---- - var blob = JsonSerializer.SerializeToUtf8Bytes(new + var spec = DeploymentArtifact.ParseDriverInstances(blob).ShouldHaveSingleItem(); + using var doc = JsonDocument.Parse(spec.DriverConfig); + var rawTags = doc.RootElement.GetProperty("RawTags").EnumerateArray().ToList(); + + // (1) No corpus tag dropped. + rawTags.Count.ShouldBe(TagConfigGoldenCorpus.Blobs.Count); + + for (var i = 0; i < TagConfigGoldenCorpus.Blobs.Count; i++) { - Namespaces = new[] - { - new { ns.NamespaceId, ns.ClusterId, Kind = (int)ns.Kind }, - }, - DriverInstances = new[] - { - new { driver.DriverInstanceId, driver.DriverType, driver.DriverConfig, driver.NamespaceId, driver.ClusterId }, - }, - Tags = tags.Select(ToSnapshot).ToArray(), - }); + var expectedBlob = TagConfigGoldenCorpus.Blobs[i]; + var expectedRawPath = $"{Folder}/{DriverName}/{DeviceName}/Sig{i:D2}"; - var decoded = DeploymentArtifact.ParseComposition(blob); + var entry = rawTags.SingleOrDefault(e => e.GetProperty("RawPath").GetString() == expectedRawPath); + entry.ValueKind.ShouldNotBe(JsonValueKind.Undefined, $"corpus blob #{i} missing at RawPath {expectedRawPath}"); - decoded.EquipmentTags.Count.ShouldBe(tags.Length); - // Full byte-parity: every field, same order (positional-record value equality). A field-by-field - // spell-out per blob so a divergence names the offending corpus entry. - for (var i = 0; i < composed.EquipmentTags.Count; i++) - { - var c = composed.EquipmentTags[i]; - var d = decoded.EquipmentTags.Single(t => t.TagId == c.TagId); - d.ShouldBe(c, customMessage: $"corpus blob #{c.TagId} diverged: '{tags.Single(t => t.TagId == c.TagId).TagConfig}'"); + // (2) The raw address blob is carried verbatim (no rewrite). + var decodedTagConfig = entry.GetProperty("TagConfig").GetString(); + decodedTagConfig.ShouldBe(expectedBlob, $"corpus blob #{i} TagConfig not byte-preserved"); + + // (3) The platform intent parses identically off the decoded blob and the authored blob. + TagConfigIntent.Parse(decodedTagConfig) + .ShouldBe(TagConfigIntent.Parse(expectedBlob), $"corpus blob #{i} intent diverged: '{expectedBlob}'"); } - decoded.EquipmentTags.SequenceEqual(composed.EquipmentTags).ShouldBeTrue(); } - private static object ToSnapshot(Tag t) => new + /// Dark address space this batch: the same snapshot materializes no equipment-tag plans. + [Fact] + public void ParseComposition_over_the_corpus_emits_dark_equipment_tags() { - t.TagId, - t.DriverInstanceId, - t.EquipmentId, - t.Name, - t.FolderPath, - t.DataType, - AccessLevel = (int)t.AccessLevel, - t.TagConfig, - }; + DeploymentArtifact.ParseComposition(Snapshot()).EquipmentTags.ShouldBeEmpty(); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigGoldenCorpus.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigGoldenCorpus.cs index a76bed77..32192ca2 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigGoldenCorpus.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigGoldenCorpus.cs @@ -2,82 +2,84 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// The shared golden corpus of TagConfig JSON blobs used to characterise the cross-driver -/// platform-intent parse (FullName / alarm / isHistorized / historianTagname / isArray / arrayLength). -/// Every entry is a NON-NULL string (the DB CK_Tag_TagConfig_IsJson constraint guarantees a -/// non-null TagConfig for a real deploy) so the compose→encode→decode parity test -/// () can round-trip each through both equipment-tag producers -/// and assert byte-parity field-by-field. The null-input divergence (composer returns the raw -/// null, artifact coalesces to "") is a documented seam handled only at the -/// TagConfigIntent.Parse unit level, not through the parity round-trip. -/// Covers: happy path, blank, non-object root, malformed JSON, FullName absent/non-string/whitespace, +/// platform-intent parse (TagConfigIntent.Parse: alarm / isHistorized / historianTagname / +/// isArray / arrayLength). v3: the retired FullName key is gone — a raw tag's driver address is +/// an ordinary driver-specific key (e.g. address), and the tag's RawPath (not any in-blob +/// FullName) is its identity. Each entry is a NON-NULL string (the DB CK_Tag_TagConfig constraint +/// guarantees non-null) so the v3 round-trip parity test () can +/// inject each as a raw tag's TagConfig, flatten it through the deploy artifact into the driver's +/// RawTags array (keyed by RawPath), and assert the decoded RawTagEntry.TagConfig is +/// byte-preserved AND parses to the identical TagConfigIntent — the cross-seam parse-agreement +/// guarantee, now over the raw path rather than the retired equipment-tag path. +/// Covers: happy path, blank, non-object root, malformed JSON, driver-only keys, /// alarm object with each field absent/wrong-type, historizeToAveva true/false/non-bool, /// historianTagname whitespace, isArray/arrayLength combinations (incl. negative/overflow/non-number/ /// length-without-flag/zero), unknown keys, and mixed platform+driver keys. /// public static class TagConfigGoldenCorpus { - /// The corpus blobs, index-ordered. The parity test builds one equipment tag per blob. + /// The corpus blobs, index-ordered. The parity test builds one raw tag per blob. public static readonly IReadOnlyList Blobs = new[] { - // 0 happy path - "{\"FullName\":\"X.Y\"}", - // 1 blank (whitespace) — FullName falls back to the raw (blank) blob on both seams + // 0 happy path (plain driver address, no platform intents) + "{\"address\":\"40001\"}", + // 1 blank (whitespace) — never-throws, no intents " ", // 2 non-object root "[1,2]", // 3 malformed JSON "not json {", - // 4 FullName absent (driver keys only) — FullName = raw blob + // 4 driver keys only — no platform intents "{\"region\":\"HoldingRegisters\",\"address\":5}", - // 5 FullName present but non-string — FullName = raw blob - "{\"FullName\":123}", - // 6 FullName whitespace value (not trimmed) - "{\"FullName\":\" \"}", + // 5 numeric address value — no platform intents + "{\"address\":123}", + // 6 whitespace address value — no platform intents + "{\"address\":\" \"}", // 7 alarm empty object → defaults (AlarmCondition, 500, historize null) - "{\"FullName\":\"A\",\"alarm\":{}}", + "{\"address\":\"A\",\"alarm\":{}}", // 8 alarm fully specified - "{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", + "{\"address\":\"A\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", // 9 alarm non-object → no alarm - "{\"FullName\":\"A\",\"alarm\":\"oops\"}", + "{\"address\":\"A\",\"alarm\":\"oops\"}", // 10 alarm historizeToAveva true - "{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":true}}", + "{\"address\":\"A\",\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":true}}", // 11 alarm historizeToAveva false - "{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":false}}", + "{\"address\":\"A\",\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":false}}", // 12 alarm historizeToAveva non-bool → null - "{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":\"oops\"}}", + "{\"address\":\"A\",\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":\"oops\"}}", // 13 alarm severity non-number → 500 - "{\"FullName\":\"A\",\"alarm\":{\"severity\":\"high\"}}", + "{\"address\":\"A\",\"alarm\":{\"severity\":\"high\"}}", // 14 alarm alarmType non-string → AlarmCondition - "{\"FullName\":\"A\",\"alarm\":{\"alarmType\":123}}", + "{\"address\":\"A\",\"alarm\":{\"alarmType\":123}}", // 15 isHistorized true - "{\"FullName\":\"A\",\"isHistorized\":true}", + "{\"address\":\"A\",\"isHistorized\":true}", // 16 isHistorized true + explicit tagname - "{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\"P.L.X\"}", + "{\"address\":\"A\",\"isHistorized\":true,\"historianTagname\":\"P.L.X\"}", // 17 isHistorized false + JSON-null tagname - "{\"FullName\":\"A\",\"isHistorized\":false,\"historianTagname\":null}", + "{\"address\":\"A\",\"isHistorized\":false,\"historianTagname\":null}", // 18 historianTagname whitespace → null - "{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\" \"}", + "{\"address\":\"A\",\"isHistorized\":true,\"historianTagname\":\" \"}", // 19 isHistorized non-bool → false - "{\"FullName\":\"A\",\"isHistorized\":\"yes\"}", + "{\"address\":\"A\",\"isHistorized\":\"yes\"}", // 20 isArray true + length - "{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":16}", + "{\"address\":\"A\",\"isArray\":true,\"arrayLength\":16}", // 21 isArray true, no length - "{\"FullName\":\"A\",\"isArray\":true}", + "{\"address\":\"A\",\"isArray\":true}", // 22 isArray false + length → scalar - "{\"FullName\":\"A\",\"isArray\":false,\"arrayLength\":16}", + "{\"address\":\"A\",\"isArray\":false,\"arrayLength\":16}", // 23 isArray true, length 0 → true, 0 - "{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":0}", + "{\"address\":\"A\",\"isArray\":true,\"arrayLength\":0}", // 24 arrayLength non-number → true, null - "{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":\"16\"}", + "{\"address\":\"A\",\"isArray\":true,\"arrayLength\":\"16\"}", // 25 arrayLength negative → true, null - "{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":-1}", + "{\"address\":\"A\",\"isArray\":true,\"arrayLength\":-1}", // 26 arrayLength float → true, null - "{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":16.5}", + "{\"address\":\"A\",\"isArray\":true,\"arrayLength\":16.5}", // 27 arrayLength overflow (uint.MaxValue + 1) → true, null - "{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":4294967296}", + "{\"address\":\"A\",\"isArray\":true,\"arrayLength\":4294967296}", // 28 unknown + mixed platform/driver keys - "{\"FullName\":\"A.B\",\"region\":\"Coils\",\"address\":5,\"dataType\":\"Int16\",\"isHistorized\":true,\"alarm\":{\"severity\":250},\"foo\":\"bar\"}", + "{\"address\":5,\"region\":\"Coils\",\"dataType\":\"Int16\",\"isHistorized\":true,\"alarm\":{\"severity\":250},\"foo\":\"bar\"}", // 29 all intents combined - "{\"FullName\":\"C.D\",\"isArray\":true,\"arrayLength\":4,\"isHistorized\":true,\"historianTagname\":\"H\",\"alarm\":{\"alarmType\":\"DiscreteAlarm\",\"severity\":900,\"historizeToAveva\":false}}", + "{\"address\":\"C.D\",\"isArray\":true,\"arrayLength\":4,\"isHistorized\":true,\"historianTagname\":\"H\",\"alarm\":{\"alarmType\":\"DiscreteAlarm\",\"severity\":900,\"historizeToAveva\":false}}", }; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs index cc3b2fa1..04894b13 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs @@ -120,7 +120,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase /// mirror holds for the MAIN node. Without the production scoping edit, the unscoped parse /// would materialise BOTH variables on every node. /// - [Fact] + [Fact(Skip = ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers.DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] public void Rebuild_materialises_only_the_nodes_cluster() { // --- SITE-A node: only the SITE-A tag's variable, never MAIN's. ---