feat(v3-batch4-wp3): raw-only binding + UNS fan-out + write routing
Wave B of Batch 4 — the runtime binding seam for the dual namespace. Applier (both realms, explicit realm at every sink call site): - MaterialiseRawSubtree: Raw containers as folders + Raw tags as variables keyed by RawPath (native-alarm tag → single Part 9 condition at the RawPath), all in AddressSpaceRealm.Raw; historian tagname = override else RawPath. - MaterialiseUnsReferences: each UNS reference Variable under its equipment folder (Uns realm) + an Organizes UNS->Raw edge; inherits writable/array/ historian tagname from the backing raw tag (both NodeIds -> one tagname). - FeedHistorizedRefs / ProvisionHistorizedTags now source RAW tags (mux ref stays single, keyed by RawPath); ApplyPureRemove tears down raw tags + UNS refs in place (raw-container removal falls back to rebuild). DriverHostActor (dual-NodeId, single-source fan-out): - _nodeIdByDriverRef value gains a realm (NodeRealmRef); rebuilt from RawTags UNION UnsReferenceVariables so one (DriverInstanceId, RawPath) fans to the raw NodeId AND every referencing UNS NodeId with identical value/quality/timestamp. Write inverse map keyed by the bare id; the ns-qualified NodeId the write hook passes is normalised (BareNodeId) so a write to either NodeId resolves the same driver ref (-> RawPath write). - Native raw alarm condition routing is realm-tagged (Raw); AttributeValueUpdate + AlarmStateUpdate carry the realm through to the sink. Retire EquipmentNodeIds -> V3NodeIds.Uns (applier/VirtualTagHostActor) and RawPaths.Combine (DiscoveredNodeMapper, discovered nodes are Raw now). DeploymentArtifact.ParseComposition emits the Raw + UNS subtrees byte-parity with the composer (reconstruct entities -> AddressSpaceComposer.Compose). Sink surface: removed the transitional `= AddressSpaceRealm.Uns` defaults from IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink / SdkAddressSpaceSink / DeferredAddressSpaceSink / NullOpcUaAddressSpaceSink — every call site is now explicit (realm reordered before the trailing optionals on EnsureVariable + MaterialiseAlarmCondition). Node-manager convenience methods keep their defaults (they are not the interface impl; Sdk delegates explicitly). Tests: rewrote DriverHostActorLiveValueTests (fan-out drift, 1:N) + DriverHostActorWriteRoutingTests (dual-NodeId raw/uns write routing) to the v3 raw+uns model; new AddressSpaceApplierRawUnsTests; migrated the EquipmentTags provisioning/feed tests to RawTags; swept EquipmentNodeIds test callers. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
+81
-195
@@ -20,22 +20,17 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the inbound operator-write routing wired into <see cref="DriverHostActor"/>: a
|
||||
/// <see cref="DriverHostActor.RouteNodeWrite"/> for a materialised equipment-variable NodeId resolves
|
||||
/// the <c>NodeId → (DriverInstanceId, FullName)</c> reverse map (built alongside the forward map in
|
||||
/// <c>PushDesiredSubscriptions</c>), gates on this node being the driver PRIMARY (reusing the same
|
||||
/// <c>RedundancyStateChanged</c> signal the alarm-emit gate uses), forwards a
|
||||
/// <see cref="DriverInstanceActor.WriteAttribute"/> carrying the driver-side <c>FullName</c> to the
|
||||
/// right driver child, and replies a <see cref="DriverHostActor.NodeWriteResult"/> to the asker.
|
||||
///
|
||||
/// v3 Batch 4 (B4-WP3) — inbound operator-write routing through EITHER NodeId. A
|
||||
/// <see cref="DriverHostActor.RouteNodeWrite"/> carries the FULL ns-qualified NodeId string (the node
|
||||
/// manager's write hook passes <c>node.NodeId.ToString()</c>); the host normalises it to the bare id and
|
||||
/// resolves the <c>NodeId → (DriverInstanceId, RawPath)</c> reverse map — populated in
|
||||
/// <c>PushDesiredSubscriptions</c> for BOTH the raw NodeId AND every referencing UNS NodeId — gates on the
|
||||
/// driver PRIMARY, and forwards a <see cref="DriverInstanceActor.WriteAttribute"/> carrying the tag's
|
||||
/// <c>RawPath</c> to the driver child. So a write to the raw node and a write to the referencing UNS node
|
||||
/// both reach the SAME driver point (they share the driver ref).
|
||||
/// <para>
|
||||
/// Drives a real apply through the existing harness (same artifact shape as
|
||||
/// <c>DriverHostActorLiveValueTests</c>) so the reverse map is populated authentically and a real
|
||||
/// (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned. The child is backed by a
|
||||
/// recording <see cref="IWritable"/> driver so the test can observe the forwarded write and assert
|
||||
/// the no-write case on the secondary. There is no test seam to inject a <c>TestProbe</c> as a
|
||||
/// driver child, so this end-to-end approach (recording driver) is the closest faithful test the
|
||||
/// harness allows.
|
||||
/// Drives a real apply (Enabled Modbus driver ⇒ a real <see cref="DriverInstanceActor"/> child backed
|
||||
/// by a recording driver), then routes writes and asserts the forwarded wire-ref + the primary gate.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
@@ -44,48 +39,68 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <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(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
public void Primary_routes_write_to_driver_by_full_name_and_replies_success()
|
||||
// The single seeded raw tag + its UNS reference.
|
||||
private const string RawPath = "Plant/Modbus/dev1/speed";
|
||||
private const string UnsNodeId = "filling/line1/station1/speed";
|
||||
|
||||
/// <summary>On the PRIMARY, a RouteNodeWrite for the ns-qualified UNS NodeId resolves to the raw tag's
|
||||
/// driver ref and forwards the write keyed by the tag's RawPath (dual-NodeId write routing).</summary>
|
||||
[Fact]
|
||||
public void Primary_routes_write_via_uns_nodeid_to_driver_by_rawpath()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var recorder = new RecordingDriverFactory("Modbus");
|
||||
// One equipment tag: eq-1, drv-1, FullName "40001", no folder, Name "speed" → NodeId "eq-1/speed".
|
||||
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
||||
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
|
||||
var deploymentId = SeedV3Deployment(db, RevA);
|
||||
|
||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||
|
||||
// Local role unknown ⇒ treated as Primary ⇒ write allowed (default-emit semantics).
|
||||
var asker = CreateTestProbe();
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
|
||||
// The node manager passes the FULL ns-qualified id; the host must normalise + resolve it.
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref);
|
||||
|
||||
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
||||
result.Success.ShouldBeTrue();
|
||||
|
||||
// The driver received exactly the write, keyed by its wire-ref FullName (not the NodeId).
|
||||
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
recorder.Writes.Count.ShouldBe(1);
|
||||
recorder.Writes[0].FullReference.ShouldBe("40001");
|
||||
recorder.Writes[0].FullReference.ShouldBe(RawPath); // the driver writes by RawPath, not the UNS id
|
||||
recorder.Writes[0].Value.ShouldBe(123.0);
|
||||
}, duration: Timeout);
|
||||
}
|
||||
|
||||
/// <summary>On a SECONDARY, RouteNodeWrite replies NodeWriteResult(false, "not primary") and the
|
||||
/// driver receives NO write — the primary gate fires before the reverse-map lookup.</summary>
|
||||
/// <summary>On the PRIMARY, a RouteNodeWrite for the ns-qualified RAW NodeId resolves to the same driver
|
||||
/// ref and forwards the write keyed by the RawPath.</summary>
|
||||
[Fact]
|
||||
public void Primary_routes_write_via_raw_nodeid_to_driver_by_rawpath()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var recorder = new RecordingDriverFactory("Modbus");
|
||||
var deploymentId = SeedV3Deployment(db, RevA);
|
||||
|
||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||
|
||||
var asker = CreateTestProbe();
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0), asker.Ref);
|
||||
|
||||
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
recorder.Writes.Count.ShouldBe(1);
|
||||
recorder.Writes[0].FullReference.ShouldBe(RawPath);
|
||||
recorder.Writes[0].Value.ShouldBe(456.0);
|
||||
}, duration: Timeout);
|
||||
}
|
||||
|
||||
/// <summary>On a SECONDARY, RouteNodeWrite (via the UNS NodeId) replies "not primary" and the driver
|
||||
/// receives NO write — the primary gate fires before the reverse-map lookup.</summary>
|
||||
[Fact]
|
||||
public void Secondary_rejects_write_and_does_not_forward_to_driver()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var recorder = new RecordingDriverFactory("Modbus");
|
||||
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
||||
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
|
||||
var deploymentId = SeedV3Deployment(db, RevA);
|
||||
|
||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||
|
||||
// Force this node Secondary so the primary gate rejects.
|
||||
actor.Tell(new RedundancyStateChanged(
|
||||
new[]
|
||||
{
|
||||
@@ -95,29 +110,26 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
CorrelationId.NewId()));
|
||||
|
||||
var asker = CreateTestProbe();
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref);
|
||||
|
||||
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Reason.ShouldBe("not primary");
|
||||
|
||||
// No write reached the driver — the gate short-circuited before the reverse-map lookup.
|
||||
recorder.Writes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>An unknown NodeId (no reverse-map entry) replies NodeWriteResult(false) and writes nothing.</summary>
|
||||
/// <summary>An unknown NodeId (no reverse-map entry) replies failure and writes nothing.</summary>
|
||||
[Fact]
|
||||
public void Unknown_node_id_replies_failure()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var recorder = new RecordingDriverFactory("Modbus");
|
||||
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
||||
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
|
||||
var deploymentId = SeedV3Deployment(db, RevA);
|
||||
|
||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||
|
||||
var asker = CreateTestProbe();
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/does-not-exist", 123.0), asker.Ref);
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=filling/line1/station1/does-not-exist", 1.0), asker.Ref);
|
||||
|
||||
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
||||
result.Success.ShouldBeFalse();
|
||||
@@ -125,49 +137,11 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
recorder.Writes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>A protocol TagConfig blob with no <c>FullName</c> key routes by the equipment NodeId, and
|
||||
/// the forwarded wire-ref is the raw blob verbatim. <c>ExtractTagFullName</c> falls back to the raw
|
||||
/// blob string when no top-level <c>FullName</c> property is present, so the reverse map keys on
|
||||
/// <c>(DriverInstanceId, <raw-blob>)</c> and the driver receives that exact string as its
|
||||
/// <c>WriteRequest.FullReference</c> — not a FullName value extracted from the blob.</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
public void Primary_routes_write_for_raw_protocol_blob_tag()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var recorder = new RecordingDriverFactory("Modbus");
|
||||
// Seed the tag with a genuine protocol blob that has NO FullName key (pure Modbus wire config).
|
||||
// ExtractTagFullName falls back to returning the raw blob string verbatim, so that string IS the
|
||||
// wire-ref the reverse map keys on and the driver receives as WriteRequest.FullReference.
|
||||
var (deploymentId, rawBlobString) = SeedDeploymentWithRawBlobTag(db, RevA,
|
||||
equip: "eq-2", driver: "drv-2", name: "torque");
|
||||
|
||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||
|
||||
// Local role unknown ⇒ treated as Primary ⇒ write allowed.
|
||||
var asker = CreateTestProbe();
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-2/torque", 456.0), asker.Ref);
|
||||
|
||||
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
||||
result.Success.ShouldBeTrue();
|
||||
|
||||
// The forwarded wire-ref is the raw blob string verbatim — ExtractTagFullName fell back because
|
||||
// there is no top-level "FullName" key in the blob.
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
recorder.Writes.Count.ShouldBe(1);
|
||||
recorder.Writes[0].FullReference.ShouldBe(rawBlobString);
|
||||
recorder.Writes[0].Value.ShouldBe(456.0);
|
||||
}, duration: Timeout);
|
||||
}
|
||||
|
||||
/// <summary>A RouteNodeWrite arriving while the host is Stale (config DB unreachable) must fast-fail
|
||||
/// with an immediate negative NodeWriteResult (reason mentions "stale") instead of dead-lettering into
|
||||
/// the node-manager's bounded-Ask timeout. Drives the host into Stale via a DB factory whose
|
||||
/// CreateDbContext throws on bootstrap (the same fall-through to <c>Become(Stale)</c> production uses).</summary>
|
||||
/// <summary>A RouteNodeWrite arriving while the host is Stale (config DB unreachable) fast-fails with an
|
||||
/// immediate negative reply mentioning "stale".</summary>
|
||||
[Fact]
|
||||
public void Stale_host_fast_fails_route_node_write()
|
||||
{
|
||||
// A factory that always throws on CreateDbContext ⇒ Bootstrap's try fails ⇒ Become(Stale).
|
||||
var db = new ThrowingDbFactory();
|
||||
var coordinator = CreateTestProbe();
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
@@ -175,7 +149,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
localRoles: new HashSet<string> { "driver" }));
|
||||
|
||||
var asker = CreateTestProbe();
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref);
|
||||
|
||||
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(TimeSpan.FromSeconds(2));
|
||||
result.Success.ShouldBeFalse();
|
||||
@@ -183,10 +157,9 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
result.Reason!.ShouldContain("stale");
|
||||
}
|
||||
|
||||
/// <summary>Spawns the host with the recording driver factory, dispatches the deployment, and waits
|
||||
/// for the Applied ACK so the apply (and thus the reverse-map build in PushDesiredSubscriptions) has
|
||||
/// completed before the test routes a write. No OPC UA / mux probes are wired — this test exercises
|
||||
/// only the write path, which doesn't depend on the publish actor.</summary>
|
||||
/// <summary>Spawns the host with the recording driver factory, dispatches the deployment, and waits for
|
||||
/// the Applied ACK so the reverse-map build in PushDesiredSubscriptions has completed before routing a
|
||||
/// write. No OPC UA / mux probes are wired — the write path doesn't depend on the publish actor.</summary>
|
||||
private IActorRef SpawnHostAndApply(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId, IDriverFactory factory)
|
||||
{
|
||||
@@ -198,49 +171,32 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
|
||||
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
||||
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||
|
||||
return actor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a Sealed deployment whose artifact carries the minimal arrays
|
||||
/// <c>DeploymentArtifact.BuildEquipmentTagPlans</c> needs to project equipment tags. Mirrors
|
||||
/// <c>DriverHostActorLiveValueTests.SeedDeploymentWithEquipmentTags</c> but also carries a
|
||||
/// <c>DriverInstances</c> row with a non-Windows-only <c>DriverType</c> ("Modbus") + Enabled flag
|
||||
/// so a REAL (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned for the write path.
|
||||
/// </summary>
|
||||
private static DeploymentId SeedDeploymentWithEquipmentTags(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
||||
params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags)
|
||||
/// <summary>Seeds a Sealed v3 deployment: RawFolder "Plant" → DriverInstance "drv-1" (Modbus, ENABLED so a
|
||||
/// real child spawns) → Device "dev1" → Tag "speed" (RawPath <c>Plant/Modbus/dev1/speed</c>, ReadWrite),
|
||||
/// projected into equipment <c>filling/line1/station1</c> by a UnsTagReference (UNS NodeId
|
||||
/// <c>filling/line1/station1/speed</c>).</summary>
|
||||
private static DeploymentId SeedV3Deployment(IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev)
|
||||
{
|
||||
var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray();
|
||||
|
||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
Namespaces = new[]
|
||||
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
||||
DriverInstances = new[]
|
||||
{
|
||||
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
|
||||
new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
|
||||
},
|
||||
DriverInstances = driverIds.Select(d => new
|
||||
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
|
||||
TagGroups = Array.Empty<object>(),
|
||||
Tags = new[]
|
||||
{
|
||||
DriverInstanceRowId = Guid.NewGuid(),
|
||||
DriverInstanceId = d,
|
||||
Name = d,
|
||||
DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
|
||||
Enabled = true,
|
||||
DriverConfig = "{}",
|
||||
NamespaceId = "ns-eq",
|
||||
}).ToArray(),
|
||||
Tags = tags.Select((t, i) => new
|
||||
{
|
||||
TagId = $"tag-{i}",
|
||||
EquipmentId = t.Equip,
|
||||
DriverInstanceId = t.Driver,
|
||||
Name = t.Name,
|
||||
FolderPath = t.Folder,
|
||||
DataType = "Double",
|
||||
TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }),
|
||||
}).ToArray(),
|
||||
new { TagId = "t-speed", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "speed", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
|
||||
},
|
||||
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } },
|
||||
UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" } },
|
||||
Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" } },
|
||||
UnsTagReferences = new[] { new { UnsTagReferenceId = "r1", EquipmentId = "EQ-1", TagId = "t-speed", DisplayNameOverride = (string?)null } },
|
||||
});
|
||||
|
||||
var id = DeploymentId.NewId();
|
||||
@@ -258,77 +214,8 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a single-tag Sealed deployment whose tag's <c>TagConfig</c> is a genuine protocol-driver
|
||||
/// blob with <strong>no <c>FullName</c> key</strong> (pure Modbus wire config:
|
||||
/// <c>{"region":"HoldingRegister","address":200,"dataType":"UInt16"}</c>). Because
|
||||
/// <c>ExtractTagFullName</c> finds no top-level <c>FullName</c> property, it falls back to
|
||||
/// returning the raw blob string verbatim — that raw string becomes the
|
||||
/// <c>(DriverInstanceId, <raw-blob>)</c> reverse-map key, and the driver receives it as
|
||||
/// <c>WriteRequest.FullReference</c>. Returns both the <see cref="DeploymentId"/> and the exact
|
||||
/// raw blob string so the caller can assert the forwarded wire-ref precisely.
|
||||
/// </summary>
|
||||
private static (DeploymentId DeploymentId, string RawBlobString) SeedDeploymentWithRawBlobTag(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
||||
string equip, string driver, string name)
|
||||
{
|
||||
// Serialize the blob with NO FullName field — ExtractTagFullName will fall back to this verbatim.
|
||||
var rawBlobString = JsonSerializer.Serialize(
|
||||
new { region = "HoldingRegister", address = 200, dataType = "UInt16" });
|
||||
|
||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
Namespaces = new[]
|
||||
{
|
||||
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
|
||||
},
|
||||
DriverInstances = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
DriverInstanceRowId = Guid.NewGuid(),
|
||||
DriverInstanceId = driver,
|
||||
Name = driver,
|
||||
DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
|
||||
Enabled = true,
|
||||
DriverConfig = "{}",
|
||||
NamespaceId = "ns-eq",
|
||||
},
|
||||
},
|
||||
Tags = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
TagId = "tag-raw",
|
||||
EquipmentId = equip,
|
||||
DriverInstanceId = driver,
|
||||
Name = name,
|
||||
FolderPath = (string?)null,
|
||||
DataType = "Double",
|
||||
// Pure protocol blob: no FullName key. ExtractTagFullName falls back to raw blob.
|
||||
TagConfig = rawBlobString,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
var id = DeploymentId.NewId();
|
||||
using var ctx = db.CreateDbContext();
|
||||
ctx.Deployments.Add(new Deployment
|
||||
{
|
||||
DeploymentId = id.Value,
|
||||
RevisionHash = rev.Value,
|
||||
Status = DeploymentStatus.Sealed,
|
||||
CreatedBy = "test",
|
||||
SealedAtUtc = DateTime.UtcNow,
|
||||
ArtifactBlob = artifact,
|
||||
});
|
||||
ctx.SaveChanges();
|
||||
return (id, rawBlobString);
|
||||
}
|
||||
|
||||
/// <summary>An <see cref="IDbContextFactory{TContext}"/> whose <c>CreateDbContext</c> always throws,
|
||||
/// driving <see cref="DriverHostActor"/>'s bootstrap into the <c>catch</c> ⇒ <c>Become(Stale)</c> path
|
||||
/// so a write can be routed at a Stale host.</summary>
|
||||
/// <summary>An <see cref="IDbContextFactory{T}"/> whose CreateDbContext always throws — drives the host
|
||||
/// into the Stale path.</summary>
|
||||
private sealed class ThrowingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
@@ -336,15 +223,14 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
throw new InvalidOperationException("config DB unreachable (test stub)");
|
||||
}
|
||||
|
||||
/// <summary>Factory producing a single <see cref="RecordingDriver"/> for the supported type, whose
|
||||
/// recorded write list is exposed for assertions.</summary>
|
||||
/// <summary>Factory producing a single <see cref="RecordingDriver"/> for the supported type.</summary>
|
||||
private sealed class RecordingDriverFactory : IDriverFactory
|
||||
{
|
||||
private readonly string _supportedType;
|
||||
private readonly RecordingDriver _driver = new();
|
||||
public RecordingDriverFactory(string supportedType) { _supportedType = supportedType; }
|
||||
|
||||
/// <summary>The writes the spawned driver received (thread-safe — WriteAsync runs off the actor thread).</summary>
|
||||
/// <summary>The writes the spawned driver received.</summary>
|
||||
public IReadOnlyList<WriteRequest> Writes => _driver.Writes;
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -389,7 +275,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
{
|
||||
foreach (var w in writes) _writes.Enqueue(w);
|
||||
return Task.FromResult<IReadOnlyList<WriteResult>>(
|
||||
writes.Select(_ => new WriteResult(0u)).ToArray()); // 0x0 = Good
|
||||
writes.Select(_ => new WriteResult(0u)).ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user