diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/AlarmRefBuilder.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/AlarmRefBuilder.cs new file mode 100644 index 0000000..bd0d4b6 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/AlarmRefBuilder.cs @@ -0,0 +1,51 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse; + +/// +/// Populates the five sub-attribute references on +/// by Galaxy convention. The server-level AlarmConditionService (PR 2.2) uses +/// these to subscribe to live alarm-state attributes and to route ack writes back to +/// the alarm tag. +/// +/// +/// Galaxy alarms expose four runtime attributes plus a write-only ack target, +/// consistently named on every alarm-bearing object: +/// +/// <tag>.<attr>.InAlarm +/// <tag>.<attr>.Priority +/// <tag>.<attr>.DescAttrName +/// <tag>.<attr>.Acked +/// <tag>.<attr>.AckMsg +/// +/// This is the same convention the legacy GalaxyAlarmTracker hard-coded; we +/// concentrate it here so PR 2.2's service receives complete AlarmConditionInfo +/// rows during discovery without the server needing to know the convention. +/// +internal static class AlarmRefBuilder +{ + private const string InAlarmSuffix = ".InAlarm"; + private const string PrioritySuffix = ".Priority"; + private const string DescAttrNameSuffix = ".DescAttrName"; + private const string AckedSuffix = ".Acked"; + private const string AckMsgSuffix = ".AckMsg"; + + /// + /// Build an for an alarm-bearing attribute with all + /// five sub-attribute references populated. is the + /// attribute's full reference (e.g. "Tank1.Level.HiHi"); the convention prefixes + /// each suffix to it. + /// + public static AlarmConditionInfo Build( + string fullReference, + AlarmSeverity initialSeverity = AlarmSeverity.Medium, + string? initialDescription = null) => new( + SourceName: fullReference, + InitialSeverity: initialSeverity, + InitialDescription: initialDescription, + InAlarmRef: fullReference + InAlarmSuffix, + PriorityRef: fullReference + PrioritySuffix, + DescAttrNameRef: fullReference + DescAttrNameSuffix, + AckedRef: fullReference + AckedSuffix, + AckMsgWriteRef: fullReference + AckMsgSuffix); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DataTypeMap.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DataTypeMap.cs new file mode 100644 index 0000000..83456c5 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DataTypeMap.cs @@ -0,0 +1,23 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse; + +/// +/// Maps Galaxy mx_data_type integer codes to . +/// Ported from the legacy GalaxyProxyDriver.MapDataType with the same fallback +/// to for unknown codes — keeps wire compatibility +/// with deployed configs while we tighten this through the parity matrix. +/// +internal static class DataTypeMap +{ + public static DriverDataType Map(int mxDataType) => mxDataType switch + { + 0 => DriverDataType.Boolean, + 1 => DriverDataType.Int32, + 2 => DriverDataType.Float32, + 3 => DriverDataType.Float64, + 4 => DriverDataType.String, + 5 => DriverDataType.DateTime, + _ => DriverDataType.String, + }; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GalaxyDiscoverer.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GalaxyDiscoverer.cs new file mode 100644 index 0000000..7987956 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GalaxyDiscoverer.cs @@ -0,0 +1,80 @@ +using MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse; + +/// +/// Translates a Galaxy object hierarchy (from ) into +/// calls — folders for each gobject, variables for +/// each dynamic attribute. Alarm-bearing attributes get all five sub-attribute refs +/// populated via so the server-level alarm subsystem +/// (PR 2.2) can subscribe + ack without help from the driver. +/// +/// +/// Hierarchy materialisation rules (mirror legacy MxAccessGalaxyBackend.DiscoverAsync): +/// +/// Browse name = contained_name when present; falls back to tag_name. +/// Folder per gobject; variables placed inside their owner folder. +/// Variable's full reference = tag_name.attribute_name — the format MXAccess +/// expects for read/write addressing (translated from the contained-name browse path). +/// Hierarchy is rendered flat (one folder per gobject under the driver root) for +/// this PR. PR 4.W's address-space wiring revisits whether to nest under +/// parent_gobject_id for a true tree shape. +/// +/// +public sealed class GalaxyDiscoverer +{ + private readonly IGalaxyHierarchySource _source; + + public GalaxyDiscoverer(IGalaxyHierarchySource source) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + } + + /// + /// Drive the supplied builder with one folder + N variables per Galaxy object the + /// gateway returns. Idempotent — caller can re-invoke after a redeploy event. + /// + public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(builder); + var objects = await _source.GetHierarchyAsync(cancellationToken).ConfigureAwait(false); + + foreach (var obj in objects) + { + var browseName = string.IsNullOrEmpty(obj.ContainedName) ? obj.TagName : obj.ContainedName; + if (string.IsNullOrEmpty(browseName)) continue; // skip objects with no usable identity + + var folder = builder.Folder(browseName, browseName); + + foreach (var attr in obj.Attributes) + { + if (string.IsNullOrEmpty(attr.AttributeName)) continue; + + var fullReference = !string.IsNullOrEmpty(attr.FullTagReference) + ? attr.FullTagReference + : obj.TagName + "." + attr.AttributeName; + + var info = new DriverAttributeInfo( + FullName: fullReference, + DriverDataType: DataTypeMap.Map(attr.MxDataType), + IsArray: attr.IsArray, + ArrayDim: attr.IsArray && attr.ArrayDimensionPresent && attr.ArrayDimension > 0 + ? (uint)attr.ArrayDimension + : null, + SecurityClass: SecurityMap.Map(attr.SecurityClassification), + IsHistorized: attr.IsHistorized, + IsAlarm: attr.IsAlarm); + + var handle = folder.Variable(attr.AttributeName, attr.AttributeName, info); + + // Alarm-bearing attributes ship the full sub-attribute ref set so the server's + // AlarmConditionService can subscribe + ack-write without re-deriving the names. + if (attr.IsAlarm) + { + handle.MarkAsAlarmCondition(AlarmRefBuilder.Build(fullReference)); + } + } + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GatewayGalaxyHierarchySource.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GatewayGalaxyHierarchySource.cs new file mode 100644 index 0000000..850fc7d --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GatewayGalaxyHierarchySource.cs @@ -0,0 +1,21 @@ +using MxGateway.Client; +using MxGateway.Contracts.Proto.Galaxy; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse; + +/// +/// Default wrapping the gateway's +/// . Pages internally via the client's overload. +/// +public sealed class GatewayGalaxyHierarchySource : IGalaxyHierarchySource +{ + private readonly GalaxyRepositoryClient _client; + + public GatewayGalaxyHierarchySource(GalaxyRepositoryClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + public Task> GetHierarchyAsync(CancellationToken cancellationToken) + => _client.DiscoverHierarchyAsync(cancellationToken); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/IGalaxyHierarchySource.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/IGalaxyHierarchySource.cs new file mode 100644 index 0000000..b81dfbe --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/IGalaxyHierarchySource.cs @@ -0,0 +1,19 @@ +using MxGateway.Contracts.Proto.Galaxy; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse; + +/// +/// Driver-side seam between and the gateway. Production +/// wraps GalaxyRepositoryClient; tests substitute a fake returning canned +/// rows so the discoverer's translation logic can be exercised +/// without a real gRPC channel. +/// +public interface IGalaxyHierarchySource +{ + /// + /// Returns the full materialised Galaxy hierarchy. The gateway client pages + /// internally; this interface deliberately exposes only the post-paging shape so + /// callers don't reimplement paging. + /// + Task> GetHierarchyAsync(CancellationToken cancellationToken); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/SecurityMap.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/SecurityMap.cs new file mode 100644 index 0000000..2ec7129 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/SecurityMap.cs @@ -0,0 +1,25 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse; + +/// +/// Maps Galaxy security_classification integer codes to +/// . Ported from the legacy +/// GalaxyProxyDriver.MapSecurity; unknown codes fall back to +/// so a forward-compatible Galaxy +/// deployment with new classifications doesn't break discovery. +/// +internal static class SecurityMap +{ + public static SecurityClassification Map(int mxSec) => mxSec switch + { + 0 => SecurityClassification.FreeAccess, + 1 => SecurityClassification.Operate, + 2 => SecurityClassification.SecuredWrite, + 3 => SecurityClassification.VerifiedWrite, + 4 => SecurityClassification.Tune, + 5 => SecurityClassification.Configure, + 6 => SecurityClassification.ViewOnly, + _ => SecurityClassification.FreeAccess, + }; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs index e09fb9f..e1aadf3 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using MxGateway.Client; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; @@ -20,12 +22,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; /// registers under driver-type name /// "GalaxyMxGateway" so both paths can be live simultaneously during parity testing. /// -public sealed class GalaxyDriver : IDriver, IDisposable +public sealed class GalaxyDriver : IDriver, ITagDiscovery, IDisposable { private readonly string _driverInstanceId; private readonly GalaxyDriverOptions _options; private readonly ILogger _logger; + // PR 4.1 — IGalaxyHierarchySource is the test seam for browse. When null, the driver + // lazily builds a GatewayGalaxyHierarchySource around a GalaxyRepositoryClient on + // first DiscoverAsync. Tests inject a fake source via the internal ctor to exercise + // GalaxyDiscoverer's translation logic without a real gRPC channel. + private IGalaxyHierarchySource? _hierarchySource; + private GalaxyRepositoryClient? _ownedRepositoryClient; + private DriverHealth _health = new(DriverState.Unknown, null, null); private bool _disposed; @@ -33,12 +42,27 @@ public sealed class GalaxyDriver : IDriver, IDisposable string driverInstanceId, GalaxyDriverOptions options, ILogger? logger = null) + : this(driverInstanceId, options, hierarchySource: null, logger) + { + } + + /// + /// Test-visible ctor — inject a custom so + /// can be exercised against canned hierarchies without + /// building a real gRPC channel. + /// + internal GalaxyDriver( + string driverInstanceId, + GalaxyDriverOptions options, + IGalaxyHierarchySource? hierarchySource, + ILogger? logger = null) { _driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId) ? driverInstanceId : throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId)); _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? NullLogger.Instance; + _hierarchySource = hierarchySource; } /// @@ -93,10 +117,52 @@ public sealed class GalaxyDriver : IDriver, IDisposable /// public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; + // ===== ITagDiscovery (PR 4.1) ===== + + /// + public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(builder); + + var source = _hierarchySource ??= BuildDefaultHierarchySource(); + var discoverer = new GalaxyDiscoverer(source); + await discoverer.DiscoverAsync(builder, cancellationToken).ConfigureAwait(false); + } + + /// + /// Lazily builds the default from + /// _options.Gateway. Owned is disposed in + /// . Tests bypass this by injecting their own source via the + /// internal ctor. + /// + private IGalaxyHierarchySource BuildDefaultHierarchySource() + { + var gw = _options.Gateway; + var clientOptions = new MxGatewayClientOptions + { + Endpoint = new Uri(gw.Endpoint, UriKind.Absolute), + // PR 4.1 stub: ApiKeySecretRef is currently treated as the literal API key. + // PR 4.W (or a follow-up) wires up DPAPI-backed secret resolution. + ApiKey = gw.ApiKeySecretRef, + UseTls = gw.UseTls, + CaCertificatePath = gw.CaCertificatePath, + ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds), + DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds), + StreamTimeout = gw.StreamTimeoutSeconds > 0 + ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) + : null, + }; + _ownedRepositoryClient = GalaxyRepositoryClient.Create(clientOptions); + return new GatewayGalaxyHierarchySource(_ownedRepositoryClient); + } + public void Dispose() { if (_disposed) return; _disposed = true; - // No owned IDisposables until PR 4.2's GalaxyMxSession lands. + _ownedRepositoryClient?.DisposeAsync().AsTask().GetAwaiter().GetResult(); + _ownedRepositoryClient = null; + _hierarchySource = null; } } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj index 4a2812d..dec2b68 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj @@ -15,6 +15,10 @@ + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Browse/GalaxyDiscovererTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Browse/GalaxyDiscovererTests.cs new file mode 100644 index 0000000..1585152 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Browse/GalaxyDiscovererTests.cs @@ -0,0 +1,301 @@ +using MxGateway.Contracts.Proto.Galaxy; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Browse; + +/// +/// Tests 's translation of rows +/// into calls. The fake builder records every Folder / +/// Variable / MarkAsAlarmCondition call so assertions can target shape without booting +/// a real OPC UA address space. +/// +public sealed class GalaxyDiscovererTests +{ + private sealed class FakeHierarchySource(IReadOnlyList objects) : IGalaxyHierarchySource + { + public Task> GetHierarchyAsync(CancellationToken cancellationToken) + => Task.FromResult(objects); + } + + private sealed record FolderCall(string BrowseName, string DisplayName); + private sealed record VariableCall(string FolderBrowseName, string AttributeName, DriverAttributeInfo Info); + + private sealed class FakeBuilder : IAddressSpaceBuilder + { + public List Folders { get; } = []; + public List Variables { get; } = []; + public Dictionary AlarmDeclarations { get; } = []; + + private readonly string? _currentFolder; + + public FakeBuilder() : this(null) { } + private FakeBuilder(string? folder) { _currentFolder = folder; } + + public IAddressSpaceBuilder Folder(string browseName, string displayName) + { + Folders.Add(new FolderCall(browseName, displayName)); + // Return a child scoped to this folder; nested folders inherit the parent reference. + return new ChildBuilder(this, browseName); + } + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + var folder = _currentFolder ?? ""; + Variables.Add(new VariableCall(folder, browseName, attributeInfo)); + return new FakeVariableHandle(this, attributeInfo.FullName); + } + + public void AddProperty(string browseName, DriverDataType dataType, object? value) { } + + // Child folder routes Variable calls back to the parent's lists with its own scope. + private sealed class ChildBuilder(FakeBuilder parent, string folderBrowseName) : IAddressSpaceBuilder + { + public IAddressSpaceBuilder Folder(string browseName, string displayName) + { + parent.Folders.Add(new FolderCall(browseName, displayName)); + return new ChildBuilder(parent, browseName); + } + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + parent.Variables.Add(new VariableCall(folderBrowseName, browseName, attributeInfo)); + return new FakeVariableHandle(parent, attributeInfo.FullName); + } + + public void AddProperty(string browseName, DriverDataType dataType, object? value) { } + } + + private sealed class FakeVariableHandle(FakeBuilder owner, string fullRef) : IVariableHandle + { + public string FullReference { get; } = fullRef; + + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) + { + owner.AlarmDeclarations[FullReference] = info; + return new NoopSink(); + } + } + + private sealed class NoopSink : IAlarmConditionSink + { + public void OnTransition(AlarmEventArgs args) { } + } + } + + private static GalaxyAttribute Attr( + string name, int mxDataType = 0, bool isArray = false, int arrayDim = 0, + int securityClass = 0, bool isHistorized = false, bool isAlarm = false, + string? fullTagReference = null) + { + var a = new GalaxyAttribute + { + AttributeName = name, + MxDataType = mxDataType, + IsArray = isArray, + ArrayDimension = arrayDim, + ArrayDimensionPresent = arrayDim > 0, + SecurityClassification = securityClass, + IsHistorized = isHistorized, + IsAlarm = isAlarm, + }; + if (fullTagReference is not null) a.FullTagReference = fullTagReference; + return a; + } + + private static GalaxyObject Obj(string tagName, string? containedName = null, params GalaxyAttribute[] attributes) + { + var o = new GalaxyObject + { + TagName = tagName, + ContainedName = containedName ?? tagName, + }; + o.Attributes.AddRange(attributes); + return o; + } + + [Fact] + public async Task DiscoverAsync_BuildsOneFolderPerObject_AndOneVariablePerAttribute() + { + var src = new FakeHierarchySource([ + Obj("Tank1_Level", "Level", Attr("PV", mxDataType: 2 /*Float32*/), Attr("SP", mxDataType: 2)), + Obj("Tank1_Pump", "Pump", Attr("Running", mxDataType: 0 /*Boolean*/)), + ]); + var discoverer = new GalaxyDiscoverer(src); + var builder = new FakeBuilder(); + + await discoverer.DiscoverAsync(builder, CancellationToken.None); + + builder.Folders.Count.ShouldBe(2); + builder.Folders[0].BrowseName.ShouldBe("Level"); + builder.Folders[1].BrowseName.ShouldBe("Pump"); + builder.Variables.Count.ShouldBe(3); + builder.Variables.ShouldContain(v => v.FolderBrowseName == "Level" && v.AttributeName == "PV"); + builder.Variables.ShouldContain(v => v.FolderBrowseName == "Level" && v.AttributeName == "SP"); + builder.Variables.ShouldContain(v => v.FolderBrowseName == "Pump" && v.AttributeName == "Running"); + } + + [Fact] + public async Task DiscoverAsync_FullReference_DefaultsToTagDotAttribute() + { + var src = new FakeHierarchySource([ + Obj("Tank1_Level", "Level", Attr("PV", mxDataType: 2)), + ]); + var discoverer = new GalaxyDiscoverer(src); + var builder = new FakeBuilder(); + + await discoverer.DiscoverAsync(builder, CancellationToken.None); + + builder.Variables[0].Info.FullName.ShouldBe("Tank1_Level.PV"); + } + + [Fact] + public async Task DiscoverAsync_FullReference_PrefersGwSuppliedFullTagReference() + { + var src = new FakeHierarchySource([ + Obj("Tank1_Level", "Level", Attr("PV", mxDataType: 2, fullTagReference: "explicit.full.ref")), + ]); + var discoverer = new GalaxyDiscoverer(src); + var builder = new FakeBuilder(); + + await discoverer.DiscoverAsync(builder, CancellationToken.None); + + builder.Variables[0].Info.FullName.ShouldBe("explicit.full.ref"); + } + + [Fact] + public async Task DiscoverAsync_BrowseName_FallsBackToTagName_WhenContainedEmpty() + { + var src = new FakeHierarchySource([ + Obj("Tank1_Level", containedName: "", attributes: Attr("PV")), + ]); + var discoverer = new GalaxyDiscoverer(src); + var builder = new FakeBuilder(); + + await discoverer.DiscoverAsync(builder, CancellationToken.None); + + builder.Folders[0].BrowseName.ShouldBe("Tank1_Level"); + } + + [Fact] + public async Task DiscoverAsync_AttributeMetadata_PropagatesEveryField() + { + var src = new FakeHierarchySource([ + Obj("T", "T", Attr("PV", + mxDataType: 3 /*Float64*/, + isArray: true, arrayDim: 16, + securityClass: 2 /*SecuredWrite*/, + isHistorized: true, isAlarm: false)), + ]); + var discoverer = new GalaxyDiscoverer(src); + var builder = new FakeBuilder(); + + await discoverer.DiscoverAsync(builder, CancellationToken.None); + + var info = builder.Variables[0].Info; + info.DriverDataType.ShouldBe(DriverDataType.Float64); + info.IsArray.ShouldBeTrue(); + info.ArrayDim.ShouldBe(16u); + info.SecurityClass.ShouldBe(SecurityClassification.SecuredWrite); + info.IsHistorized.ShouldBeTrue(); + info.IsAlarm.ShouldBeFalse(); + } + + [Fact] + public async Task DiscoverAsync_AlarmAttribute_PopulatesAllFiveSubAttributeRefs() + { + var src = new FakeHierarchySource([ + Obj("Tank1_Level", "Level", Attr("HiHi", mxDataType: 0, isAlarm: true)), + ]); + var discoverer = new GalaxyDiscoverer(src); + var builder = new FakeBuilder(); + + await discoverer.DiscoverAsync(builder, CancellationToken.None); + + builder.AlarmDeclarations.ShouldContainKey("Tank1_Level.HiHi"); + var info = builder.AlarmDeclarations["Tank1_Level.HiHi"]; + info.SourceName.ShouldBe("Tank1_Level.HiHi"); + info.InAlarmRef.ShouldBe("Tank1_Level.HiHi.InAlarm"); + info.PriorityRef.ShouldBe("Tank1_Level.HiHi.Priority"); + info.DescAttrNameRef.ShouldBe("Tank1_Level.HiHi.DescAttrName"); + info.AckedRef.ShouldBe("Tank1_Level.HiHi.Acked"); + info.AckMsgWriteRef.ShouldBe("Tank1_Level.HiHi.AckMsg"); + } + + [Fact] + public async Task DiscoverAsync_NonAlarmAttribute_DoesNotMarkCondition() + { + var src = new FakeHierarchySource([ + Obj("T", "T", Attr("PV", isAlarm: false), Attr("HiHi", isAlarm: true)), + ]); + var discoverer = new GalaxyDiscoverer(src); + var builder = new FakeBuilder(); + + await discoverer.DiscoverAsync(builder, CancellationToken.None); + + builder.AlarmDeclarations.Count.ShouldBe(1); + builder.AlarmDeclarations.ShouldContainKey("T.HiHi"); + builder.AlarmDeclarations.ShouldNotContainKey("T.PV"); + } + + [Fact] + public async Task DiscoverAsync_SkipsObjectsWithEmptyIdentity() + { + var src = new FakeHierarchySource([ + new GalaxyObject { TagName = "", ContainedName = "" }, // skip + Obj("Real", "Real", Attr("PV")), + ]); + var discoverer = new GalaxyDiscoverer(src); + var builder = new FakeBuilder(); + + await discoverer.DiscoverAsync(builder, CancellationToken.None); + + builder.Folders.Count.ShouldBe(1); + builder.Folders[0].BrowseName.ShouldBe("Real"); + } + + [Fact] + public async Task DiscoverAsync_SkipsAttributesWithEmptyName() + { + var src = new FakeHierarchySource([ + Obj("T", "T", new GalaxyAttribute { AttributeName = "", MxDataType = 0 }, Attr("PV")), + ]); + var discoverer = new GalaxyDiscoverer(src); + var builder = new FakeBuilder(); + + await discoverer.DiscoverAsync(builder, CancellationToken.None); + + builder.Variables.Count.ShouldBe(1); + builder.Variables[0].AttributeName.ShouldBe("PV"); + } + + [Fact] + public async Task DriverDiscoverAsync_RoutesThroughInjectedSource() + { + var src = new FakeHierarchySource([Obj("T", "T", Attr("PV"))]); + var driver = new GalaxyDriverHelper().CreateWithFakeSource(src); + var builder = new FakeBuilder(); + + await driver.DiscoverAsync(builder, CancellationToken.None); + + builder.Folders.Count.ShouldBe(1); + builder.Variables.Count.ShouldBe(1); + } + + /// Helper that exercises the internal ctor (test seam) without exposing it publicly. + private sealed class GalaxyDriverHelper + { + public GalaxyDriver CreateWithFakeSource(IGalaxyHierarchySource source) + => new GalaxyDriver( + "galaxy-test", + new Config.GalaxyDriverOptions( + new Config.GalaxyGatewayOptions("https://x", "k"), + new Config.GalaxyMxAccessOptions("OtOpcUa-T"), + new Config.GalaxyRepositoryOptions(), + new Config.GalaxyReconnectOptions()), + source, + logger: null); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj index e5c3a06..d63966c 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj @@ -21,6 +21,9 @@ + +