Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverReadTests.cs
Joseph Doherty 42f41fbe50 v2-mxgw follow-ups: production reads, secret resolution, perf knobs
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>
2026-04-29 17:27:24 -04:00

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);
}
}