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; /// /// v3 RawPath identity: the server addresses Galaxy nodes by RawPath, while MXAccess dials the /// Galaxy attributeRef (tag_name.AttributeName). These tests pin the driver-side boundary /// translation built from — 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 TagConfig carries the dialled address under the camelCase key /// attributeRef. /// 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 ===== /// ReadAsync translates the incoming RawPath to the Galaxy attributeRef before dialling. [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]); } /// An unmapped RawPath dials as-is (identity) — the gateway then rejects an unknown address. [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 ===== /// /// 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. /// [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 ===== /// /// 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. /// [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(); 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 ===== /// /// 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. /// [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(); 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 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 objects) : IGalaxyHierarchySource { public Task> GetHierarchyAsync(CancellationToken cancellationToken) => Task.FromResult(objects); } /// Minimal builder — discovery only needs the SecurityCapturingBuilder wrapper to run. 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? LastRequest { get; private set; } public Task> ReadAsync( IReadOnlyList fullReferences, CancellationToken cancellationToken) { LastRequest = fullReferences; return Task.FromResult>( 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> 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); } public void InvalidateHandleCaches() { } } private sealed class FakeAlarmFeed : IGalaxyAlarmFeed { public event EventHandler? 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; } } }