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. ---