feat(browse): Wave-0 Batch E — UntilStable settle loop + BrowserSessionService fallback

Task 9: DiscoverTreeAsync UntilStable settle loop (FOCAS) — re-capture on a 1s interval until
        the node set is non-empty and stable across two passes, bounded by open-timeout; on
        timeout with a prior non-empty capture, return it (best-effort) instead of failing.
Task 11: BrowserSessionService resolves bespoke-first, falls back to IUniversalDriverBrowser
         when no bespoke browser matches and CanBrowse is true; new CanBrowse on the service.
16 unit tests green (4 settle + 12 service).
This commit is contained in:
Joseph Doherty
2026-07-15 17:31:50 -04:00
parent fa339a5565
commit 698703744f
7 changed files with 266 additions and 11 deletions
@@ -13,7 +13,11 @@ public sealed class BrowserSessionServiceTests
{
private static BrowserSessionService NewService(
BrowseSessionRegistry registry, params IDriverBrowser[] browsers) =>
new(browsers, registry, NullLogger<BrowserSessionService>.Instance);
new(browsers, registry, NullLogger<BrowserSessionService>.Instance, new FakeUniversalDriverBrowser());
private static BrowserSessionService NewService(
BrowseSessionRegistry registry, IUniversalDriverBrowser universal, params IDriverBrowser[] browsers) =>
new(browsers, registry, NullLogger<BrowserSessionService>.Instance, universal);
[Fact]
public async Task OpenAsync_unknown_driver_type_returns_Ok_false_with_message()
@@ -68,6 +72,89 @@ public sealed class BrowserSessionServiceTests
result.Message!.ShouldContain("boom");
}
[Fact]
public async Task Open_BespokeBrowserWinsOverUniversal()
{
var registry = new BrowseSessionRegistry();
var bespokeSession = new FakeBrowseSession();
var bespoke = new FakeDriverBrowser("AbCip")
{
OpenHandler = (_, _) => Task.FromResult<IBrowseSession>(bespokeSession),
};
var universal = new FakeUniversalDriverBrowser
{
CanBrowsePredicate = (_, _) => true, // universal would also serve it
OpenHandler = (_, _, _) => Task.FromResult<IBrowseSession>(new FakeBrowseSession()),
};
var service = NewService(registry, universal, bespoke);
var result = await service.OpenAsync("AbCip", "{}", CancellationToken.None);
result.Ok.ShouldBeTrue();
result.Token.ShouldBe(bespokeSession.Token); // bespoke session, not the universal one
}
[Fact]
public async Task Open_FallsBackToUniversal_WhenNoBespokeAndCanBrowse()
{
var registry = new BrowseSessionRegistry();
var universalSession = new FakeBrowseSession();
var universal = new FakeUniversalDriverBrowser
{
CanBrowsePredicate = (t, _) => t == "AbCip",
OpenHandler = (_, _, _) => Task.FromResult<IBrowseSession>(universalSession),
};
var service = NewService(registry, universal); // no bespoke browsers
var result = await service.OpenAsync("AbCip", "{}", CancellationToken.None);
result.Ok.ShouldBeTrue();
result.Token.ShouldBe(universalSession.Token);
registry.TryGet(result.Token, out _).ShouldBeTrue();
}
[Fact]
public async Task Open_NoBespokeAndCannotBrowse_ReturnsNoBrowserMessage()
{
var registry = new BrowseSessionRegistry();
var service = NewService(registry, new FakeUniversalDriverBrowser()); // CanBrowse=false
var result = await service.OpenAsync("Modbus", "{}", CancellationToken.None);
result.Ok.ShouldBeFalse();
result.Token.ShouldBe(Guid.Empty);
result.Message!.ShouldContain("Modbus");
}
[Fact]
public async Task Open_UniversalThrow_ReturnsOkFalseWithMessage()
{
var registry = new BrowseSessionRegistry();
var universal = new FakeUniversalDriverBrowser
{
CanBrowsePredicate = (_, _) => true,
OpenHandler = (_, _, _) => throw new InvalidOperationException("universal-boom"),
};
var service = NewService(registry, universal);
var result = await service.OpenAsync("AbCip", "{}", CancellationToken.None);
result.Ok.ShouldBeFalse();
result.Message!.ShouldContain("universal-boom");
}
[Fact]
public void CanBrowse_TrueForBespokeType_TrueForUniversalType_FalseOtherwise()
{
var registry = new BrowseSessionRegistry();
var universal = new FakeUniversalDriverBrowser { CanBrowsePredicate = (t, _) => t == "AbCip" };
var service = NewService(registry, universal, new FakeDriverBrowser("Galaxy"));
service.CanBrowse("Galaxy", "{}").ShouldBeTrue(); // bespoke registered
service.CanBrowse("AbCip", "{}").ShouldBeTrue(); // universal can serve
service.CanBrowse("Modbus", "{}").ShouldBeFalse(); // neither
}
[Fact]
public async Task RootAsync_unknown_token_throws_BrowseSessionNotFoundException()
{
@@ -0,0 +1,20 @@
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
/// <summary>Test double for <see cref="IUniversalDriverBrowser"/>. CanBrowse is driven by a
/// predicate (default: never); OpenAsync delegates to a handler or returns a fresh session.</summary>
internal sealed class FakeUniversalDriverBrowser : IUniversalDriverBrowser
{
/// <summary>CanBrowse gate. Defaults to "no universal affordance for any type".</summary>
public Func<string, string, bool> CanBrowsePredicate { get; init; } = (_, _) => false;
/// <summary>OpenAsync behavior. Defaults to a fresh FakeBrowseSession.</summary>
public Func<string, string, CancellationToken, Task<IBrowseSession>> OpenHandler { get; init; } =
(_, _, _) => Task.FromResult<IBrowseSession>(new FakeBrowseSession());
public bool CanBrowse(string driverType, string configJson) => CanBrowsePredicate(driverType, configJson);
public Task<IBrowseSession> OpenAsync(string driverType, string configJson, CancellationToken ct) =>
OpenHandler(driverType, configJson, ct);
}