diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs index 0b35375..31892e1 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs @@ -23,7 +23,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; /// registers under driver-type name /// "GalaxyMxGateway" so both paths can be live simultaneously during parity testing. /// -public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposable +public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable, IDisposable { private readonly string _driverInstanceId; private readonly GalaxyDriverOptions _options; @@ -43,6 +43,14 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposabl // capability-routing). private readonly IGalaxyDataReader? _dataReader; + // PR 4.3 — IGalaxyDataWriter is the test seam for IWritable. Production wraps + // GalaxyMxSession via GatewayGalaxyDataWriter (Write / WriteSecured routing). The + // per-tag SecurityClassification map is populated during ITagDiscovery and consumed + // here at write time. + private readonly IGalaxyDataWriter? _dataWriter; + private readonly System.Collections.Concurrent.ConcurrentDictionary + _securityByFullRef = new(StringComparer.OrdinalIgnoreCase); + private DriverHealth _health = new(DriverState.Unknown, null, null); private bool _disposed; @@ -50,20 +58,21 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposabl string driverInstanceId, GalaxyDriverOptions options, ILogger? logger = null) - : this(driverInstanceId, options, hierarchySource: null, dataReader: null, logger) + : this(driverInstanceId, options, hierarchySource: null, dataReader: null, dataWriter: null, logger) { } /// - /// Test-visible ctor — inject custom seams so + - /// can be exercised against canned data without building - /// real gRPC channels. + /// Test-visible ctor — inject custom seams so , + /// , and can be exercised against + /// canned data without building real gRPC channels. /// internal GalaxyDriver( string driverInstanceId, GalaxyDriverOptions options, IGalaxyHierarchySource? hierarchySource, IGalaxyDataReader? dataReader = null, + IGalaxyDataWriter? dataWriter = null, ILogger? logger = null) { _driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId) @@ -73,6 +82,7 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposabl _logger = logger ?? NullLogger.Instance; _hierarchySource = hierarchySource; _dataReader = dataReader; + _dataWriter = dataWriter; } /// @@ -135,11 +145,18 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposabl ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(builder); + // PR 4.3 — wrap the supplied builder in a capturing proxy that records each + // attribute's SecurityClassification into _securityByFullRef. The wrapper is + // transparent to GalaxyDiscoverer; it forwards every call to the real builder. + var capturingBuilder = new SecurityCapturingBuilder(builder, _securityByFullRef); var source = _hierarchySource ??= BuildDefaultHierarchySource(); var discoverer = new GalaxyDiscoverer(source); - await discoverer.DiscoverAsync(builder, cancellationToken).ConfigureAwait(false); + await discoverer.DiscoverAsync(capturingBuilder, cancellationToken).ConfigureAwait(false); } + private SecurityClassification ResolveSecurity(string fullReference) => + _securityByFullRef.TryGetValue(fullReference, out var sec) ? sec : SecurityClassification.FreeAccess; + // ===== IReadable (PR 4.2 — abstraction; PR 4.4 supplies production reader) ===== /// @@ -165,6 +182,30 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposabl return _dataReader.ReadAsync(fullReferences, cancellationToken); } + // ===== IWritable (PR 4.3) ===== + + /// + public Task> WriteAsync( + IReadOnlyList writes, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(writes); + if (writes.Count == 0) return Task.FromResult>([]); + + if (_dataWriter is null) + { + // Mirror the IReadable fallback: production write path runs on top of + // GalaxyMxSession (PR 4.2 skeleton; PR 4.4 wires the live session). Until + // that lands, deployments selecting Galaxy:Backend=mxgateway can't write. + throw new NotSupportedException( + "GalaxyDriver.WriteAsync requires GatewayGalaxyDataWriter wired against a connected " + + "GalaxyMxSession (PR 4.4). Until that lands, route writes through the legacy-host " + + "backend (Galaxy:Backend=legacy-host)."); + } + + return _dataWriter.WriteAsync(writes, ResolveSecurity, cancellationToken); + } + /// /// Lazily builds the default from /// _options.Gateway. Owned is disposed in @@ -200,4 +241,30 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposabl _ownedRepositoryClient = null; _hierarchySource = null; } + + /// + /// Address-space builder wrapper that records each variable's + /// into the supplied dictionary + /// before delegating to the inner builder. Used by + /// to capture per-tag classifications for the IWritable routing decision — + /// PR 4.3 needs the data, but the discoverer itself doesn't (and shouldn't) + /// know about the driver's internal state. + /// + private sealed class SecurityCapturingBuilder( + IAddressSpaceBuilder inner, + System.Collections.Concurrent.ConcurrentDictionary map) + : IAddressSpaceBuilder + { + public IAddressSpaceBuilder Folder(string browseName, string displayName) + => new SecurityCapturingBuilder(inner.Folder(browseName, displayName), map); + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + map[attributeInfo.FullName] = attributeInfo.SecurityClass; + return inner.Variable(browseName, displayName, attributeInfo); + } + + public void AddProperty(string browseName, DriverDataType dataType, object? value) + => inner.AddProperty(browseName, dataType, value); + } } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs new file mode 100644 index 0000000..3b0d22b --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs @@ -0,0 +1,162 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using MxGateway.Client; +using MxGateway.Contracts.Proto; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; + +/// +/// Production over . +/// For each batch entry: lazy-AddItem to obtain the MXAccess item handle, encode +/// the value via , route through Write or WriteSecured +/// based on the per-tag , and translate the +/// reply's MxStatusProxy into an OPC UA . +/// +/// +/// Item handle cache survives across writes — repeated writes to the same tag avoid +/// re-AddItem. Per-tag failures are isolated: one bad write doesn't fail the batch. +/// PR 4.4 will share this cache with the subscription registry; for now it lives +/// here so the writer is independently testable. +/// +public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter +{ + private readonly GalaxyMxSession _session; + private readonly int _writeUserId; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _itemHandles = + new(StringComparer.OrdinalIgnoreCase); + + public GatewayGalaxyDataWriter(GalaxyMxSession session, int writeUserId, ILogger? logger = null) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _writeUserId = writeUserId; + _logger = logger ?? NullLogger.Instance; + } + + public async Task> WriteAsync( + IReadOnlyList writes, + Func securityResolver, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(writes); + ArgumentNullException.ThrowIfNull(securityResolver); + + var session = _session.Session + ?? throw new InvalidOperationException( + "GalaxyMxSession is not connected. Call ConnectAsync before issuing writes."); + var serverHandle = _session.ServerHandle; + + var results = new WriteResult[writes.Count]; + for (var i = 0; i < writes.Count; i++) + { + results[i] = await WriteOneAsync(session, serverHandle, writes[i], + securityResolver(writes[i].FullReference), cancellationToken) + .ConfigureAwait(false); + } + return results; + } + + private async Task WriteOneAsync( + MxGatewaySession session, int serverHandle, WriteRequest request, + SecurityClassification classification, CancellationToken ct) + { + try + { + var itemHandle = await EnsureItemHandleAsync(session, serverHandle, request.FullReference, ct) + .ConfigureAwait(false); + var mxValue = MxValueEncoder.Encode(request.Value); + + var reply = NeedsSecuredWrite(classification) + ? await InvokeWriteSecuredAsync(session, serverHandle, itemHandle, mxValue, ct).ConfigureAwait(false) + : await session.WriteRawAsync(serverHandle, itemHandle, mxValue, _writeUserId, ct).ConfigureAwait(false); + + return TranslateReply(reply, request.FullReference); + } + catch (ArgumentException ex) + { + // Bad value type — caller passed a CLR type the encoder can't render. + _logger.LogWarning(ex, + "GalaxyDriver write rejected — unsupported value type for {FullRef}", request.FullReference); + return new WriteResult(StatusCodeMap.BadInternalError); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) + { + _logger.LogWarning(ex, "GalaxyDriver write failed for {FullRef}", request.FullReference); + return new WriteResult(StatusCodeMap.BadCommunicationError); + } + } + + private static bool NeedsSecuredWrite(SecurityClassification classification) => + classification is SecurityClassification.SecuredWrite or SecurityClassification.VerifiedWrite; + + private async Task EnsureItemHandleAsync( + MxGatewaySession session, int serverHandle, string fullRef, CancellationToken ct) + { + if (_itemHandles.TryGetValue(fullRef, out var existing)) return existing; + var handle = await session.AddItemAsync(serverHandle, fullRef, ct).ConfigureAwait(false); + _itemHandles[fullRef] = handle; + return handle; + } + + /// + /// Issue a WriteSecured command. The high-level session client doesn't expose + /// WriteSecuredAsync as a typed method — we build the + /// directly and route through InvokeAsync. Verifier user is left at zero + /// for SecuredWrite; VerifiedWrite uses the same path because the gw's worker + /// interprets the underlying MXAccess command kind. + /// + private static Task InvokeWriteSecuredAsync( + MxGatewaySession session, int serverHandle, int itemHandle, MxValue value, CancellationToken ct) + { + var command = new MxCommand + { + Kind = MxCommandKind.WriteSecured, + WriteSecured = new WriteSecuredCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + Value = value, + CurrentUserId = 0, + VerifierUserId = 0, + }, + }; + var request = new MxCommandRequest + { + SessionId = session.SessionId, + ClientCorrelationId = Guid.NewGuid().ToString("N"), + Command = command, + }; + return session.InvokeAsync(request, ct); + } + + /// + /// Translate a gateway into an OPC UA + /// . Honours the protocol-level Status field first + /// (transport / dispatch failures), then the first MXAccess status row. + /// + private WriteResult TranslateReply(MxCommandReply reply, string fullRef) + { + // Protocol status — wraps transport / worker-side failures that happen before + // MXAccess saw the command. + if (reply.ProtocolStatus is { } proto && proto.Code != ProtocolStatusCode.Ok) + { + _logger.LogWarning( + "GalaxyDriver write protocol failure {Code} for {FullRef}: {Message}", + proto.Code, fullRef, proto.Message); + return new WriteResult(StatusCodeMap.BadCommunicationError); + } + + // MX-side status — the worker's WriteCompleteEvent rolls into the reply's + // statuses array. Use the first row (single-write contract). + if (reply.Statuses.Count > 0) + { + var status = reply.Statuses[0]; + return new WriteResult(StatusCodeMap.FromMxStatus(status, _logger)); + } + + return new WriteResult(StatusCodeMap.Good); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/IGalaxyDataWriter.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/IGalaxyDataWriter.cs new file mode 100644 index 0000000..f91e66e --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/IGalaxyDataWriter.cs @@ -0,0 +1,33 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; + +/// +/// Driver-side seam for batched writes. Production implementation routes by +/// : SecuredWrite / VerifiedWrite go through +/// MxCommandKind.WriteSecured, everything else through +/// MxGatewaySession.WriteAsync. Tests substitute a fake to record routing +/// decisions without touching real gw infrastructure. +/// +public interface IGalaxyDataWriter +{ + /// + /// Write each entry; return one + /// per request entry, in input order. Implementations + /// MUST return the same length as the input — partial-tag failures are encoded + /// as Bad-status results, not omitted. + /// + /// Pairs of full reference + value to write. + /// + /// Maps a full reference to its discovered + /// so the writer can route SecuredWrite / VerifiedWrite tags through the + /// WriteSecured command instead of Write. Returns + /// when the tag isn't tracked + /// (the safest default — non-secured Write). + /// + /// Aborts the in-flight batch. + Task> WriteAsync( + IReadOnlyList writes, + Func securityResolver, + CancellationToken cancellationToken); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxValueEncoder.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxValueEncoder.cs new file mode 100644 index 0000000..b5519f5 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxValueEncoder.cs @@ -0,0 +1,85 @@ +using Google.Protobuf.WellKnownTypes; +using MxGateway.Contracts.Proto; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; + +/// +/// Translates boxed CLR values from WriteRequest.Value into gateway-side +/// instances. Inverse of . +/// Handles the seven Galaxy data types — Boolean, Int32, Int64, Float32, Float64, +/// String, DateTime — and their array variants. Null + unsupported types throw +/// so the IWritable caller can fail the write with a +/// clear status code rather than silently mis-typing the wire payload. +/// +internal static class MxValueEncoder +{ + public static MxValue Encode(object? value) + { + if (value is null) return new MxValue { IsNull = true }; + + switch (value) + { + case bool b: return new MxValue { BoolValue = b }; + case sbyte i8: return new MxValue { Int32Value = i8 }; + case short i16: return new MxValue { Int32Value = i16 }; + case int i32: return new MxValue { Int32Value = i32 }; + case byte u8: return new MxValue { Int32Value = u8 }; + case ushort u16: return new MxValue { Int32Value = u16 }; + case uint u32 when u32 <= int.MaxValue: return new MxValue { Int32Value = (int)u32 }; + case long i64: return new MxValue { Int64Value = i64 }; + case ulong u64 when u64 <= long.MaxValue: return new MxValue { Int64Value = (long)u64 }; + case float f32: return new MxValue { FloatValue = f32 }; + case double f64: return new MxValue { DoubleValue = f64 }; + case string s: return new MxValue { StringValue = s }; + case DateTime dt: return new MxValue { TimestampValue = Timestamp.FromDateTime(EnsureUtc(dt)) }; + case DateTimeOffset dto: return new MxValue { TimestampValue = Timestamp.FromDateTimeOffset(dto) }; + + case bool[] arr: return EncodeArray(arr, (mx, vs) => mx.BoolValues = ToBoolArray(vs)); + case int[] arr: return EncodeArray(arr, (mx, vs) => mx.Int32Values = ToInt32Array(vs)); + case long[] arr: return EncodeArray(arr, (mx, vs) => mx.Int64Values = ToInt64Array(vs)); + case float[] arr: return EncodeArray(arr, (mx, vs) => mx.FloatValues = ToFloatArray(vs)); + case double[] arr: return EncodeArray(arr, (mx, vs) => mx.DoubleValues = ToDoubleArray(vs)); + case string[] arr: return EncodeArray(arr, (mx, vs) => mx.StringValues = ToStringArray(vs)); + case DateTime[] arr: return EncodeArray(arr, (mx, vs) => mx.TimestampValues = ToTimestampArray(vs)); + + default: + throw new ArgumentException( + $"Cannot encode value of type {value.GetType()} as MxValue. Supported: " + + "bool, int / long (and their unsigned variants), float, double, string, DateTime, " + + "and their 1-D array variants.", + nameof(value)); + } + } + + private static MxValue EncodeArray(T[] values, Action populate) + { + var array = new MxArray(); + populate(array, values); + array.Dimensions.Add((uint)values.Length); + return new MxValue { ArrayValue = array }; + } + + private static BoolArray ToBoolArray(bool[] vs) { var a = new BoolArray(); a.Values.AddRange(vs); return a; } + private static Int32Array ToInt32Array(int[] vs) { var a = new Int32Array(); a.Values.AddRange(vs); return a; } + private static Int64Array ToInt64Array(long[] vs) { var a = new Int64Array(); a.Values.AddRange(vs); return a; } + private static FloatArray ToFloatArray(float[] vs) { var a = new FloatArray(); a.Values.AddRange(vs); return a; } + private static DoubleArray ToDoubleArray(double[] vs) { var a = new DoubleArray(); a.Values.AddRange(vs); return a; } + private static StringArray ToStringArray(string[] vs) { var a = new StringArray(); a.Values.AddRange(vs); return a; } + private static TimestampArray ToTimestampArray(DateTime[] vs) + { + var a = new TimestampArray(); + foreach (var dt in vs) a.Values.Add(Timestamp.FromDateTime(EnsureUtc(dt))); + return a; + } + + /// + /// requires UTC. Convert non-UTC inputs + /// explicitly so a caller passing local time gets predictable wire bytes. + /// + private static DateTime EnsureUtc(DateTime dt) => dt.Kind switch + { + DateTimeKind.Utc => dt, + DateTimeKind.Local => dt.ToUniversalTime(), + _ => DateTime.SpecifyKind(dt, DateTimeKind.Utc), + }; +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverWriteTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverWriteTests.cs new file mode 100644 index 0000000..8a00748 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverWriteTests.cs @@ -0,0 +1,175 @@ +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; + +/// +/// Tests for 's IWritable 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 +/// level — this test class focuses on the +/// driver-side wiring. +/// +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 objects) : IGalaxyHierarchySource + { + public Task> GetHierarchyAsync(CancellationToken cancellationToken) + => Task.FromResult(objects); + } + + private sealed class FakeBuilder : IAddressSpaceBuilder + { + public List 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> WriteAsync( + IReadOnlyList writes, + Func 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>(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(() => + 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(() => + driver.WriteAsync([new WriteRequest("x", 1)], CancellationToken.None)); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/MxValueEncoderTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/MxValueEncoderTests.cs new file mode 100644 index 0000000..1584659 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/MxValueEncoderTests.cs @@ -0,0 +1,126 @@ +using Google.Protobuf.WellKnownTypes; +using MxGateway.Contracts.Proto; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime; + +/// +/// Tests for . Pinning each scalar + array case here +/// guards against accidental drift in the IWritable wire format. +/// +public sealed class MxValueEncoderTests +{ + [Fact] + public void Encode_Null_SetsIsNullFlag() + { + var v = MxValueEncoder.Encode(null); + v.IsNull.ShouldBeTrue(); + } + + [Fact] + public void Encode_Bool() => MxValueEncoder.Encode(true).BoolValue.ShouldBe(true); + + [Theory] + [InlineData((sbyte)-5, -5)] + [InlineData((short)-1000, -1000)] + [InlineData((byte)42, 42)] + [InlineData((ushort)42_000, 42_000)] + public void Encode_NarrowSignedAndUnsigned_FitsInInt32(object input, int expected) + { + var v = MxValueEncoder.Encode(input); + v.KindCase.ShouldBe(MxValue.KindOneofCase.Int32Value); + v.Int32Value.ShouldBe(expected); + } + + [Fact] + public void Encode_Int32_RoundTrip() => MxValueEncoder.Encode(int.MinValue).Int32Value.ShouldBe(int.MinValue); + + [Fact] + public void Encode_Int64_RoundTrip() + { + var v = MxValueEncoder.Encode(long.MaxValue); + v.KindCase.ShouldBe(MxValue.KindOneofCase.Int64Value); + v.Int64Value.ShouldBe(long.MaxValue); + } + + [Fact] + public void Encode_UInt32_FitsInInt32() => MxValueEncoder.Encode((uint)int.MaxValue).Int32Value.ShouldBe(int.MaxValue); + + [Fact] + public void Encode_Float() => MxValueEncoder.Encode(3.14f).FloatValue.ShouldBe(3.14f); + + [Fact] + public void Encode_Double() => MxValueEncoder.Encode(2.71828).DoubleValue.ShouldBe(2.71828); + + [Fact] + public void Encode_String() => MxValueEncoder.Encode("hello").StringValue.ShouldBe("hello"); + + [Fact] + public void Encode_DateTimeUtc() + { + var when = new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc); + var v = MxValueEncoder.Encode(when); + v.TimestampValue.ShouldNotBeNull(); + v.TimestampValue.ToDateTime().ShouldBe(when); + } + + [Fact] + public void Encode_DateTimeLocal_ConvertsToUtc() + { + var local = new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Local); + var v = MxValueEncoder.Encode(local); + v.TimestampValue.ToDateTime().ShouldBe(local.ToUniversalTime()); + } + + [Fact] + public void Encode_BoolArray() + { + var v = MxValueEncoder.Encode(new[] { true, false, true }); + v.ArrayValue.BoolValues.Values.ToArray().ShouldBe(new[] { true, false, true }); + v.ArrayValue.Dimensions[0].ShouldBe(3u); + } + + [Fact] + public void Encode_DoubleArray() + { + var v = MxValueEncoder.Encode(new[] { 1.0, 2.0, 3.5 }); + v.ArrayValue.DoubleValues.Values.ToArray().ShouldBe(new[] { 1.0, 2.0, 3.5 }); + } + + [Fact] + public void Encode_StringArray() + { + var v = MxValueEncoder.Encode(new[] { "a", "b" }); + v.ArrayValue.StringValues.Values.ToArray().ShouldBe(new[] { "a", "b" }); + } + + [Fact] + public void Encode_DateTimeArray_ConvertsAllToUtc() + { + var inputs = new[] { new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc) }; + var v = MxValueEncoder.Encode(inputs); + v.ArrayValue.TimestampValues.Values[0].ToDateTime().ShouldBe(inputs[0]); + } + + [Fact] + public void Encode_UnsupportedType_Throws() + { + Should.Throw(() => MxValueEncoder.Encode(new { Foo = 1 })); + } + + [Fact] + public void RoundTrip_AllScalarTypes_DecodeMatchesOriginal() + { + // The encoder + decoder must be inverses for every scalar a Galaxy driver might + // hand to a write. This pin-test catches accidental drift in either direction. + object[] inputs = [true, 42, 12345L, 3.14f, 2.71828, "x"]; + foreach (var input in inputs) + { + var encoded = MxValueEncoder.Encode(input); + var decoded = MxValueDecoder.Decode(encoded); + decoded.ShouldBe(input); + } + } +}