698703744f
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).
230 lines
8.6 KiB
C#
230 lines
8.6 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
|
|
|
|
/// <summary>Unit tests for <see cref="BrowserSessionService"/> — driver-type dispatch
|
|
/// on open, NotFound semantics on unknown tokens, exception swallowing, and per-call
|
|
/// timeout enforcement.</summary>
|
|
public sealed class BrowserSessionServiceTests
|
|
{
|
|
private static BrowserSessionService NewService(
|
|
BrowseSessionRegistry registry, params IDriverBrowser[] browsers) =>
|
|
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()
|
|
{
|
|
var registry = new BrowseSessionRegistry();
|
|
var service = NewService(registry, new FakeDriverBrowser("Known"));
|
|
|
|
var result = await service.OpenAsync("Unknown", "{}", CancellationToken.None);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Token.ShouldBe(Guid.Empty);
|
|
result.Message.ShouldNotBeNull();
|
|
result.Message!.ShouldContain("Unknown");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenAsync_happy_path_returns_token_and_registers()
|
|
{
|
|
var registry = new BrowseSessionRegistry();
|
|
var session = new FakeBrowseSession();
|
|
var browser = new FakeDriverBrowser("Galaxy")
|
|
{
|
|
OpenHandler = (_, _) => Task.FromResult<IBrowseSession>(session),
|
|
};
|
|
var service = NewService(registry, browser);
|
|
|
|
var result = await service.OpenAsync("Galaxy", "{}", CancellationToken.None);
|
|
|
|
result.Ok.ShouldBeTrue();
|
|
result.Message.ShouldBeNull();
|
|
result.Token.ShouldNotBe(Guid.Empty);
|
|
result.Token.ShouldBe(session.Token);
|
|
registry.TryGet(result.Token, out var registered).ShouldBeTrue();
|
|
registered.ShouldBeSameAs((IBrowseSession)session);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenAsync_swallows_driver_throws_returns_Ok_false()
|
|
{
|
|
var registry = new BrowseSessionRegistry();
|
|
var browser = new FakeDriverBrowser("Galaxy")
|
|
{
|
|
OpenHandler = (_, _) => throw new InvalidOperationException("boom"),
|
|
};
|
|
var service = NewService(registry, browser);
|
|
|
|
var result = await service.OpenAsync("Galaxy", "{}", CancellationToken.None);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Token.ShouldBe(Guid.Empty);
|
|
result.Message.ShouldNotBeNull();
|
|
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()
|
|
{
|
|
var registry = new BrowseSessionRegistry();
|
|
var service = NewService(registry);
|
|
|
|
await Should.ThrowAsync<BrowseSessionNotFoundException>(
|
|
() => service.RootAsync(Guid.NewGuid(), CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RootAsync_invokes_session_Root()
|
|
{
|
|
var registry = new BrowseSessionRegistry();
|
|
IReadOnlyList<BrowseNode> expected = new[]
|
|
{
|
|
new BrowseNode("ns=2;s=A", "A", BrowseNodeKind.Folder, true),
|
|
new BrowseNode("ns=2;s=B", "B", BrowseNodeKind.Leaf, false),
|
|
};
|
|
var session = new FakeBrowseSession { RootHandler = _ => Task.FromResult(expected) };
|
|
registry.Register(session);
|
|
var service = NewService(registry);
|
|
|
|
var actual = await service.RootAsync(session.Token, CancellationToken.None);
|
|
|
|
actual.ShouldBe(expected);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RootAsync_enforces_PerCallTimeout()
|
|
{
|
|
var registry = new BrowseSessionRegistry();
|
|
var session = new FakeBrowseSession
|
|
{
|
|
// Awaits the linked CTS — the service must cancel it via PerCallTimeout (20s).
|
|
RootHandler = ct => Task.Delay(TimeSpan.FromSeconds(40), ct)
|
|
.ContinueWith<IReadOnlyList<BrowseNode>>(_ => Array.Empty<BrowseNode>(), ct),
|
|
};
|
|
registry.Register(session);
|
|
var service = NewService(registry);
|
|
|
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
await Should.ThrowAsync<OperationCanceledException>(
|
|
() => service.RootAsync(session.Token, CancellationToken.None));
|
|
sw.Stop();
|
|
|
|
// Real timeout is 20s; allow generous slack but cap well below the 40s task delay.
|
|
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(35));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CloseAsync_removes_and_disposes_session()
|
|
{
|
|
var registry = new BrowseSessionRegistry();
|
|
var session = new FakeBrowseSession();
|
|
var browser = new FakeDriverBrowser("Galaxy")
|
|
{
|
|
OpenHandler = (_, _) => Task.FromResult<IBrowseSession>(session),
|
|
};
|
|
var service = NewService(registry, browser);
|
|
var opened = await service.OpenAsync("Galaxy", "{}", CancellationToken.None);
|
|
opened.Ok.ShouldBeTrue();
|
|
|
|
await service.CloseAsync(opened.Token);
|
|
|
|
registry.TryGet(opened.Token, out _).ShouldBeFalse();
|
|
session.Disposed.ShouldBeTrue();
|
|
|
|
// Unknown token is a no-op.
|
|
await Should.NotThrowAsync(() => service.CloseAsync(Guid.NewGuid()));
|
|
}
|
|
}
|