283 lines
13 KiB
C#
283 lines
13 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.Tests.Harness;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|
|
|
/// <summary>
|
|
/// Verifies the inbound native-condition <b>acknowledge</b> routing wired into
|
|
/// <see cref="DriverHostActor"/> (H6d): an OPC UA client Acknowledges a NATIVE condition, the
|
|
/// node manager invokes <c>NativeAlarmAckRouter</c>, and the host (NEXT task) Tells a
|
|
/// <see cref="DriverHostActor.RouteNativeAlarmAck"/> in. The host resolves the condition NodeId →
|
|
/// owning <c>(DriverInstanceId, FullName)</c> via the <c>_driverRefByAlarmNodeId</c> inverse map
|
|
/// (built alongside the alarm forward map in <c>PushDesiredSubscriptions</c>), applies the SAME
|
|
/// primary gate the inbound write path uses, and routes to the owning driver child's
|
|
/// <see cref="IAlarmSource.AcknowledgeAsync"/> carrying the principal.
|
|
///
|
|
/// <para>
|
|
/// Mirrors <c>DriverHostActorWriteRoutingTests</c>: a real apply through the existing harness
|
|
/// spawns a real (non-stubbed) <see cref="DriverInstanceActor"/> child backed by a recording
|
|
/// <see cref="IAlarmSource"/> driver, so the inverse map is populated authentically and the
|
|
/// forwarded acknowledge request can be observed. The seeded tag carries an <c>alarm</c> object so
|
|
/// it materialises as a Part 9 condition (folder-scoped condition NodeId), not a value variable.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTestBase
|
|
{
|
|
private static readonly NodeId TestNode = NodeId.Parse("driver-ack-test");
|
|
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
|
|
|
/// <summary>On the PRIMARY (role unknown ⇒ Primary), a RouteNativeAlarmAck for a mapped condition NodeId
|
|
/// forwards exactly one <see cref="AlarmAcknowledgeRequest"/> to the owning driver's
|
|
/// <see cref="IAlarmSource.AcknowledgeAsync"/>, with <c>ConditionId == FullName</c>, the operator
|
|
/// principal, and the comment.</summary>
|
|
[Fact]
|
|
public void RouteNativeAlarmAck_routes_to_driver_AcknowledgeAsync_with_principal()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
|
|
// One alarm-bearing equipment tag: eq-1, drv-1, FullName "Temp.HiHi", no folder, Name "temp_hi"
|
|
// → condition NodeId "eq-1/temp_hi".
|
|
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
|
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
|
|
|
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
|
|
|
// Local role unknown ⇒ treated as Primary ⇒ ack allowed (default-allow semantics).
|
|
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/temp_hi", "cmt", "alice"));
|
|
|
|
// The driver received exactly one acknowledge, correlated on its wire-ref FullName, with principal.
|
|
AwaitAssert(() =>
|
|
{
|
|
recorder.Acks.Count.ShouldBe(1);
|
|
recorder.Acks[0].ConditionId.ShouldBe("Temp.HiHi");
|
|
recorder.Acks[0].SourceNodeId.ShouldBe("Temp.HiHi");
|
|
recorder.Acks[0].Comment.ShouldBe("cmt");
|
|
recorder.Acks[0].OperatorUser.ShouldBe("alice");
|
|
}, duration: Timeout);
|
|
}
|
|
|
|
/// <summary>An ack for an unmapped condition NodeId is dropped: no throw, and the driver's
|
|
/// <see cref="IAlarmSource.AcknowledgeAsync"/> is never called.</summary>
|
|
[Fact]
|
|
public void RouteNativeAlarmAck_unknown_node_is_dropped()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
|
|
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
|
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
|
|
|
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
|
|
|
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/does-not-exist", "cmt", "alice"));
|
|
|
|
// Give the (fire-and-forget) handler time to run; the unmapped node must produce no ack.
|
|
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800));
|
|
}
|
|
|
|
/// <summary>On a SECONDARY node the ack is gated off (same primary gate as the inbound write path): the
|
|
/// driver's <see cref="IAlarmSource.AcknowledgeAsync"/> is NOT called — a secondary keeps its address
|
|
/// space warm but must not push commands to the shared upstream alarm system.</summary>
|
|
[Fact]
|
|
public void RouteNativeAlarmAck_on_non_primary_is_dropped()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
|
|
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
|
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
|
|
|
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
|
|
|
// Force this node Secondary so the primary gate rejects the ack.
|
|
actor.Tell(new RedundancyStateChanged(
|
|
new[]
|
|
{
|
|
new NodeRedundancyState(TestNode, RedundancyRole.Secondary,
|
|
IsClusterLeader: false, IsRoleLeaderForDriver: false, AsOfUtc: DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId()));
|
|
|
|
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/temp_hi", "cmt", "alice"));
|
|
|
|
// No ack reached the driver — the gate short-circuited before the inverse-map lookup.
|
|
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800));
|
|
}
|
|
|
|
/// <summary>Spawns the host with the recording alarm-driver factory, dispatches the deployment, and waits
|
|
/// for the Applied ACK so the apply (and thus the inverse-map build in PushDesiredSubscriptions) has
|
|
/// completed before the test routes an ack.</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 one alarm-bearing equipment tag: the tag's
|
|
/// <c>TagConfig</c> carries both a <c>FullName</c> and an <c>alarm</c> object so
|
|
/// <c>DeploymentArtifact.ExtractTagAlarm</c> projects a non-null <c>EquipmentTagAlarmInfo</c> —
|
|
/// making the tag a condition (folder-scoped condition NodeId) rather than a value variable. The
|
|
/// <c>DriverInstances</c> row carries a non-Windows-only <c>DriverType</c> ("GalaxyMxGateway") + an
|
|
/// Enabled flag so a REAL (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned.
|
|
/// </summary>
|
|
private static DeploymentId SeedDeploymentWithAlarmTag(
|
|
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
|
string Equip, string Driver, string FullName, string? Folder, 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 = "GalaxyMxGateway", // not Windows-only ⇒ a real child is spawned (not stubbed)
|
|
Enabled = true,
|
|
DriverConfig = "{}",
|
|
NamespaceId = "ns-eq",
|
|
},
|
|
},
|
|
Tags = new[]
|
|
{
|
|
new
|
|
{
|
|
TagId = "tag-0",
|
|
EquipmentId = Equip,
|
|
DriverInstanceId = Driver,
|
|
Name,
|
|
FolderPath = Folder,
|
|
DataType = "Boolean",
|
|
TagConfig = JsonSerializer.Serialize(new
|
|
{
|
|
FullName,
|
|
alarm = new { alarmType = "OffNormalAlarm", severity = 700 },
|
|
}),
|
|
},
|
|
},
|
|
});
|
|
|
|
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>Factory producing a single <see cref="RecordingAlarmDriver"/> for the supported type, whose
|
|
/// recorded acknowledge list is exposed for assertions.</summary>
|
|
private sealed class RecordingAlarmDriverFactory : IDriverFactory
|
|
{
|
|
private readonly string _supportedType;
|
|
private readonly RecordingAlarmDriver _driver = new();
|
|
public RecordingAlarmDriverFactory(string supportedType) { _supportedType = supportedType; }
|
|
|
|
/// <summary>The acknowledge requests the spawned driver received (thread-safe — AcknowledgeAsync runs
|
|
/// off the actor thread).</summary>
|
|
public IReadOnlyList<AlarmAcknowledgeRequest> Acks => _driver.Acks;
|
|
|
|
/// <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="IAlarmSource"/> that records every acknowledge.</summary>
|
|
private sealed class RecordingAlarmDriver : IDriver, IAlarmSource
|
|
{
|
|
private readonly ConcurrentQueue<AlarmAcknowledgeRequest> _acks = new();
|
|
/// <inheritdoc />
|
|
public string DriverInstanceId { get; private set; } = string.Empty;
|
|
/// <inheritdoc />
|
|
public string DriverType { get; private set; } = string.Empty;
|
|
/// <summary>The acknowledge requests received so far.</summary>
|
|
public IReadOnlyList<AlarmAcknowledgeRequest> Acks => _acks.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<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
|
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken) =>
|
|
Task.FromResult<IAlarmSubscriptionHandle>(new StubAlarmHandle());
|
|
|
|
/// <inheritdoc />
|
|
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
|
|
Task.CompletedTask;
|
|
|
|
/// <inheritdoc />
|
|
public Task AcknowledgeAsync(
|
|
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
|
{
|
|
foreach (var a in acknowledgements) _acks.Enqueue(a);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public event EventHandler<AlarmEventArgs>? OnAlarmEvent
|
|
{
|
|
add { }
|
|
remove { }
|
|
}
|
|
|
|
private sealed class StubAlarmHandle : IAlarmSubscriptionHandle
|
|
{
|
|
public string DiagnosticId => "stub-alarm-sub";
|
|
}
|
|
}
|
|
}
|