using ZB.MOM.WW.MxGateway.Contracts.Proto; 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; /// /// Tests for 's IReadable wiring. PR 4.2 ships the /// abstraction () 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. /// 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 { /// Gets the last read request. public IReadOnlyList? LastRequest { get; private set; } /// Gets or sets the function that decides the result for a given tag list. public Func, IReadOnlyList> Decide { get; set; } = tags => tags.Select(t => new DataValueSnapshot( Value: t, StatusCode: StatusCodeMap.Good, SourceTimestampUtc: DateTime.UtcNow, ServerTimestampUtc: DateTime.UtcNow)).ToArray(); /// public Task> ReadAsync( IReadOnlyList fullReferences, CancellationToken cancellationToken) { LastRequest = fullReferences; return Task.FromResult(Decide(fullReferences)); } } /// Verifies that ReadAsync routes through the injected reader. [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); } /// Verifies that ReadAsync returns empty without calling the reader for an empty request. [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(); } /// Verifies that ReadAsync throws when seams and production runtime are not built. [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(() => driver.ReadAsync(["x"], CancellationToken.None)); ex.Message.ShouldContain("production runtime not built"); } /// Verifies that ReadAsync throws after the driver is disposed. [Fact] public async Task ReadAsync_AfterDispose_Throws() { var driver = new GalaxyDriver("g", Opts(), hierarchySource: null, dataReader: new FakeReader()); driver.Dispose(); await Should.ThrowAsync(() => driver.ReadAsync(["x"], CancellationToken.None)); } /// Verifies that ReadAsync resolves from the first OnDataChange event on the subscribe-once path. [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); } /// Verifies that ReadAsync surfaces rejected tags as bad status on the subscribe-once path. [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 } /// Verifies that ReadAsync preserves reader status codes. [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); } /// A never-publishing tag with CancellationToken.None must complete with BadTimeout at the internal deadline (06/S-3). [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"]); } /// /// A subscribe RPC that outlives the internal deadline (caller passed None) must yield BadTimeout for /// every tag, not an OperationCanceledException, and the handles the late-completing RPC created must /// still be unsubscribed. /// [Fact] public async Task ReadAsync_SubscribeOncePath_SlowSubscribeBulk_NoCallerToken_ReturnsBadTimeoutAndUnsubscribesLateHandles() { var subscriber = new SlowSubscribeSubscriber(); using var driver = new GalaxyDriver( "g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber) { ReadViaSubscribeDeadline = TimeSpan.FromMilliseconds(200), }; var result = await driver.ReadAsync(["Slow.A", "Slow.B"], CancellationToken.None) .WaitAsync(TimeSpan.FromSeconds(10)); result.Count.ShouldBe(2); result.ShouldAllBe(r => r.StatusCode == StatusCodeMap.BadTimeout); // The RPC finally completes after the read gave up; its handles must not leak. subscriber.Gate.SetResult(); var deadline = DateTime.UtcNow.AddSeconds(10); while (subscriber.UnsubscribedHandles.Count < 2 && DateTime.UtcNow < deadline) await Task.Delay(20); subscriber.UnsubscribedHandles.OrderBy(h => h).ShouldBe([101, 102]); } private sealed class SlowSubscribeSubscriber : IGalaxySubscriber { private readonly List _unsubscribed = []; public TaskCompletionSource Gate { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public IReadOnlyList UnsubscribedHandles { get { lock (_unsubscribed) return _unsubscribed.ToArray(); } } public async Task> SubscribeBulkAsync( IReadOnlyList fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken) { await Gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false); var h = 100; return fullReferences.Select(r => new SubscribeResult { TagAddress = r, ItemHandle = ++h, WasSuccessful = true, }).ToList(); } public Task UnsubscribeBulkAsync(IReadOnlyList itemHandles, CancellationToken cancellationToken) { lock (_unsubscribed) _unsubscribed.AddRange(itemHandles); return Task.CompletedTask; } public IAsyncEnumerable StreamEventsAsync(CancellationToken cancellationToken) => System.Threading.Channels.Channel.CreateUnbounded() .Reader.ReadAllAsync(cancellationToken); } }