Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyBrowseSessionTests.cs
T
Joseph Doherty 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
refactor(galaxy): migrate to ZB.MOM.WW.MxGateway.* nupkg packages
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).
2026-05-29 07:14:18 -04:00

116 lines
5.6 KiB
C#

using ZB.MOM.WW.MxGateway.Client;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests;
/// <summary>
/// Pure-construction + lifecycle coverage of <see cref="GalaxyBrowseSession"/>.
/// The session is <c>internal</c>; visibility comes via <c>InternalsVisibleTo</c>
/// on the production project.
/// <para>
/// <b>Blocker:</b> the hierarchy/expand/attribute paths all call into
/// <see cref="GalaxyRepositoryClient"/>, which only ships an <c>internal</c>
/// transport seam (<c>IGalaxyRepositoryClientTransport</c>) and an <c>internal</c>
/// constructor — both keyed via <c>InternalsVisibleTo</c> on the vendored
/// <c>ZB.MOM.WW.MxGateway.Client</c> assembly, and only granted to that repo's own
/// <c>ZB.MOM.WW.MxGateway.Client.Tests</c>. We can't substitute a fake transport from
/// here without changing the upstream repo, and the public <c>Create</c>
/// factory always opens a real gRPC channel. So in-memory traversal coverage
/// (RootAsync / ExpandAsync / AttributesAsync, including the SecurityClass
/// mapping) is deferred to the integration suite (Task 17) and the manual
/// smoke pass (Task 18) — both of which run the gateway for real.
/// </para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class GalaxyBrowseSessionTests
{
/// <summary>Builds a <see cref="GalaxyRepositoryClient"/> bound to an
/// unreachable endpoint. No connection is opened — <c>Create</c> just builds the
/// gRPC channel object — so this is safe to call without a fixture.</summary>
private static GalaxyRepositoryClient NewClient() =>
GalaxyRepositoryClient.Create(new MxGatewayClientOptions
{
Endpoint = new Uri("http://127.0.0.1:1"),
ApiKey = "test-key",
UseTls = false,
ConnectTimeout = TimeSpan.FromSeconds(1),
DefaultCallTimeout = TimeSpan.FromSeconds(1),
});
/// <summary>The internal ctor must reject a null client — the production caller
/// (the factory in <c>GalaxyDriverBrowser.OpenAsync</c>) hands off ownership of a
/// real client and never passes null, but defence-in-depth catches a future caller
/// who skips that handoff.</summary>
[Fact]
public void Constructor_with_null_client_throws_ArgumentNullException()
{
Should.Throw<ArgumentNullException>(() => new GalaxyBrowseSession(null!));
}
/// <summary>Each session must publish a distinct <see cref="GalaxyBrowseSession.Token"/>
/// so the AdminUI registry can disambiguate concurrent browse sessions against the
/// same driver config.</summary>
[Fact]
public async Task Token_is_unique_per_session()
{
await using var a = new GalaxyBrowseSession(NewClient());
await using var b = new GalaxyBrowseSession(NewClient());
a.Token.ShouldNotBe(b.Token);
a.Token.ShouldNotBe(Guid.Empty);
}
/// <summary><see cref="GalaxyBrowseSession.LastUsedUtc"/> is primed to the
/// construction time so the registry reaper has a sensible baseline before the
/// first Root/Expand/Attributes call lands.</summary>
[Fact]
public async Task LastUsedUtc_is_initialized_at_construction()
{
var before = DateTime.UtcNow;
await using var session = new GalaxyBrowseSession(NewClient());
var after = DateTime.UtcNow;
// Allow generous slop — the field is set inside the ctor body, both bookends
// are wall-clock UtcNow, and we only care that it isn't default(DateTime).
session.LastUsedUtc.ShouldBeGreaterThanOrEqualTo(before.AddSeconds(-1));
session.LastUsedUtc.ShouldBeLessThanOrEqualTo(after.AddSeconds(1));
}
/// <summary><see cref="GalaxyBrowseSession.DisposeAsync"/> is idempotent — the
/// registry's reaper may race a client-initiated close, so the second call must
/// no-op rather than throw <see cref="ObjectDisposedException"/> or hit the
/// already-disposed gRPC channel.</summary>
[Fact]
public async Task DisposeAsync_is_idempotent()
{
var session = new GalaxyBrowseSession(NewClient());
await session.DisposeAsync();
// Second call should silently no-op.
await Should.NotThrowAsync(async () => await session.DisposeAsync());
}
/// <summary>After disposal, any <see cref="GalaxyBrowseSession.ExpandAsync"/> call
/// must surface <see cref="ObjectDisposedException"/> — not a downstream channel
/// fault — so the AdminUI sees a clean "session closed" signal.</summary>
[Fact]
public async Task ExpandAsync_after_dispose_throws_ObjectDisposedException()
{
var session = new GalaxyBrowseSession(NewClient());
await session.DisposeAsync();
await Should.ThrowAsync<ObjectDisposedException>(
() => session.ExpandAsync("anything", TestContext.Current.CancellationToken));
}
/// <summary><see cref="GalaxyBrowseSession.ExpandAsync"/> must reject a tag that
/// hasn't been seen by a prior Root/Expand call — the cache is the source of
/// truth, and silently returning [] would mask AdminUI bugs that browse with a
/// stale path.</summary>
[Fact]
public async Task ExpandAsync_unknown_tag_throws_ArgumentException()
{
await using var session = new GalaxyBrowseSession(NewClient());
// No RootAsync call ⇒ cache is empty ⇒ any tag is unknown.
await Should.ThrowAsync<ArgumentException>(
() => session.ExpandAsync("Galaxy.Unknown", TestContext.Current.CancellationToken));
}
}