Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverRawPathKeyingTests.cs
T
Joseph Doherty 636c755b04 v3(galaxy): re-key GalaxyDriver to RawPath identity (dial attributeRef)
Wave-B driver re-keying. Under v3 the server addresses Galaxy nodes by RawPath
and the driver dials the Galaxy attributeRef (tag_name.AttributeName). The
dialled address moved into TagConfig under the camelCase key `attributeRef`
(renamed from the pre-v3 PascalCase FullName) and rides on RawTagEntry.

Options + factory:
- GalaxyDriverOptions gains `IReadOnlyList<RawTagEntry> RawTags` (Contracts now
  references the zero-dep Core.Abstractions leaf for RawTagEntry — same as
  Modbus.Contracts). Factory binds `List<RawTagEntry>? RawTags` from DriverConfig.

Boundary translation (built once from options.RawTags):
- RawPath -> attributeRef (forward dial) on Read / Write / Subscribe / Ack.
- attributeRef -> RawPath (reverse) on the value fan-out (OnDataChange) and the
  alarm ConditionId — so the alarm resolves back to the same RawPath the value
  path surfaces. Both legs fall back to identity on a miss, so the live-browse
  discovery path (no authored tags) and synthetic alarm sub-refs
  (attributeRef.InAlarm) dial straight through unchanged.
- WriteAsync dials the attributeRef but resolves security via the RawPath-keyed
  map the discoverer captured (reverse-maps in the resolver closure).

Discovery + alarms:
- GalaxyDiscoverer emits FullName = RawPath for authored attributes (falls back
  to attributeRef), threads RawTagEntry.WriteIdempotent onto DriverAttributeInfo,
  and gains an optional attributeRef -> RawTagEntry resolver ctor param.
- AlarmRefBuilder.Build(conditionReference, dialReference): SourceName/ConditionId
  = RawPath identity; the five live-state/ack sub-refs dial off the attributeRef.

ReinitializeAsync equivalence now compares the session-shape sections field-wise,
excluding RawTags (address-space state re-applied via rediscovery, and a record's
synthesized equality would compare the lists by reference).

Tests: +9 (discoverer RawPath/WriteIdempotent + alarm identity; factory RawTags
binding; driver read/write/subscribe/alarm boundary translation). Existing 304
preserved via identity fallback. 313 pass, 5 live-gated skips.
2026-07-15 20:12:22 -04:00

