using ZB.MOM.WW.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 { /// Returns the fake Galaxy object hierarchy. /// Token to cancel the operation. public Task> GetHierarchyAsync(CancellationToken cancellationToken) => Task.FromResult(objects); } private sealed class FakeBuilder : IAddressSpaceBuilder { /// Gets the list of variables added to this builder. public List Variables { get; } = []; /// Adds a folder and returns this builder for chaining. /// The browse name of the folder. /// The display name of the folder. public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; /// Adds a variable to the variables list and returns a handle. /// The browse name of the variable. /// The display name of the variable. /// The attribute information for the variable. public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) { Variables.Add(attributeInfo); return new FakeHandle(attributeInfo.FullName); } /// No-op property adding operation for test compatibility. /// The browse name of the property. /// The data type of the property. /// The value of the property. public void AddProperty(string browseName, DriverDataType dataType, object? value) { } private sealed class FakeHandle(string fullRef) : IVariableHandle { /// Gets the full reference for this variable handle. public string FullReference { get; } = fullRef; /// Marks this variable as an alarm condition and returns a noop sink. /// The alarm condition information. public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NoopSink(); /// No-op alarm transition handler. private sealed class NoopSink : IAlarmConditionSink { /// Handles alarm state transition events. /// The alarm event arguments. public void OnTransition(AlarmEventArgs args) { } } } } private sealed class FakeWriter : IGalaxyDataWriter { /// Gets the list of write calls received by this writer. public List<(string FullRef, object? Value, SecurityClassification Resolved)> Calls { get; } = []; /// Records write requests with their resolved security classifications. /// The list of write requests to process. /// Function to resolve security classification for each request. /// Token to cancel the operation. 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; } /// Verifies that WriteAsync routes through the injected writer and propagates values correctly. [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); } /// Verifies that WriteAsync resolves every security classification from discovery data. /// The raw MXAccess security integer from the discovery attribute. /// The expected resolved security classification. [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); } /// Verifies that unknown tags resolve to FreeAccess classification and writes proceed. [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); } /// Verifies that an empty write request returns empty without calling the writer. [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(); } /// Verifies that WriteAsync throws when no writer is configured, referencing PR 4.4. [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"); } /// Verifies that WriteAsync throws ObjectDisposedException after the driver is disposed. [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)); } }