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)); } }