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