Lands the five concrete code-level follow-ups identified after Phase 7.1: #1 GalaxyDriver.ReadAsync now works in production. Previously threw NotSupportedException when no test reader was injected. New path subscribes through the existing SubscriptionRegistry + EventPump, waits for the first OnDataChange per item handle (gw pushes the initial value after SubscribeBulk), then unsubscribes. Tags the gw rejects up front, or that don't publish before the caller's CT fires, return Bad-status snapshots in input order so callers still get one snapshot per requested reference. #2 ResolveApiKey() routes Gateway.ApiKeySecretRef through three forms: env:NAME, file:PATH, or literal-string fallback. A future DPAPI arm slots in here without touching the call site. #3 GatewayGalaxySubscriber actually honors bufferedUpdateIntervalMs now (was being silently dropped). Calls SetBufferedUpdateInterval via the gw's MxCommandKind.SetBufferedUpdateInterval before SubscribeBulk when the requested interval differs from the cached last-applied value. Soft-fails on a non-Ok protocol status (the SubscribeBulk still succeeds at gw cadence). #4 GalaxyMxAccessOptions.EventPumpChannelCapacity surfaces the bounded- channel size through DriverConfig JSON, defaulting to 50_000. #5 Stale doc-comments in HostStatusAggregator and GatewayGalaxySubscriber describing follow-ups that already shipped. Tests: +6 (read subscribe-once happy path + rejected-tag fallback; five resolver scenarios). Total Galaxy driver tests now 180/180 green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
152 lines
6.3 KiB
C#
152 lines
6.3 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
|
|
{
|
|
public IReadOnlyList<string>? LastRequest { get; private set; }
|
|
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();
|
|
|
|
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
|
{
|
|
LastRequest = fullReferences;
|
|
return Task.FromResult(Decide(fullReferences));
|
|
}
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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();
|
|
}
|
|
|
|
[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());
|
|
|
|
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
|
driver.ReadAsync(["x"], CancellationToken.None));
|
|
ex.Message.ShouldContain("production runtime not built");
|
|
}
|
|
|
|
[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));
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|