v3 B1-WP6: migrate Core + Server test projects to greenfield schema

Fix TESTS only (production already green) so the four in-scope test projects
compile + pass under the v3 dark address space:

- Core.Abstractions.Tests: drop AllowedNamespaceKinds/NamespaceKindCompatibility
  from DriverTypeMetadata; rewrite EquipmentTagRefResolver tests to the single-func
  RawPath-lookup contract (no parseRef/transient cache).
- Core.Tests: rewrite EquipmentNodeWalkerTests to EquipmentNamespaceContent(Areas,
  Lines,Equipment,VirtualTags?,ScriptedAlarms?) — folders + VirtualTags +
  ScriptedAlarms only; raw-tag variables dark (skipped Batch-4 placeholder). Drop
  retired Equipment.DriverInstanceId.
- Runtime.Tests: rewrite golden corpus + TagConfigCorpusParityTests to the v3
  RawPath/RawTagEntry round-trip (byte + TagConfigIntent parity; EquipmentTags dark).
  Salvage VirtualTag + ScriptedAlarm artifact parity to the new Compose signature.
  Retire the equipment-tag-materialization parity files (Array/Historize/Alias) and
  the equipment-device-binding DeviceHost parity to documented skipped placeholders.
  Skip 34 dark equipment-tag routing/value/alarm/write/discovery actor tests with a
  shared DarkAddressSpaceReasons constant. Re-key cluster-scoped tests to v3 UNS
  line->area attribution.
- ControlPlane.Tests: ConfigComposer round-trips re-keyed to RawFolder/Device (no
  Namespaces); tag-config gate resolves driver via Device; deploy-gate collision test
  re-keyed to UnsEffectiveNameCollision; drop retired BadCrossClusterNamespaceBinding
  case.
