390 lines
18 KiB
C#
390 lines
18 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.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>
|
|
/// 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.
|
|
///
|
|
/// <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.
|
|
/// </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);
|
|
|
|
/// <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]
|
|
public void Primary_routes_write_to_driver_by_full_name_and_replies_success()
|
|
{
|
|
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 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);
|
|
|
|
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).
|
|
AwaitAssert(() =>
|
|
{
|
|
recorder.Writes.Count.ShouldBe(1);
|
|
recorder.Writes[0].FullReference.ShouldBe("40001");
|
|
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>
|
|
[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 actor = SpawnHostAndApply(db, deploymentId, recorder);
|
|
|
|
// Force this node Secondary so the primary gate rejects.
|
|
actor.Tell(new RedundancyStateChanged(
|
|
new[]
|
|
{
|
|
new NodeRedundancyState(TestNode, RedundancyRole.Secondary,
|
|
IsClusterLeader: false, IsRoleLeaderForDriver: false, AsOfUtc: DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId()));
|
|
|
|
var asker = CreateTestProbe();
|
|
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 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>
|
|
[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 actor = SpawnHostAndApply(db, deploymentId, recorder);
|
|
|
|
var asker = CreateTestProbe();
|
|
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/does-not-exist", 123.0), asker.Ref);
|
|
|
|
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
|
result.Success.ShouldBeFalse();
|
|
result.Reason.ShouldNotBeNull();
|
|
recorder.Writes.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>The router keys purely on NodeId — the tag's TagConfig blob shape is irrelevant. A tag
|
|
/// seeded with a RAW protocol-driver config blob (Modbus-shaped, no <c>FullName</c> key) routes the
|
|
/// write to its owning child exactly like the Galaxy-style <c>{FullName}</c> blob does, because the
|
|
/// reverse map is built from the resolved <c>FullName</c> the composer projects, not the raw blob.</summary>
|
|
[Fact]
|
|
public void Primary_routes_write_for_raw_protocol_blob_tag()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var recorder = new RecordingDriverFactory("Modbus");
|
|
// Seed the tag with a RAW protocol blob ({region/address/dataType}) instead of {FullName}; the
|
|
// composer still resolves a FullName, so the reverse map keys on that and the blob never matters.
|
|
var deploymentId = SeedDeploymentWithRawBlobTag(db, RevA,
|
|
equip: "eq-2", driver: "drv-2", fullName: "40002", 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 write was forwarded to the owning child keyed by the resolved FullName, not the blob.
|
|
AwaitAssert(() =>
|
|
{
|
|
recorder.Writes.Count.ShouldBe(1);
|
|
recorder.Writes[0].FullReference.ShouldBe("40002");
|
|
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>
|
|
[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(
|
|
db, TestNode, coordinator.Ref,
|
|
localRoles: new HashSet<string> { "driver" }));
|
|
|
|
var asker = CreateTestProbe();
|
|
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
|
|
|
|
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(TimeSpan.FromSeconds(2));
|
|
result.Success.ShouldBeFalse();
|
|
result.Reason.ShouldNotBeNull();
|
|
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>
|
|
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 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)
|
|
{
|
|
var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray();
|
|
|
|
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
Namespaces = new[]
|
|
{
|
|
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
|
|
},
|
|
DriverInstances = driverIds.Select(d => 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(),
|
|
});
|
|
|
|
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 a single-tag Sealed deployment exactly like <see cref="SeedDeploymentWithEquipmentTags"/>,
|
|
/// except the tag's <c>TagConfig</c> is a RAW protocol-driver blob (Modbus-shaped:
|
|
/// <c>{FullName, region, address, dataType}</c>) instead of the bare Galaxy-style
|
|
/// <c>{FullName}</c> blob. The composer keys the reverse map purely on the blob's <c>FullName</c>
|
|
/// (<c>ExtractTagFullName</c> reads only that field), so the extra raw protocol keys alongside it
|
|
/// are irrelevant — proving routing is independent of the blob's broader shape.
|
|
/// </summary>
|
|
private static DeploymentId SeedDeploymentWithRawBlobTag(
|
|
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
|
string equip, string driver, string fullName, string name)
|
|
{
|
|
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",
|
|
// RAW protocol-driver TagConfig: FullName alongside the actual Modbus wire fields
|
|
// (region/address/dataType), NOT the bare Galaxy {FullName} blob. The composer extracts
|
|
// only FullName, proving the extra protocol keys don't change routing.
|
|
TagConfig = JsonSerializer.Serialize(
|
|
new { FullName = fullName, region = "HoldingRegister", address = 200, dataType = "UInt16" }),
|
|
},
|
|
},
|
|
});
|
|
|
|
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{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>
|
|
private sealed class ThrowingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext>
|
|
{
|
|
/// <inheritdoc />
|
|
public OtOpcUaConfigDbContext CreateDbContext() =>
|
|
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>
|
|
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>
|
|
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()); // 0x0 = Good
|
|
}
|
|
}
|
|
}
|