Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/History/HistoryRouterTests.cs
Joseph Doherty ef22a61c39 v2 mxgw migration — Phase 1+2+3.1 wiring (7 PRs)
Foundational PRs from lmx_mxgw_impl.md, all green. Bodies only — DI/wiring
deferred to PR 1+2.W (combined wire-up) and PR 3.W.

PR 1.1 — IHistorianDataSource lifted to Core.Abstractions/Historian/
  Reuses existing DataValueSnapshot + HistoricalEvent shapes; sidecar (PR
  3.4) translates byte-quality → uint StatusCode internally.

PR 1.2 — IHistoryRouter + HistoryRouter on the server
  Longest-prefix-match resolution, case-insensitive, ObjectDisposed-guarded,
  swallow-on-shutdown disposal of misbehaving sources.

PR 1.3 — DriverNodeManager.HistoryRead* dispatch through IHistoryRouter
  Per-tag resolution with LegacyDriverHistoryAdapter wrapping
  `_driver as IHistoryProvider` so existing tests + drivers keep working
  until PR 7.2 retires the fallback.

PR 2.1 — AlarmConditionInfo extended with five sub-attribute refs
  InAlarmRef / PriorityRef / DescAttrNameRef / AckedRef / AckMsgWriteRef.
  Optional defaulted parameters preserve all existing 3-arg call sites.

PR 2.2 — AlarmConditionService state machine in Server/Alarms/
  Driver-agnostic port of GalaxyAlarmTracker. Sub-attribute refs come from
  AlarmConditionInfo, values arrive as DataValueSnapshot, ack writes route
  through IAlarmAcknowledger. State machine preserves Active/Acknowledged/
  Inactive transitions, Acked-on-active reset, post-disposal silence.

PR 2.3 — DriverNodeManager wires AlarmConditionService
  MarkAsAlarmCondition registers each alarm-bearing variable with the
  service; DriverWritableAcknowledger routes ack-message writes through
  the driver's IWritable + CapabilityInvoker. Service-raised transitions
  route via OnAlarmServiceTransition → matching ConditionSink. Legacy
  IAlarmSource path unchanged for null service.

PR 3.1 — Driver.Historian.Wonderware shell project (net48 x86)
  Console host shell + smoke test; SDK references + code lift come in
  PR 3.2.

Tests: 9 (PR 1.1) + 5 (PR 2.1) + 10 (PR 1.2) + 19 (PR 2.2) + 1 (PR 3.1)
all pass. Existing AlarmSubscribeIntegrationTests + HistoryReadIntegrationTests
unchanged.

Plan + audit docs (lmx_backend.md, lmx_mxgw.md, lmx_mxgw_impl.md)
included so parallel subagent worktrees can read them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:03:36 -04:00

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, []);
}
}