154171f48c
The reported symptom was "Runtime.Tests occasionally reports Failed: 1" with no test
name. Instrumenting 30 full-assembly runs reproduced it at 13% (4 runs) and showed it
was never ONE flaky test: five failures across three distinct tests in that batch, and
five more distinct tests over the verification rounds that followed.
Two hypotheses were tested and DISPROVED by measurement before anything was changed:
- Cluster-formation timeout. Every test in this assembly forms a real single-node Akka
cluster over TCP in RuntimeActorTestBase's constructor and waits 5 s for MemberStatus.Up
— 219 formations per run. Instrumented: idle max 960 ms, under-load max 1470 ms, zero
timeouts. A 3.4x margin; not the cause.
- Ephemeral-port exhaustion from that bind churn. TIME_WAIT measured at ~0 throughout.
Not the cause.
FOUR GENUINE ORDERING/LOGIC DEFECTS (none is a timeout):
1. VirtualTagHostActorTests.ApplyVirtualTags_respawns_child_when_plan_changes_in_place
The test loops to accept RegisterInterest/UnregisterInterest in EITHER order — and
then asserts on mux.LastSender, which DEPENDS on that order. Under the [Register,
Unregister] interleaving LastSender is the DYING child and the assertion fails. Now
captures the sender of the Register message as it arrives. Fully deterministic; no
timing involved. (Swept the other four LastSender sites: all drain the Unregister
first, so their ordering is fixed. Only this one was unsafe.)
2. DriverHostActorWriteRoutingTests.Primary_routes_write_via_uns_nodeid_to_driver_by_rawpath
ApplyAck marks the end of the APPLY, not the point a write can succeed: the child is
still in Connecting, which deliberately fast-fails writes ("driver not connected"),
and the NodeId->driver reverse map has not been pushed. Now retries until accepted.
This does not weaken the assertion — every rejection branch replies WITHOUT reaching
the driver, so Writes.Count.ShouldBe(1) still means exactly what it did.
3. HistorianAdapterActorTests.Redundancy_snapshot_without_local_node_...
One 500 ms constant served both presence budgets (AwaitAssert) and absence windows
(ExpectNoMsg). Different quantities that happened to share a number, so the presence
budget could not be raised without slowing every absence check. Split into
AssertTimeout (5 s) and Settle (500 ms).
4. ContinuousHistorizationRecorderTests.Retry_after_writer_failure_eventually_acks
Waited on `outbox == 0`, which is ALSO true before the first append — so the poll
could win the race against the recorder and return having observed the INITIAL state,
after which the CallCount>=2 check outside the block failed against a recorder that
had not run. Both conditions are now polled together with the retry count as the
discriminator. (The preceding test in the same file documents this exact trap.)
THE PRESENCE-BUDGET CLASS:
After those four, three consecutive 30-run rounds each still failed once — on a
DIFFERENT test, in a DIFFERENT file, every time (OpcUaPublishActorApplyFailureTests 2 s,
OpcUaPublishActorRebuildTests 2 s, OpcUaPublishActorTests 500 ms). That is a
distribution, not a defect, and fixing it one test at a time was the wrong method.
RuntimeActorTestBase now carries a documented PresenceBudget (15 s) and 57 sub-3 s
AwaitAssert/AwaitCondition durations across four files route through it.
Why this is safe rather than sloppy: a presence budget is an upper bound before giving
up, not a wait — these helpers poll and return the instant the condition holds. Raising
one costs nothing on the happy path and CANNOT make a genuinely failing assertion pass;
it only changes how quickly a real breakage reports. Absence windows are the opposite
(the elapsed time IS the assertion) and were deliberately left untouched with their own
short, individually-calibrated literals.
ScriptedAlarmHostActorTests is a special case, diagnosed rather than merely raised: its
8 s budget was sized for message passing but actually waits on a REAL Roslyn compile
(ApplyScriptedAlarms -> ScriptedAlarmEngine.LoadAsync compiles every predicate). Cold
script compiles are the slowest and most variable thing in the assembly, so that class
gets 30 s with the reason recorded.
Verification: 30 consecutive full-assembly runs, 30 passed / 0 failed, against a 13%
baseline. Full solution builds; all 41 unit-test projects green.
NOT fixed here, filed as #501: DriverHostActorNativeAlarmAckRoutingTests:90 and :116 use
AwaitAssert for an ABSENCE assertion, so they return instantly and prove nothing. They
are excluded from the budget change on purpose — a bigger number does not fix them, and
rewriting them to settle-then-assert may legitimately turn them red.
442 lines
22 KiB
C#
442 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 long GetMemoryFootprint() => 0;
|
|
/// <inheritdoc />
|
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
/// <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());
|
|
}
|
|
}
|
|
}
|