261 lines
12 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>
/// v3 RawPath identity: the server addresses Galaxy nodes by RawPath, while MXAccess dials the
/// Galaxy attributeRef (<c>tag_name.AttributeName</c>). These tests pin the driver-side boundary
/// translation built from <see cref="GalaxyDriverOptions.RawTags"/> — RawPath → attributeRef on
/// read / write / subscribe / ack (forward dial), and attributeRef → RawPath on the value fan-out
/// and alarm ConditionId (reverse), so the alarm resolves back to the same RawPath the value path
/// surfaces. Each entry's <c>TagConfig</c> carries the dialled address under the camelCase key
/// <c>attributeRef</c>.
/// </summary>
public sealed class GalaxyDriverRawPathKeyingTests
{
private const string RawPath = "area/line/tank1/level";
private const string AttributeRef = "Tank1.Level";
private static GalaxyDriverOptions OptsWithRawTags(bool writeIdempotent = false) => new(
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
new GalaxyMxAccessOptions("OtOpcUa-A"),
new GalaxyRepositoryOptions(),
new GalaxyReconnectOptions())
{
RawTags = [new RawTagEntry(RawPath, $"{{\"attributeRef\":\"{AttributeRef}\"}}", writeIdempotent)],
};
// ===== Read: RawPath in → attributeRef dialled =====
/// <summary>ReadAsync translates the incoming RawPath to the Galaxy attributeRef before dialling.</summary>
[Fact]
public async Task ReadAsync_TranslatesRawPath_ToAttributeRef()
{
var reader = new RecordingReader();
using var driver = new GalaxyDriver("g", OptsWithRawTags(), hierarchySource: null, dataReader: reader);
await driver.ReadAsync([RawPath], CancellationToken.None);
reader.LastRequest.ShouldBe([AttributeRef]);
}
/// <summary>An unmapped RawPath dials as-is (identity) — the gateway then rejects an unknown address.</summary>
[Fact]
public async Task ReadAsync_UnmappedRawPath_DialsIdentity()
{
var reader = new RecordingReader();
using var driver = new GalaxyDriver("g", OptsWithRawTags(), hierarchySource: null, dataReader: reader);
await driver.ReadAsync(["not/authored"], CancellationToken.None);
reader.LastRequest.ShouldBe(["not/authored"]);
}
// ===== Write: RawPath in → attributeRef dialled, security resolved by RawPath =====
/// <summary>
/// WriteAsync dials the attributeRef, and the security the writer resolves is the one captured at
/// discovery under the tag's RawPath (the discoverer emitted FullName = RawPath) — proving the
/// reverse mapping the resolver applies (attributeRef → RawPath) lands on the right classification.
/// </summary>
[Fact]
public async Task WriteAsync_DialsAttributeRef_AndResolvesSecurityByRawPath()
{
var writer = new RecordingWriter();
// Hierarchy: Tank1.Level classified Operate (sec 1). Discovery keys security by the RawPath.
var src = new FakeHierarchySource([Obj("Tank1", Attr("Level", sec: 1))]);
using var driver = new GalaxyDriver(
"g", OptsWithRawTags(), hierarchySource: src, dataReader: null, dataWriter: writer);
await driver.DiscoverAsync(new CapturingBuilder(), CancellationToken.None);
await driver.WriteAsync([new WriteRequest(RawPath, 42.0)], CancellationToken.None);
writer.Calls.ShouldHaveSingleItem();
writer.Calls[0].FullRef.ShouldBe(AttributeRef); // dialled address
writer.Calls[0].Resolved.ShouldBe(SecurityClassification.Operate); // resolved by RawPath
}
// ===== Subscribe + value fan-out: RawPath in → attributeRef dialled → RawPath out =====
/// <summary>
/// SubscribeAsync dials the attributeRef; the resulting value fan-out surfaces on the public
/// OnDataChange keyed by the tag's RawPath — the same identity the alarm ConditionId uses.
/// </summary>
[Fact]
public async Task Subscribe_DialsAttributeRef_AndValueFanOutSurfacesRawPath()
{
var subscriber = new GalaxyDriverSubscribeTests.FakeSubscriber();
using var driver = new GalaxyDriver(
"g", OptsWithRawTags(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
var captured = new List<DataChangeEventArgs>();
driver.OnDataChange += (_, args) => captured.Add(args);
await driver.SubscribeAsync([RawPath], TimeSpan.FromSeconds(1), CancellationToken.None);
// The gateway saw the dialled attributeRef, not the RawPath.
subscriber.Map.ShouldContainKey(AttributeRef);
subscriber.Map.ShouldNotContainKey(RawPath);
await subscriber.EmitOnDataChangeAsync(subscriber.Map[AttributeRef], 42.0);
await WaitForAsync(() => captured.Count >= 1);
captured.ShouldHaveSingleItem();
captured[0].FullReference.ShouldBe(RawPath); // surfaced under the RawPath identity
((double)captured[0].Snapshot.Value!).ShouldBe(42.0);
}
// ===== Alarm: ConditionId is the RawPath; ack round-trips back to the attributeRef =====
/// <summary>
/// The alarm feed reports the Galaxy attributeRef; the driver surfaces the transition with
/// ConditionId = the tag's RawPath (matching the value path), and a subsequent ack keyed by that
/// RawPath dials back down to the attributeRef the gateway acknowledges.
/// </summary>
[Fact]
public async Task Alarm_ConditionIdIsRawPath_AndAckRoundTripsToAttributeRef()
{
var feed = new FakeAlarmFeed();
var ack = new RecordingAcknowledger();
using var driver = new GalaxyDriver(
"g", OptsWithRawTags(), hierarchySource: null, alarmAcknowledger: ack, alarmFeed: feed);
var handle = await driver.SubscribeAlarmsAsync(["Tank1"], CancellationToken.None);
var observed = new List<AlarmEventArgs>();
driver.OnAlarmEvent += (_, args) => observed.Add(args);
// Feed reports the dialled attributeRef.
feed.Emit(NewTransition(AttributeRef, "Tank1"));
observed.ShouldHaveSingleItem();
observed[0].ConditionId.ShouldBe(RawPath); // reverse-mapped to the RawPath identity
observed[0].SubscriptionHandle.ShouldBe(handle);
// Ack keyed by the RawPath the driver reported → dials back to the attributeRef.
await driver.AcknowledgeAsync(
[new AlarmAcknowledgeRequest("Tank1", RawPath, "ack it")], CancellationToken.None);
ack.Calls.ShouldHaveSingleItem();
ack.Calls[0].AlarmRef.ShouldBe(AttributeRef);
}
// ===== helpers / fakes =====
private static async Task WaitForAsync(Func<bool> condition, int timeoutMs = 2000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (DateTime.UtcNow < deadline)
{
if (condition()) return;
await Task.Delay(10);
}
}
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;
}
private static GalaxyAlarmTransition NewTransition(string alarmFullReference, string source)
=> new(
AlarmFullReference: alarmFullReference,
SourceObjectReference: source,
AlarmTypeName: "AnalogLimitAlarm.HiHi",
TransitionKind: GalaxyAlarmTransitionKind.Raise,
SeverityBucket: AlarmSeverity.High,
OpcUaSeverity: 800,
RawMxAccessSeverity: 750,
OriginalRaiseTimestampUtc: null,
TransitionTimestampUtc: DateTime.UtcNow,
OperatorUser: string.Empty,
OperatorComment: string.Empty,
Category: "Process",
Description: "high level");
private sealed class FakeHierarchySource(IReadOnlyList<GalaxyObject> objects) : IGalaxyHierarchySource
{
public Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken)
=> Task.FromResult(objects);
}
/// <summary>Minimal builder — discovery only needs the SecurityCapturingBuilder wrapper to run.</summary>
private sealed class CapturingBuilder : IAddressSpaceBuilder
{
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
=> new Handle(attributeInfo.FullName);
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference { get; } = fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink();
private sealed class Sink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
}
}
private sealed class RecordingReader : IGalaxyDataReader
{
public IReadOnlyList<string>? LastRequest { get; private set; }
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
LastRequest = fullReferences;
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(
fullReferences.Select(_ => new DataValueSnapshot(0.0, StatusCodeMap.Good, null, DateTime.UtcNow)).ToArray());
}
}
private sealed class RecordingWriter : IGalaxyDataWriter
{
public List<(string FullRef, object? Value, SecurityClassification Resolved)> Calls { get; } = [];
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);
}
public void InvalidateHandleCaches() { }
}
private sealed class FakeAlarmFeed : IGalaxyAlarmFeed
{
public event EventHandler<GalaxyAlarmTransition>? OnAlarmTransition;
public void Start() { }
public void Emit(GalaxyAlarmTransition transition) => OnAlarmTransition?.Invoke(this, transition);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private sealed class RecordingAcknowledger : IGalaxyAlarmAcknowledger
{
public List<(string AlarmRef, string Comment, string Operator)> Calls { get; } = [];
public Task AcknowledgeAsync(string alarmFullReference, string comment, string operatorUser, CancellationToken cancellationToken)
{
Calls.Add((alarmFullReference, comment, operatorUser));
return Task.CompletedTask;
}
}
}