bd6c0b4d3d
Add missing <returns>/<param>/<summary>/<typeparam> tags and clean up misused inheritdoc across 481 files so the documented API surface is complete. Documentation-only (zero code lines changed). The 131 remaining findings are inheritdoc-style warnings deliberately left to preserve hand-written implementation rationale (plan-decision notes, race-condition explanations).
295 lines
13 KiB
C#
295 lines
13 KiB
C#
using Akka.Actor;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
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;
|
|
|
|
public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
|
{
|
|
/// <summary>Verifies that ApplyDelta calls ReinitializeAsync when connected and replies success.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task ApplyDelta_when_Connected_calls_ReinitializeAsync_and_replies_success()
|
|
{
|
|
var driver = new StubDriver();
|
|
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver));
|
|
|
|
// Drive: Initialize → Connected.
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
|
|
|
|
// Issue ApplyDelta and capture the reply via Ask.
|
|
var correlation = CorrelationId.NewId();
|
|
var reply = await actor.Ask<DriverInstanceActor.ApplyResult>(
|
|
new DriverInstanceActor.ApplyDelta("{\"changed\":true}", correlation),
|
|
TimeSpan.FromSeconds(3));
|
|
|
|
reply.Success.ShouldBeTrue();
|
|
reply.Correlation.ShouldBe(correlation);
|
|
driver.ReinitializeCount.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>Verifies that initialize failure keeps the actor in Reconnecting state.</summary>
|
|
[Fact]
|
|
public void Initialize_failure_keeps_actor_in_Reconnecting_state()
|
|
{
|
|
var driver = new StubDriver { InitializeShouldThrow = true };
|
|
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
|
|
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
|
|
// The actor should keep trying — we expect multiple Initialize calls because the
|
|
// reconnect timer fires every 50ms.
|
|
AwaitCondition(() => driver.InitializeCount >= 3, TimeSpan.FromSeconds(2));
|
|
}
|
|
|
|
/// <summary>Verifies that writing to a non-IWritable driver returns failure.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Write_against_non_IWritable_driver_returns_failure()
|
|
{
|
|
var driver = new StubDriver(); // IDriver only, no IWritable.
|
|
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver));
|
|
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
|
|
|
|
var reply = await actor.Ask<DriverInstanceActor.WriteAttributeResult>(
|
|
new DriverInstanceActor.WriteAttribute("tag-1", 42),
|
|
TimeSpan.FromSeconds(3));
|
|
|
|
reply.Success.ShouldBeFalse();
|
|
reply.Reason!.ShouldContain("IWritable");
|
|
}
|
|
|
|
/// <summary>Verifies that writing to an IWritable driver returns success when status is Good.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Write_against_IWritable_returns_success_when_status_is_Good()
|
|
{
|
|
var driver = new WritableStubDriver();
|
|
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver));
|
|
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
|
|
|
|
var reply = await actor.Ask<DriverInstanceActor.WriteAttributeResult>(
|
|
new DriverInstanceActor.WriteAttribute("tag-1", 42),
|
|
TimeSpan.FromSeconds(3));
|
|
|
|
reply.Success.ShouldBeTrue();
|
|
driver.Writes.Single().FullReference.ShouldBe("tag-1");
|
|
driver.Writes.Single().Value.ShouldBe(42);
|
|
}
|
|
|
|
/// <summary>Verifies that write propagates status code on Bad result.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Write_propagates_status_code_on_Bad_result()
|
|
{
|
|
const uint badStatus = 0x80340000; // BadOutOfService — top severity bits = 10b
|
|
var driver = new WritableStubDriver { NextStatusCode = badStatus };
|
|
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver));
|
|
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
|
|
|
|
var reply = await actor.Ask<DriverInstanceActor.WriteAttributeResult>(
|
|
new DriverInstanceActor.WriteAttribute("tag-1", 42),
|
|
TimeSpan.FromSeconds(3));
|
|
|
|
reply.Success.ShouldBeFalse();
|
|
reply.Reason!.ShouldContain("80340000");
|
|
}
|
|
|
|
/// <summary>Verifies that subscribing to an ISubscribable driver forwards OnDataChange to parent.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Subscribe_against_ISubscribable_forwards_OnDataChange_to_parent()
|
|
{
|
|
var driver = new SubscribableStubDriver();
|
|
var parent = CreateTestProbe();
|
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
|
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
|
|
|
|
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
|
|
new DriverInstanceActor.Subscribe(new[] { "tag-a", "tag-b" }, TimeSpan.FromMilliseconds(250)),
|
|
TimeSpan.FromSeconds(3));
|
|
|
|
// Driver fires an OnDataChange — actor should forward it to its parent as
|
|
// AttributeValuePublished with Quality mapped from StatusCode.
|
|
driver.FireDataChange("tag-a", value: 3.14, statusCode: 0u);
|
|
|
|
var published = parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(2));
|
|
published.FullReference.ShouldBe("tag-a");
|
|
published.Value.ShouldBe(3.14);
|
|
published.Quality.ShouldBe(OpcUaQuality.Good);
|
|
}
|
|
|
|
/// <summary>Verifies that subscribe translates OPC UA status severity bits to OpcUaQuality.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Subscribe_translates_OPC_UA_status_severity_bits_to_OpcUaQuality()
|
|
{
|
|
var driver = new SubscribableStubDriver();
|
|
var parent = CreateTestProbe();
|
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
|
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
|
|
|
|
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
|
|
new DriverInstanceActor.Subscribe(new[] { "tag-1" }, TimeSpan.FromMilliseconds(100)),
|
|
TimeSpan.FromSeconds(3));
|
|
|
|
// Uncertain — severity bits 01 (top 2 bits = 01).
|
|
driver.FireDataChange("tag-1", value: 1, statusCode: 0x40000000u);
|
|
parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>().Quality.ShouldBe(OpcUaQuality.Uncertain);
|
|
|
|
// Bad — severity bits 10.
|
|
driver.FireDataChange("tag-1", value: 2, statusCode: 0x80000000u);
|
|
parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>().Quality.ShouldBe(OpcUaQuality.Bad);
|
|
}
|
|
|
|
/// <summary>Verifies that subscribing to a non-ISubscribable driver replies with failure.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Subscribe_against_non_ISubscribable_replies_with_failure()
|
|
{
|
|
var driver = new StubDriver(); // IDriver only
|
|
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver));
|
|
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
|
|
|
|
var reply = await actor.Ask<DriverInstanceActor.SubscriptionFailed>(
|
|
new DriverInstanceActor.Subscribe(new[] { "tag-1" }, TimeSpan.FromMilliseconds(100)),
|
|
TimeSpan.FromSeconds(3));
|
|
|
|
reply.Reason.ShouldContain("ISubscribable");
|
|
}
|
|
|
|
/// <summary>Verifies that DisconnectObserved detaches subscription handler so late events are dropped.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task DisconnectObserved_detaches_subscription_handler_so_late_events_are_dropped()
|
|
{
|
|
var driver = new SubscribableStubDriver();
|
|
var parent = CreateTestProbe();
|
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromSeconds(30)));
|
|
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
|
|
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
|
|
new DriverInstanceActor.Subscribe(new[] { "tag-1" }, TimeSpan.FromMilliseconds(100)),
|
|
TimeSpan.FromSeconds(3));
|
|
|
|
actor.Tell(new DriverInstanceActor.DisconnectObserved("backend went away"));
|
|
|
|
// Race window — once disconnect is processed, subsequent FireDataChange calls hit a
|
|
// detached handler and don't push anything to the parent.
|
|
AwaitCondition(() => driver.OnDataChangeSubscriberCount == 0, TimeSpan.FromSeconds(2));
|
|
driver.FireDataChange("tag-1", value: 99, statusCode: 0u);
|
|
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
}
|
|
|
|
private class StubDriver : IDriver
|
|
{
|
|
/// <summary>Gets or sets a value indicating whether initialization should throw.</summary>
|
|
public bool InitializeShouldThrow { get; set; }
|
|
/// <summary>Gets the number of times initialization was called.</summary>
|
|
public int InitializeCount;
|
|
/// <summary>Gets the number of times reinitialization was called.</summary>
|
|
public int ReinitializeCount;
|
|
|
|
/// <inheritdoc />
|
|
public string DriverInstanceId => "stub-driver-1";
|
|
/// <inheritdoc />
|
|
public string DriverType => "Stub";
|
|
|
|
/// <inheritdoc />
|
|
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
|
{
|
|
Interlocked.Increment(ref InitializeCount);
|
|
if (InitializeShouldThrow) throw new InvalidOperationException("stub-init-fail");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
|
{
|
|
Interlocked.Increment(ref ReinitializeCount);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
/// <inheritdoc />
|
|
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
|
/// <inheritdoc />
|
|
public long GetMemoryFootprint() => 0;
|
|
/// <inheritdoc />
|
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
}
|
|
|
|
private sealed class WritableStubDriver : StubDriver, IWritable
|
|
{
|
|
/// <summary>Gets or sets the next status code to return from write operations.</summary>
|
|
public uint NextStatusCode { get; set; } = 0u;
|
|
/// <summary>Gets the list of write requests received.</summary>
|
|
public List<WriteRequest> Writes { get; } = new();
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<WriteResult>> WriteAsync(
|
|
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
|
{
|
|
Writes.AddRange(writes);
|
|
IReadOnlyList<WriteResult> results = writes.Select(_ => new WriteResult(NextStatusCode)).ToList();
|
|
return Task.FromResult(results);
|
|
}
|
|
}
|
|
|
|
private sealed class SubscribableStubDriver : StubDriver, ISubscribable
|
|
{
|
|
/// <summary>Occurs when data changes.</summary>
|
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
|
|
|
private readonly StubHandle _handle = new();
|
|
|
|
/// <summary>Gets the number of subscribers to OnDataChange.</summary>
|
|
public int OnDataChangeSubscriberCount => OnDataChange?.GetInvocationList().Length ?? 0;
|
|
|
|
/// <inheritdoc />
|
|
public Task<ISubscriptionHandle> SubscribeAsync(
|
|
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
|
=> Task.FromResult<ISubscriptionHandle>(_handle);
|
|
|
|
/// <inheritdoc />
|
|
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
|
=> Task.CompletedTask;
|
|
|
|
/// <summary>Fires a data change event with the specified parameters.</summary>
|
|
/// <param name="fullRef">The full reference of the data that changed.</param>
|
|
/// <param name="value">The new value.</param>
|
|
/// <param name="statusCode">The OPC UA status code.</param>
|
|
public void FireDataChange(string fullRef, object? value, uint statusCode)
|
|
{
|
|
var snapshot = new DataValueSnapshot(value, statusCode, DateTime.UtcNow, DateTime.UtcNow);
|
|
OnDataChange?.Invoke(this, new DataChangeEventArgs(_handle, fullRef, snapshot));
|
|
}
|
|
|
|
private sealed class StubHandle : ISubscriptionHandle
|
|
{
|
|
/// <inheritdoc />
|
|
public string DiagnosticId => "stub-sub";
|
|
}
|
|
}
|
|
}
|