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.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( () => 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( () => 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( () => browser.OpenAsync("Flat", "{}", CancellationToken.None)); } [Fact] public async Task OpenAsync_FlagFalse_Throws() { var browser = Browser(FakeDriverFactory.Of(() => new FakeDiscoveryDriver { SupportsOnlineDiscovery = false })); await Should.ThrowAsync( () => 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 } }