b815446940
Both `IDriver` members had no production call site. Their only consumer was `MemoryTracking`, deleted with the driver-tier recycle machinery in #522, and `GetMemoryFootprint`'s doc-comment claimed "polled every 30s by Core" — which had not been true since v3, even before #522 removed the poller. Owner decision on #525 was (a) delete rather than (b) build the process-wide watchdog that would consume them. The driver-side implementations are in git history and were mostly reusable; what was always missing is the trigger, and a member nobody calls is how the misleading doc-comment survived this long. Removed from `IDriver`, all 12 driver implementations, and every test stub — plus the support code that existed only to feed them, which is the part a member-only deletion would have left behind as dead weight: - MTConnect's three per-entry byte-weight constants, its cached `ProbeModelDataItemCount`, and `CountDataItems` — a full device-tree walk run on every cache fill to populate a number with no remaining reader. - OpcUaClient's `_discoveredNodeCount` field and the discovery-pass write to it. - MQTT's `ApproxBytesPerAuthoredTag`. Two behavioural notes, neither a change in what the software does: - `AbCipTemplateCache` now has no production reset path. Its only caller was AB CIP's flush, which itself had no caller — so nothing changes except how visible the fact is. Recorded on the type, along with the correction that its comment's claim about a device `ReinitializeAsync` also flushing it was never true. A UDT redefined on a live controller keeps its stale shape until the driver instance is rebuilt. - MTConnect's `/probe` cache is still dropped, by `InvalidateProbeModel()` on an Agent restart. That is now `internal` rather than `private` so the tests which used the flush purely to empty the cache drive the real path instead — they now cover a case that actually occurs in production. Several unrelated doc comments cited `FlushOptionalCachesAsync` as the canonical CAS example and are repointed to it. Tests: the ones that existed only to assert on the removed members are deleted (3 OpcUaClient, 3 Galaxy, 2 MQTT, 1 MTConnect, 1 AbCip). Worth noting one was vacuous — `FlushOptionalCachesAsync_resets_footprint_counter` asserted a footprint of 0 before the flush and 0 after with no discovery in between, so it would have passed against a flush that did nothing at all. Full suite green except 7 failures verified identical on master before and after (4 AbLegacy integration with the compose profile down, 3 Host.IntegrationTests), checked by name rather than assumed. Docs corrected where they described this as live: `DriverLifecycle.md` (which carried the #525 placeholder), `drivers/AbCip.md`, `drivers/AbLegacy.md`, and `v2/driver-stability.md` — whose "polls this every 30 s" + per-driver `DriverConfig` memory budget were never built as written and are now banner-ed as design intent rather than description. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
438 lines
22 KiB
C#
438 lines
22 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Text.Json;
|
|
using Akka.Actor;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|
|
|
/// <summary>
|
|
/// 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 (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
|
|
{
|
|
private static readonly NodeId TestNode = NodeId.Parse("driver-wr-test");
|
|
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
|
|
|
// 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");
|
|
var deploymentId = SeedV3Deployment(db, RevA);
|
|
|
|
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
|
|
|
// The node manager passes the FULL ns-qualified id; the host must normalise + resolve it.
|
|
RouteWriteUntilAccepted(actor, $"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns);
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
recorder.Writes.Count.ShouldBe(1);
|
|
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 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);
|
|
|
|
RouteWriteUntilAccepted(actor, $"ns=2;s={RawPath}", 456.0, AddressSpaceRealm.Raw);
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
recorder.Writes.Count.ShouldBe(1);
|
|
recorder.Writes[0].FullReference.ShouldBe(RawPath);
|
|
recorder.Writes[0].Value.ShouldBe(456.0);
|
|
}, duration: Timeout);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Routes a write, retrying until the host accepts it, and fails with the host's own rejection
|
|
/// <c>Reason</c> if it never does.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para><b>Why a retry rather than a single Tell.</b> <c>ApplyAck</c> — which
|
|
/// <see cref="SpawnHostAndApply"/> waits for — marks the end of the <em>apply</em>, not the point at
|
|
/// which a write can succeed. Several things still have to happen after it: the spawned child has to
|
|
/// finish <c>InitializeAsync</c> and leave <c>Connecting</c> (that state deliberately fast-fails
|
|
/// writes with "driver not connected"), and the host has to push the NodeId→driver reverse map that
|
|
/// resolves the write. Telling the write immediately therefore races the setup, which is why this
|
|
/// assertion failed roughly 1 run in 30 of the fully parallel assembly — and never once in 60
|
|
/// consecutive runs of this class alone.</para>
|
|
/// <para><b>Why retrying does not weaken the assertion.</b> Every rejection branch — the Primary
|
|
/// gate, an unresolved reverse map, "driver not running", and the child's own pre-Connected
|
|
/// fast-fail — replies <em>without</em> reaching the driver. A rejected attempt records nothing, so
|
|
/// the caller's <c>Writes.Count.ShouldBe(1)</c> still means exactly what it did before: the accepted
|
|
/// write reached the driver exactly once.</para>
|
|
/// <para>The failure message carries the last <c>Reason</c> so a genuine regression is diagnosable
|
|
/// rather than surfacing as a bare "expected True".</para>
|
|
/// </remarks>
|
|
/// <param name="actor">The driver-host actor.</param>
|
|
/// <param name="nodeId">The ns-qualified NodeId string, as the node manager passes it.</param>
|
|
/// <param name="value">The value to write.</param>
|
|
/// <param name="realm">The address-space realm the NodeId belongs to.</param>
|
|
private void RouteWriteUntilAccepted(
|
|
IActorRef actor, string nodeId, object value, AddressSpaceRealm realm)
|
|
{
|
|
var lastReason = "(no reply)";
|
|
AwaitAssert(
|
|
() =>
|
|
{
|
|
var asker = CreateTestProbe();
|
|
actor.Tell(new DriverHostActor.RouteNodeWrite(nodeId, value, realm), asker.Ref);
|
|
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(TimeSpan.FromSeconds(1));
|
|
lastReason = result.Reason ?? "(none)";
|
|
result.Success.ShouldBeTrue($"host kept rejecting the write; last reason: {lastReason}");
|
|
},
|
|
duration: Timeout,
|
|
interval: TimeSpan.FromMilliseconds(50));
|
|
}
|
|
|
|
/// <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 = SeedV3Deployment(db, RevA);
|
|
|
|
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
|
|
|
actor.Tell(new RedundancyStateChanged(
|
|
new[]
|
|
{
|
|
new NodeRedundancyState(TestNode, RedundancyRole.Secondary,
|
|
IsClusterLeader: false, IsDriverPrimary: false, AsOfUtc: DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId()));
|
|
|
|
var asker = CreateTestProbe();
|
|
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref);
|
|
|
|
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
|
result.Success.ShouldBeFalse();
|
|
result.Reason.ShouldBe("not primary");
|
|
recorder.Writes.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <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 = SeedV3Deployment(db, RevA);
|
|
|
|
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
|
|
|
var asker = CreateTestProbe();
|
|
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=filling/line1/station1/does-not-exist", 1.0, AddressSpaceRealm.Uns), asker.Ref);
|
|
|
|
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
|
result.Success.ShouldBeFalse();
|
|
result.Reason.ShouldNotBeNull();
|
|
recorder.Writes.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <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()
|
|
{
|
|
var db = new ThrowingDbFactory();
|
|
var coordinator = CreateTestProbe();
|
|
var actor = Sys.ActorOf(DriverHostActor.Props(
|
|
db, TestNode, coordinator.Ref,
|
|
localRoles: new HashSet<string> { "driver" }));
|
|
|
|
var asker = CreateTestProbe();
|
|
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref);
|
|
|
|
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(TimeSpan.FromSeconds(2));
|
|
result.Success.ShouldBeFalse();
|
|
result.Reason.ShouldNotBeNull();
|
|
result.Reason!.ShouldContain("stale");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Review H1 regression — a raw <c>RawPath</c> and a UNS path that share a BARE <c>s=</c> id but back
|
|
/// DIFFERENT driver instances must each route to their OWN driver. Seeds raw tag <c>A/B/C/D</c> on
|
|
/// <c>drv-1</c> and a UnsTagReference whose UNS NodeId is ALSO <c>A/B/C/D</c> (Area A / Line B / Equip C
|
|
/// / effective name D) but which backs a raw tag <c>X/Y/Z/W</c> on <c>drv-2</c>. A bare-only routing key
|
|
/// would collide (last-writer-wins → wrong device); the <c>(realm, bareId)</c> key keeps them distinct.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Colliding_raw_and_uns_bare_ids_route_to_their_own_driver_by_realm()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var factory = new PerInstanceRecordingDriverFactory("Modbus");
|
|
var deploymentId = SeedCollisionDeployment(db, RevA);
|
|
|
|
var actor = SpawnHostAndApply(db, deploymentId, factory);
|
|
|
|
// Write to the RAW node A/B/C/D → drv-1, forwarded by the raw tag's RawPath A/B/C/D.
|
|
var asker1 = CreateTestProbe();
|
|
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=2;s=A/B/C/D", 111.0, AddressSpaceRealm.Raw), asker1.Ref);
|
|
asker1.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
|
|
|
|
// Write to the UNS node A/B/C/D → drv-2, forwarded by the BACKING raw tag's RawPath X/Y/Z/W.
|
|
var asker2 = CreateTestProbe();
|
|
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=A/B/C/D", 222.0, AddressSpaceRealm.Uns), asker2.Ref);
|
|
asker2.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
// drv-1 got ONLY the raw write (A/B/C/D, 111); drv-2 got ONLY the UNS-routed write (X/Y/Z/W, 222).
|
|
var w1 = factory.WritesFor("drv-1");
|
|
var w2 = factory.WritesFor("drv-2");
|
|
w1.Count.ShouldBe(1);
|
|
w1[0].FullReference.ShouldBe("A/B/C/D");
|
|
w1[0].Value.ShouldBe(111.0);
|
|
w2.Count.ShouldBe(1);
|
|
w2[0].FullReference.ShouldBe("X/Y/Z/W"); // the backing RawPath, NOT the shared bare id
|
|
w2[0].Value.ShouldBe(222.0);
|
|
}, duration: Timeout);
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
var coordinator = CreateTestProbe();
|
|
var actor = Sys.ActorOf(DriverHostActor.Props(
|
|
db, TestNode, coordinator.Ref,
|
|
driverFactory: factory,
|
|
localRoles: new HashSet<string> { "driver" }));
|
|
|
|
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
|
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
|
return actor;
|
|
}
|
|
|
|
/// <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 artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
|
DriverInstances = new[]
|
|
{
|
|
new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
|
|
},
|
|
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
|
|
TagGroups = Array.Empty<object>(),
|
|
Tags = new[]
|
|
{
|
|
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();
|
|
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;
|
|
}
|
|
|
|
/// <summary>Seeds the review-H1 collision config: raw tag <c>A/B/C/D</c> on drv-1, and a UnsTagReference
|
|
/// (Area A / Line B / Equip C, effective name D → UNS NodeId <c>A/B/C/D</c>) backing raw tag <c>X/Y/Z/W</c>
|
|
/// on drv-2. Both drivers ENABLED (Modbus) so both children spawn.</summary>
|
|
private static DeploymentId SeedCollisionDeployment(IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev)
|
|
{
|
|
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
RawFolders = new[]
|
|
{
|
|
new { RawFolderId = "rf-a", ParentRawFolderId = (string?)null, Name = "A", ClusterId = "c1" },
|
|
new { RawFolderId = "rf-x", ParentRawFolderId = (string?)null, Name = "X", ClusterId = "c1" },
|
|
},
|
|
DriverInstances = new[]
|
|
{
|
|
new { DriverInstanceId = "drv-1", RawFolderId = "rf-a", Name = "B", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
|
|
new { DriverInstanceId = "drv-2", RawFolderId = "rf-x", Name = "Y", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
|
|
},
|
|
Devices = new[]
|
|
{
|
|
new { DeviceId = "dev-c", DriverInstanceId = "drv-1", Name = "C", DeviceConfig = "{}" },
|
|
new { DeviceId = "dev-z", DriverInstanceId = "drv-2", Name = "Z", DeviceConfig = "{}" },
|
|
},
|
|
TagGroups = Array.Empty<object>(),
|
|
Tags = new[]
|
|
{
|
|
new { TagId = "t-d", DeviceId = "dev-c", TagGroupId = (string?)null, Name = "D", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
|
|
new { TagId = "t-w", DeviceId = "dev-z", TagGroupId = (string?)null, Name = "W", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
|
|
},
|
|
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "A", ClusterId = "c1" } },
|
|
UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "B" } },
|
|
Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "C", MachineCode = "M1" } },
|
|
// Effective name "D" (override) → UNS NodeId A/B/C/D, backing raw tag t-w (X/Y/Z/W) on drv-2.
|
|
UnsTagReferences = new[] { new { UnsTagReferenceId = "r1", EquipmentId = "EQ-1", TagId = "t-w", DisplayNameOverride = "D" } },
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
/// <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 />
|
|
public OtOpcUaConfigDbContext CreateDbContext() =>
|
|
throw new InvalidOperationException("config DB unreachable (test stub)");
|
|
}
|
|
|
|
/// <summary>Factory producing a SEPARATE <see cref="RecordingDriver"/> per driver instance id, so a test
|
|
/// with two drivers can assert which instance received a given write (used by the H1 collision test).</summary>
|
|
private sealed class PerInstanceRecordingDriverFactory : IDriverFactory
|
|
{
|
|
private readonly string _supportedType;
|
|
private readonly Dictionary<string, RecordingDriver> _byId = new(StringComparer.Ordinal);
|
|
private readonly object _lock = new();
|
|
public PerInstanceRecordingDriverFactory(string supportedType) { _supportedType = supportedType; }
|
|
|
|
/// <summary>The writes the given driver instance received.</summary>
|
|
public IReadOnlyList<WriteRequest> WritesFor(string driverInstanceId)
|
|
{
|
|
lock (_lock) return _byId.TryGetValue(driverInstanceId, out var d) ? d.Writes : Array.Empty<WriteRequest>();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
|
|
{
|
|
if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null;
|
|
lock (_lock)
|
|
{
|
|
if (!_byId.TryGetValue(driverInstanceId, out var d))
|
|
{
|
|
d = new RecordingDriver();
|
|
d.Bind(driverInstanceId, driverType);
|
|
_byId[driverInstanceId] = d;
|
|
}
|
|
return d;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyCollection<string> SupportedTypes => new[] { _supportedType };
|
|
}
|
|
|
|
/// <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.</summary>
|
|
public IReadOnlyList<WriteRequest> Writes => _driver.Writes;
|
|
|
|
/// <inheritdoc />
|
|
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
|
|
{
|
|
if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null;
|
|
_driver.Bind(driverInstanceId, driverType);
|
|
return _driver;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyCollection<string> SupportedTypes => new[] { _supportedType };
|
|
}
|
|
|
|
/// <summary>An <see cref="IDriver"/> + <see cref="IWritable"/> that records every write and returns Good.</summary>
|
|
private sealed class RecordingDriver : IDriver, IWritable
|
|
{
|
|
private readonly ConcurrentQueue<WriteRequest> _writes = new();
|
|
/// <inheritdoc />
|
|
public string DriverInstanceId { get; private set; } = string.Empty;
|
|
/// <inheritdoc />
|
|
public string DriverType { get; private set; } = string.Empty;
|
|
/// <summary>The writes received so far.</summary>
|
|
public IReadOnlyList<WriteRequest> Writes => _writes.ToArray();
|
|
/// <summary>Sets the identity once the factory is asked to create it.</summary>
|
|
public void Bind(string id, string type) { DriverInstanceId = id; DriverType = type; }
|
|
/// <inheritdoc />
|
|
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
|
/// <inheritdoc />
|
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
|
/// <inheritdoc />
|
|
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
/// <inheritdoc />
|
|
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<WriteResult>> WriteAsync(
|
|
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
|
{
|
|
foreach (var w in writes) _writes.Enqueue(w);
|
|
return Task.FromResult<IReadOnlyList<WriteResult>>(
|
|
writes.Select(_ => new WriteResult(0u)).ToArray());
|
|
}
|
|
}
|
|
}
|