560b327ee1
v2-ci / build (push) Failing after 33s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Imports the freshly-rebuilt ZB.MOM.WW.MxGateway.Client + ZB.MOM.WW.MxGateway.Contracts nupkgs (0.1.0) from /tmp/mxgw-dist. Replaces the vendored libs/ DLLs and the pre-restructure MxGateway.* namespaces across the runtime Galaxy driver, Galaxy.Browser, and their tests. Key changes: - nuget-packages/ added as a local feed via NuGet.config; .gitignore exempts it from the *.nupkg rule so the packages are tracked - Directory.Packages.props pins both packages at 0.1.0 - 4 csprojs swap <Reference HintPath="libs/...dll"/> for <PackageReference/> - 36 .cs files renamed `using MxGateway.*` -> `using ZB.MOM.WW.MxGateway.*` - libs/ removed (vendored DLLs + README.md) GalaxyBrowseSession rewritten around the new lazy API: - RootAsync calls GalaxyRepositoryClient.BrowseAsync (returns LazyBrowseNodes) and caches them by TagName instead of bulk-fetching the whole hierarchy - ExpandAsync looks up the cached LazyBrowseNode and calls its ExpandAsync, giving true one-wire-call-per-click instead of in-memory parent/child scan - _byGobjectId + _hasChildrenSet dropped (LazyBrowseNode carries HasChildrenHint) - AttributesAsync unchanged (already uses DiscoverHierarchyAsync MaxDepth=0) Tests: Galaxy.Tests 245/245, Galaxy.Browser.Tests 10/10, AdminUI.Tests 66/66. Pre-existing 12 solution errors unchanged (test sinks + Cli XML comments).
315 lines
12 KiB
C#
315 lines
12 KiB
C#
using System.Runtime.CompilerServices;
|
|
using System.Threading.Channels;
|
|
using Google.Protobuf.WellKnownTypes;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Browse;
|
|
|
|
/// <summary>
|
|
/// Tests <see cref="DeployWatcher"/>'s consumption of <see cref="IGalaxyDeployWatchSource"/>:
|
|
/// bootstrap suppression, change detection, presence-flip handling, clean shutdown,
|
|
/// and reconnect-on-error backoff.
|
|
/// </summary>
|
|
public sealed class DeployWatcherTests
|
|
{
|
|
/// <summary>
|
|
/// Test helper exposing a <see cref="Channel{T}"/> as the event source plus an
|
|
/// optional fault hook so reconnect / retry paths can be exercised deterministically.
|
|
/// </summary>
|
|
private sealed class FakeDeployWatchSource : IGalaxyDeployWatchSource
|
|
{
|
|
private readonly Func<int, Channel<DeployEvent>> _channelFactory;
|
|
|
|
/// <summary>
|
|
/// Gets the list of last-seen deployment times from each watch iteration.
|
|
/// </summary>
|
|
public List<DateTimeOffset?> LastSeenTimes { get; } = [];
|
|
|
|
/// <summary>
|
|
/// Gets the number of times WatchAsync has been called.
|
|
/// </summary>
|
|
public int CallCount { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets a function that can throw an exception on specific iterations.
|
|
/// </summary>
|
|
public Func<int, Exception?>? ThrowOnIteration { get; init; }
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="FakeDeployWatchSource"/> class with a single channel.
|
|
/// </summary>
|
|
/// <param name="channel">The deploy event channel to use for all iterations.</param>
|
|
public FakeDeployWatchSource(Channel<DeployEvent> channel)
|
|
{
|
|
_channelFactory = _ => channel;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="FakeDeployWatchSource"/> class with a channel factory.
|
|
/// </summary>
|
|
/// <param name="channelFactory">A factory function that creates channels for each iteration.</param>
|
|
public FakeDeployWatchSource(Func<int, Channel<DeployEvent>> channelFactory)
|
|
{
|
|
_channelFactory = channelFactory;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Watches for deploy events asynchronously.
|
|
/// </summary>
|
|
/// <param name="lastSeenDeployTime">The last deployment time seen.</param>
|
|
/// <param name="cancellationToken">The cancellation token.</param>
|
|
/// <returns>An async enumerable of deploy events.</returns>
|
|
public async IAsyncEnumerable<DeployEvent> WatchAsync(
|
|
DateTimeOffset? lastSeenDeployTime,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
int iteration = ++CallCount;
|
|
LastSeenTimes.Add(lastSeenDeployTime);
|
|
|
|
if (ThrowOnIteration?.Invoke(iteration) is { } ex)
|
|
{
|
|
throw ex;
|
|
}
|
|
|
|
var channel = _channelFactory(iteration);
|
|
await foreach (var ev in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
|
{
|
|
yield return ev;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static DeployEvent Event(ulong sequence, DateTimeOffset? deployTime)
|
|
{
|
|
var ev = new DeployEvent
|
|
{
|
|
Sequence = sequence,
|
|
ObservedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
|
TimeOfLastDeployPresent = deployTime is not null,
|
|
};
|
|
if (deployTime is { } t)
|
|
{
|
|
ev.TimeOfLastDeploy = Timestamp.FromDateTimeOffset(t);
|
|
}
|
|
return ev;
|
|
}
|
|
|
|
private static List<RediscoveryEventArgs> CaptureRediscoverEvents(DeployWatcher watcher)
|
|
{
|
|
var captured = new List<RediscoveryEventArgs>();
|
|
watcher.OnRediscoveryNeeded += (_, args) =>
|
|
{
|
|
lock (captured) captured.Add(args);
|
|
};
|
|
return captured;
|
|
}
|
|
|
|
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
|
|
{
|
|
var deadline = DateTimeOffset.UtcNow + timeout;
|
|
while (DateTimeOffset.UtcNow < deadline)
|
|
{
|
|
if (condition()) return;
|
|
await Task.Delay(10).ConfigureAwait(false);
|
|
}
|
|
throw new TimeoutException("Condition was not met within timeout.");
|
|
}
|
|
|
|
/// <summary>Verifies that bootstrap deploy events are suppressed.</summary>
|
|
[Fact]
|
|
public async Task BootstrapEventIsSuppressed()
|
|
{
|
|
var channel = Channel.CreateUnbounded<DeployEvent>();
|
|
var source = new FakeDeployWatchSource(channel);
|
|
using var watcher = new DeployWatcher(source);
|
|
var captured = CaptureRediscoverEvents(watcher);
|
|
|
|
await watcher.StartAsync(CancellationToken.None);
|
|
|
|
// Push only the bootstrap event.
|
|
await channel.Writer.WriteAsync(Event(0, DateTimeOffset.Parse("2026-01-01T00:00:00Z")));
|
|
|
|
// Give the loop a moment to consume + ack.
|
|
await WaitUntilAsync(() => source.CallCount > 0 && channel.Reader.Count == 0, TimeSpan.FromSeconds(2));
|
|
await Task.Delay(50);
|
|
|
|
captured.ShouldBeEmpty();
|
|
|
|
await watcher.StopAsync();
|
|
}
|
|
|
|
/// <summary>Verifies that a deployment time change fires a rediscovery event.</summary>
|
|
[Fact]
|
|
public async Task DeployTimeChangeFiresRediscover()
|
|
{
|
|
var t0 = DateTimeOffset.Parse("2026-01-01T00:00:00Z");
|
|
var t1 = DateTimeOffset.Parse("2026-01-02T12:00:00Z");
|
|
|
|
var channel = Channel.CreateUnbounded<DeployEvent>();
|
|
var source = new FakeDeployWatchSource(channel);
|
|
using var watcher = new DeployWatcher(source);
|
|
var captured = CaptureRediscoverEvents(watcher);
|
|
|
|
await watcher.StartAsync(CancellationToken.None);
|
|
|
|
await channel.Writer.WriteAsync(Event(0, t0)); // bootstrap
|
|
await channel.Writer.WriteAsync(Event(1, t1)); // real change
|
|
|
|
await WaitUntilAsync(() => captured.Count >= 1, TimeSpan.FromSeconds(2));
|
|
|
|
captured.Count.ShouldBe(1);
|
|
captured[0].Reason.ShouldBe("deploy-time-changed");
|
|
captured[0].ScopeHint.ShouldNotBeNull();
|
|
DateTimeOffset.Parse(captured[0].ScopeHint!).ToUniversalTime()
|
|
.ShouldBe(t1.ToUniversalTime());
|
|
|
|
await watcher.StopAsync();
|
|
}
|
|
|
|
/// <summary>Verifies that the same deployment time does not fire a rediscovery event.</summary>
|
|
[Fact]
|
|
public async Task SameDeployTimeDoesNotFire()
|
|
{
|
|
var t0 = DateTimeOffset.Parse("2026-01-01T00:00:00Z");
|
|
|
|
var channel = Channel.CreateUnbounded<DeployEvent>();
|
|
var source = new FakeDeployWatchSource(channel);
|
|
using var watcher = new DeployWatcher(source);
|
|
var captured = CaptureRediscoverEvents(watcher);
|
|
|
|
await watcher.StartAsync(CancellationToken.None);
|
|
|
|
await channel.Writer.WriteAsync(Event(0, t0)); // bootstrap
|
|
await channel.Writer.WriteAsync(Event(2, t0)); // duplicate state — gateway re-sent
|
|
|
|
await WaitUntilAsync(() => channel.Reader.Count == 0, TimeSpan.FromSeconds(2));
|
|
await Task.Delay(50);
|
|
|
|
captured.ShouldBeEmpty();
|
|
|
|
await watcher.StopAsync();
|
|
}
|
|
|
|
/// <summary>Verifies that a deployment time presence flip fires a rediscovery event.</summary>
|
|
[Fact]
|
|
public async Task TimeOfLastDeployPresentFlipFiresRediscover()
|
|
{
|
|
var t1 = DateTimeOffset.Parse("2026-03-01T08:00:00Z");
|
|
|
|
var channel = Channel.CreateUnbounded<DeployEvent>();
|
|
var source = new FakeDeployWatchSource(channel);
|
|
using var watcher = new DeployWatcher(source);
|
|
var captured = CaptureRediscoverEvents(watcher);
|
|
|
|
await watcher.StartAsync(CancellationToken.None);
|
|
|
|
// Bootstrap with absent deploy time (Galaxy never deployed).
|
|
await channel.Writer.WriteAsync(Event(0, deployTime: null));
|
|
// Now a deploy lands and the present flag flips.
|
|
await channel.Writer.WriteAsync(Event(1, t1));
|
|
|
|
await WaitUntilAsync(() => captured.Count >= 1, TimeSpan.FromSeconds(2));
|
|
|
|
captured.Count.ShouldBe(1);
|
|
captured[0].Reason.ShouldBe("deploy-time-changed");
|
|
captured[0].ScopeHint.ShouldNotBeNull();
|
|
|
|
await watcher.StopAsync();
|
|
}
|
|
|
|
/// <summary>Verifies that stop cancels the watcher loop cleanly.</summary>
|
|
[Fact]
|
|
public async Task StopCancelsLoopCleanly()
|
|
{
|
|
var channel = Channel.CreateUnbounded<DeployEvent>();
|
|
var source = new FakeDeployWatchSource(channel);
|
|
using var watcher = new DeployWatcher(source);
|
|
|
|
await watcher.StartAsync(CancellationToken.None);
|
|
|
|
// Push bootstrap so the loop enters its enumeration body before stop.
|
|
await channel.Writer.WriteAsync(Event(0, DateTimeOffset.UtcNow));
|
|
await WaitUntilAsync(() => source.CallCount > 0, TimeSpan.FromSeconds(2));
|
|
|
|
// StopAsync should complete without throwing and within a reasonable window.
|
|
var stopTask = watcher.StopAsync();
|
|
var completed = await Task.WhenAny(stopTask, Task.Delay(TimeSpan.FromSeconds(5)));
|
|
completed.ShouldBe(stopTask);
|
|
await stopTask; // observe (no) exception
|
|
}
|
|
|
|
/// <summary>Verifies that disposing stops a running watcher.</summary>
|
|
[Fact]
|
|
public async Task DisposeStopsRunningWatcher()
|
|
{
|
|
var channel = Channel.CreateUnbounded<DeployEvent>();
|
|
var source = new FakeDeployWatchSource(channel);
|
|
var watcher = new DeployWatcher(source);
|
|
|
|
await watcher.StartAsync(CancellationToken.None);
|
|
await channel.Writer.WriteAsync(Event(0, DateTimeOffset.UtcNow));
|
|
await WaitUntilAsync(() => source.CallCount > 0, TimeSpan.FromSeconds(2));
|
|
|
|
// Should not throw, should not hang.
|
|
var disposeTask = Task.Run(watcher.Dispose);
|
|
var completed = await Task.WhenAny(disposeTask, Task.Delay(TimeSpan.FromSeconds(5)));
|
|
completed.ShouldBe(disposeTask);
|
|
await disposeTask;
|
|
}
|
|
|
|
/// <summary>Verifies that a source exception triggers retry with backoff.</summary>
|
|
[Fact]
|
|
public async Task SourceExceptionTriggersRetryWithBackoff()
|
|
{
|
|
var t0 = DateTimeOffset.Parse("2026-04-01T00:00:00Z");
|
|
var t1 = DateTimeOffset.Parse("2026-04-02T00:00:00Z");
|
|
|
|
var firstChannel = Channel.CreateUnbounded<DeployEvent>();
|
|
var secondChannel = Channel.CreateUnbounded<DeployEvent>();
|
|
|
|
var source = new FakeDeployWatchSource(iteration => iteration switch
|
|
{
|
|
1 => firstChannel,
|
|
_ => secondChannel,
|
|
})
|
|
{
|
|
ThrowOnIteration = i => i == 1 ? new InvalidOperationException("transport drop") : null,
|
|
};
|
|
|
|
// Tiny backoff so the test doesn't sit in Task.Delay.
|
|
using var watcher = new DeployWatcher(
|
|
source,
|
|
logger: null,
|
|
initialBackoff: TimeSpan.FromMilliseconds(10),
|
|
maxBackoff: TimeSpan.FromMilliseconds(50),
|
|
jitter: _ => TimeSpan.Zero);
|
|
var captured = CaptureRediscoverEvents(watcher);
|
|
|
|
await watcher.StartAsync(CancellationToken.None);
|
|
|
|
// Wait for the second iteration (post-retry) to start.
|
|
await WaitUntilAsync(() => source.CallCount >= 2, TimeSpan.FromSeconds(2));
|
|
|
|
// Now feed bootstrap + real event into the second channel.
|
|
await secondChannel.Writer.WriteAsync(Event(0, t0));
|
|
await secondChannel.Writer.WriteAsync(Event(1, t1));
|
|
|
|
await WaitUntilAsync(() => captured.Count >= 1, TimeSpan.FromSeconds(2));
|
|
|
|
captured.Count.ShouldBe(1);
|
|
captured[0].Reason.ShouldBe("deploy-time-changed");
|
|
|
|
// The retry call passed null lastSeenDeployTime because no events were seen
|
|
// before the throw — confirms baseline tracking is per-instance, not per-stream.
|
|
source.LastSeenTimes.Count.ShouldBeGreaterThanOrEqualTo(2);
|
|
source.LastSeenTimes[0].ShouldBeNull();
|
|
source.LastSeenTimes[1].ShouldBeNull();
|
|
|
|
await watcher.StopAsync();
|
|
}
|
|
}
|