Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverWriteTests.cs
T
Joseph Doherty 560b327ee1
v2-ci / build (push) Failing after 33s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
refactor(galaxy): migrate to ZB.MOM.WW.MxGateway.* nupkg packages
Imports the freshly-rebuilt ZB.MOM.WW.MxGateway.Client + ZB.MOM.WW.MxGateway.Contracts
nupkgs (0.1.0) from /tmp/mxgw-dist. Replaces the vendored libs/ DLLs and the
pre-restructure MxGateway.* namespaces across the runtime Galaxy driver,
Galaxy.Browser, and their tests.

Key changes:
- nuget-packages/ added as a local feed via NuGet.config; .gitignore exempts it
  from the *.nupkg rule so the packages are tracked
- Directory.Packages.props pins both packages at 0.1.0
- 4 csprojs swap <Reference HintPath="libs/...dll"/> for <PackageReference/>
- 36 .cs files renamed `using MxGateway.*` -> `using ZB.MOM.WW.MxGateway.*`
- libs/ removed (vendored DLLs + README.md)

GalaxyBrowseSession rewritten around the new lazy API:
- RootAsync calls GalaxyRepositoryClient.BrowseAsync (returns LazyBrowseNodes)
  and caches them by TagName instead of bulk-fetching the whole hierarchy
- ExpandAsync looks up the cached LazyBrowseNode and calls its ExpandAsync,
  giving true one-wire-call-per-click instead of in-memory parent/child scan
- _byGobjectId + _hasChildrenSet dropped (LazyBrowseNode carries HasChildrenHint)
- AttributesAsync unchanged (already uses DiscoverHierarchyAsync MaxDepth=0)

Tests: Galaxy.Tests 245/245, Galaxy.Browser.Tests 10/10, AdminUI.Tests 66/66.
Pre-existing 12 solution errors unchanged (test sinks + Cli XML comments).
2026-05-29 07:14:18 -04:00

211 lines
10 KiB
C#

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