Write path online. GalaxyDriver implements IWritable; routes by SecurityClassification — SecuredWrite / VerifiedWrite tags go through MxCommandKind.WriteSecured, everything else through MxGatewaySession. WriteAsync. Per-tag classifications are captured during ITagDiscovery via a SecurityCapturingBuilder wrapper that intercepts Variable() calls without the discoverer needing to know about the driver's internal state. Files: - Runtime/MxValueEncoder.cs — boxed CLR value → MxValue. Covers seven Galaxy scalar types (bool/int8-32/uint8-32 → Int32, int64/uint64 → Int64, float, double, string, DateTime/DateTimeOffset → Timestamp) and 1-D array variants. Inverse of MxValueDecoder; round-trip pinned by tests. DateTime.Local converts to UTC; unsupported types throw ArgumentException. - Runtime/IGalaxyDataWriter.cs — driver-side seam. Tests inject a fake to capture routing decisions; production path uses GatewayGalaxyDataWriter. - Runtime/GatewayGalaxyDataWriter.cs — production. Lazy-AddItem caches itemHandles, encodes value, routes Write vs WriteSecured, translates MxCommandReply (ProtocolStatus → BadCommunicationError; first MxStatusProxy in statuses[] via StatusCodeMap.FromMxStatus). Per-tag exception isolation: one bad write doesn't fail the batch. - GalaxyDriver: now implements IWritable. Discovery wraps the supplied IAddressSpaceBuilder in SecurityCapturingBuilder which records each attribute's SecurityClass into _securityByFullRef before delegating. WriteAsync resolves classification per tag (FreeAccess default for unknown tags — matches the legacy backend), routes through the injected writer. Throws NotSupportedException with PR 4.4 pointer when no writer is wired (production path requires GalaxyMxSession.Connect from PR 4.4). Tests (32 new, 94 Galaxy total): - MxValueEncoder: every scalar type, narrowing checks (sbyte/short/byte/ ushort fit Int32; uint within Int32 range; ulong within Int64), DateTime.Local → UTC conversion, array variants for bool/double/string/ DateTime, Dimensions populated, unsupported-type throws ArgumentException, encoder/decoder round-trip pin. - GalaxyDriverWriteTests: WriteAsync routes through fake writer with values intact; theory exercises every SecurityClassification value through the discovery-then-write path; unknown-tag defaults to FreeAccess; empty- request short-circuit; no-writer fail-loud; post-dispose throws. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
176 lines
7.1 KiB
C#
176 lines
7.1 KiB
C#
using MxGateway.Contracts.Proto.Galaxy;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
|
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>IWritable</c> wiring. Verifies the
|
|
/// SecurityClassification per-tag map gets populated during Discovery and routes the
|
|
/// subsequent WriteAsync calls to the right gateway command (Write vs WriteSecured).
|
|
/// The actual Write / WriteSecured invocation is tested separately at the
|
|
/// <see cref="GatewayGalaxyDataWriter"/> level — this test class focuses on the
|
|
/// driver-side wiring.
|
|
/// </summary>
|
|
public sealed class GalaxyDriverWriteTests
|
|
{
|
|
private static GalaxyDriverOptions Opts() => new(
|
|
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
|
|
new GalaxyMxAccessOptions("OtOpcUa-A"),
|
|
new GalaxyRepositoryOptions(),
|
|
new GalaxyReconnectOptions());
|
|
|
|
private sealed class FakeHierarchySource(IReadOnlyList<GalaxyObject> objects) : IGalaxyHierarchySource
|
|
{
|
|
public Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken)
|
|
=> Task.FromResult(objects);
|
|
}
|
|
|
|
private sealed class FakeBuilder : IAddressSpaceBuilder
|
|
{
|
|
public List<DriverAttributeInfo> Variables { get; } = [];
|
|
|
|
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
|
{
|
|
Variables.Add(attributeInfo);
|
|
return new FakeHandle(attributeInfo.FullName);
|
|
}
|
|
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
|
|
|
private sealed class FakeHandle(string fullRef) : IVariableHandle
|
|
{
|
|
public string FullReference { get; } = fullRef;
|
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NoopSink();
|
|
private sealed class NoopSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
|
}
|
|
}
|
|
|
|
private sealed class FakeWriter : IGalaxyDataWriter
|
|
{
|
|
public List<(string FullRef, object? Value, SecurityClassification Resolved)> Calls { get; } = [];
|
|
|
|
public Task<IReadOnlyList<WriteResult>> WriteAsync(
|
|
IReadOnlyList<WriteRequest> writes,
|
|
Func<string, SecurityClassification> securityResolver,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var results = new WriteResult[writes.Count];
|
|
for (var i = 0; i < writes.Count; i++)
|
|
{
|
|
Calls.Add((writes[i].FullReference, writes[i].Value, securityResolver(writes[i].FullReference)));
|
|
results[i] = new WriteResult(StatusCodeMap.Good);
|
|
}
|
|
return Task.FromResult<IReadOnlyList<WriteResult>>(results);
|
|
}
|
|
}
|
|
|
|
private static GalaxyAttribute Attr(string name, int sec)
|
|
=> new() { AttributeName = name, MxDataType = 2 /*Float32*/, SecurityClassification = sec };
|
|
|
|
private static GalaxyObject Obj(string tag, params GalaxyAttribute[] attrs)
|
|
{
|
|
var o = new GalaxyObject { TagName = tag, ContainedName = tag };
|
|
o.Attributes.AddRange(attrs);
|
|
return o;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_RoutesThroughInjectedWriter_AndPropagatesValues()
|
|
{
|
|
var src = new FakeHierarchySource([
|
|
Obj("Tank1_Level", Attr("PV", sec: 0 /*FreeAccess*/), Attr("SP", sec: 1 /*Operate*/)),
|
|
]);
|
|
var writer = new FakeWriter();
|
|
var driver = new GalaxyDriver(
|
|
"g", Opts(), hierarchySource: src, dataReader: null, dataWriter: writer);
|
|
|
|
var builder = new FakeBuilder();
|
|
await driver.DiscoverAsync(builder, CancellationToken.None);
|
|
|
|
await driver.WriteAsync([
|
|
new WriteRequest("Tank1_Level.PV", 42.0),
|
|
new WriteRequest("Tank1_Level.SP", 50.0),
|
|
], CancellationToken.None);
|
|
|
|
writer.Calls.Count.ShouldBe(2);
|
|
writer.Calls[0].Resolved.ShouldBe(SecurityClassification.FreeAccess);
|
|
writer.Calls[1].Resolved.ShouldBe(SecurityClassification.Operate);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0, SecurityClassification.FreeAccess)]
|
|
[InlineData(1, SecurityClassification.Operate)]
|
|
[InlineData(2, SecurityClassification.SecuredWrite)]
|
|
[InlineData(3, SecurityClassification.VerifiedWrite)]
|
|
[InlineData(4, SecurityClassification.Tune)]
|
|
[InlineData(5, SecurityClassification.Configure)]
|
|
[InlineData(6, SecurityClassification.ViewOnly)]
|
|
public async Task WriteAsync_ResolvesEverySecurityClassification_FromDiscovery(int mxSec, SecurityClassification expected)
|
|
{
|
|
var src = new FakeHierarchySource([
|
|
Obj("Tank", Attr("PV", sec: mxSec)),
|
|
]);
|
|
var writer = new FakeWriter();
|
|
var driver = new GalaxyDriver(
|
|
"g", Opts(), hierarchySource: src, dataReader: null, dataWriter: writer);
|
|
|
|
await driver.DiscoverAsync(new FakeBuilder(), CancellationToken.None);
|
|
await driver.WriteAsync([new WriteRequest("Tank.PV", 1.0)], CancellationToken.None);
|
|
|
|
writer.Calls[0].Resolved.ShouldBe(expected);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_UnknownTag_ResolvesToFreeAccess_DefaultsToWrite()
|
|
{
|
|
var writer = new FakeWriter();
|
|
var driver = new GalaxyDriver(
|
|
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: writer);
|
|
|
|
// No DiscoverAsync call → classification map is empty → resolver returns FreeAccess
|
|
// for any tag the gateway might attempt. WriteAsync must not throw on unknown tags.
|
|
await driver.WriteAsync([new WriteRequest("Random.Tag", 1.0)], CancellationToken.None);
|
|
|
|
writer.Calls[0].Resolved.ShouldBe(SecurityClassification.FreeAccess);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_EmptyRequest_ReturnsEmpty_WithoutCallingWriter()
|
|
{
|
|
var writer = new FakeWriter();
|
|
var driver = new GalaxyDriver(
|
|
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: writer);
|
|
|
|
var result = await driver.WriteAsync([], CancellationToken.None);
|
|
|
|
result.ShouldBeEmpty();
|
|
writer.Calls.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_NoWriter_Throws_PointingAtPR44()
|
|
{
|
|
var driver = new GalaxyDriver("g", Opts());
|
|
|
|
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
|
driver.WriteAsync([new WriteRequest("x", 1)], CancellationToken.None));
|
|
ex.Message.ShouldContain("PR 4.4");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_AfterDispose_Throws()
|
|
{
|
|
var writer = new FakeWriter();
|
|
var driver = new GalaxyDriver(
|
|
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: writer);
|
|
driver.Dispose();
|
|
await Should.ThrowAsync<ObjectDisposedException>(() =>
|
|
driver.WriteAsync([new WriteRequest("x", 1)], CancellationToken.None));
|
|
}
|
|
}
|