Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
170 lines
6.2 KiB
C#
170 lines
6.2 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="HistoryRouter"/> registration + resolution semantics added
|
|
/// in PR 1.2. The router is the only seam between OPC UA HistoryRead service calls
|
|
/// and registered <see cref="IHistorianDataSource"/> implementations, so the
|
|
/// resolution rules (case-insensitive prefix, longest-match wins, no source =>
|
|
/// null) need explicit coverage.
|
|
/// </summary>
|
|
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<InvalidOperationException>(
|
|
() => 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<ObjectDisposedException>(() => router.Resolve("anything"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Register_Throws_AfterDisposal()
|
|
{
|
|
var router = new HistoryRouter();
|
|
router.Dispose();
|
|
|
|
Should.Throw<ObjectDisposedException>(
|
|
() => 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<HistoryReadResult> ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<HistoryReadResult> ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<HistoryReadResult> ReadAtTimeAsync(string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<HistoricalEventsResult> 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<HistoryReadResult> ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<HistoryReadResult> ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<HistoryReadResult> ReadAtTimeAsync(string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task<HistoricalEventsResult> 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, []);
|
|
}
|
|
}
|