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