7c4c7f9653
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
183 lines
8.2 KiB
C#
183 lines
8.2 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="GalaxyDriver"/>'s <c>IReadable</c> wiring. PR 4.2 ships the
|
|
/// abstraction (<see cref="IGalaxyDataReader"/>) and the wiring; PR 4.4 supplies the
|
|
/// production gateway-backed reader. These tests verify the wiring against a fake
|
|
/// reader plus the explicit "no reader → NotSupportedException" fallback that protects
|
|
/// deployments running on this PR from silently producing wrong reads.
|
|
/// </summary>
|
|
public sealed class GalaxyDriverReadTests
|
|
{
|
|
private static GalaxyDriverOptions Opts() => new(
|
|
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
|
|
new GalaxyMxAccessOptions("OtOpcUa-A"),
|
|
new GalaxyRepositoryOptions(),
|
|
new GalaxyReconnectOptions());
|
|
|
|
private sealed class FakeReader : IGalaxyDataReader
|
|
{
|
|
/// <summary>Gets the last read request.</summary>
|
|
public IReadOnlyList<string>? LastRequest { get; private set; }
|
|
/// <summary>Gets or sets the function that decides the result for a given tag list.</summary>
|
|
public Func<IReadOnlyList<string>, IReadOnlyList<DataValueSnapshot>> Decide { get; set; } =
|
|
tags => tags.Select(t => new DataValueSnapshot(
|
|
Value: t,
|
|
StatusCode: StatusCodeMap.Good,
|
|
SourceTimestampUtc: DateTime.UtcNow,
|
|
ServerTimestampUtc: DateTime.UtcNow)).ToArray();
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
|
{
|
|
LastRequest = fullReferences;
|
|
return Task.FromResult(Decide(fullReferences));
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that ReadAsync routes through the injected reader.</summary>
|
|
[Fact]
|
|
public async Task ReadAsync_RoutesThroughInjectedReader()
|
|
{
|
|
var reader = new FakeReader();
|
|
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null, dataReader: reader);
|
|
|
|
var result = await driver.ReadAsync(["Tank1.Level", "Tank2.Level"], CancellationToken.None);
|
|
|
|
reader.LastRequest.ShouldBe(new[] { "Tank1.Level", "Tank2.Level" });
|
|
result.Count.ShouldBe(2);
|
|
result[0].Value.ShouldBe("Tank1.Level");
|
|
result[0].StatusCode.ShouldBe(StatusCodeMap.Good);
|
|
}
|
|
|
|
/// <summary>Verifies that ReadAsync returns empty without calling the reader for an empty request.</summary>
|
|
[Fact]
|
|
public async Task ReadAsync_EmptyRequest_ReturnsEmpty_WithoutCallingReader()
|
|
{
|
|
var reader = new FakeReader();
|
|
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null, dataReader: reader);
|
|
|
|
var result = await driver.ReadAsync([], CancellationToken.None);
|
|
|
|
result.ShouldBeEmpty();
|
|
reader.LastRequest.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>Verifies that ReadAsync throws when seams and production runtime are not built.</summary>
|
|
[Fact]
|
|
public async Task ReadAsync_NoSeams_AndNoProductionRuntime_Throws()
|
|
{
|
|
// Construction without seams + without InitializeAsync gives a driver where
|
|
// _dataReader and _subscriber are both null. The follow-up read path can't
|
|
// synthesise a Read without one, so it surfaces a NotSupportedException
|
|
// pointing at the misuse rather than NullRef'ing inside the pump path.
|
|
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null);
|
|
|
|
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
|
driver.ReadAsync(["x"], CancellationToken.None));
|
|
ex.Message.ShouldContain("production runtime not built");
|
|
}
|
|
|
|
/// <summary>Verifies that ReadAsync throws after the driver is disposed.</summary>
|
|
[Fact]
|
|
public async Task ReadAsync_AfterDispose_Throws()
|
|
{
|
|
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null, dataReader: new FakeReader());
|
|
driver.Dispose();
|
|
await Should.ThrowAsync<ObjectDisposedException>(() =>
|
|
driver.ReadAsync(["x"], CancellationToken.None));
|
|
}
|
|
|
|
/// <summary>Verifies that ReadAsync resolves from the first OnDataChange event on the subscribe-once path.</summary>
|
|
[Fact]
|
|
public async Task ReadAsync_SubscribeOncePath_ResolvesFromFirstOnDataChange()
|
|
{
|
|
// Follow-up #1: when no test reader is injected but a subscriber IS, the driver
|
|
// synthesises a Read by subscribing, waiting for the first OnDataChange event
|
|
// per item handle (gw pushes initial value), then unsubscribing.
|
|
var subscriber = new GalaxyDriverSubscribeTests.FakeSubscriber();
|
|
using var driver = new GalaxyDriver(
|
|
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
|
|
|
|
var readTask = driver.ReadAsync(["Tank.Level"], CancellationToken.None);
|
|
// Push the "initial value" event the gw would emit immediately after SubscribeBulk.
|
|
await Task.Delay(50); // give SubscribeBulk a beat to register + handler to attach
|
|
var itemHandle = subscriber.Map["Tank.Level"];
|
|
await subscriber.EmitOnDataChangeAsync(itemHandle, 42.0);
|
|
|
|
var result = await readTask;
|
|
result.Count.ShouldBe(1);
|
|
result[0].Value.ShouldBe(42.0);
|
|
// Cleanup unsubscribed the live handle.
|
|
subscriber.UnsubscribedHandles.ShouldContain(itemHandle);
|
|
}
|
|
|
|
/// <summary>Verifies that ReadAsync surfaces rejected tags as bad status on the subscribe-once path.</summary>
|
|
[Fact]
|
|
public async Task ReadAsync_SubscribeOncePath_RejectedTagSurfacesAsBadStatus()
|
|
{
|
|
// gw rejects "Bad" at SubscribeBulk; the read path completes that slot with a
|
|
// Bad-status snapshot rather than waiting forever for an event that won't come.
|
|
var subscriber = new GalaxyDriverSubscribeTests.FakeSubscriber { Decide = tag => tag != "Bad" };
|
|
using var driver = new GalaxyDriver(
|
|
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
|
|
|
|
var readTask = driver.ReadAsync(["Good", "Bad"], CancellationToken.None);
|
|
await Task.Delay(50);
|
|
await subscriber.EmitOnDataChangeAsync(subscriber.Map["Good"], 1.0);
|
|
|
|
var result = await readTask;
|
|
result.Count.ShouldBe(2);
|
|
result[0].Value.ShouldBe(1.0);
|
|
result[1].StatusCode.ShouldBe(0x80000000u); // Bad
|
|
}
|
|
|
|
/// <summary>Verifies that ReadAsync preserves reader status codes.</summary>
|
|
[Fact]
|
|
public async Task ReadAsync_PreservesReaderStatusCodes()
|
|
{
|
|
var reader = new FakeReader
|
|
{
|
|
Decide = tags => new DataValueSnapshot[]
|
|
{
|
|
new(42.0, StatusCodeMap.Good, DateTime.UtcNow, DateTime.UtcNow),
|
|
new(null, StatusCodeMap.BadNotConnected, null, DateTime.UtcNow),
|
|
},
|
|
};
|
|
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null, dataReader: reader);
|
|
|
|
var result = await driver.ReadAsync(["a", "b"], CancellationToken.None);
|
|
|
|
result[0].StatusCode.ShouldBe(StatusCodeMap.Good);
|
|
result[1].StatusCode.ShouldBe(StatusCodeMap.BadNotConnected);
|
|
}
|
|
|
|
/// <summary>A never-publishing tag with CancellationToken.None must complete with BadTimeout at the internal deadline (06/S-3).</summary>
|
|
[Fact]
|
|
public async Task ReadAsync_SubscribeOncePath_NeverPublishes_NoCallerToken_TimesOutWithBadTimeout()
|
|
{
|
|
var subscriber = new GalaxyDriverSubscribeTests.FakeSubscriber();
|
|
using var driver = new GalaxyDriver(
|
|
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber)
|
|
{
|
|
ReadViaSubscribeDeadline = TimeSpan.FromMilliseconds(200),
|
|
};
|
|
|
|
var readTask = driver.ReadAsync(["Silent.Tag"], CancellationToken.None);
|
|
var finished = await Task.WhenAny(readTask, Task.Delay(TimeSpan.FromSeconds(10)));
|
|
|
|
finished.ShouldBe(readTask);
|
|
var result = await readTask;
|
|
result.Count.ShouldBe(1);
|
|
result[0].StatusCode.ShouldBe(StatusCodeMap.BadTimeout);
|
|
subscriber.UnsubscribedHandles.ShouldContain(subscriber.Map["Silent.Tag"]);
|
|
}
|
|
}
|