diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs new file mode 100644 index 0000000..88e64c3 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; + +/// +/// Galaxy backend that uses the live ZB repository for — +/// real gobject hierarchy + attributes flow through to the Proxy without needing the MXAccess +/// COM client. Runtime data-plane calls (Read/Write/Subscribe/Alarm/History) still surface +/// as "MXAccess code lift pending" until the COM client port lands. This is the highest-value +/// intermediate state because Discover is what powers the OPC UA address-space build, so +/// downstream Proxy + parity tests can exercise the complete tree shape today. +/// +public sealed class DbBackedGalaxyBackend(GalaxyRepository repository) : IGalaxyBackend +{ + private long _nextSessionId; + private long _nextSubscriptionId; + + public Task OpenSessionAsync(OpenSessionRequest req, CancellationToken ct) + { + var id = Interlocked.Increment(ref _nextSessionId); + return Task.FromResult(new OpenSessionResponse { Success = true, SessionId = id }); + } + + public Task CloseSessionAsync(CloseSessionRequest req, CancellationToken ct) => Task.CompletedTask; + + public async Task DiscoverAsync(DiscoverHierarchyRequest req, CancellationToken ct) + { + try + { + var hierarchy = await repository.GetHierarchyAsync(ct).ConfigureAwait(false); + var attributes = await repository.GetAttributesAsync(ct).ConfigureAwait(false); + + // Group attributes by their owning gobject for the IPC payload. + var attrsByGobject = attributes + .GroupBy(a => a.GobjectId) + .ToDictionary(g => g.Key, g => g.Select(MapAttribute).ToArray()); + + var parentByChild = hierarchy + .ToDictionary(o => o.GobjectId, o => o.ParentGobjectId); + var nameByGobject = hierarchy + .ToDictionary(o => o.GobjectId, o => o.TagName); + + var objects = hierarchy.Select(o => new GalaxyObjectInfo + { + ContainedName = string.IsNullOrEmpty(o.ContainedName) ? o.TagName : o.ContainedName, + TagName = o.TagName, + ParentContainedName = parentByChild.TryGetValue(o.GobjectId, out var p) + && p != 0 + && nameByGobject.TryGetValue(p, out var pName) + ? pName + : null, + TemplateCategory = MapCategory(o.CategoryId), + Attributes = attrsByGobject.TryGetValue(o.GobjectId, out var a) ? a : System.Array.Empty(), + }).ToArray(); + + return new DiscoverHierarchyResponse { Success = true, Objects = objects }; + } + catch (Exception ex) when (ex is System.Data.SqlClient.SqlException + or InvalidOperationException + or TimeoutException) + { + return new DiscoverHierarchyResponse + { + Success = false, + Error = $"Galaxy ZB repository error: {ex.Message}", + Objects = System.Array.Empty(), + }; + } + } + + public Task ReadValuesAsync(ReadValuesRequest req, CancellationToken ct) + => Task.FromResult(new ReadValuesResponse + { + Success = false, + Error = "MXAccess code lift pending (Phase 2 Task B.1) — DB-backed backend covers Discover only", + Values = System.Array.Empty(), + }); + + public Task WriteValuesAsync(WriteValuesRequest req, CancellationToken ct) + { + var results = new WriteValueResult[req.Writes.Length]; + for (var i = 0; i < req.Writes.Length; i++) + { + results[i] = new WriteValueResult + { + TagReference = req.Writes[i].TagReference, + StatusCode = 0x80020000u, + Error = "MXAccess code lift pending (Phase 2 Task B.1)", + }; + } + return Task.FromResult(new WriteValuesResponse { Results = results }); + } + + public Task SubscribeAsync(SubscribeRequest req, CancellationToken ct) + { + var sid = Interlocked.Increment(ref _nextSubscriptionId); + return Task.FromResult(new SubscribeResponse + { + Success = true, + SubscriptionId = sid, + ActualIntervalMs = req.RequestedIntervalMs, + }); + } + + public Task UnsubscribeAsync(UnsubscribeRequest req, CancellationToken ct) => Task.CompletedTask; + public Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct) => Task.CompletedTask; + public Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct) => Task.CompletedTask; + + public Task HistoryReadAsync(HistoryReadRequest req, CancellationToken ct) + => Task.FromResult(new HistoryReadResponse + { + Success = false, + Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)", + Tags = System.Array.Empty(), + }); + + public Task RecycleAsync(RecycleHostRequest req, CancellationToken ct) + => Task.FromResult(new RecycleStatusResponse { Accepted = true, GraceSeconds = 15 }); + + private static GalaxyAttributeInfo MapAttribute(GalaxyAttributeRow row) => new() + { + AttributeName = row.AttributeName, + MxDataType = row.MxDataType, + IsArray = row.IsArray, + ArrayDim = row.ArrayDimension is int d and > 0 ? (uint)d : null, + SecurityClassification = row.SecurityClassification, + IsHistorized = row.IsHistorized, + }; + + /// + /// Galaxy template_definition.category_id → human-readable name. + /// Mirrors v1 Host's AlarmObjectFilter mapping. + /// + private static string MapCategory(int categoryId) => categoryId switch + { + 1 => "$WinPlatform", + 3 => "$AppEngine", + 4 => "$Area", + 10 => "$UserDefined", + 11 => "$ApplicationObject", + 13 => "$Area", + 17 => "$DeviceIntegration", + 24 => "$ViewEngine", + 26 => "$ViewApp", + _ => $"category-{categoryId}", + }; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyHierarchyRow.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyHierarchyRow.cs new file mode 100644 index 0000000..8f0ede4 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyHierarchyRow.cs @@ -0,0 +1,35 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; + +/// +/// One row from the v1 HierarchySql. Galaxy gobject deployed instance with its +/// hierarchy parent + template-chain context. +/// +public sealed class GalaxyHierarchyRow +{ + public int GobjectId { get; init; } + public string TagName { get; init; } = string.Empty; + public string ContainedName { get; init; } = string.Empty; + public string BrowseName { get; init; } = string.Empty; + public int ParentGobjectId { get; init; } + public bool IsArea { get; init; } + public int CategoryId { get; init; } + public int HostedByGobjectId { get; init; } + public System.Collections.Generic.IReadOnlyList TemplateChain { get; init; } = System.Array.Empty(); +} + +/// One row from the v1 AttributesSql. +public sealed class GalaxyAttributeRow +{ + public int GobjectId { get; init; } + public string TagName { get; init; } = string.Empty; + public string AttributeName { get; init; } = string.Empty; + public string FullTagReference { get; init; } = string.Empty; + public int MxDataType { get; init; } + public string? DataTypeName { get; init; } + public bool IsArray { get; init; } + public int? ArrayDimension { get; init; } + public int MxAttributeCategory { get; init; } + public int SecurityClassification { get; init; } + public bool IsHistorized { get; init; } + public bool IsAlarm { get; init; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepository.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepository.cs new file mode 100644 index 0000000..2d511be --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepository.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.Data.SqlClient; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; + +/// +/// SQL access to the Galaxy ZB repository — port of v1 GalaxyRepositoryService. +/// The two SQL bodies (Hierarchy + Attributes) are byte-for-byte identical to v1 so the +/// queries surface the same row set at parity time. Extended-attributes and scope-filter +/// queries from v1 are intentionally not ported yet — they're refinements that aren't on +/// the Phase 2 critical path. +/// +public sealed class GalaxyRepository(GalaxyRepositoryOptions options) +{ + public async Task TestConnectionAsync(CancellationToken ct = default) + { + try + { + using var conn = new SqlConnection(options.ConnectionString); + await conn.OpenAsync(ct).ConfigureAwait(false); + using var cmd = new SqlCommand("SELECT 1", conn) { CommandTimeout = options.CommandTimeoutSeconds }; + var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); + return result is int i && i == 1; + } + catch (SqlException) { return false; } + catch (InvalidOperationException) { return false; } + } + + public async Task GetLastDeployTimeAsync(CancellationToken ct = default) + { + using var conn = new SqlConnection(options.ConnectionString); + await conn.OpenAsync(ct).ConfigureAwait(false); + using var cmd = new SqlCommand("SELECT time_of_last_deploy FROM galaxy", conn) + { CommandTimeout = options.CommandTimeoutSeconds }; + var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); + return result is DateTime dt ? dt : null; + } + + public async Task> GetHierarchyAsync(CancellationToken ct = default) + { + var rows = new List(); + + using var conn = new SqlConnection(options.ConnectionString); + await conn.OpenAsync(ct).ConfigureAwait(false); + + using var cmd = new SqlCommand(HierarchySql, conn) { CommandTimeout = options.CommandTimeoutSeconds }; + using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + var templateChainRaw = reader.IsDBNull(8) ? string.Empty : reader.GetString(8); + var templateChain = templateChainRaw.Length == 0 + ? Array.Empty() + : templateChainRaw.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => s.Length > 0) + .ToArray(); + + rows.Add(new GalaxyHierarchyRow + { + GobjectId = Convert.ToInt32(reader.GetValue(0)), + TagName = reader.GetString(1), + ContainedName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2), + BrowseName = reader.GetString(3), + ParentGobjectId = Convert.ToInt32(reader.GetValue(4)), + IsArea = Convert.ToInt32(reader.GetValue(5)) == 1, + CategoryId = Convert.ToInt32(reader.GetValue(6)), + HostedByGobjectId = Convert.ToInt32(reader.GetValue(7)), + TemplateChain = templateChain, + }); + } + return rows; + } + + public async Task> GetAttributesAsync(CancellationToken ct = default) + { + var rows = new List(); + + using var conn = new SqlConnection(options.ConnectionString); + await conn.OpenAsync(ct).ConfigureAwait(false); + + using var cmd = new SqlCommand(AttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds }; + using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + rows.Add(new GalaxyAttributeRow + { + GobjectId = Convert.ToInt32(reader.GetValue(0)), + TagName = reader.GetString(1), + AttributeName = reader.GetString(2), + FullTagReference = reader.GetString(3), + MxDataType = Convert.ToInt32(reader.GetValue(4)), + DataTypeName = reader.IsDBNull(5) ? null : reader.GetString(5), + IsArray = Convert.ToInt32(reader.GetValue(6)) == 1, + ArrayDimension = reader.IsDBNull(7) ? (int?)null : Convert.ToInt32(reader.GetValue(7)), + MxAttributeCategory = Convert.ToInt32(reader.GetValue(8)), + SecurityClassification = Convert.ToInt32(reader.GetValue(9)), + IsHistorized = Convert.ToInt32(reader.GetValue(10)) == 1, + IsAlarm = Convert.ToInt32(reader.GetValue(11)) == 1, + }); + } + return rows; + } + + private const string HierarchySql = @" +;WITH template_chain AS ( + SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id, + t.tag_name AS template_tag_name, t.derived_from_gobject_id, 0 AS depth + FROM gobject g + INNER JOIN gobject t ON t.gobject_id = g.derived_from_gobject_id + WHERE g.is_template = 0 AND g.deployed_package_id <> 0 AND g.derived_from_gobject_id <> 0 + UNION ALL + SELECT tc.instance_gobject_id, t.gobject_id, t.tag_name, t.derived_from_gobject_id, tc.depth + 1 + FROM template_chain tc + INNER JOIN gobject t ON t.gobject_id = tc.derived_from_gobject_id + WHERE tc.derived_from_gobject_id <> 0 AND tc.depth < 10 +) +SELECT DISTINCT + g.gobject_id, + g.tag_name, + g.contained_name, + CASE WHEN g.contained_name IS NULL OR g.contained_name = '' + THEN g.tag_name + ELSE g.contained_name + END AS browse_name, + CASE WHEN g.contained_by_gobject_id = 0 + THEN g.area_gobject_id + ELSE g.contained_by_gobject_id + END AS parent_gobject_id, + CASE WHEN td.category_id = 13 + THEN 1 + ELSE 0 + END AS is_area, + td.category_id AS category_id, + g.hosted_by_gobject_id AS hosted_by_gobject_id, + ISNULL( + STUFF(( + SELECT '|' + tc.template_tag_name + FROM template_chain tc + WHERE tc.instance_gobject_id = g.gobject_id + ORDER BY tc.depth + FOR XML PATH('') + ), 1, 1, ''), + '' + ) AS template_chain +FROM gobject g +INNER JOIN template_definition td + ON g.template_definition_id = td.template_definition_id +WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26) + AND g.is_template = 0 + AND g.deployed_package_id <> 0 +ORDER BY parent_gobject_id, g.tag_name"; + + private const string AttributesSql = @" +;WITH deployed_package_chain AS ( + SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth + FROM gobject g + INNER JOIN package p ON p.package_id = g.deployed_package_id + WHERE g.is_template = 0 AND g.deployed_package_id <> 0 + UNION ALL + SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1 + FROM deployed_package_chain dpc + INNER JOIN package p ON p.package_id = dpc.derived_from_package_id + WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10 +) +SELECT gobject_id, tag_name, attribute_name, full_tag_reference, + mx_data_type, data_type_name, is_array, array_dimension, + mx_attribute_category, security_classification, is_historized, is_alarm +FROM ( + SELECT + dpc.gobject_id, + g.tag_name, + da.attribute_name, + g.tag_name + '.' + da.attribute_name + + CASE WHEN da.is_array = 1 THEN '[]' ELSE '' END + AS full_tag_reference, + da.mx_data_type, + dt.description AS data_type_name, + da.is_array, + CASE WHEN da.is_array = 1 + THEN CONVERT(int, CONVERT(varbinary(2), + SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2)) + ELSE NULL + END AS array_dimension, + da.mx_attribute_category, + da.security_classification, + CASE WHEN EXISTS ( + SELECT 1 FROM deployed_package_chain dpc2 + INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name + INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension' + WHERE dpc2.gobject_id = dpc.gobject_id + ) THEN 1 ELSE 0 END AS is_historized, + CASE WHEN EXISTS ( + SELECT 1 FROM deployed_package_chain dpc2 + INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name + INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension' + WHERE dpc2.gobject_id = dpc.gobject_id + ) THEN 1 ELSE 0 END AS is_alarm, + ROW_NUMBER() OVER ( + PARTITION BY dpc.gobject_id, da.attribute_name + ORDER BY dpc.depth + ) AS rn + FROM deployed_package_chain dpc + INNER JOIN dynamic_attribute da + ON da.package_id = dpc.package_id + INNER JOIN gobject g + ON g.gobject_id = dpc.gobject_id + INNER JOIN template_definition td + ON td.template_definition_id = g.template_definition_id + LEFT JOIN data_type dt + ON dt.mx_data_type = da.mx_data_type + WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26) + AND da.attribute_name NOT LIKE '[_]%' + AND da.attribute_name NOT LIKE '%.Description' + AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24) +) ranked +WHERE rn = 1 +ORDER BY tag_name, attribute_name"; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepositoryOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepositoryOptions.cs new file mode 100644 index 0000000..b72a759 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepositoryOptions.cs @@ -0,0 +1,13 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; + +/// +/// Connection settings for the Galaxy ZB repository database. Set from the +/// DriverConfig JSON section Database per plan.md §"Galaxy DriverConfig". +/// +public sealed class GalaxyRepositoryOptions +{ + public string ConnectionString { get; init; } = + "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;"; + + public int CommandTimeoutSeconds { get; init; } = 60; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj index 565eb6c..0c713f8 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj @@ -20,6 +20,7 @@ + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRepositoryLiveSmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRepositoryLiveSmokeTests.cs new file mode 100644 index 0000000..fe5f741 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRepositoryLiveSmokeTests.cs @@ -0,0 +1,100 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests +{ + /// + /// Live smoke against the Galaxy ZB repository. Skipped when ZB is unreachable so + /// CI / dev boxes without an AVEVA install still pass. Exercises the ported + /// + against the same + /// SQL the v1 Host uses, proving the lift is byte-for-byte equivalent at the + /// DiscoverHierarchyResponse shape. + /// + [Trait("Category", "LiveGalaxy")] + public sealed class GalaxyRepositoryLiveSmokeTests + { + private static GalaxyRepositoryOptions DevZbOptions() => new() + { + ConnectionString = + "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;Connect Timeout=2;", + CommandTimeoutSeconds = 10, + }; + + private static async Task ZbReachableAsync() + { + try + { + var repo = new GalaxyRepository(DevZbOptions()); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + return await repo.TestConnectionAsync(cts.Token); + } + catch { return false; } + } + + [Fact] + public async Task TestConnection_returns_true_against_live_ZB() + { + if (!await ZbReachableAsync()) return; + + var repo = new GalaxyRepository(DevZbOptions()); + (await repo.TestConnectionAsync()).ShouldBeTrue(); + } + + [Fact] + public async Task GetHierarchy_returns_at_least_one_deployed_gobject() + { + if (!await ZbReachableAsync()) return; + + var repo = new GalaxyRepository(DevZbOptions()); + var rows = await repo.GetHierarchyAsync(); + + rows.Count.ShouldBeGreaterThan(0, + "the dev Galaxy has at least the WinPlatform + AppEngine deployed"); + rows.ShouldAllBe(r => !string.IsNullOrEmpty(r.TagName)); + } + + [Fact] + public async Task GetAttributes_returns_attributes_for_deployed_objects() + { + if (!await ZbReachableAsync()) return; + + var repo = new GalaxyRepository(DevZbOptions()); + var attrs = await repo.GetAttributesAsync(); + + attrs.Count.ShouldBeGreaterThan(0); + attrs.ShouldAllBe(a => !string.IsNullOrEmpty(a.FullTagReference) && a.FullTagReference.Contains(".")); + } + + [Fact] + public async Task GetLastDeployTime_returns_a_value() + { + if (!await ZbReachableAsync()) return; + + var repo = new GalaxyRepository(DevZbOptions()); + var ts = await repo.GetLastDeployTimeAsync(); + ts.ShouldNotBeNull(); + } + + [Fact] + public async Task DbBackedBackend_DiscoverAsync_returns_objects_with_attributes_and_categories() + { + if (!await ZbReachableAsync()) return; + + var backend = new DbBackedGalaxyBackend(new GalaxyRepository(DevZbOptions())); + var resp = await backend.DiscoverAsync(new DiscoverHierarchyRequest { SessionId = 1 }, CancellationToken.None); + + resp.Success.ShouldBeTrue(resp.Error); + resp.Objects.Length.ShouldBeGreaterThan(0); + + var firstWithAttrs = System.Linq.Enumerable.FirstOrDefault(resp.Objects, o => o.Attributes.Length > 0); + firstWithAttrs.ShouldNotBeNull("at least one gobject in the dev Galaxy carries dynamic attributes"); + firstWithAttrs!.TemplateCategory.ShouldNotBeNullOrEmpty(); + } + } +}