Merge B1-wp6b-srv

This commit is contained in:
Joseph Doherty
2026-07-15 21:51:20 -04:00
11 changed files with 186 additions and 282 deletions
@@ -225,14 +225,7 @@ public sealed class DriverReconnectE2eTests
CreatedBy = "test",
});
db.Namespaces.Add(new Namespace
{
NamespaceId = "RECONNECT-E2E-equipment",
ClusterId = ClusterId,
Kind = NamespaceKind.Equipment,
NamespaceUri = "urn:zb:reconnect-e2e:equipment",
});
// v3: the Namespace entity is deleted (DriverInstance no longer binds a Namespace).
db.ClusterNodes.Add(new ClusterNode
{
NodeId = harness.NodeANodeId,
@@ -258,7 +251,6 @@ public sealed class DriverReconnectE2eTests
{
DriverInstanceId = DriverId,
ClusterId = ClusterId,
NamespaceId = "RECONNECT-E2E-equipment",
Name = DriverId,
DriverType = "Modbus",
Enabled = true,
@@ -13,20 +13,32 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// End-to-end deploy of an Equipment-kind namespace through the <b>real</b>
/// <c>ConfigComposer</c>: seed a 1-area / 1-line / 1-equipment / 1-tag Equipment namespace,
/// <c>StartDeployment</c>, then assert the deployment's persisted artifact decodes (via
/// <see cref="DeploymentArtifact.ParseComposition"/>) to the equipment signal + the friendly
/// UNS folder name. This covers the <c>ConfigComposer → ArtifactBlob → ParseComposition.EquipmentTags</c>
/// seam that the OpcUaServer unit tests only approximate with hand-built JSON.
/// End-to-end deploy of a UNS folder hierarchy through the <b>real</b> <c>ConfigComposer</c>: seed a
/// 1-area / 1-line / 1-equipment UNS plus a v3 raw-tag chain (RawFolder → DriverInstance → Device →
/// Tag), <c>StartDeployment</c>, then assert the deployment's persisted artifact decodes (via
/// <see cref="DeploymentArtifact.ParseComposition"/>).
/// <para>
/// <b>v3 DARK address space (Batch 1):</b> the composer + artifact deliberately emit an EMPTY
/// equipment-tag plan set — raw-tag variable nodes are lit up in Batch 4's dual-namespace UNS↔Raw
/// fan-out. So the live assertion here is the DARK contract: the Area/Line/Equipment folder nodes
/// decode with their friendly UNS names, and <c>EquipmentTags</c> is empty EVEN THOUGH a raw tag was
/// seeded. The full equipment-signal materialization (raw tag → EquipmentTag with FullName) is
/// preserved as a Batch-4-pending Skipped test below (the plan explicitly says keep it, don't delete).
/// </para>
/// <para>
/// The OPC UA address-space browse is exercised separately against a real SDK node manager in
/// <c>AddressSpaceApplierHierarchyTests.Equipment_namespace_structure_materialises_end_to_end_against_real_SDK</c>,
/// because the in-process <see cref="TwoNodeClusterHarness"/> binds the no-op address-space sink.
/// <c>AddressSpaceApplierHierarchyTests</c>, because the in-process <see cref="TwoNodeClusterHarness"/>
/// binds the no-op address-space sink.
/// </para>
/// </summary>
public sealed class EquipmentNamespaceMaterializationTests
{
private const string Batch4Pending =
"v3 dark address space: equipment-tag variable plans (raw tag → EquipmentTag with FullName) do " +
"not materialize until Batch 4 (dual-namespace UNS↔Raw fan-out). The composer + artifact emit an " +
"empty equipment-tag plan set this batch; re-enable this full-materialization assertion (re-authored " +
"against the UnsTagReference fan-out) when Batch 4 lights the raw/UNS variable nodes.";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
// Equipment.EquipmentId must equal DraftValidator.DeriveEquipmentId(EquipmentUuid) — the
@@ -35,16 +47,18 @@ public sealed class EquipmentNamespaceMaterializationTests
private static readonly Guid EquipmentUuid = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly string EquipmentId = DraftValidator.DeriveEquipmentId(EquipmentUuid); // EQ-111111111111
/// <summary>Verifies a deployed Equipment namespace carries its signal into the composed artifact.</summary>
/// <summary>Verifies a deployed UNS hierarchy carries its Area/Line/Equipment folder nodes into the
/// composed artifact with their friendly UNS names, and — the v3 Batch-1 DARK contract — that the
/// equipment-tag plan set is EMPTY even though a raw tag was seeded (raw-tag variables are Batch 4).</summary>
[Fact]
public async Task Deploying_an_equipment_namespace_carries_the_signal_into_the_artifact()
public async Task Deploying_a_uns_hierarchy_carries_folder_nodes_and_leaves_equipment_tags_dark()
{
await using var harness = await TwoNodeClusterHarness.StartAsync();
// Seed the parent ServerCluster + ClusterNode rows the SQL FKs require (the c1 config below
// FKs to ServerCluster; on the in-memory provider this is unnecessary — FKs aren't enforced).
await harness.SeedDefaultClusterAsync("c1");
await SeedEquipmentNamespaceAsync(harness);
await SeedUnsHierarchyAsync(harness);
await using var scope = harness.NodeA.Services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IAdminOperationsClient>();
@@ -71,47 +85,88 @@ public sealed class EquipmentNamespaceMaterializationTests
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.
// The UNS folder hierarchy decodes with its friendly UNS names (browse names), not MachineCodes.
composition.EquipmentNodes.ShouldContain(e => e.EquipmentId == EquipmentId && 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");
// v3 DARK contract: no equipment-tag variable plans materialize this batch — even though a raw
// tag ("Speed" / FullName 40001) was seeded under the device. Batch 4 lights these via the UNS↔Raw fan-out.
composition.EquipmentTags.ShouldBeEmpty();
}
/// <summary>BATCH-4-PENDING: the full equipment-signal materialization — the seeded raw tag decodes
/// (via the real ConfigComposer → ParseComposition) into an EquipmentTag carrying its FullName. Dark
/// until Batch 4's UNS↔Raw fan-out; preserved (not deleted) so re-enabling is a one-line change once
/// the equipment-tag plan set is lit up.</summary>
[Fact(Skip = Batch4Pending)]
public async Task Deploying_an_equipment_namespace_carries_the_signal_into_the_artifact()
{
await using var harness = await TwoNodeClusterHarness.StartAsync();
await harness.SeedDefaultClusterAsync("c1");
await SeedUnsHierarchyAsync(harness);
await using var scope = harness.NodeA.Services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IAdminOperationsClient>();
var result = await client.StartDeploymentAsync(createdBy: "alice@test", Ct);
result.Outcome.ShouldBe(StartDeploymentOutcome.Accepted, $"Deploy not accepted: {result.Message}");
var deploymentId = result.DeploymentId!.Value.Value;
var artifact = Array.Empty<byte>();
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);
// Batch 4: the raw tag surfaces as an EquipmentTag with FullName pulled from TagConfig.
var tag = composition.EquipmentTags.ShouldHaveSingleItem();
tag.TagId.ShouldBe("tag-speed");
tag.EquipmentId.ShouldBe(EquipmentId);
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 == EquipmentId && 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)
private static async Task SeedUnsHierarchyAsync(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",
});
// v3 raw-tag chain: RawFolder → DriverInstance(RawFolderId) → Device(DriverInstanceId) →
// Tag(DeviceId). The Namespace entity is deleted; equipment no longer binds a driver/device.
db.RawFolders.Add(new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = "Plant" });
// 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",
DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant",
Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", Enabled = false,
});
db.Devices.Add(new Device
{
DeviceId = "dev-1", DriverInstanceId = "drv-modbus",
Name = "dev-1", DeviceConfig = "{}",
});
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 = EquipmentId, EquipmentUuid = EquipmentUuid,
DriverInstanceId = "drv-modbus", UnsLineId = "nw-line-1",
Name = "station-1", MachineCode = "STATION_001",
UnsLineId = "nw-line-1", Name = "station-1", MachineCode = "STATION_001",
});
db.Tags.Add(new Tag
{
TagId = "tag-speed", DriverInstanceId = "drv-modbus", EquipmentId = EquipmentId,
TagId = "tag-speed", DeviceId = "dev-1",
Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"FullName\":\"40001\"}",
});
@@ -120,17 +120,15 @@ public sealed class MultiClusterScopingTests
NewCluster(MainCluster, "Main Cluster", "central"),
NewCluster(SiteACluster, "Site A Cluster", "site-a"));
db.Namespaces.AddRange(
NewNamespace(MainCluster, "MAIN-equipment", "urn:zb:central:equipment"),
NewNamespace(SiteACluster, "SITE-A-equipment", "urn:zb:site-a:equipment"));
// v3: the Namespace entity is deleted (DriverInstance no longer binds a Namespace). Cluster
// scoping filters by NodeId/ClusterId, so the namespace seed is dropped here.
db.ClusterNodes.AddRange(
NewNode(mainNodeId, MainCluster, "urn:zb:central:node-a"),
NewNode(siteANodeId, SiteACluster, "urn:zb:site-a:node-b"));
db.DriverInstances.AddRange(
NewDriver(MainDriverId, MainCluster, "MAIN-equipment"),
NewDriver(SiteADriverId, SiteACluster, "SITE-A-equipment"));
NewDriver(MainDriverId, MainCluster),
NewDriver(SiteADriverId, SiteACluster));
await db.SaveChangesAsync(Ct);
}
@@ -146,14 +144,6 @@ public sealed class MultiClusterScopingTests
CreatedBy = "test",
};
private static Namespace NewNamespace(string clusterId, string namespaceId, string uri) => new()
{
NamespaceId = namespaceId,
ClusterId = clusterId,
Kind = NamespaceKind.Equipment,
NamespaceUri = uri,
};
private static ClusterNode NewNode(string nodeId, string clusterId, string applicationUri) => new()
{
NodeId = nodeId,
@@ -163,11 +153,10 @@ public sealed class MultiClusterScopingTests
CreatedBy = "test",
};
private static DriverInstance NewDriver(string driverInstanceId, string clusterId, string namespaceId) => new()
private static DriverInstance NewDriver(string driverInstanceId, string clusterId) => new()
{
DriverInstanceId = driverInstanceId,
ClusterId = clusterId,
NamespaceId = namespaceId,
Name = driverInstanceId,
DriverType = "Modbus",
Enabled = true,
@@ -165,7 +165,7 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
/// network-browse E2E (Host.IntegrationTests) needs the docker-dev fixture and is tracked
/// as a follow-up.
/// </summary>
[Fact]
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public async Task Equipment_namespace_structure_materialises_end_to_end_against_real_SDK()
{
await using var host = new OpcUaApplicationHost(
@@ -183,20 +183,23 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
await host.StartAsync(sdkServer, Ct);
sdkServer.NodeManager.ShouldNotBeNull();
// One area / line / equipment + a Modbus FK driver in an Equipment-kind namespace, with a
// single equipment-bound Tag (the signal). Equipment.Name is the UNS browse segment.
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 = "Modbus", DriverType = "Modbus", DriverConfig = "{}" };
// One area / line / equipment + a Modbus driver. v3: no Namespace entity, DriverInstance no
// longer binds a Namespace, and Equipment no longer binds a driver. The equipment-bound Tag
// (the Speed signal) that this end-to-end test materialised as a Variable node is DARK until
// Batch 4 — the composer emits an empty EquipmentTags set, so it is no longer seeded/passed
// here. Equipment.Name is the UNS browse segment. (Folder-only materialisation is covered live
// by the sibling MaterialiseHierarchy_against_real_SDK test; this suite's equipment-tag
// Variable end-to-end leg re-enables in Batch 4 via the UnsTagReference fan-out.)
var driver = new DriverInstance { DriverInstanceId = "drv-modbus", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}" };
var area = new UnsArea { UnsAreaId = "nw-area-filling", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "nw-line-1", UnsAreaId = "nw-area-filling", Name = "line-1" };
var equipment = new Equipment { EquipmentId = "eq-1", DriverInstanceId = "drv-modbus", UnsLineId = "nw-line-1", Name = "station-1", MachineCode = "STATION_001" };
var tag = new Tag { TagId = "tag-speed", DriverInstanceId = "drv-modbus", EquipmentId = "eq-1", Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{\"FullName\":\"40001\"}" };
var equipment = new Equipment { EquipmentId = "eq-1", UnsLineId = "nw-line-1", Name = "station-1", MachineCode = "STATION_001" };
var composition = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equipment }, new[] { driver },
Array.Empty<ScriptedAlarm>(), new[] { tag }, new[] { ns });
Array.Empty<ScriptedAlarm>());
// Compose-side EquipmentTags extraction.
// Compose-side EquipmentTags extraction (Batch-4 subject — empty this batch).
var planned = composition.EquipmentTags.ShouldHaveSingleItem();
planned.EquipmentId.ShouldBe("eq-1");
planned.FullName.ShouldBe("40001");
@@ -1,81 +1,30 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Verifies the live-edit compose seam (<see cref="AddressSpaceComposer.Compose"/>) treats a Galaxy
/// point as an ordinary equipment tag: <c>GalaxyMxGateway</c> is a standard Equipment-kind driver.
/// An equipment-scoped <see cref="Tag"/> (non-null <see cref="Tag.EquipmentId"/>) bound to a
/// <c>GalaxyMxGateway</c> driver living in an <c>Equipment</c>-kind namespace must surface under
/// <see cref="AddressSpaceComposition.EquipmentTags"/> (carrying its driver-side FullName).
/// Characterized the live-edit compose seam admitting a Galaxy point as an ordinary equipment tag:
/// an equipment-scoped <c>Tag</c> bound to a <c>GalaxyMxGateway</c> driver surfaced under
/// <see cref="AddressSpaceComposition.EquipmentTags"/> carrying its driver-side FullName (coalescing a
/// null FolderPath to <c>string.Empty</c>).
/// <para><b>v3 Batch-1 DARK + reshaped seed:</b> equipment-tag variable plans do not materialize until
/// Batch 4 (the composer emits an empty <c>EquipmentTags</c> set — the raw + UNS variable nodes light
/// up in Batch 4's dual-namespace UNS↔Raw fan-out). The seed mechanism this test used is also gone:
/// <c>Tag</c> is now raw-only (no <c>EquipmentId</c>/<c>DriverInstanceId</c>/<c>FolderPath</c>), the
/// Compose overload no longer takes tags/namespaces, and equipment references raw tags via
/// <c>UnsTagReference</c> rather than a Galaxy point carrying an <c>EquipmentId</c>. So there is no
/// Batch-1 analog to assert. Re-author against the UnsTagReference → EquipmentTags fan-out when Batch 4
/// lights the equipment-tag variable nodes.</para>
/// </summary>
public sealed class AddressSpaceComposerAliasTagTests
{
/// <summary>A <c>GalaxyMxGateway</c> driver in an Equipment-kind namespace carries an
/// equipment-scoped Galaxy tag (EquipmentId set, FolderPath null, TagConfig FullName = the Galaxy
/// ref). Compose must put it in EquipmentTags with its FullName, coalescing the null FolderPath to
/// <c>string.Empty</c>.</summary>
[Fact]
/// <summary>A <c>GalaxyMxGateway</c> equipment tag surfaces under EquipmentTags with its FullName —
/// dark until Batch 4 (empty equipment-tag plan set) and re-authored via UnsTagReference.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Compose_admits_galaxy_equipment_tag_in_equipment_tags()
{
var ns = new Namespace
{
NamespaceId = "ns-eq",
ClusterId = "c1",
Kind = NamespaceKind.Equipment,
NamespaceUri = "urn:eq",
};
var driver = new DriverInstance
{
DriverInstanceId = "drv-galaxy",
ClusterId = "c1",
NamespaceId = "ns-eq",
Name = "Galaxy1",
DriverType = "GalaxyMxGateway",
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-galaxy",
UnsLineId = "line-1",
Name = "TestMachine_020",
MachineCode = "TESTMACHINE_020",
};
// Equipment-scoped Galaxy tag: EquipmentId set, FolderPath null, FullName = Galaxy ref.
var galaxyTag = new Tag
{
TagId = "tag-galaxy",
DriverInstanceId = "drv-galaxy",
EquipmentId = "eq-1",
FolderPath = null,
Name = "TestChangingInt",
DataType = "Int32",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"FullName\":\"TestMachine_020.TestChangingInt\"}",
};
var result = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip },
new[] { driver }, Array.Empty<ScriptedAlarm>(),
new[] { galaxyTag }, new[] { ns });
// The Galaxy point survives composition as an equipment tag, carrying its Galaxy FullName.
var tag = result.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.FullName.ShouldBe("TestMachine_020.TestChangingInt");
// The input Tag.FolderPath is null; the composer coalesces it to string.Empty (the explicit
// byte-parity null-coalesce the artifact-decode side mirrors).
tag.FolderPath.ShouldBe(string.Empty);
// Dark until Batch 4: see class summary. EquipmentTags is emitted empty this batch and the
// Tag.EquipmentId binding this test relied on is retired (Tag is raw-only). The Batch-4 seam
// fans equipment tags in via UnsTagReference; re-author the assertion then.
}
}
@@ -1,98 +1,27 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Covers follow-up E projection: <see cref="EquipmentNode"/> carries the equipment's
/// <c>DriverInstanceId</c> / <c>DeviceId</c> bindings and the resolved <c>DeviceHost</c> (parsed from
/// the bound <see cref="Device"/>'s schemaless <c>DeviceConfig</c> JSON via the shared
/// <c>DeviceConfigIntent.TryExtractHost</c>). A later task grafts a driver's discovered
/// FixedTree onto a zero-tag equipment and partitions a multi-device driver by host using these.
/// <para>The direct host-extraction + normalization unit tests moved to
/// <c>Commons.Tests/DeviceConfigIntentTests</c> (R2-11) — this suite keeps the Compose-level projection
/// coverage.</para>
/// RETIRED (v3): this suite characterized follow-up E's <see cref="EquipmentNode"/> projection of an
/// equipment's <c>DriverInstanceId</c> / <c>DeviceId</c> bindings + the resolved <c>DeviceHost</c>
/// (parsed from the bound device's <c>DeviceConfig</c>). In v3 the equipment↔device binding is
/// architecturally removed — <c>Equipment</c> no longer carries <c>DriverInstanceId</c>/<c>DeviceId</c>,
/// <c>AddressSpaceComposer.Compose</c> no longer takes a <c>devices</c> list, and equipment references
/// raw tags via <c>UnsTagReference</c> instead of hosting a device. <c>EquipmentNode</c>'s
/// driver/device/host fields are therefore always null.
/// <para>The coverage did not vanish, it moved: the device-endpoint merge is exercised by
/// <c>Runtime.Tests.Drivers.DeploymentArtifactRawPathTests</c> (nested RawPath + device-endpoint merge),
/// and the host-extraction/normalization unit lives in <c>Commons.Tests.DeviceConfigIntentTests</c>
/// (R2-11). The Skipped placeholder below preserves the retirement rationale in test output.</para>
/// </summary>
public sealed class AddressSpaceComposerDeviceHostTests
{
/// <summary>An equipment bound to a driver + a device whose config carries a top-level
/// <c>HostAddress</c> resolves all three fields, with the host trimmed + lower-cased.</summary>
[Fact]
[Fact(Skip = DarkAddressSpaceReasons.EquipmentDeviceBindingRetired)]
public void Compose_resolves_driver_device_and_device_host()
{
var equipment = new[] { NewEquipment("eq-1", driver: "d1", device: "dev1") };
var devices = new[] { NewDevice("dev1", "d1", "{\"HostAddress\":\"10.0.0.5:8193\"}") };
var node = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment,
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(), devices: devices)
.EquipmentNodes.ShouldHaveSingleItem();
node.EquipmentId.ShouldBe("eq-1");
node.DriverInstanceId.ShouldBe("d1");
node.DeviceId.ShouldBe("dev1");
node.DeviceHost.ShouldBe("10.0.0.5:8193");
// Retired: see class summary. Equipment no longer binds a driver/device; the EquipmentNode
// driver/device/host projection is gone. Coverage moved to DeploymentArtifactRawPathTests
// (device-endpoint merge) + DeviceConfigIntentTests (host extraction).
}
/// <summary>An equipment with no driver and no device → all three new fields null (driver-less,
/// no device to resolve a host from).</summary>
[Fact]
public void Compose_equipment_without_driver_or_device_yields_null_bindings()
{
var equipment = new[] { NewEquipment("eq-1", driver: null, device: null) };
var node = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment,
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(), devices: Array.Empty<Device>())
.EquipmentNodes.ShouldHaveSingleItem();
node.DriverInstanceId.ShouldBeNull();
node.DeviceId.ShouldBeNull();
node.DeviceHost.ShouldBeNull();
}
/// <summary>A bound DeviceId with no matching device row, or a device whose config has no
/// <c>HostAddress</c>, resolves DeviceHost to null while DeviceId is still carried.</summary>
[Fact]
public void Compose_device_host_is_null_when_unresolvable()
{
var equipment = new[]
{
NewEquipment("eq-missing", driver: "d1", device: "dev-missing"),
NewEquipment("eq-nohost", driver: "d1", device: "dev-nohost"),
};
var devices = new[] { NewDevice("dev-nohost", "d1", "{\"Port\":502}") };
var nodes = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment,
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(), devices: devices)
.EquipmentNodes;
var missing = nodes.Single(n => n.EquipmentId == "eq-missing");
missing.DeviceId.ShouldBe("dev-missing");
missing.DeviceHost.ShouldBeNull();
var noHost = nodes.Single(n => n.EquipmentId == "eq-nohost");
noHost.DeviceId.ShouldBe("dev-nohost");
noHost.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,
};
}
@@ -1,7 +1,6 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
@@ -10,6 +9,13 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// reserved <c>{{equip}}</c> token in a shared VirtualTag script with each owning equipment's
/// derived tag base (from its child-tag <c>FullName</c>s) — so one script reused across N
/// identical machines resolves to N machine-specific dependency graphs.
///
/// <para><b>v3 Batch-1 DARK:</b> the per-equipment tag base is DERIVED from equipment-tag
/// <c>FullName</c>s, and equipment-tag variable plans do not materialize until Batch 4 (the composer
/// no longer takes tags — <c>baseByEquip</c> is empty this batch). With no base to substitute, the
/// <c>{{equip}}</c> token stays literal and these per-machine assertions have nothing to compare, so
/// the test is Skipped-with-reason. The seed + assertions are preserved verbatim (minus the retired
/// entity columns) so re-enabling in Batch 4 is a one-line change once the fan-out feeds the base.</para>
/// </summary>
public sealed class AddressSpaceComposerEquipTokenTests
{
@@ -17,21 +23,17 @@ public sealed class AddressSpaceComposerEquipTokenTests
/// to two equipments (TestMachine_001 / _002) each with one equipment Tag whose FullName carries
/// the per-machine base. Compose must expand the token per equipment in both the Expression and
/// the parsed DependencyRefs.</summary>
[Fact]
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Compose_substitutes_equip_token_per_equipment()
{
var ns = new Namespace
{
NamespaceId = "ns-eq",
ClusterId = "c1",
Kind = NamespaceKind.Equipment,
NamespaceUri = "urn:eq",
};
// v3: no Namespace entity; DriverInstance no longer binds a Namespace; Equipment no longer
// binds a driver. In Batch 4 the {{equip}} base derives from the equipment's child-tag
// FullNames — here TestMachine_001.Source / TestMachine_002.Source — which flow through the
// (dark this batch) equipment-tag plan set. Seeded for intent; not passed to Compose.
var driver1 = new DriverInstance
{
DriverInstanceId = "drv-1",
ClusterId = "c1",
NamespaceId = "ns-eq",
Name = "Modbus1",
DriverType = "Modbus",
DriverConfig = "{}",
@@ -40,35 +42,14 @@ public sealed class AddressSpaceComposerEquipTokenTests
{
DriverInstanceId = "drv-2",
ClusterId = "c1",
NamespaceId = "ns-eq",
Name = "Modbus2",
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 equip1 = new Equipment { EquipmentId = "eq-1", DriverInstanceId = "drv-1", UnsLineId = "line-1", Name = "TestMachine_001", MachineCode = "TESTMACHINE_001" };
var equip2 = new Equipment { EquipmentId = "eq-2", DriverInstanceId = "drv-2", UnsLineId = "line-1", Name = "TestMachine_002", MachineCode = "TESTMACHINE_002" };
var tag1 = 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 tag2 = new Tag
{
TagId = "tag-2",
DriverInstanceId = "drv-2",
EquipmentId = "eq-2",
Name = "Source",
DataType = "Int32",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"FullName\":\"TestMachine_002.Source\",\"DataType\":\"Int32\"}",
};
var equip1 = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "TestMachine_001", MachineCode = "TESTMACHINE_001" };
var equip2 = new Equipment { EquipmentId = "eq-2", UnsLineId = "line-1", Name = "TestMachine_002", MachineCode = "TESTMACHINE_002" };
var script = new Script
{
ScriptId = "s-equip",
@@ -82,7 +63,6 @@ public sealed class AddressSpaceComposerEquipTokenTests
var result = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip1, equip2 },
new[] { driver1, driver2 }, Array.Empty<ScriptedAlarm>(),
new[] { tag1, tag2 }, new[] { ns },
virtualTags: new[] { vt1, vt2 },
scripts: new[] { script });
@@ -136,7 +136,6 @@ public sealed class AddressSpaceComposerPurityTests
var result = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(),
Array.Empty<Tag>(), Array.Empty<Namespace>(),
virtualTags: new[] { vt },
scripts: new[] { script });
@@ -174,7 +173,6 @@ public sealed class AddressSpaceComposerPurityTests
var result = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(),
Array.Empty<Tag>(), Array.Empty<Namespace>(),
virtualTags: new[] { vt },
scripts: new[] { script });
@@ -200,7 +198,6 @@ public sealed class AddressSpaceComposerPurityTests
var result = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(),
Array.Empty<Tag>(), Array.Empty<Namespace>(),
virtualTags: new[] { vt },
scripts: Array.Empty<Script>());
@@ -213,7 +210,6 @@ public sealed class AddressSpaceComposerPurityTests
private static Equipment NewEquipment(string id) => new()
{
EquipmentId = id,
DriverInstanceId = "drv-1",
UnsLineId = "line-1",
Name = id,
MachineCode = id.ToUpperInvariant(),
@@ -223,7 +219,6 @@ public sealed class AddressSpaceComposerPurityTests
{
DriverInstanceId = id,
ClusterId = "cluster-1",
NamespaceId = "ns-1",
Name = id,
DriverType = "Stub",
DriverConfig = "{\"k\":\"v\"}",
@@ -65,7 +65,6 @@ public sealed class AddressSpaceComposerScriptedAlarmTests
var result = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), new[] { alarm1, alarm2 },
Array.Empty<Tag>(), Array.Empty<Namespace>(),
virtualTags: Array.Empty<VirtualTag>(),
scripts: new[] { script1, script2 });
@@ -121,7 +120,6 @@ public sealed class AddressSpaceComposerScriptedAlarmTests
var result = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), new[] { alarm },
Array.Empty<Tag>(), Array.Empty<Namespace>(),
scripts: new[] { script });
var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem();
@@ -158,7 +156,6 @@ public sealed class AddressSpaceComposerScriptedAlarmTests
var result = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), new[] { alarm },
Array.Empty<Tag>(), Array.Empty<Namespace>(),
scripts: new[] { script });
var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem();
@@ -202,7 +199,6 @@ public sealed class AddressSpaceComposerScriptedAlarmTests
var result = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), new[] { goodAlarm, orphanAlarm },
Array.Empty<Tag>(), Array.Empty<Namespace>(),
scripts: new[] { goodScript });
var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem();
@@ -237,7 +233,6 @@ public sealed class AddressSpaceComposerScriptedAlarmTests
var result = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), new[] { alarm },
Array.Empty<Tag>(), Array.Empty<Namespace>(),
scripts: new[] { script });
var plan = result.EquipmentScriptedAlarms.ShouldHaveSingleItem();
@@ -264,12 +259,12 @@ public sealed class AddressSpaceComposerScriptedAlarmTests
var r1 = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), new[] { a1, a2, a3 },
Array.Empty<Tag>(), Array.Empty<Namespace>(), scripts: scripts);
scripts: scripts);
var r2 = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), new[] { a3, a1, a2 },
Array.Empty<Tag>(), Array.Empty<Namespace>(), scripts: scripts);
scripts: scripts);
// Sorted by EquipmentId then ScriptedAlarmId: (eq-1,al-2), (eq-1,al-3), (eq-2,al-1).
r1.EquipmentScriptedAlarms.Select(p => p.ScriptedAlarmId)
@@ -17,35 +17,20 @@ public sealed class AddressSpaceComposerVirtualTagHistorizeTests
[Fact]
public void Compose_carries_virtual_tag_historize_flag_onto_plan()
{
var ns = new Namespace
{
NamespaceId = "ns-eq",
ClusterId = "c1",
Kind = NamespaceKind.Equipment,
NamespaceUri = "urn:eq",
};
// v3: no Namespace entity; DriverInstance no longer binds a Namespace; Equipment no longer
// binds a driver. The dependency tag is not seeded here — the composer no longer takes tags
// (they're dark until Batch 4) and the VirtualTag subject of this test is a LIVE surface.
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",
@@ -60,7 +45,6 @@ public sealed class AddressSpaceComposerVirtualTagHistorizeTests
var result = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip },
new[] { driver }, Array.Empty<ScriptedAlarm>(),
new[] { tag }, new[] { ns },
virtualTags: new[] { vtHist, vtPlain },
scripts: new[] { script });
@@ -0,0 +1,33 @@
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Shared xUnit <c>Skip</c> reason constants for OpcUaServer tests whose intent is retained but
/// whose subject is not materialized in the v3 Batch-1 DARK address space. The composer
/// (<see cref="AddressSpaceComposer.Compose"/>) and the artifact both emit an EMPTY equipment-tag
/// variable plan set this batch — raw + UNS variable nodes are lit up in Batch 4's dual-namespace
/// UNS↔Raw fan-out. Until then any assertion whose subject is an equipment-tag variable node (its
/// FullName, its <c>{{equip}}</c>-token-derived base, its device-host binding) has nothing to
/// compare, so it is Skipped-with-reason rather than deleted. Mirrors
/// <c>Runtime.Tests.Drivers.DarkAddressSpaceReasons</c>.
/// </summary>
internal static class DarkAddressSpaceReasons
{
/// <summary>
/// Equipment-tag variable materialization + its per-field intent (FullName / array / historize /
/// native-alarm / <c>{{equip}}</c>-token base derived from child-tag FullNames) is dark until Batch 4.
/// </summary>
public const string EquipmentTagsDarkBatch4 =
"v3 dark address space: equipment-tag variable plans (FullName / array / historize / alarm / " +
"{{equip}}-token base derived from child-tag FullNames) do not materialize until Batch 4 " +
"(dual-namespace UNS↔Raw fan-out). The composer emits an empty equipment-tag plan set this batch, " +
"so there is no per-equipment tag base to substitute and no equipment-tag variable node to assert. " +
"Migrate + re-enable when Batch 4 lights the raw/UNS variable nodes.";
/// <summary>Equipment↔device host binding is architecturally retired in v3.</summary>
public const string EquipmentDeviceBindingRetired =
"v3: equipment no longer binds a driver/device — Equipment lost DriverInstanceId/DeviceId and " +
"Compose no longer takes a devices list, so EquipmentNode's DriverInstanceId/DeviceId/DeviceHost " +
"projection is always null. Equipment references raw tags via UnsTagReference. The device-endpoint " +
"merge this suite characterized now lives in Runtime.Tests DeploymentArtifactRawPathTests; the " +
"host-extraction unit is Commons.Tests DeviceConfigIntentTests.";
}