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; 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 { /// Gets the hierarchy asynchronously from the fake source. /// The cancellation token. /// A task that returns the pre-built Galaxy object list. public Task> GetHierarchyAsync(CancellationToken cancellationToken) => Task.FromResult(objects); } /// ParentBrowseName is the browse-name of the folder this folder was created under /// (null = created at the driver root). private sealed record FolderCall(string BrowseName, string DisplayName, string? ParentBrowseName); private sealed record VariableCall(string FolderBrowseName, string AttributeName, DriverAttributeInfo Info); private sealed class FakeBuilder : IAddressSpaceBuilder { /// Gets the list of folder creation calls recorded by this builder. public List Folders { get; } = []; /// Gets the list of variable creation calls recorded by this builder. public List Variables { get; } = []; /// Gets the dictionary of alarm declarations recorded by this builder. public Dictionary AlarmDeclarations { get; } = []; private readonly string? _currentFolder; /// Initializes a new instance of the FakeBuilder class at the root level. public FakeBuilder() : this(null) { } private FakeBuilder(string? folder) { _currentFolder = folder; } /// Adds a folder call to the recorded list. /// The browse name for the folder. /// The display name for the folder. /// An IAddressSpaceBuilder scoped to the new folder. public IAddressSpaceBuilder Folder(string browseName, string displayName) { // Root-level folder — no parent. Folders.Add(new FolderCall(browseName, displayName, ParentBrowseName: null)); // Return a child scoped to this folder; nested folders inherit the parent reference. return new ChildBuilder(this, browseName); } /// Adds a variable call to the recorded list. /// The browse name for the variable. /// The display name for the variable. /// The attribute metadata for the variable. /// An IVariableHandle for further configuration. 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); } /// Adds a property call to the builder (not recorded in this fake). /// The browse name for the property. /// The driver data type of the property. /// The property value. 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 { /// Adds a child folder call to the parent builder's recorded list. /// The browse name for the folder. /// The display name for the folder. /// An IAddressSpaceBuilder scoped to the new child folder. public IAddressSpaceBuilder Folder(string browseName, string displayName) { // Nested folder — record the folder it was created under. parent.Folders.Add(new FolderCall(browseName, displayName, ParentBrowseName: folderBrowseName)); return new ChildBuilder(parent, browseName); } /// Adds a variable call to the parent builder's recorded list, scoped to this folder. /// The browse name for the variable. /// The display name for the variable. /// The attribute metadata for the variable. /// An IVariableHandle for further configuration. public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) { parent.Variables.Add(new VariableCall(folderBrowseName, browseName, attributeInfo)); return new FakeVariableHandle(parent, attributeInfo.FullName); } /// Adds a property call to the builder (not recorded in this fake). /// The browse name for the property. /// The driver data type of the property. /// The property value. public void AddProperty(string browseName, DriverDataType dataType, object? value) { } } private sealed class FakeVariableHandle(FakeBuilder owner, string fullRef) : IVariableHandle { /// Gets the full reference for this variable. public string FullReference { get; } = fullRef; /// Marks this variable as an alarm condition and records it. /// The alarm condition metadata. /// An IAlarmConditionSink for further alarm configuration. public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) { owner.AlarmDeclarations[FullReference] = info; return new NoopSink(); } } private sealed class NoopSink : IAlarmConditionSink { /// Records an alarm transition event (no-op in this fake). /// The alarm event arguments. 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; } /// Builds a gobject with explicit id + parent id so nesting-by-parent can be asserted. private static GalaxyObject Node( int gobjectId, int parentGobjectId, string tagName, string? containedName = null, params GalaxyAttribute[] attributes) { var o = new GalaxyObject { GobjectId = gobjectId, ParentGobjectId = parentGobjectId, TagName = tagName, ContainedName = containedName ?? tagName, }; o.Attributes.AddRange(attributes); return o; } /// Verifies that discovery creates one folder per object and one variable per attribute. [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"); } /// Verifies that full reference defaults to tag.attribute format when not explicitly supplied. [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"); } /// Verifies that full reference uses gateway-supplied value when provided. [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"); } /// Verifies that browse name falls back to tag name when contained name is empty. [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"); } /// Verifies that attribute metadata fields are all propagated to the discovered variable. [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(); } /// Verifies that alarm attributes populate all five sub-attribute references. [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"); } /// Verifies that non-alarm attributes are not marked as alarm conditions. [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"); } /// Verifies that objects with empty identity are skipped during discovery. [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"); } /// Verifies that attributes with empty names are skipped during discovery. [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"); } /// Verifies that driver discovery routes through the injected hierarchy source. [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); } /// Child gobject's folder is created UNDER its parent's folder, not at the root. [Fact] public async Task Nests_child_folder_under_its_parent() { var src = new FakeHierarchySource([ Node(1, 0, "Parent", "parent", Attr("PV")), Node(2, 1, "Child", "child", Attr("PV")), ]); var builder = new FakeBuilder(); await new GalaxyDiscoverer(src).DiscoverAsync(builder, CancellationToken.None); var parent = builder.Folders.Single(f => f.BrowseName == "parent"); parent.ParentBrowseName.ShouldBeNull(); var child = builder.Folders.Single(f => f.BrowseName == "child"); child.ParentBrowseName.ShouldBe("parent"); } /// Nesting is order-independent: child returned before its parent still nests correctly. [Fact] public async Task Is_order_independent_child_before_parent() { var src = new FakeHierarchySource([ Node(2, 1, "Child", "child", Attr("PV")), // child FIRST Node(1, 0, "Parent", "parent", Attr("PV")), ]); var builder = new FakeBuilder(); await new GalaxyDiscoverer(src).DiscoverAsync(builder, CancellationToken.None); var child = builder.Folders.Single(f => f.BrowseName == "child"); child.ParentBrowseName.ShouldBe("parent"); } /// Both gobjects with parent=0 land flat at the driver root. [Fact] public async Task Degrades_to_flat_when_parent_is_zero() { var src = new FakeHierarchySource([ Node(1, 0, "A", "a", Attr("PV")), Node(2, 0, "B", "b", Attr("PV")), ]); var builder = new FakeBuilder(); await new GalaxyDiscoverer(src).DiscoverAsync(builder, CancellationToken.None); builder.Folders.Single(f => f.BrowseName == "a").ParentBrowseName.ShouldBeNull(); builder.Folders.Single(f => f.BrowseName == "b").ParentBrowseName.ShouldBeNull(); } /// A child whose parent_gobject_id is not in the returned set degrades to the root. [Fact] public async Task Degrades_to_flat_when_parent_not_in_set() { var src = new FakeHierarchySource([ Node(2, 99, "Child", "child", Attr("PV")), // parent 99 absent ]); var builder = new FakeBuilder(); await new GalaxyDiscoverer(src).DiscoverAsync(builder, CancellationToken.None); builder.Folders.Single(f => f.BrowseName == "child").ParentBrowseName.ShouldBeNull(); } /// A self-referential parent (id == parent id) attaches to the root, never to itself. [Fact] public async Task Self_parent_attaches_to_root() { var src = new FakeHierarchySource([ Node(5, 5, "Selfie", "selfie", Attr("PV")), ]); var builder = new FakeBuilder(); await new GalaxyDiscoverer(src).DiscoverAsync(builder, CancellationToken.None); builder.Folders.Single(f => f.BrowseName == "selfie").ParentBrowseName.ShouldBeNull(); } /// A mutual parent cycle (A→B, B→A) degrades EVERY member to the root — neither /// folder nests under the other, and discovery stays finite (no infinite recursion / crash). [Fact] public async Task Mutual_cycle_degrades_both_to_root() { var src = new FakeHierarchySource([ Node(1, 2, "a", "a", Attr("PV")), // A's parent is B Node(2, 1, "b", "b", Attr("PV")), // B's parent is A ]); var builder = new FakeBuilder(); await new GalaxyDiscoverer(src).DiscoverAsync(builder, CancellationToken.None); builder.Folders.Single(f => f.BrowseName == "a").ParentBrowseName.ShouldBeNull(); builder.Folders.Single(f => f.BrowseName == "b").ParentBrowseName.ShouldBeNull(); } /// A child object's attributes are added into the child's own folder, not the parent's. [Fact] public async Task Variables_land_in_their_owner_folder() { var src = new FakeHierarchySource([ Node(1, 0, "Parent", "parent", Attr("ParentPV")), Node(2, 1, "Child", "child", Attr("ChildPV")), ]); var builder = new FakeBuilder(); await new GalaxyDiscoverer(src).DiscoverAsync(builder, CancellationToken.None); builder.Variables.ShouldContain(v => v.FolderBrowseName == "parent" && v.AttributeName == "ParentPV"); builder.Variables.ShouldContain(v => v.FolderBrowseName == "child" && v.AttributeName == "ChildPV"); builder.Variables.ShouldNotContain(v => v.FolderBrowseName == "parent" && v.AttributeName == "ChildPV"); } /// Helper that exercises the internal ctor (test seam) without exposing it publicly. private sealed class GalaxyDriverHelper { /// Creates a GalaxyDriver with a fake hierarchy source for testing. /// The fake hierarchy source to inject. /// A GalaxyDriver configured with the fake source. 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); } }