From a5d857d5b27903ef2cd3a046808aab7f34a2982a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 6 Jun 2026 15:22:25 -0400 Subject: [PATCH] test(opcua): E2E deploy of an Equipment namespace through the real ConfigComposer Seed a 1-area/1-line/1-equipment/1-tag Equipment namespace, StartDeployment via the in-process 2-node harness, and assert the persisted artifact decodes (ParseComposition) to the equipment signal (FullName from TagConfig) + friendly UNS folder names. Covers the ConfigComposer -> ArtifactBlob -> ParseComposition.EquipmentTags seam the unit tests only approximated with hand-built JSON. (OPC UA browse is covered against a real SDK node manager in Phase7ApplierHierarchyTests; the cluster harness binds the no-op sink.) --- .../EquipmentNamespaceMaterializationTests.cs | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs new file mode 100644 index 00000000..37ca48fa --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs @@ -0,0 +1,127 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; + +/// +/// End-to-end deploy of an Equipment-kind namespace through the real +/// ConfigComposer: seed a 1-area / 1-line / 1-equipment / 1-tag Equipment namespace, +/// StartDeployment, then assert the deployment's persisted artifact decodes (via +/// ) to the equipment signal + the friendly +/// UNS folder name. This covers the ConfigComposer → ArtifactBlob → ParseComposition.EquipmentTags +/// seam that the OpcUaServer unit tests only approximate with hand-built JSON. +/// +/// The OPC UA address-space browse is exercised separately against a real SDK node manager in +/// Phase7ApplierHierarchyTests.Equipment_namespace_structure_materialises_end_to_end_against_real_SDK, +/// because the in-process binds the no-op address-space sink. +/// +/// +public sealed class EquipmentNamespaceMaterializationTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + /// Verifies a deployed Equipment namespace carries its signal into the composed artifact. + [Fact] + public async Task Deploying_an_equipment_namespace_carries_the_signal_into_the_artifact() + { + await using var harness = await TwoNodeClusterHarness.StartAsync(); + + await SeedEquipmentNamespaceAsync(harness); + + await using var scope = harness.NodeA.Services.CreateAsyncScope(); + var client = scope.ServiceProvider.GetRequiredService(); + + var result = await client.StartDeploymentAsync(createdBy: "alice@test", Ct); + result.Outcome.ShouldBe(StartDeploymentOutcome.Accepted); + var deploymentId = result.DeploymentId!.Value.Value; + + // The artifact is composed + persisted at dispatch time; assert on it without waiting for + // Seal (sealing depends on driver-node acks, orthogonal to composition correctness). + var artifact = Array.Empty(); + await WaitForAsync(async () => + { + await using var db = await CreateDbAsync(harness); + var d = await db.Deployments.AsNoTracking() + .FirstOrDefaultAsync(x => x.DeploymentId == deploymentId, Ct); + if (d is { ArtifactBlob.Length: > 0 }) + { + artifact = d.ArtifactBlob; + return true; + } + return false; + }, TimeSpan.FromSeconds(15)); + + var composition = DeploymentArtifact.ParseComposition(artifact); + + // The real ConfigComposer serialised the equipment Tag (incl. its TagConfig), and + // ParseComposition extracted it as an EquipmentTag with FullName pulled from TagConfig. + var tag = composition.EquipmentTags.ShouldHaveSingleItem(); + tag.TagId.ShouldBe("tag-speed"); + tag.EquipmentId.ShouldBe("eq-1"); + tag.Name.ShouldBe("Speed"); + tag.DataType.ShouldBe("Float"); + tag.FullName.ShouldBe("40001"); + + // The equipment folder browses by its friendly UNS Name, not the MachineCode. + composition.EquipmentNodes.ShouldContain(e => e.EquipmentId == "eq-1" && e.DisplayName == "station-1"); + composition.UnsAreas.ShouldContain(a => a.UnsAreaId == "nw-area-filling" && a.DisplayName == "filling"); + composition.UnsLines.ShouldContain(l => l.UnsLineId == "nw-line-1" && l.DisplayName == "line-1"); + } + + private static async Task SeedEquipmentNamespaceAsync(TwoNodeClusterHarness harness) + { + await using var db = await CreateDbAsync(harness); + + db.Namespaces.Add(new Namespace + { + NamespaceId = "ns-eq", ClusterId = "c1", Kind = NamespaceKind.Equipment, NamespaceUri = "urn:eq", + }); + // Disabled so the driver-role nodes don't spawn a live Modbus driver (no endpoint to reach); + // the composition still carries the instance (ParseComposition does not filter on Enabled). + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = "drv-modbus", ClusterId = "c1", NamespaceId = "ns-eq", + Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", Enabled = false, + }); + db.UnsAreas.Add(new UnsArea { UnsAreaId = "nw-area-filling", ClusterId = "c1", Name = "filling" }); + db.UnsLines.Add(new UnsLine { UnsLineId = "nw-line-1", UnsAreaId = "nw-area-filling", Name = "line-1" }); + db.Equipment.Add(new Equipment + { + EquipmentId = "eq-1", DriverInstanceId = "drv-modbus", UnsLineId = "nw-line-1", + Name = "station-1", MachineCode = "STATION_001", + }); + db.Tags.Add(new Tag + { + TagId = "tag-speed", DriverInstanceId = "drv-modbus", EquipmentId = "eq-1", + Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.Read, + TagConfig = "{\"FullName\":\"40001\"}", + }); + + await db.SaveChangesAsync(Ct); + } + + private static async Task CreateDbAsync(TwoNodeClusterHarness harness) + { + var factory = harness.NodeA.Services.GetRequiredService>(); + return await factory.CreateDbContextAsync(); + } + + private static async Task WaitForAsync(Func> condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (await condition()) return; + await Task.Delay(200); + } + throw new TimeoutException($"Condition not met within {timeout}"); + } +}