using System.Runtime.CompilerServices; using System.Threading.Channels; using Google.Protobuf.WellKnownTypes; using Shouldly; using Xunit; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse; 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; /// /// Review 2026-10-21 G-4 / G-7 / G-11: the per-platform ScanState probing was structurally /// dead (its handles were never in the registry the pump resolves through, and a reconnect /// never re-subscribed them), discovery fetched the full galaxy twice, and /// LastSuccessfulRead never moved on data. These drive the real driver → pump → watcher /// → aggregator path with a fake subscriber and a fake hierarchy. /// public sealed class GalaxyDriverProbeWiringTests { private const int WaitMs = 10_000; private static GalaxyDriverOptions Opts() => new( new GalaxyGatewayOptions("https://mxgw.test:5001", "key"), new GalaxyMxAccessOptions("ProbeWiring"), new GalaxyRepositoryOptions(WatchDeployEvents: false), new GalaxyReconnectOptions()); private sealed class CountingHierarchySource : IGalaxyHierarchySource { public int Calls; public Task> GetHierarchyAsync(CancellationToken cancellationToken) { Interlocked.Increment(ref Calls); var platform = new GalaxyObject { GobjectId = 1, TagName = "Plat01", ContainedName = "Plat01" }; platform.TemplateChain.Add("$WinPlatform"); var tank = new GalaxyObject { GobjectId = 2, TagName = "Tank01", ContainedName = "Tank01", ParentGobjectId = 1 }; tank.TemplateChain.Add("$UserDefined"); tank.Attributes.Add(new GalaxyAttribute { AttributeName = "Level", FullTagReference = "Tank01.Level", MxDataType = 5 }); return Task.FromResult>([platform, tank]); } } private sealed class StreamingSubscriber : IGalaxySubscriber { private readonly Channel _events = Channel.CreateUnbounded(); private int _nextHandle; public List Subscribed { get; } = []; public Dictionary HandleByRef { get; } = new(StringComparer.OrdinalIgnoreCase); public Task> SubscribeBulkAsync( IReadOnlyList fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken) { var results = new List(); lock (Subscribed) { foreach (var r in fullReferences) { var h = Interlocked.Increment(ref _nextHandle); Subscribed.Add(r); HandleByRef[r] = h; results.Add(new SubscribeResult { TagAddress = r, ItemHandle = h, WasSuccessful = true }); } } return Task.FromResult>(results); } public Task UnsubscribeBulkAsync(IReadOnlyList itemHandles, CancellationToken cancellationToken) => Task.CompletedTask; public async IAsyncEnumerable StreamEventsAsync([EnumeratorCancellation] CancellationToken cancellationToken) { await foreach (var ev in _events.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) { yield return ev; } } public ValueTask EmitBoolAsync(int handle, bool value) => _events.Writer.WriteAsync(new MxEvent { Family = MxEventFamily.OnDataChange, ItemHandle = handle, Value = new MxValue { BoolValue = value }, Quality = 192, SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow), }); public ValueTask EmitDoubleAsync(int handle, double value) => _events.Writer.WriteAsync(new MxEvent { Family = MxEventFamily.OnDataChange, ItemHandle = handle, Value = new MxValue { DoubleValue = value }, Quality = 192, SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow), }); } private sealed class NullBuilder : IAddressSpaceBuilder { public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) => new Handle(attributeInfo.FullName); public void AddProperty(string browseName, DriverDataType dataType, object? value) { } private sealed class Handle(string fullReference) : IVariableHandle { public string FullReference { get; } = fullReference; public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo alarmInfo) => new NoopSink(); } private sealed class NoopSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } } } private static async Task WaitUntilAsync(Func condition, string because) { var deadline = DateTime.UtcNow.AddMilliseconds(WaitMs); while (!condition()) { if (DateTime.UtcNow > deadline) throw new ShouldAssertException(because); await Task.Delay(10); } } private static HostState? StateOf(GalaxyDriver driver, string host) => driver.GetHostStatuses().FirstOrDefault(s => s.HostName == host)?.State; /// /// A ScanState event on the probe handle updates GetHostStatuses; after a reconnect replay /// the probe is re-subscribed and events on the NEW handle keep updating it, while the dead /// pre-reconnect handle no longer does. Probe traffic never reaches public OnDataChange. /// [Fact] public async Task ScanState_event_updates_GetHostStatuses_and_survives_a_reconnect() { var sub = new StreamingSubscriber(); var source = new CountingHierarchySource(); await using var driver = new GalaxyDriver("drv", Opts(), hierarchySource: source, subscriber: sub); var publicRefs = new List(); driver.OnDataChange += (_, e) => { lock (publicRefs) publicRefs.Add(e.FullReference); }; await driver.InitializeAsync("{}", CancellationToken.None); await driver.DiscoverAsync(new NullBuilder(), CancellationToken.None); // The pump starts on the first value subscription (the probe watcher does not start it). await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None); var probeHandle = sub.HandleByRef["Plat01.ScanState"]; await sub.EmitBoolAsync(probeHandle, true); await WaitUntilAsync(() => StateOf(driver, "Plat01") == HostState.Running, "a ScanState=true event must surface Plat01 as Running"); // Reconnect replay: every pre-reconnect handle is dead; the probe must rebind. await driver.InvokeReplayForTestAsync(CancellationToken.None); var rebound = sub.HandleByRef["Plat01.ScanState"]; rebound.ShouldNotBe(probeHandle, "replay must re-subscribe the probe and record the fresh handle"); await sub.EmitBoolAsync(rebound, false); await WaitUntilAsync(() => StateOf(driver, "Plat01") == HostState.Stopped, "a ScanState=false event on the post-reconnect handle must surface Plat01 as Stopped"); // The dead handle is no longer routed (positive control: the live one still is). await sub.EmitBoolAsync(probeHandle, true); await sub.EmitDoubleAsync(sub.HandleByRef["Tank01.Level"], 1.0); await WaitUntilAsync(() => { lock (publicRefs) return publicRefs.Contains("Tank01.Level"); }, "value traffic must still flow"); StateOf(driver, "Plat01").ShouldBe(HostState.Stopped, "an event on the pre-reconnect handle must be dropped"); lock (publicRefs) publicRefs.ShouldNotContain("Plat01.ScanState", "probe traffic is the watcher's, never the host's"); } /// Discovery asks the gateway for the hierarchy once; the probe set is selected from that same sweep. [Fact] public async Task DiscoverAsync_fetches_the_hierarchy_once_and_still_syncs_the_probe_set() { var sub = new StreamingSubscriber(); var source = new CountingHierarchySource(); await using var driver = new GalaxyDriver("drv", Opts(), hierarchySource: source, subscriber: sub); await driver.InitializeAsync("{}", CancellationToken.None); await driver.DiscoverAsync(new NullBuilder(), CancellationToken.None); source.Calls.ShouldBe(1, "the probe selection must reuse the discoverer's sweep"); sub.Subscribed.ShouldContain("Plat01.ScanState", "positive control: the platform probe was still synced"); driver.ProbeWatcherForTests.ShouldNotBeNull().WatchedPlatforms.ShouldBe(["Plat01"]); } /// Inbound stream data advances LastSuccessfulRead (throttled); the throttle holds the stamp still. [Fact] public async Task Stream_data_advances_LastSuccessfulRead_under_a_throttle() { var sub = new StreamingSubscriber(); await using var driver = new GalaxyDriver("drv", Opts(), hierarchySource: null, subscriber: sub) { LivenessStampThrottle = TimeSpan.FromHours(1), }; await driver.InitializeAsync("{}", CancellationToken.None); var initStamp = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull(); await Task.Delay(20); var delivered = 0; driver.OnDataChange += (_, _) => Interlocked.Increment(ref delivered); await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None); var handle = sub.HandleByRef["Tank01.Level"]; await sub.EmitDoubleAsync(handle, 1.0); await WaitUntilAsync(() => Volatile.Read(ref delivered) == 1, "first event must be delivered"); var first = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull(); first.ShouldBeGreaterThan(initStamp, "data on the stream is liveness evidence"); await Task.Delay(20); await sub.EmitDoubleAsync(handle, 2.0); await WaitUntilAsync(() => Volatile.Read(ref delivered) == 2, "second event must be delivered"); driver.GetHealth().LastSuccessfulRead.ShouldBe(first, "inside the throttle window the stamp holds still"); } }