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",