Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverReadTests.cs
Joseph Doherty 85bdf0d58b PR 4.2 — IReadable abstraction + StatusCodeMap + MxValueDecoder
Read path scaffold + the byte→uint quality mapping table that the parity
matrix (PR 5.x) pins. PR 4.4 supplies the production GW-backed reader; this
PR ships the abstraction and the supporting infrastructure so 4.4 just
plugs the implementation in.

Files:
- Runtime/StatusCodeMap.cs — explicit OPC DA quality byte → OPC UA
  StatusCode uint mapping. Extends the legacy Galaxy.Host
  HistorianQualityMapper with named constants (Good / GoodLocalOverride,
  Uncertain + 4 substatuses, Bad + 7 substatuses, BadInternalError) and an
  MxStatusProxy → uint helper that honors success flag → detail byte →
  detected_by transport-error fallback. Unknown bytes fall back to category
  bucket with a once-per-session diagnostic log so field captures can
  extend the table.
- Runtime/MxValueDecoder.cs — gateway MxValue → boxed CLR value for the
  seven Galaxy data types (Boolean, Int32, Int64, Float32, Float64, String,
  DateTime) plus their array variants. Honors MxValue.IsNull and
  RawValue passthrough.
- Runtime/IGalaxyDataReader.cs — driver-side seam for one-shot reads. PR
  4.4 ships the production wrapper around MxGatewaySession.SubscribeBulk +
  StreamEvents + UnsubscribeBulk; this PR exposes the contract so
  GalaxyDriver.ReadAsync wires through it.
- Runtime/GalaxyMxSession.cs — wrapper around MxGatewaySession that owns
  the Register handle. ConnectAsync opens session + Register; AttachForTests
  lets tests bypass real gw construction. PR 4.3/4.4/4.5 add write,
  subscribe, and reconnect surfaces.

GalaxyDriver:
- Implements IReadable. ReadAsync routes through the injected
  IGalaxyDataReader (test seam) when present; production path throws
  NotSupportedException pointing at PR 4.4 — protects deployments running
  this PR from silent wrong reads while signaling that the legacy-host
  backend (Galaxy:Backend=legacy-host) handles reads in the meantime.
- Internal ctor extended with optional dataReader parameter (default null,
  preserves PR 4.0/4.1 callers).

Tests: 42 new — exhaustive byte→uint table for StatusCodeMap (15 known
codes + category-bucket fallback for unknowns + MxStatusProxy precedence
rules + OPC UA top-byte invariants), every MxValue oneof case for the
decoder (bool/int32/int64/float/double/string/timestamp/3 array variants/
raw bytes/null), GalaxyDriver IReadable wiring (route-through, empty-
request, no-reader-throws, post-dispose-throws, status-code preservation).
62 Galaxy tests total pass.

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

106 lines
3.9 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// Tests for <see cref="GalaxyDriver"/>'s <c>IReadable</c> wiring. PR 4.2 ships the
/// abstraction (<see cref="IGalaxyDataReader"/>) and the wiring; PR 4.4 supplies the
/// production gateway-backed reader. These tests verify the wiring against a fake
/// reader plus the explicit "no reader → NotSupportedException" fallback that protects
/// deployments running on this PR from silently producing wrong reads.
/// </summary>
public sealed class GalaxyDriverReadTests
{
private static GalaxyDriverOptions Opts() => new(
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
new GalaxyMxAccessOptions("OtOpcUa-A"),
new GalaxyRepositoryOptions(),
new GalaxyReconnectOptions());
private sealed class FakeReader : IGalaxyDataReader
{
public IReadOnlyList<string>? LastRequest { get; private set; }
public Func<IReadOnlyList<string>, IReadOnlyList<DataValueSnapshot>> Decide { get; set; } =
tags => tags.Select(t => new DataValueSnapshot(
Value: t,
StatusCode: StatusCodeMap.Good,
SourceTimestampUtc: DateTime.UtcNow,
ServerTimestampUtc: DateTime.UtcNow)).ToArray();
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
LastRequest = fullReferences;
return Task.FromResult(Decide(fullReferences));
}
}
[Fact]
public async Task ReadAsync_RoutesThroughInjectedReader()
{
var reader = new FakeReader();
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null, dataReader: reader);
var result = await driver.ReadAsync(["Tank1.Level", "Tank2.Level"], CancellationToken.None);
reader.LastRequest.ShouldBe(new[] { "Tank1.Level", "Tank2.Level" });
result.Count.ShouldBe(2);
result[0].Value.ShouldBe("Tank1.Level");
result[0].StatusCode.ShouldBe(StatusCodeMap.Good);
}
[Fact]
public async Task ReadAsync_EmptyRequest_ReturnsEmpty_WithoutCallingReader()
{
var reader = new FakeReader();
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null, dataReader: reader);
var result = await driver.ReadAsync([], CancellationToken.None);
result.ShouldBeEmpty();
reader.LastRequest.ShouldBeNull();
}
[Fact]
public async Task ReadAsync_NoReader_Throws_PointingAtPR44()
{
var driver = new GalaxyDriver("g", Opts());
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
driver.ReadAsync(["x"], CancellationToken.None));
ex.Message.ShouldContain("PR 4.4");
}
[Fact]
public async Task ReadAsync_AfterDispose_Throws()
{
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null, dataReader: new FakeReader());
driver.Dispose();
await Should.ThrowAsync<ObjectDisposedException>(() =>
driver.ReadAsync(["x"], CancellationToken.None));
}
[Fact]
public async Task ReadAsync_PreservesReaderStatusCodes()
{
var reader = new FakeReader
{
Decide = tags => new DataValueSnapshot[]
{
new(42.0, StatusCodeMap.Good, DateTime.UtcNow, DateTime.UtcNow),
new(null, StatusCodeMap.BadNotConnected, null, DateTime.UtcNow),
},
};
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null, dataReader: reader);
var result = await driver.ReadAsync(["a", "b"], CancellationToken.None);
result[0].StatusCode.ShouldBe(StatusCodeMap.Good);
result[1].StatusCode.ShouldBe(StatusCodeMap.BadNotConnected);
}
}