This commit is contained in:
Joseph Doherty
2026-07-15 21:26:00 -04:00
parent 604928b29d
commit 329144b1aa
26 changed files with 425 additions and 1445 deletions
@@ -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);
}
/// <summary>
/// Verifies that NamespaceKindCompatibility flags are implemented as a bitmask.
/// </summary>
[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();
}
/// <summary>
/// Verifies that Get rejects empty or null type names.
/// </summary>
@@ -4,54 +4,65 @@ using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// v3: <see cref="EquipmentTagRefResolver{TDef}"/> is now a thin wrapper over the driver's
/// authored <c>RawPath → TDef</c> 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.
/// </summary>
public class EquipmentTagRefResolverTests
{
private sealed record Def(string Id);
private static EquipmentTagRefResolver<Def> Make(
Dictionary<string, Def> byName, Func<string, Def?> parse)
=> new(r => byName.TryGetValue(r, out var d) ? d : null, parse);
private static EquipmentTagRefResolver<Def> Make(Dictionary<string, Def> 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<string, Def>();
var r = new EquipmentTagRefResolver<Def>(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");
}
/// <summary>
/// Core.Abstractions-009: <see cref="EquipmentTagRefResolver{TDef}.TryResolve"/> carries
/// <c>[MaybeNullWhen(false)]</c> on its <c>out</c> 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
@@ -9,12 +9,23 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa;
[Trait("Category", "Unit")]
public sealed class EquipmentNodeWalkerTests
{
/// <summary>
/// v3: raw tags no longer flow into <see cref="EquipmentNamespaceContent"/> and the walker
/// emits no raw-tag variable nodes. Raw tags reach the UNS namespace via
/// <c>UnsTagReference</c> 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.
/// </summary>
internal const string DarkBatch4 =
"v3 dark address space: raw-tag → UNS variable materialization lands in Batch 4 " +
"(UnsTagReference fan-out), not in EquipmentNodeWalker.";
/// <summary>Verifies that walking empty content emits no nodes.</summary>
[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");
}
/// <summary>Verifies that walking emits a variable for each bound tag under equipment.</summary>
[Fact]
public void Walk_Emits_Variable_Per_BoundTag_Under_Equipment()
/// <summary>
/// Batch-4 pending: the walker must emit a raw-tag variable per <c>UnsTagReference</c> 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 <c>Tag</c>,
/// which no longer exists (tags bind to Devices, not equipment). Intent retained; unskip when
/// the Batch-4 UNS projection lands.
/// </summary>
[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);
}
/// <summary>Verifies that walking falls back to String type for unparseable data types.</summary>
[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).
}
/// <summary>Verifies that walking emits virtual tag variables with Virtual source discriminator.</summary>
@@ -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();
}
/// <summary>Verifies that driver tag default NodeSourceKind is Driver.</summary>
[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();
}
/// <summary>Verifies that ExtractFullName unwraps a JSON object with FullName field.</summary>
[Fact]
public void ExtractFullName_unwraps_json_object_with_FullName_field()
{
EquipmentNodeWalker.ExtractFullName(
"{\"FullName\":\"MESReceiver_001.MoveInBatchID\",\"DataType\":\"Int32\"}")
.ShouldBe("MESReceiver_001.MoveInBatchID");
}
/// <summary>Verifies that ExtractFullName handles S7-style extra fields.</summary>
[Fact]
public void ExtractFullName_handles_S7_style_extra_fields()
{
EquipmentNodeWalker.ExtractFullName(
"{\"FullName\":\"DB1_DBW0\",\"Address\":\"DB1.DBW0\",\"DataType\":\"Int16\"}")
.ShouldBe("DB1_DBW0");
}
/// <summary>Verifies that ExtractFullName returns raw string when input is not JSON.</summary>
[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");
}
/// <summary>Verifies that ExtractFullName returns raw string when JSON is missing FullName field.</summary>
[Fact]
public void ExtractFullName_returns_raw_when_json_missing_FullName_field()
{
EquipmentNodeWalker.ExtractFullName("{\"Address\":\"DB1.DBW0\"}")
.ShouldBe("{\"Address\":\"DB1.DBW0\"}");
}
/// <summary>Verifies that driver tag FullName passes through from TagConfig JSON.</summary>
[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 -----
/// <summary>Test implementation of IAddressSpaceBuilder that records calls.</summary>
@@ -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",
@@ -23,29 +23,25 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// </summary>
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<OtOpcUaConfigDbContext> 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(
@@ -175,13 +175,14 @@ public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase
db.ConfigEdits.Single().EntityType.ShouldBe("Deployment");
}
/// <summary>Verifies the full DraftValidator gate (reject on ANY error): a Tag↔VirtualTag
/// NodeId collision in the live config rejects the deploy (422-mapped
/// <see cref="StartDeploymentOutcome.Rejected"/>) before any coordinator dispatch — and inserts
/// no Deployment row. The colliding equipment uses a canonical EquipmentId so the rejection is
/// <summary>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
/// <see cref="StartDeploymentOutcome.Rejected"/>) 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.</summary>
[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<StartDeploymentResult>(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);
}
/// <summary>Verifies that a DriverInstance whose NamespaceId lives in a different cluster
/// triggers <c>BadCrossClusterNamespaceBinding</c> 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.</summary>
[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<IDriverProbe>()));
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
var reply = ExpectMsg<StartDeploymentResult>(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.
/// <summary>Verifies the warn-only compile-cost advisory: seeding N distinct non-passthrough
/// (genuinely-compiled) Script rows yields an <see cref="StartDeploymentOutcome.Accepted"/>
@@ -87,29 +87,27 @@ public sealed class ConfigComposerTests : ControlPlaneActorTestBase
}
/// <summary>
/// Verifies that <see cref="ConfigComposer.SnapshotAndFlattenAsync"/> serialises a
/// <see cref="Device"/>'s <c>HostAddress</c> into the artifact blob and that
/// <see cref="DeploymentArtifact.ParseComposition(ReadOnlySpan{byte})"/> decodes it back
/// as the equipment's <see cref="ZB.MOM.WW.OtOpcUa.OpcUaServer.EquipmentNode.DeviceHost"/>
/// (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 <see cref="ConfigComposer.SnapshotAndFlattenAsync"/> serialises a
/// <see cref="Device"/>'s endpoint into the artifact blob and that
/// <see cref="DeploymentArtifact.ParseDriverInstances(ReadOnlySpan{byte})"/> folds the sole
/// device's <c>DeviceConfig</c> up into the driver's merged <c>DriverConfig</c> (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
/// <c>EquipmentNode.DeviceHost</c>; v3 retired that binding — equipment references raw tags via
/// UnsTagReference, and the endpoint reaches the driver via the RawPath device merge.)
/// </summary>
[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");
}
/// <summary>
@@ -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();
@@ -0,0 +1,27 @@
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Shared xUnit <c>Skip</c> 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 <see cref="TagConfigCorpusParityTests"/> (RawTagEntry
/// round-trip) and <c>TagConfigIntentTests</c> (the parse unit).
/// </summary>
internal static class DarkAddressSpaceReasons
{
/// <summary>Equipment-tag variable materialization + its per-field intent is dark until Batch 4.</summary>
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.";
/// <summary>Equipment↔device host binding is architecturally retired in v3.</summary>
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).";
}
@@ -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;
/// <summary>
/// Verifies the artifact-decode mirror (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>)
/// treats a Galaxy point as an ordinary equipment tag — an equipment-scoped tag (non-null
/// <c>EquipmentId</c>) bound to a <c>GalaxyMxGateway</c> driver in an <c>Equipment</c>-kind namespace —
/// into the decoded <c>EquipmentTags</c> 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 <c>Equipment</c> (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
/// <c>GalaxyMxGateway</c> driver) surfaced into <c>EquipmentTags</c> with the same FullName /
/// EquipmentId / DriverInstanceId / AccessLevel→Writable / native-alarm intent on both the composer
/// and the artifact-decode seam.
/// <para>
/// <b>v3 DARK (Batch 1):</b> equipment-tag variable plans do not materialize — both producers emit an
/// EMPTY <c>EquipmentTags</c> set until the Batch-4 dual-namespace UNS↔Raw fan-out. Additionally, the
/// v3 schema retired the <c>Namespace</c>/<c>NamespaceKind</c> entities and the Galaxy
/// <c>ExplicitFullName</c> / namespace-Kind gate this test keyed off; Galaxy is an ordinary
/// Device-bound driver whose raw tags reach the UNS via <c>UnsTagReference</c>. The AccessLevel→Writable
/// and native-alarm parse this asserted are covered at the parse unit (<c>TagConfigIntentTests</c>) and
/// the RawTagEntry round-trip (<see cref="TagConfigCorpusParityTests"/>). Unskip + rewrite to the
/// Batch-4 UNS-projection shape when equipment-tag plans light up.
/// </para>
/// </summary>
public sealed class DeploymentArtifactAliasParityTests
{
/// <summary>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
/// <c>string.Empty</c>.</summary>
[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();
}
/// <summary>The artifact decoder reads <c>AccessLevel</c> into <c>EquipmentTagPlan.Writable</c>:
/// 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.</summary>
[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);
}
/// <summary>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.</summary>
[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);
}
/// <summary>An equipment-scoped tag in a non-Equipment (Simulated) namespace must NOT surface in
/// EquipmentTags — byte-parity with the composer's pure <c>ns.Kind == NamespaceKind.Equipment</c>
/// 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.</summary>
[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();
}
/// <summary>
/// The load-bearing direct byte-parity proof for the two equipment-tag producers: for the SAME
/// input draft, the live-edit composer (<see cref="AddressSpaceComposer.Compose"/>) and the
/// artifact decoder (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>)
/// must emit IDENTICAL <c>EquipmentTags</c> — 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 <c>{"FullName":"DelmiaReceiver_001.DownloadPath"}</c>)
/// 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. <c>EquipmentTagPlan</c> is a plain positional record (all string members), so
/// <c>SequenceEqual</c> is full value-and-order equality.
/// </summary>
[Fact]
/// <summary>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.</summary>
[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<ScriptedAlarm>(), 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.
}
/// <summary>
/// The native-alarm <c>historizeToAveva</c> opt-out (bool?, the per-condition durable-AVEVA-write
/// gate) is parsed by BOTH equipment-tag producers' <c>ExtractTagAlarm</c> and MUST stay byte-parity:
/// for the SAME tag TagConfig carrying <c>alarm.historizeToAveva: true</c> (and <c>: false</c>), the
/// live-edit composer (<see cref="AddressSpaceComposer.Compose"/>) and the artifact decoder
/// (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>) must derive the
/// identical <c>EquipmentTagAlarmInfo.HistorizeToAveva</c>. The galaxy-tag parity test above already
/// covers the absent ⇒ null case; this pins the explicit-bool branch on both sides.
/// </summary>
[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<ScriptedAlarm>(), 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
}
/// <summary>The full Pascal-case snapshot a <see cref="Tag"/> EF entity serialises to in the
/// artifact (matches ConfigComposer): the equipment-tag decoder reads exactly these fields.</summary>
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,
};
}
@@ -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;
/// <summary>
/// Proves the Phase 4c array intent (<c>isArray</c> + optional <c>arrayLength</c>), which rides
/// inside the raw <c>TagConfig</c> JSON blob, round-trips with byte-parity through both
/// equipment-tag producers: the live-edit composer (<see cref="AddressSpaceComposer.Compose"/>) and the
/// artifact decoder (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>).
/// A secondary/follower node decoding a serialized deployment artifact MUST materialise array tags
/// identically to the primary, so the artifact side must derive <c>IsArray</c> / <c>ArrayLength</c>
/// from the same blob the composer parses. The composer's <c>ExtractTagArray</c> is internal and not
/// visible to this test assembly (<c>InternalsVisibleTo</c> only names the OpcUaServer.Tests
/// project), so byte-parity is asserted via the public-surface round-trip — exactly as the sibling
/// <see cref="DeploymentArtifactHistorizeParityTests"/> does.
/// Phase 4c array intent (<c>isArray</c> + optional <c>arrayLength</c>) byte-parity between the two
/// equipment-tag producers.
/// <para>
/// <b>v3 DARK (Batch 1):</b> equipment-tag variable plans do not materialize — the composer and the
/// artifact both emit an EMPTY <c>EquipmentTags</c> set until the Batch-4 dual-namespace UNS↔Raw
/// fan-out, so there is nothing to compare. The underlying parse of <c>isArray</c> / <c>arrayLength</c>
/// from a raw <c>TagConfig</c> blob is characterized today by <c>TagConfigIntentTests</c> and
/// round-tripped through the artifact by <see cref="TagConfigCorpusParityTests"/> (RawTagEntry
/// byte-preservation). Unskip when Batch 4 lights the equipment-tag plans.
/// </para>
/// </summary>
public sealed class DeploymentArtifactArrayParityTests
{
/// <summary>
/// One draft consumed by both producers, exercising every <c>ExtractTagArray</c> branch:
/// <c>isArray:true,arrayLength:16</c> ⇒ <c>(true, 16u)</c>; absent ⇒ <c>(false, null)</c>;
/// <c>isArray:true</c> with no length ⇒ <c>(true, null)</c>; a non-number (string) length while
/// <c>isArray:true</c> ⇒ <c>(true, null)</c>. The decoded <c>EquipmentTags</c> must equal the
/// composer's element-wise (positional-record value equality) and in the same order, proving
/// <c>IsArray</c> / <c>ArrayLength</c> are derived byte-identically on both seams.
/// </summary>
[Fact]
/// <summary>Batch-4 pending: composer and artifact agree on array equipment-tag plans
/// (IsArray + ArrayLength). See class remarks — equipment-tag plans are dark this batch.</summary>
[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<ScriptedAlarm>(), 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.
}
/// <summary>The Pascal-case snapshot a <see cref="Tag"/> EF entity serialises to in the artifact
/// (matches ConfigComposer); the equipment-tag decoder re-parses these fields — including the raw
/// <c>TagConfig</c> blob the array flags ride inside.</summary>
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,
};
}
@@ -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;
/// <summary>
/// Proves follow-up E: the equipment's <c>DriverInstanceId</c> / <c>DeviceId</c> bindings and the
/// resolved <c>DeviceHost</c> (parsed from the bound <see cref="Device"/>'s schemaless
/// <c>DeviceConfig</c> JSON) round-trip with byte-parity through both <see cref="EquipmentNode"/>
/// producers: the live-edit composer (<see cref="AddressSpaceComposer.Compose(System.Collections.Generic.IReadOnlyList{UnsArea},System.Collections.Generic.IReadOnlyList{UnsLine},System.Collections.Generic.IReadOnlyList{Equipment},System.Collections.Generic.IReadOnlyList{DriverInstance},System.Collections.Generic.IReadOnlyList{ScriptedAlarm},System.Collections.Generic.IReadOnlyList{Device})"/>)
/// and the artifact decoder (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>).
/// 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 <c>DeviceConfigIntent.TryExtractHost</c> (single source
/// of truth + identical trim + lower-case normalization).
/// Equipment <c>DriverInstanceId</c> / <c>DeviceId</c> binding + resolved <c>DeviceHost</c> byte-parity
/// across the two <c>EquipmentNode</c> producers (composer + artifact decode).
/// <para>
/// <b>v3 RETIRED:</b> equipment no longer binds a driver/device — <c>EquipmentNode</c> is
/// <c>(EquipmentId, DisplayName, UnsLineId)</c> only, <c>Equipment</c> dropped
/// <c>DriverInstanceId</c>/<c>DeviceId</c>, and devices bind to drivers (tags bind to devices) while
/// equipment references raw tags via <c>UnsTagReference</c>. The device-endpoint resolution this test
/// characterized (fold the sole device's <c>DeviceConfig</c> host into the driver config so a follower
/// node dials the same endpoint) is now proven by
/// <see cref="DeploymentArtifactRawPathTests.ParseDriverInstances_computes_nested_RawPath_and_merges_device_endpoint"/>.
/// Kept as a skipped marker so the retired coverage is discoverable, not silently deleted.
/// </para>
/// </summary>
public sealed class DeploymentArtifactDeviceHostParityTests
{
/// <summary>
/// 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 <c>HostAddress</c> (DeviceId carried, DeviceHost null). The decoded
/// <c>EquipmentNodes</c> must equal the composer's element-wise (positional-record value equality)
/// and in the same order.
/// </summary>
[Fact]
/// <summary>Retired: equipment↔device host binding removed in v3; superseded by
/// DeploymentArtifactRawPathTests device-endpoint merge. See class remarks.</summary>
[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<UnsArea>(), Array.Empty<UnsLine>(), equipment,
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(), 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,
};
/// <summary>The Pascal-case snapshot an <see cref="Equipment"/> EF entity serialises to in the
/// artifact (matches ConfigComposer) — including the nullable <c>DriverInstanceId</c> / <c>DeviceId</c>
/// the equipment-node decoder re-reads.</summary>
private static object ToEquipmentSnapshot(Equipment e) => new
{
e.EquipmentId,
e.Name,
e.MachineCode,
e.UnsLineId,
e.DriverInstanceId,
e.DeviceId,
};
/// <summary>The Pascal-case snapshot a <see cref="Device"/> EF entity serialises to in the artifact —
/// the decoder re-reads <c>DeviceId</c> + the raw <c>DeviceConfig</c> blob the host rides inside.</summary>
private static object ToDeviceSnapshot(Device d) => new
{
d.DeviceId,
d.DriverInstanceId,
d.Name,
d.DeviceConfig,
};
}
@@ -14,7 +14,10 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// </summary>
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
@@ -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;
/// <summary>
/// Proves the Phase C HistoryRead intent (<c>isHistorized</c> + optional <c>historianTagname</c>),
/// which rides inside the raw <c>TagConfig</c> JSON blob, round-trips with byte-parity through both
/// equipment-tag producers: the live-edit composer (<see cref="AddressSpaceComposer.Compose"/>) and the
/// artifact decoder (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>).
/// The artifact serializer re-parses the SAME <c>TagConfig</c> string both sides emit, so no
/// ConfigComposer change is needed — the flags are already carried in the blob.
/// Phase C HistoryRead intent (<c>isHistorized</c> + optional <c>historianTagname</c>) byte-parity
/// between the two equipment-tag producers.
/// <para>
/// <b>v3 DARK (Batch 1):</b> equipment-tag variable plans do not materialize —
/// <c>AddressSpaceComposer.Compose</c> and <c>DeploymentArtifact.ParseComposition</c> both emit an
/// EMPTY <c>EquipmentTags</c> 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 <c>isHistorized</c> /
/// <c>historianTagname</c> from a raw <c>TagConfig</c> blob is characterized today by
/// <c>TagConfigIntentTests</c> (the parse unit) and round-tripped through the artifact by
/// <see cref="TagConfigCorpusParityTests"/> (RawTagEntry byte-preservation). Unskip + restore the
/// composer↔artifact equipment-tag comparison when Batch 4 lights the equipment-tag plans.
/// </para>
/// </summary>
public sealed class DeploymentArtifactHistorizeParityTests
{
/// <summary>
/// One draft consumed by both producers: a historized equipment tag with no explicit historian
/// tagname (defaults to FullName later, so <c>HistorianTagname</c> is null on both sides), plus a
/// historized equipment tag WITH an explicit <c>historianTagname</c> override, plus a plain
/// non-historized tag. The decoded <c>EquipmentTags</c> must equal the composer's element-wise
/// (positional-record value equality) and in the same order, proving <c>IsHistorized</c> /
/// <c>HistorianTagname</c> are derived byte-identically on both seams.
/// </summary>
[Fact]
/// <summary>Batch-4 pending: composer and artifact agree on historized equipment-tag plans
/// (IsHistorized + HistorianTagname). See class remarks — equipment-tag plans are dark this batch.</summary>
[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<ScriptedAlarm>(), 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.
}
/// <summary>The Pascal-case snapshot a <see cref="Tag"/> EF entity serialises to in the artifact
/// (matches ConfigComposer); the equipment-tag decoder re-parses these fields — including the raw
/// <c>TagConfig</c> blob the Phase C flags ride inside.</summary>
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,
};
}
@@ -73,7 +73,6 @@ public sealed class DeploymentArtifactScriptedAlarmParityTests
var composed = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), new[] { alarm1, alarm2 },
Array.Empty<Tag>(), Array.Empty<Namespace>(),
virtualTags: Array.Empty<VirtualTag>(),
scripts: new[] { script1, script2 });
@@ -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<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), new[] { goodAlarm, orphanAlarm },
Array.Empty<Tag>(), Array.Empty<Namespace>(),
scripts: new[] { goodScript });
var blob = BlobOf(new
@@ -198,7 +198,7 @@ public sealed class DeploymentArtifactTests
/// <c>ns.Kind == NamespaceKind.Equipment</c> predicate (only Equipment-kind namespaces route
/// into EquipmentTags, so such a tag routes nowhere).
/// </summary>
[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
/// <summary>Verifies the inconsistency callback fires when a kept equipment's UNS line belongs to
/// another cluster (a cross-cluster orphan binding).</summary>
[Fact]
[Fact(Skip = DarkAddressSpaceReasons.EquipmentDeviceBindingRetired)]
public void ParseComposition_scoped_flags_cross_cluster_orphan_equipment()
{
var warnings = new List<string>();
@@ -622,9 +637,11 @@ public sealed class DeploymentArtifactTests
comp.EquipmentVirtualTags.ShouldBeEmpty();
}
/// <summary>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.</summary>
[Fact]
/// <summary>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.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentDeviceBindingRetired)]
public void Driverbound_equipment_kept_when_driver_in_cluster()
{
var blob = BlobOf(new
@@ -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<ScriptedAlarm>(),
new[] { tag }, new[] { ns },
virtualTags: new[] { vtHist, vtPlain },
scripts: new[] { script });
@@ -108,7 +108,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
/// <see cref="OpcUaQuality.Good"/> — proving the live value routed end-to-end and (in production)
/// overwrote the BadWaitingForInitialData seed.
/// </summary>
[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
/// <c>WriteValue</c>s Good there (the routing map was rebuilt, not left empty by the Clear()).
/// </summary>
[Fact]
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Discovered_node_and_value_survive_a_redeploy()
{
var db = NewInMemoryDbFactory();
@@ -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).</summary>
[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
/// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> 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".</summary>
[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 <c>DeviceConfigIntent.NormalizeHost</c> match.</summary>
[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).</summary>
[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 <c>loglevel = WARNING</c> 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 <c>PlansRoutingEqual</c>.)</summary>
[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
/// <summary>Dedup: a discovered node whose <c>FullReference</c> equals an authored equipment tag's
/// FullName is NOT injected (it would shadow the authored node) — only the genuinely-new FixedTree refs
/// are materialised.</summary>
[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.</summary>
[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.</summary>
[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
/// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> for the now-unresolved driver.</summary>
[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 <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> is
/// re-told. (Complements <see cref="Discovered_nodes_dropped_when_equipment_no_longer_resolves"/>, which
/// covers the Count==0 branch; this covers the rebind/StartsWith branch.)</summary>
[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 <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> re-grafts the FixedTree
/// under the new equipment EQ-2.</summary>
[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 <c>droppedAny</c> on a non-drop path). The
/// regression is observable here: a spurious trigger would advance <c>DiscoverCount</c> past the single Once
/// pass.</summary>
[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 <see cref="DriverInstanceActor"/>): 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".)</summary>
[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 <c>SubscribeCount</c> (+1) and the subscribed set ("40001" only, NOT the dropped "ft-ref-1"). (It also
/// gets a <see cref="DriverInstanceActor.TriggerRediscovery"/> — 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.</summary>
[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 <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> 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 <c>SubscribeCount</c> (+1) and the subscribed set ("40001" only).</summary>
[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 <c>Unsubscribe</c> (dropping the stale FixedTree handle) — NOT a subscribe.
/// Proven by <c>SubscribeCount</c> staying FLAT across the redeploy (no spurious subscribe), closing the
/// SubscribeCount-proxy blind spot for the empty-set fallback.</summary>
[Fact]
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Cached_tag_less_driver_fully_dropped_redeploy_sends_empty_fallback_without_subscribing()
{
var db = NewInMemoryDbFactory();
@@ -42,7 +42,7 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
/// <summary>A driver value published by FullName lands on the equipment variable's folder-scoped
/// NodeId (here <c>eq-1/speed</c>, no sub-folder), carrying the value/quality/timestamp.</summary>
[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
/// <summary>The same driver ref backing two equipments fans out: a single publish produces one
/// <see cref="OpcUaPublishActor.AttributeValueUpdate"/> per equipment variable NodeId.</summary>
[Fact]
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Same_ref_on_two_equipments_fans_out_to_both_NodeIds()
{
var db = NewInMemoryDbFactory();
@@ -46,7 +46,7 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
/// forwards exactly one <see cref="AlarmAcknowledgeRequest"/> to the owning driver's
/// <see cref="IAlarmSource.AcknowledgeAsync"/>, with <c>ConditionId == FullName</c>, the operator
/// principal, and the comment.</summary>
[Fact]
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void RouteNativeAlarmAck_routes_to_driver_AcknowledgeAsync_with_principal()
{
var db = NewInMemoryDbFactory();
@@ -54,7 +54,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
/// <see cref="OpcUaPublishActor.AlarmStateUpdate"/> with <c>State.Active == true</c>. The event carries a
/// production-shaped <c>SourceNodeId</c> (the bare owning object, distinct from <c>ConditionId</c>) so the
/// lookup is proven to key on <c>ConditionId</c>, not <c>SourceNodeId</c>.</summary>
[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 <see cref="AlarmTransitionEvent"/> to the cluster <c>alerts</c> topic with
/// <c>AlarmId</c> = the folder-scoped condition NodeId, alongside the (ungated) OPC UA condition
/// update. No <see cref="RedundancyStateChanged"/> is sent, so the cached role is unknown ⇒ emit.</summary>
[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
/// (<c>historizeToAveva is not false</c>) suppresses the durable AVEVA write — the same opt-out the
/// scripted-alarm plan flag drives. The live <c>/alerts</c> fan-out is unaffected (the transition still
/// publishes; only the durable row is gated downstream).</summary>
[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
/// <summary>Native-alarm HistorizeToAveva opt-IN (Task 3): an explicit <c>historizeToAveva: true</c>
/// rides through as <c>true</c> (distinct from the absent ⇒ null default-on case) so an operator who
/// deliberately opts in is recorded as such on the transition.</summary>
[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 <c>alerts</c> transition (the Primary publishes
/// the single fleet-wide copy).</summary>
[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
/// <c>Comment</c> carries the operator string and <c>User</c> is <c>"device"</c> (a non-null comment
/// signals the upstream alarm system provided an operator origin, but without a specific user identity).
/// </summary>
[Fact]
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Native_alarm_operator_comment_flows_to_transition_event()
{
var db = NewInMemoryDbFactory();
@@ -61,7 +61,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
/// <summary>Role unknown + no driver peer (count 1) ⇒ the write is SERVICED (single-node / boot-window
/// posture preserved).</summary>
[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
/// <summary>A Primary snapshot services even at count 2; a Secondary snapshot denies with the steady-state
/// reason (distinct from the boot-window reason).</summary>
[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
/// <summary>Role unknown + count 2 ⇒ ForwardNativeAlarm still writes the (ungated) OPC UA condition update
/// but publishes NO cluster-wide alerts transition.</summary>
[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
/// <summary>Role unknown + count 1 ⇒ ForwardNativeAlarm publishes the alerts transition (single-node
/// posture preserved).</summary>
[Fact]
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Unknown_role_single_driver_publishes_alerts_emit()
{
var db = NewInMemoryDbFactory();
@@ -46,7 +46,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
/// <summary>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).</summary>
[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 <c>FullName</c> property is present, so the reverse map keys on
/// <c>(DriverInstanceId, &lt;raw-blob&gt;)</c> and the driver receives that exact string as its
/// <c>WriteRequest.FullReference</c> — not a FullName value extracted from the blob.</summary>
[Fact]
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Primary_routes_write_for_raw_protocol_blob_tag()
{
var db = NewInMemoryDbFactory();
@@ -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;
/// <summary>
/// The cross-seam characterization net for R2-11 (01/C-1 + 01/P-1). Composes one equipment tag per
/// <see cref="TagConfigGoldenCorpus"/> blob, serialises the SAME draft to the artifact blob shape,
/// decodes it, and asserts the decoded <see cref="EquipmentTagPlan"/> list equals the composer's
/// element-wise (positional-record value equality) over the WHOLE corpus. Because both producers parse
/// the identical raw <c>TagConfig</c> string, this proves the four byte-parity parse sites agree TODAY,
/// and is the permanent regression guard held green through every subsequent seam swap
/// (<c>TagConfigIntent.Parse</c> consolidation).
/// v3 cross-seam characterization net for the raw-tag path. Injects every
/// <see cref="TagConfigGoldenCorpus"/> blob as a raw <c>Tag</c>'s <c>TagConfig</c> under a
/// RawFolder → Driver → Device chain, flattens the snapshot through
/// <see cref="DeploymentArtifact.ParseDriverInstances(System.ReadOnlySpan{byte})"/> into the driver's
/// merged <c>RawTags</c> array (keyed by RawPath), and asserts over the WHOLE corpus that:
/// <list type="number">
/// <item>every corpus tag survives (none dropped) and lands at its expected RawPath;</item>
/// <item>the decoded <see cref="RawTagEntry.TagConfig"/> is byte-identical to the authored blob
/// (the artifact carries the raw address blob verbatim — no PascalCase/FullName rewrite); and</item>
/// <item><see cref="TagConfigIntent.Parse"/> 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.</item>
/// </list>
/// <b>Dark address space (Batch 1):</b> 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).
/// </summary>
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<ScriptedAlarm>(), tags, new[] { ns });
/// <summary>Byte-parity + intent-parity of every corpus blob round-tripped through the deploy artifact
/// as a raw tag's TagConfig (keyed by RawPath).</summary>
[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
/// <summary>Dark address space this batch: the same snapshot materializes no equipment-tag plans.</summary>
[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();
}
}
@@ -2,82 +2,84 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// The shared golden corpus of <c>TagConfig</c> 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 <c>CK_Tag_TagConfig_IsJson</c> constraint guarantees a
/// non-null TagConfig for a real deploy) so the compose→encode→decode parity test
/// (<see cref="TagConfigCorpusParityTests"/>) can round-trip each through both equipment-tag producers
/// and assert byte-parity field-by-field. The <c>null</c>-input divergence (composer returns the raw
/// null, artifact coalesces to "") is a documented seam handled only at the
/// <c>TagConfigIntent.Parse</c> unit level, not through the parity round-trip.
/// <para>Covers: happy path, blank, non-object root, malformed JSON, FullName absent/non-string/whitespace,
/// platform-intent parse (<c>TagConfigIntent.Parse</c>: alarm / isHistorized / historianTagname /
/// isArray / arrayLength). v3: the retired <c>FullName</c> key is gone — a raw tag's driver address is
/// an ordinary driver-specific key (e.g. <c>address</c>), and the tag's RawPath (not any in-blob
/// FullName) is its identity. Each entry is a NON-NULL string (the DB <c>CK_Tag_TagConfig</c> constraint
/// guarantees non-null) so the v3 round-trip parity test (<see cref="TagConfigCorpusParityTests"/>) can
/// inject each as a raw tag's <c>TagConfig</c>, flatten it through the deploy artifact into the driver's
/// <c>RawTags</c> array (keyed by RawPath), and assert the decoded <c>RawTagEntry.TagConfig</c> is
/// byte-preserved AND parses to the identical <c>TagConfigIntent</c> — the cross-seam parse-agreement
/// guarantee, now over the raw path rather than the retired equipment-tag path.
/// <para>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.</para>
/// </summary>
public static class TagConfigGoldenCorpus
{
/// <summary>The corpus blobs, index-ordered. The parity test builds one equipment tag per blob.</summary>
/// <summary>The corpus blobs, index-ordered. The parity test builds one raw tag per blob.</summary>
public static readonly IReadOnlyList<string> 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}}",
};
}
@@ -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.
/// </summary>
[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. ---