From 4f1925870f90d71d2f8611b1fa0c50732a548586 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 05:40:24 -0400 Subject: [PATCH] feat(transport): name-map types + preview/selection/summary extensions (M8 A1) --- .../Types/Transport/BundleNameMap.cs | 53 ++++++++ .../Types/Transport/BundleSummary.cs | 7 +- .../Types/Transport/ExportSelection.cs | 13 +- .../Types/Transport/ImportPreview.cs | 36 ++++- .../Types/Transport/BundleNameMapTests.cs | 127 ++++++++++++++++++ 5 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleNameMap.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Transport/BundleNameMapTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleNameMap.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleNameMap.cs new file mode 100644 index 00000000..467962b9 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleNameMap.cs @@ -0,0 +1,53 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; + +/// +/// How a source-environment site or connection referenced by an incoming bundle is +/// resolved against the destination environment during import. +/// +public enum MappingAction +{ + /// Bind the source name to an existing destination site/connection + /// (the corresponding Target* identifier is required). + MapToExisting, + + /// Create a new destination site/connection from the bundle's payload + /// (the corresponding Target* identifier is left null). + CreateNew, +} + +/// +/// Resolves one site referenced by an incoming bundle (M8 site/instance-scoped +/// transport). is required when +/// is and null +/// when it is . +/// +public sealed record SiteMapping( + string SourceSiteIdentifier, + MappingAction Action, + string? TargetSiteIdentifier); + +/// +/// Resolves one data connection (scoped to a source site) referenced by an incoming +/// bundle. is required when +/// is and null +/// when it is . +/// +public sealed record ConnectionMapping( + string SourceSiteIdentifier, + string SourceConnectionName, + MappingAction Action, + string? TargetConnectionName); + +/// +/// The operator-supplied resolution of every source-environment site and connection +/// name an incoming bundle references, applied during import. Crosses the Central UI ⇆ +/// ManagementActor boundary via System.Text.Json. +/// +public sealed record BundleNameMap( + IReadOnlyList Sites, + IReadOnlyList Connections) +{ + /// An empty map (no site or connection mappings). + public static BundleNameMap Empty { get; } = + new(Array.Empty(), Array.Empty()); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSummary.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSummary.cs index 328a682e..5397980e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSummary.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSummary.cs @@ -10,4 +10,9 @@ public sealed record BundleSummary( int DbConnections, int NotificationLists, int SmtpConfigs, - int ApiMethods); + int ApiMethods, + // Additive (M8 A1): site/instance-scoped artifacts. Defaulted to 0 so manifests + // written by older exporters (which omit these JSON properties) deserialize fine. + int Sites = 0, + int DataConnections = 0, + int Instances = 0); diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ExportSelection.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ExportSelection.cs index b0d2d622..051e6413 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ExportSelection.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ExportSelection.cs @@ -13,4 +13,15 @@ public sealed record ExportSelection( IReadOnlyList NotificationListIds, IReadOnlyList SmtpConfigurationIds, IReadOnlyList ApiMethodIds, - bool IncludeDependencies); + bool IncludeDependencies, + // Additive (M8 A1): site/instance-scoped export. Defaulted to empty so every + // existing positional caller keeps compiling; older callers select no sites/instances. + IReadOnlyList? SiteIds = null, + IReadOnlyList? InstanceIds = null) +{ + /// Sites selected for site/instance-scoped export (M8). Never null. + public IReadOnlyList SiteIds { get; init; } = SiteIds ?? Array.Empty(); + + /// Instances selected for site/instance-scoped export (M8). Never null. + public IReadOnlyList InstanceIds { get; init; } = InstanceIds ?? Array.Empty(); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ImportPreview.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ImportPreview.cs index 35914147..c0fa855b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ImportPreview.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ImportPreview.cs @@ -11,6 +11,40 @@ public sealed record ImportPreviewItem( string? FieldDiffJson, string? BlockerReason); +/// +/// A site reference in an incoming bundle that the operator must resolve before import +/// (M8 A1). is the destination site the +/// importer auto-matched by identifier, or null when no match was found. +/// +public sealed record RequiredSiteMapping( + string SourceSiteIdentifier, + string SourceSiteName, + string? AutoMatchTargetIdentifier); + +/// +/// A connection reference (scoped to a source site) in an incoming bundle that the +/// operator must resolve before import (M8 A1). +/// is the destination connection the importer auto-matched by name, or null when no +/// match was found. +/// +public sealed record RequiredConnectionMapping( + string SourceSiteIdentifier, + string SourceConnectionName, + string? AutoMatchTargetName); + public sealed record ImportPreview( Guid SessionId, - IReadOnlyList Items); + IReadOnlyList Items, + // Additive (M8 A1): site/connection references the operator must resolve before + // applying. Defaulted to empty so every existing positional caller keeps compiling. + IReadOnlyList? RequiredSiteMappings = null, + IReadOnlyList? RequiredConnectionMappings = null) +{ + /// Site references the operator must resolve before import (M8). Never null. + public IReadOnlyList RequiredSiteMappings { get; init; } = + RequiredSiteMappings ?? Array.Empty(); + + /// Connection references the operator must resolve before import (M8). Never null. + public IReadOnlyList RequiredConnectionMappings { get; init; } = + RequiredConnectionMappings ?? Array.Empty(); +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Transport/BundleNameMapTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Transport/BundleNameMapTests.cs new file mode 100644 index 00000000..ea12f9a9 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Transport/BundleNameMapTests.cs @@ -0,0 +1,127 @@ +using System.Text.Json; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Transport; + +/// +/// M8 A1: focused shape / value-equality / round-trip tests for the Transport (#24) +/// name-mapping DTOs — , , +/// and . These records cross the Central UI ⇆ ManagementActor +/// boundary (and may be persisted in an import session) via System.Text.Json, so a +/// positional/tuple slip would silently mis-map sites/connections during import. +/// Uses plain (no dependency on the Transport project's +/// BundleJsonOptions) per the A1 task contract. +/// +public sealed class BundleNameMapTests +{ + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + + [Fact] + public void Empty_HasNoSitesOrConnections() + { + var map = BundleNameMap.Empty; + + Assert.Empty(map.Sites); + Assert.Empty(map.Connections); + } + + [Fact] + public void Empty_IsStableInstance() + { + // The Empty singleton must be reusable without aliasing surprises. + Assert.Same(BundleNameMap.Empty, BundleNameMap.Empty); + } + + [Fact] + public void SiteMapping_ValueEquality_HoldsForIdenticalFields() + { + var a = new SiteMapping("site-east", MappingAction.MapToExisting, "site-1"); + var b = new SiteMapping("site-east", MappingAction.MapToExisting, "site-1"); + + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void SiteMapping_ValueEquality_DiffersWhenActionDiffers() + { + var mapTo = new SiteMapping("site-east", MappingAction.MapToExisting, "site-1"); + var createNew = new SiteMapping("site-east", MappingAction.CreateNew, null); + + Assert.NotEqual(mapTo, createNew); + } + + [Fact] + public void ConnectionMapping_ValueEquality_HoldsForIdenticalFields() + { + var a = new ConnectionMapping("site-east", "OPC-Main", MappingAction.MapToExisting, "OPC-Primary"); + var b = new ConnectionMapping("site-east", "OPC-Main", MappingAction.MapToExisting, "OPC-Primary"); + + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void MapToExisting_CarriesNonNullTarget() + { + var site = new SiteMapping("site-east", MappingAction.MapToExisting, "site-1"); + var conn = new ConnectionMapping("site-east", "OPC-Main", MappingAction.MapToExisting, "OPC-Primary"); + + Assert.Equal(MappingAction.MapToExisting, site.Action); + Assert.NotNull(site.TargetSiteIdentifier); + Assert.Equal("site-1", site.TargetSiteIdentifier); + + Assert.Equal(MappingAction.MapToExisting, conn.Action); + Assert.NotNull(conn.TargetConnectionName); + Assert.Equal("OPC-Primary", conn.TargetConnectionName); + } + + [Fact] + public void CreateNew_LeavesTargetNull() + { + var site = new SiteMapping("site-west", MappingAction.CreateNew, null); + var conn = new ConnectionMapping("site-west", "OPC-Aux", MappingAction.CreateNew, null); + + Assert.Null(site.TargetSiteIdentifier); + Assert.Null(conn.TargetConnectionName); + } + + [Fact] + public void BundleNameMap_JsonRoundTrip_PreservesSitesAndConnections() + { + var map = new BundleNameMap( + Sites: new[] + { + new SiteMapping("site-east", MappingAction.MapToExisting, "site-1"), + new SiteMapping("site-west", MappingAction.CreateNew, null), + }, + Connections: new[] + { + new ConnectionMapping("site-east", "OPC-Main", MappingAction.MapToExisting, "OPC-Primary"), + new ConnectionMapping("site-west", "OPC-Aux", MappingAction.CreateNew, null), + }); + + var json = JsonSerializer.Serialize(map, JsonOpts); + var rt = JsonSerializer.Deserialize(json, JsonOpts); + + Assert.NotNull(rt); + // BundleNameMap's record Equals is reference-based over its IReadOnlyList members, + // and JSON deserialization yields List rather than the original T[] — so the + // collections never reference-equal. The records *within* the lists have proper + // value-equality, so compare element-wise (SequenceEqual uses SiteMapping/ + // ConnectionMapping value-equality). + Assert.True(map.Sites.SequenceEqual(rt!.Sites)); + Assert.True(map.Connections.SequenceEqual(rt.Connections)); + } + + [Fact] + public void BundleNameMap_Empty_JsonRoundTrip_PreservesEmptiness() + { + var json = JsonSerializer.Serialize(BundleNameMap.Empty, JsonOpts); + var rt = JsonSerializer.Deserialize(json, JsonOpts); + + Assert.NotNull(rt); + Assert.Empty(rt!.Sites); + Assert.Empty(rt.Connections); + } +}