using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Server.History; namespace ZB.MOM.WW.OtOpcUa.Server.Tests.History; /// /// Tests for registration + resolution semantics added /// in PR 1.2. The router is the only seam between OPC UA HistoryRead service calls /// and registered implementations, so the /// resolution rules (case-insensitive prefix, longest-match wins, no source => /// null) need explicit coverage. /// public sealed class HistoryRouterTests { [Fact] public void Resolve_ReturnsNull_WhenNoSourceRegistered() { using var router = new HistoryRouter(); router.Resolve("anything").ShouldBeNull(); } [Fact] public void Resolve_ReturnsRegisteredSource_WhenPrefixMatches() { using var router = new HistoryRouter(); var source = new FakeSource("galaxy"); router.Register("galaxy", source); router.Resolve("galaxy.TankFarm.Tank1.Level").ShouldBe(source); } [Fact] public void Resolve_ReturnsNull_WhenPrefixDoesNotMatch() { using var router = new HistoryRouter(); router.Register("galaxy", new FakeSource("galaxy")); router.Resolve("modbus.MyDevice.Tag1").ShouldBeNull(); } [Fact] public void Resolve_LongestPrefixWins_WhenMultipleRegistered() { using var router = new HistoryRouter(); var generic = new FakeSource("generic"); var specific = new FakeSource("specific"); router.Register("galaxy", generic); router.Register("galaxy.HighRate", specific); router.Resolve("galaxy.HighRate.Sensor1").ShouldBe(specific); router.Resolve("galaxy.LowRate.Sensor2").ShouldBe(generic); } [Fact] public void Resolve_IsCaseInsensitive_OnPrefixMatch() { using var router = new HistoryRouter(); var source = new FakeSource("galaxy"); router.Register("Galaxy", source); router.Resolve("galaxy.foo").ShouldBe(source); router.Resolve("GALAXY.foo").ShouldBe(source); } [Fact] public void Register_Throws_WhenPrefixAlreadyRegistered() { using var router = new HistoryRouter(); router.Register("galaxy", new FakeSource("first")); Should.Throw( () => router.Register("galaxy", new FakeSource("second"))); } [Fact] public void Dispose_DisposesAllRegisteredSources() { var router = new HistoryRouter(); var a = new FakeSource("a"); var b = new FakeSource("b"); router.Register("ns_a", a); router.Register("ns_b", b); router.Dispose(); a.IsDisposed.ShouldBeTrue(); b.IsDisposed.ShouldBeTrue(); } [Fact] public void Dispose_SwallowsExceptionsFromMisbehavingSource() { var router = new HistoryRouter(); var throwing = new ThrowingFakeSource(); var clean = new FakeSource("clean"); router.Register("bad", throwing); router.Register("good", clean); // Even when one source's Dispose throws, the router must finish disposing the // remaining sources (server shutdown invariant). Should.NotThrow(() => router.Dispose()); clean.IsDisposed.ShouldBeTrue(); } [Fact] public void Resolve_Throws_AfterDisposal() { var router = new HistoryRouter(); router.Dispose(); Should.Throw(() => router.Resolve("anything")); } [Fact] public void Register_Throws_AfterDisposal() { var router = new HistoryRouter(); router.Dispose(); Should.Throw( () => router.Register("ns", new FakeSource("x"))); } private sealed class FakeSource(string name) : IHistorianDataSource { public string Name { get; } = name; public bool IsDisposed { get; private set; } public void Dispose() => IsDisposed = true; public Task ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task ReadAtTimeAsync(string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) => throw new NotImplementedException(); public HistorianHealthSnapshot GetHealthSnapshot() => new(0, 0, 0, 0, null, null, null, false, false, null, null, []); } private sealed class ThrowingFakeSource : IHistorianDataSource { public void Dispose() => throw new InvalidOperationException("boom"); public Task ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task ReadAtTimeAsync(string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) => throw new NotImplementedException(); public HistorianHealthSnapshot GetHealthSnapshot() => new(0, 0, 0, 0, null, null, null, false, false, null, null, []); } }