feat(browse): Wave-0 Batch D — IUniversalDriverBrowser + DiscoveryDriverBrowser core

Task 8: IUniversalDriverBrowser + DiscoveryDriverBrowser open path — construct(+PatchForBrowse)
        -> connect -> one-shot capture -> bounded (10s) shutdown; CanBrowse gate (catch-throwing
        TryCreate, defensive teardown of the throwaway instance); deep-merge browse patch.
        Commons gains Microsoft.Extensions.Logging.Abstractions (ILogger doesn't flow transitively)
        + InternalsVisibleTo(Commons.Tests). OTOPCUA0001 suppressed on the deliberate one-shot
        browse-capture DiscoverAsync (not a runtime dispatch path). 12 unit tests green.
This commit is contained in:
Joseph Doherty
2026-07-15 17:27:04 -04:00
parent 15da8d4f0d
commit fa339a5565
6 changed files with 445 additions and 1 deletions
@@ -0,0 +1,118 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
/// <summary>Shared test doubles for the DiscoveryDriverBrowser suites (Tasks 810).</summary>
internal sealed class FakeDiscoveryDriver : IDriver, ITagDiscovery
{
public int InitializeCalls;
public int DiscoverCalls;
public int ShutdownCalls;
/// <summary>Ordered lifecycle trace ("init"/"discover"/"shutdown").</summary>
public List<string> Trace { get; } = [];
/// <summary>Completes once ShutdownAsync has finished (even the fire-and-forget CanBrowse path).</summary>
public TaskCompletionSource ShutdownDone { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public bool SupportsOnlineDiscovery { get; init; } = true;
public DiscoveryRediscoverPolicy RediscoverPolicy { get; init; } = DiscoveryRediscoverPolicy.Once;
/// <summary>Discovery behavior: (builder, 1-based call index) → task. Default streams one leaf.</summary>
public Func<IAddressSpaceBuilder, int, Task>? OnDiscover { get; init; }
public Func<CancellationToken, Task>? OnInitialize { get; init; }
public Func<CancellationToken, Task>? OnShutdown { get; init; }
public string DriverInstanceId { get; }
public string DriverType { get; }
public string? LastConfigJson { get; private set; }
public FakeDiscoveryDriver(string driverType = "Fake", string id = "fake-1")
{
DriverType = driverType;
DriverInstanceId = id;
}
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
LastConfigJson = driverConfigJson;
Interlocked.Increment(ref InitializeCalls);
lock (Trace) Trace.Add("init");
if (OnInitialize is not null) await OnInitialize(cancellationToken).ConfigureAwait(false);
}
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
var idx = Interlocked.Increment(ref DiscoverCalls);
lock (Trace) Trace.Add("discover");
if (OnDiscover is not null)
{
await OnDiscover(builder, idx).ConfigureAwait(false);
return;
}
// Default: stream one leaf so the captured tree is non-empty.
builder.Variable("Tag1", "Tag1",
new DriverAttributeInfo("Dev.Tag1", DriverDataType.Float64, false, null,
SecurityClassification.Operate, false));
}
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
Interlocked.Increment(ref ShutdownCalls);
lock (Trace) Trace.Add("shutdown");
try
{
if (OnShutdown is not null) await OnShutdown(cancellationToken).ConfigureAwait(false);
}
finally
{
ShutdownDone.TrySetResult();
}
}
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
/// <summary>An IDriver that is NOT ITagDiscovery — exercises the not-discovery-capable gate.</summary>
internal sealed class FakeNonDiscoveryDriver(string driverType = "Flat", string id = "flat-1") : IDriver
{
public int ShutdownCalls;
public TaskCompletionSource ShutdownDone { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public string DriverInstanceId { get; } = id;
public string DriverType { get; } = driverType;
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken cancellationToken)
{
Interlocked.Increment(ref ShutdownCalls);
ShutdownDone.TrySetResult();
return Task.CompletedTask;
}
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
/// <summary>Configurable IDriverFactory: delegates construction to a supplied func and records what it made.</summary>
internal sealed class FakeDriverFactory(Func<string, string, string, IDriver?> create) : IDriverFactory
{
public int CreateCalls;
public List<IDriver> Created { get; } = [];
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
{
Interlocked.Increment(ref CreateCalls);
var d = create(driverType, driverInstanceId, driverConfigJson);
if (d is not null) lock (Created) Created.Add(d);
return d;
}
public IReadOnlyCollection<string> SupportedTypes { get; } = Array.Empty<string>();
/// <summary>Factory that always returns a fresh default FakeDiscoveryDriver.</summary>
public static FakeDriverFactory Of(Func<FakeDiscoveryDriver> make) =>
new((_, _, _) => make());
}
@@ -0,0 +1,145 @@
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
public class DiscoveryDriverBrowserTests
{
private static readonly TimeSpan ShortOpen = TimeSpan.FromSeconds(5);
private static readonly TimeSpan ShortShutdown = TimeSpan.FromMilliseconds(200);
private static DiscoveryDriverBrowser Browser(IDriverFactory factory) =>
new(factory, NullLogger<DiscoveryDriverBrowser>.Instance, ShortOpen, ShortShutdown);
[Fact]
public async Task OpenAsync_RunsInitializeDiscoverShutdown_InOrder()
{
var driver = new FakeDiscoveryDriver();
var browser = Browser(FakeDriverFactory.Of(() => driver));
var session = await browser.OpenAsync("Fake", "{}", CancellationToken.None);
session.ShouldNotBeNull();
driver.Trace.ShouldBe(new[] { "init", "discover", "shutdown" });
driver.InitializeCalls.ShouldBe(1);
driver.ShutdownCalls.ShouldBe(1);
}
[Fact]
public async Task OpenAsync_ShutdownRuns_EvenWhenDiscoverThrows()
{
var driver = new FakeDiscoveryDriver
{
OnDiscover = (_, _) => throw new InvalidOperationException("boom-discovery"),
};
var browser = Browser(FakeDriverFactory.Of(() => driver));
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => browser.OpenAsync("Fake", "{}", CancellationToken.None));
ex.Message.ShouldBe("boom-discovery"); // discovery error surfaces, not a shutdown error
driver.ShutdownCalls.ShouldBe(1); // shutdown still ran
}
[Fact]
public async Task OpenAsync_UnknownType_ThrowsCleanMessage()
{
var browser = Browser(new FakeDriverFactory((_, _, _) => null));
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => browser.OpenAsync("Ghost", "{}", CancellationToken.None));
ex.Message.ShouldContain("Ghost");
}
[Fact]
public async Task OpenAsync_NotDiscoveryCapable_Throws()
{
var browser = Browser(new FakeDriverFactory((_, _, _) => new FakeNonDiscoveryDriver()));
await Should.ThrowAsync<InvalidOperationException>(
() => browser.OpenAsync("Flat", "{}", CancellationToken.None));
}
[Fact]
public async Task OpenAsync_FlagFalse_Throws()
{
var browser = Browser(FakeDriverFactory.Of(() => new FakeDiscoveryDriver { SupportsOnlineDiscovery = false }));
await Should.ThrowAsync<InvalidOperationException>(
() => browser.OpenAsync("Fake", "{}", CancellationToken.None));
}
[Fact]
public void PatchForBrowse_MergesAbCipPatch()
{
var patched = DiscoveryDriverBrowser.PatchForBrowse("AbCip", """{ "Host": "1.2.3.4" }""");
var obj = System.Text.Json.Nodes.JsonNode.Parse(patched)!.AsObject();
((string?)obj["Host"]).ShouldBe("1.2.3.4");
((bool?)obj["EnableControllerBrowse"]).ShouldBe(true);
// Unknown type: unchanged.
DiscoveryDriverBrowser.PatchForBrowse("Modbus", """{ "X": 1 }""").ShouldBe("""{ "X": 1 }""");
}
[Fact]
public void PatchForBrowse_DeepMergesFocasFixedTree()
{
var patched = DiscoveryDriverBrowser.PatchForBrowse("FOCAS", """{ "FixedTree": { "Series": "30i" } }""");
var fixedTree = System.Text.Json.Nodes.JsonNode.Parse(patched)!["FixedTree"]!.AsObject();
((string?)fixedTree["Series"]).ShouldBe("30i"); // kept
((bool?)fixedTree["Enabled"]).ShouldBe(true); // added
}
[Fact]
public void CanBrowse_TrueForDiscoveryCapable()
{
var browser = Browser(FakeDriverFactory.Of(() => new FakeDiscoveryDriver()));
browser.CanBrowse("Fake", "{}").ShouldBeTrue();
}
[Fact]
public void CanBrowse_FalseWhenTryCreateReturnsNull()
{
var browser = Browser(new FakeDriverFactory((_, _, _) => null));
browser.CanBrowse("Ghost", "{}").ShouldBeFalse();
}
[Fact]
public void CanBrowse_FalseWhenTryCreateThrows_NoExceptionEscapes()
{
var browser = Browser(new FakeDriverFactory((_, _, _) => throw new System.Text.Json.JsonException("bad json")));
Should.NotThrow(() => browser.CanBrowse("Fake", "{ half-typed").ShouldBeFalse());
}
[Fact]
public async Task CanBrowse_ShutsDownThrowawayInstance()
{
var driver = new FakeDiscoveryDriver { SupportsOnlineDiscovery = false };
var factory = FakeDriverFactory.Of(() => driver);
var browser = Browser(factory);
browser.CanBrowse("Fake", "{}").ShouldBeFalse();
// Teardown is fire-and-forget — await its completion.
await driver.ShutdownDone.Task.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
driver.ShutdownCalls.ShouldBe(1);
}
[Fact]
public async Task BoundedShutdown_AbandonsWedgedDriver()
{
// Shutdown ignores its token and would never complete; the browser must abandon it
// and still return the successfully-captured session within the shutdown bound.
var driver = new FakeDiscoveryDriver
{
OnShutdown = _ => Task.Delay(Timeout.Infinite, CancellationToken.None),
};
var browser = Browser(FakeDriverFactory.Of(() => driver));
var sw = System.Diagnostics.Stopwatch.StartNew();
var session = await browser.OpenAsync("Fake", "{}", CancellationToken.None);
sw.Stop();
session.ShouldNotBeNull(); // discovery succeeded → usable session
driver.ShutdownCalls.ShouldBe(1); // shutdown was attempted
sw.Elapsed.ShouldBeLessThan(ShortOpen); // returned on the shutdown bound, not the open timeout
}
}