diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Export/DependencyResolver.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Export/DependencyResolver.cs index 4f7c3d50..aac641ae 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Export/DependencyResolver.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Export/DependencyResolver.cs @@ -1,7 +1,9 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; @@ -22,6 +24,13 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Export; /// /// Templates are returned topologically sorted (base-before-derived) via Kahn's /// algorithm so importers can apply them in order. +/// +/// M8 adds site/instance-scoped expansion: selecting a site pulls its +/// DataConnections and Instances; selecting an instance (with +/// ) pulls its owning site, the +/// data connections it binds, and feeds its template into the existing template +/// closure so the template/shared-script/external-system graph expands too. +/// /// public sealed class DependencyResolver { @@ -29,22 +38,26 @@ public sealed class DependencyResolver private readonly IExternalSystemRepository _externalSystems; private readonly INotificationRepository _notifications; private readonly IInboundApiRepository _inboundApi; + private readonly ISiteRepository _siteRepository; /// Initializes a new instance of . - /// Repository for template and shared script access. + /// Repository for template, instance and shared script access. /// Repository for external system definitions and methods. /// Repository for notification lists and SMTP configurations. /// Repository for inbound API keys and methods. + /// Repository for sites and site-scoped data connections. public DependencyResolver( ITemplateEngineRepository templates, IExternalSystemRepository externalSystems, INotificationRepository notifications, - IInboundApiRepository inboundApi) + IInboundApiRepository inboundApi, + ISiteRepository siteRepository) { _templates = templates ?? throw new ArgumentNullException(nameof(templates)); _externalSystems = externalSystems ?? throw new ArgumentNullException(nameof(externalSystems)); _notifications = notifications ?? throw new ArgumentNullException(nameof(notifications)); _inboundApi = inboundApi ?? throw new ArgumentNullException(nameof(inboundApi)); + _siteRepository = siteRepository ?? throw new ArgumentNullException(nameof(siteRepository)); } /// Expands the selection into a fully self-consistent set of entities for bundling. @@ -108,9 +121,53 @@ public sealed class DependencyResolver if (m is not null) apiMethods[m.Id] = m; } + // ---- Seed: site/instance-scoped selection (M8) ---- + // sites/dataConnections/instances are keyed by surrogate id for dedup. + var sites = new Dictionary(); + var dataConnections = new Dictionary(); + var instances = new Dictionary(); + + // Selecting a SITE pulls its data connections and all of its instances. The + // instance's overrides/bindings are loaded eagerly so the serializer can read + // them off the entity navigation collections. + foreach (var siteId in selection.SiteIds.Distinct()) + { + var site = await _siteRepository.GetSiteByIdAsync(siteId, ct).ConfigureAwait(false); + if (site is null) continue; + sites[site.Id] = site; + + foreach (var conn in await _siteRepository.GetDataConnectionsBySiteIdAsync(site.Id, ct).ConfigureAwait(false)) + { + dataConnections[conn.Id] = conn; + } + + foreach (var siteInstance in await _siteRepository.GetInstancesBySiteIdAsync(site.Id, ct).ConfigureAwait(false)) + { + if (instances.ContainsKey(siteInstance.Id)) continue; + var loaded = await LoadInstanceWithChildrenAsync(siteInstance.Id, ct).ConfigureAwait(false); + if (loaded is not null) instances[loaded.Id] = loaded; + } + } + + // Selecting an INSTANCE directly. Dedup against instances already pulled in + // via their owning site above (a site + one of its instances selected together + // must not double-add the instance). + foreach (var instanceId in selection.InstanceIds.Distinct()) + { + if (instances.ContainsKey(instanceId)) continue; + var loaded = await LoadInstanceWithChildrenAsync(instanceId, ct).ConfigureAwait(false); + if (loaded is not null) instances[loaded.Id] = loaded; + } + // ---- Dependency expansion ---- if (selection.IncludeDependencies) { + // Each gathered instance pulls in its owning site, the data connections it + // binds, and (by feeding instance.TemplateId into the template dictionary) + // its template — so the template/shared-script/external-system closure below + // expands transitively over instance templates too. + await ExpandSiteInstanceClosureAsync(instances, sites, dataConnections, templates, ct).ConfigureAwait(false); + await ExpandTemplateClosureAsync(templates, ct).ConfigureAwait(false); await ExpandSharedScriptsFromTemplatesAsync(templates, sharedScripts, ct).ConfigureAwait(false); await ExpandSharedScriptsFromApiMethodsAsync(apiMethods, sharedScripts, ct).ConfigureAwait(false); @@ -134,6 +191,22 @@ public sealed class DependencyResolver // ---- Topological sort of templates (base-before-derived) ---- var orderedTemplates = TopologicallySortTemplates(templates.Values); + // ---- Deterministic site/instance ordering (M8) ---- + // siteId → SiteIdentifier map: every connection's/instance's owning site lands + // in `sites` after closure expansion, so ordering + dependsOn edges resolve names. + var siteIdentifierById = sites.Values.ToDictionary(s => s.Id, s => s.SiteIdentifier); + + var orderedSites = sites.Values + .OrderBy(s => s.SiteIdentifier, StringComparer.Ordinal) + .ToList(); + var orderedDataConnections = dataConnections.Values + .OrderBy(c => SiteIdentifierOf(c.SiteId, siteIdentifierById), StringComparer.Ordinal) + .ThenBy(c => c.Name, StringComparer.Ordinal) + .ToList(); + var orderedInstances = instances.Values + .OrderBy(i => i.UniqueName, StringComparer.Ordinal) + .ToList(); + // ---- Build deterministic content manifest ---- var manifest = BuildContentManifest( folders, @@ -144,7 +217,12 @@ public sealed class DependencyResolver dbConnections.Values, notificationLists.Values, smtpConfigs.Values, - apiMethods.Values); + apiMethods.Values, + orderedSites, + orderedDataConnections, + orderedInstances, + siteIdentifierById, + templates); return new ResolvedExport( TemplateFolders: folders, @@ -156,9 +234,101 @@ public sealed class DependencyResolver NotificationLists: notificationLists.Values.OrderBy(n => n.Name, StringComparer.Ordinal).ToList(), SmtpConfigs: smtpConfigs.Values.OrderBy(s => s.Host, StringComparer.Ordinal).ToList(), ApiMethods: apiMethods.Values.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(), - ContentManifest: manifest); + ContentManifest: manifest) + { + Sites = orderedSites, + DataConnections = orderedDataConnections, + Instances = orderedInstances, + }; } + // ---- Instance child loading (M8) ---- + // Loads an instance and attaches its overrides/bindings onto the entity's own + // navigation collections, so the serializer reads children off the entity (the + // same shape central-config entities use). Returns null if the instance is gone. + private async Task LoadInstanceWithChildrenAsync(int instanceId, CancellationToken ct) + { + var instance = await _templates.GetInstanceByIdAsync(instanceId, ct).ConfigureAwait(false); + if (instance is null) return null; + + var attributeOverrides = await _templates.GetOverridesByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false); + var alarmOverrides = await _templates.GetAlarmOverridesByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false); + var nativeAlarmOverrides = await _templates.GetNativeAlarmSourceOverridesByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false); + var bindings = await _templates.GetBindingsByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false); + + instance.AttributeOverrides = attributeOverrides.ToList(); + instance.AlarmOverrides = alarmOverrides.ToList(); + instance.NativeAlarmSourceOverrides = nativeAlarmOverrides.ToList(); + instance.ConnectionBindings = bindings.ToList(); + return instance; + } + + // ---- Site/instance closure (M8) ---- + // For every gathered instance: ensure its owning Site is in the bundle, include + // the DataConnections it binds (via ConnectionBinding.DataConnectionId resolved + // within the instance's site, plus NativeAlarmSourceOverride.ConnectionNameOverride + // resolved by name within that site), and feed its TemplateId into the template + // dictionary so the template closure expands over it. + private async Task ExpandSiteInstanceClosureAsync( + Dictionary instances, + Dictionary sites, + Dictionary dataConnections, + Dictionary templates, + CancellationToken ct) + { + // Snapshot the instances: the loop only grows sites/dataConnections/templates, + // never instances, so iterating a snapshot keeps the dictionary stable. + foreach (var instance in instances.Values.ToList()) + { + // 1. Owning site. + if (!sites.ContainsKey(instance.SiteId)) + { + var site = await _siteRepository.GetSiteByIdAsync(instance.SiteId, ct).ConfigureAwait(false); + if (site is not null) sites[site.Id] = site; + } + + // 2. Template → the existing template closure picks it up below. + if (!templates.ContainsKey(instance.TemplateId)) + { + var template = await _templates.GetTemplateWithChildrenAsync(instance.TemplateId, ct).ConfigureAwait(false); + if (template is not null) templates[template.Id] = template; + } + + // 3. Data connections the instance references, resolved within its site. + // Connection bindings reference connections by id; native-alarm-source + // overrides reference them by name — resolve both against the site set. + var bindingConnectionIds = instance.ConnectionBindings + .Select(b => b.DataConnectionId) + .Where(id => !dataConnections.ContainsKey(id)) + .Distinct() + .ToList(); + + var referencedConnectionNames = instance.NativeAlarmSourceOverrides + .Select(o => o.ConnectionNameOverride) + .Where(n => !string.IsNullOrEmpty(n)) + .Select(n => n!) + .ToHashSet(StringComparer.Ordinal); + + if (bindingConnectionIds.Count == 0 && referencedConnectionNames.Count == 0) continue; + + var siteConnections = await _siteRepository + .GetDataConnectionsBySiteIdAsync(instance.SiteId, ct) + .ConfigureAwait(false); + + foreach (var conn in siteConnections) + { + if (dataConnections.ContainsKey(conn.Id)) continue; + if (bindingConnectionIds.Contains(conn.Id) || referencedConnectionNames.Contains(conn.Name)) + { + dataConnections[conn.Id] = conn; + } + } + } + } + + private static string SiteIdentifierOf(int siteId, IReadOnlyDictionary siteIdentifierById) => + siteIdentifierById.TryGetValue(siteId, out var id) ? id : siteId.ToString(); + // ---- Template composition closure ---- private async Task ExpandTemplateClosureAsync(Dictionary templates, CancellationToken ct) { @@ -362,7 +532,12 @@ public sealed class DependencyResolver IEnumerable dbConnections, IEnumerable notificationLists, IEnumerable smtpConfigs, - IEnumerable apiMethods) + IEnumerable apiMethods, + IReadOnlyList sites, + IReadOnlyList dataConnections, + IReadOnlyList instances, + IReadOnlyDictionary siteIdentifierById, + IReadOnlyDictionary templatesById) { var entries = new List(); @@ -418,6 +593,66 @@ public sealed class DependencyResolver entries.Add(new ManifestContentEntry("ApiMethod", m.Name, 1, Array.Empty())); } + // ---- M8: site/instance-scoped manifest rows ---- + // Sites are roots (no dependsOn). DataConnections depend on their site. + // Instances depend on their template, their site, and each data connection + // they bind. Inputs arrive pre-ordered, so iterate as-is for determinism. + var connectionById = dataConnections.ToDictionary(c => c.Id); + var connectionBySiteAndName = dataConnections + .GroupBy(c => c.SiteId) + .ToDictionary(g => g.Key, g => g.ToDictionary(c => c.Name, StringComparer.Ordinal)); + + foreach (var site in sites) + { + entries.Add(new ManifestContentEntry("Site", site.SiteIdentifier, 1, Array.Empty())); + } + foreach (var conn in dataConnections) + { + var siteIdentifier = SiteIdentifierOf(conn.SiteId, siteIdentifierById); + entries.Add(new ManifestContentEntry( + "DataConnection", + $"{siteIdentifier}/{conn.Name}", + 1, + new[] { $"Site:{siteIdentifier}" })); + } + foreach (var instance in instances) + { + var siteIdentifier = SiteIdentifierOf(instance.SiteId, siteIdentifierById); + var templateName = templatesById.TryGetValue(instance.TemplateId, out var tpl) + ? tpl.Name + : instance.TemplateId.ToString(); + + var deps = new List + { + $"Template:{templateName}", + $"Site:{siteIdentifier}", + }; + + // Connection-binding edges (resolved by id within the export closure). + foreach (var binding in instance.ConnectionBindings) + { + if (connectionById.TryGetValue(binding.DataConnectionId, out var conn)) + { + deps.Add($"DataConnection:{SiteIdentifierOf(conn.SiteId, siteIdentifierById)}/{conn.Name}"); + } + } + // Native-alarm-source connection-name overrides (resolved by name within the site). + if (connectionBySiteAndName.TryGetValue(instance.SiteId, out var byName)) + { + foreach (var ovr in instance.NativeAlarmSourceOverrides) + { + if (!string.IsNullOrEmpty(ovr.ConnectionNameOverride) + && byName.ContainsKey(ovr.ConnectionNameOverride)) + { + deps.Add($"DataConnection:{siteIdentifier}/{ovr.ConnectionNameOverride}"); + } + } + } + + var orderedDeps = deps.Distinct().OrderBy(x => x, StringComparer.Ordinal).ToList(); + entries.Add(new ManifestContentEntry("Instance", instance.UniqueName, 1, orderedDeps)); + } + return entries; } } diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Export/ResolvedExport.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Export/ResolvedExport.cs index c1ca966d..1e57f8c5 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Export/ResolvedExport.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Export/ResolvedExport.cs @@ -1,7 +1,9 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; // ApiMethod +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; @@ -25,4 +27,33 @@ public sealed record ResolvedExport( IReadOnlyList SmtpConfigs, // Inbound API keys are not transported between environments (re-arch C4); only methods. IReadOnlyList ApiMethods, - IReadOnlyList ContentManifest); + IReadOnlyList ContentManifest) +{ + // M8: site/instance-scoped closure. Additive init-only collections with empty + // defaults so the positional ctor stays source-compatible for callers/tests + // that only resolve central-config selections (sites/instances stay empty). + // The resolver opts in via object-initializer. Each + // carries its overrides/bindings on its own navigation collections + // (AttributeOverrides / AlarmOverrides / NativeAlarmSourceOverrides / + // ConnectionBindings), populated by the resolver — the serializer reads them + // off the entity, matching how the central-config entities carry their children. + // + // Ordering is fixed by the resolver: Sites by SiteIdentifier; DataConnections + // by (SiteIdentifier, Name); Instances by UniqueName. + + /// Sites in the export closure, ordered by . + public IReadOnlyList Sites { get; init; } = Array.Empty(); + + /// + /// Site-scoped s in the closure, ordered by + /// (owning site identifier, connection name). + /// + public IReadOnlyList DataConnections { get; init; } = Array.Empty(); + + /// + /// Instances in the closure, ordered by . Each + /// carries its loaded override/binding child collections on its navigation + /// properties. + /// + public IReadOnlyList Instances { get; init; } = Array.Empty(); +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Export/DependencyResolverTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Export/DependencyResolverTests.cs index 517fe856..295a24f2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Export/DependencyResolverTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Export/DependencyResolverTests.cs @@ -1,7 +1,9 @@ using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; @@ -15,8 +17,9 @@ public sealed class DependencyResolverTests private readonly IExternalSystemRepository _externalSystems = Substitute.For(); private readonly INotificationRepository _notifications = Substitute.For(); private readonly IInboundApiRepository _inboundApi = Substitute.For(); + private readonly ISiteRepository _sites = Substitute.For(); - private DependencyResolver Sut() => new(_templates, _externalSystems, _notifications, _inboundApi); + private DependencyResolver Sut() => new(_templates, _externalSystems, _notifications, _inboundApi, _sites); private static ExportSelection SelectTemplates(params int[] ids) => new( TemplateIds: ids, @@ -32,12 +35,35 @@ public sealed class DependencyResolverTests TemplateIds: Array.Empty(), SharedScriptIds: Array.Empty(), ExternalSystemIds: Array.Empty(), - DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), + DatabaseConnectionIds: Array.Empty(), SmtpConfigurationIds: Array.Empty(), ApiMethodIds: ids, IncludeDependencies: true); + private static ExportSelection SelectSites(int[] siteIds, bool includeDeps = true) => new( + TemplateIds: Array.Empty(), + SharedScriptIds: Array.Empty(), + ExternalSystemIds: Array.Empty(), + DatabaseConnectionIds: Array.Empty(), + NotificationListIds: Array.Empty(), + SmtpConfigurationIds: Array.Empty(), + ApiMethodIds: Array.Empty(), + IncludeDependencies: includeDeps, + SiteIds: siteIds); + + private static ExportSelection SelectInstances(int[] instanceIds, bool includeDeps = true, int[]? siteIds = null) => new( + TemplateIds: Array.Empty(), + SharedScriptIds: Array.Empty(), + ExternalSystemIds: Array.Empty(), + DatabaseConnectionIds: Array.Empty(), + NotificationListIds: Array.Empty(), + SmtpConfigurationIds: Array.Empty(), + ApiMethodIds: Array.Empty(), + IncludeDependencies: includeDeps, + SiteIds: siteIds ?? Array.Empty(), + InstanceIds: instanceIds); + private void StubTemplate(Template t) { _templates.GetTemplateWithChildrenAsync(t.Id, Arg.Any()).Returns(t); @@ -64,6 +90,36 @@ public sealed class DependencyResolverTests _templates.GetAllFoldersAsync(Arg.Any()).Returns(folders); } + private void StubSite(Site site) + { + _sites.GetSiteByIdAsync(site.Id, Arg.Any()).Returns(site); + } + + private void StubSiteConnections(int siteId, params DataConnection[] connections) + { + _sites.GetDataConnectionsBySiteIdAsync(siteId, Arg.Any()).Returns(connections); + } + + private void StubSiteInstances(int siteId, params Instance[] instances) + { + _sites.GetInstancesBySiteIdAsync(siteId, Arg.Any()).Returns(instances); + } + + // Stubs the per-instance child getters on the template repo so the resolver can + // load an instance + its (possibly empty) override/binding collections by id. + private void StubInstance(Instance instance) + { + _templates.GetInstanceByIdAsync(instance.Id, Arg.Any()).Returns(instance); + _templates.GetOverridesByInstanceIdAsync(instance.Id, Arg.Any()) + .Returns(instance.AttributeOverrides.ToList()); + _templates.GetAlarmOverridesByInstanceIdAsync(instance.Id, Arg.Any()) + .Returns(instance.AlarmOverrides.ToList()); + _templates.GetNativeAlarmSourceOverridesByInstanceIdAsync(instance.Id, Arg.Any()) + .Returns(instance.NativeAlarmSourceOverrides.ToList()); + _templates.GetBindingsByInstanceIdAsync(instance.Id, Arg.Any()) + .Returns(instance.ConnectionBindings.ToList()); + } + [Fact] public async Task Resolve_includes_base_template_for_composed_template() { @@ -212,4 +268,200 @@ public sealed class DependencyResolverTests Assert.Equal("Middle", result.Templates[1].Name); Assert.Equal("Top", result.Templates[2].Name); } + + // --------------------------------------------------------------------------- + // M8 (B1): site/instance-scoped expansion. + // --------------------------------------------------------------------------- + + [Fact] + public async Task Resolve_site_pulls_its_connections_and_instances() + { + var site = new Site("Plant North", "site-n") { Id = 1 }; + var conn = new DataConnection("opc-1", "OpcUa", site.Id) { Id = 5 }; + var template = new Template("PumpTpl") { Id = 9 }; + var instance = new Instance("Pump01") { Id = 30, SiteId = site.Id, TemplateId = template.Id }; + + StubSite(site); + StubSiteConnections(site.Id, conn); + StubSiteInstances(site.Id, instance); + StubInstance(instance); + StubTemplate(template); + StubAllSharedScripts(); + StubAllExternalSystems(); + StubAllFolders(); + + var result = await Sut().ResolveAsync(SelectSites(new[] { site.Id }), CancellationToken.None); + + Assert.Single(result.Sites, s => s.SiteIdentifier == "site-n"); + Assert.Single(result.DataConnections, c => c.Name == "opc-1"); + Assert.Single(result.Instances, i => i.UniqueName == "Pump01"); + // IncludeDependencies feeds the instance's template into the template closure. + Assert.Single(result.Templates, t => t.Id == 9); + } + + [Fact] + public async Task Resolve_instance_with_deps_pulls_site_connections_and_template() + { + var site = new Site("Plant South", "site-s") { Id = 2 }; + var boundConn = new DataConnection("opc-bound", "OpcUa", site.Id) { Id = 11 }; + var alarmConn = new DataConnection("opc-alarm", "OpcUa", site.Id) { Id = 12 }; + var unusedConn = new DataConnection("opc-unused", "OpcUa", site.Id) { Id = 13 }; + var template = new Template("TankTpl") { Id = 40 }; + + var instance = new Instance("Tank01") { Id = 60, SiteId = site.Id, TemplateId = template.Id }; + instance.ConnectionBindings.Add(new InstanceConnectionBinding("level") { InstanceId = 60, DataConnectionId = boundConn.Id }); + instance.NativeAlarmSourceOverrides.Add( + new InstanceNativeAlarmSourceOverride("Tank.HiHi") { InstanceId = 60, ConnectionNameOverride = "opc-alarm" }); + + StubInstance(instance); + StubSite(site); + // The instance's site connections are loaded during dependency expansion. + StubSiteConnections(site.Id, boundConn, alarmConn, unusedConn); + StubTemplate(template); + StubAllSharedScripts(); + StubAllExternalSystems(); + StubAllFolders(); + + var result = await Sut().ResolveAsync(SelectInstances(new[] { instance.Id }), CancellationToken.None); + + // Owning site pulled in. + Assert.Single(result.Sites, s => s.SiteIdentifier == "site-s"); + // Template fed into the closure. + Assert.Single(result.Templates, t => t.Id == 40); + // Only the connections the instance references travel — bound (by id) + alarm (by name). + Assert.Contains(result.DataConnections, c => c.Name == "opc-bound"); + Assert.Contains(result.DataConnections, c => c.Name == "opc-alarm"); + Assert.DoesNotContain(result.DataConnections, c => c.Name == "opc-unused"); + // The instance carries its overrides/bindings on its navigation collections. + var resolvedInstance = Assert.Single(result.Instances); + Assert.Single(resolvedInstance.ConnectionBindings); + Assert.Single(resolvedInstance.NativeAlarmSourceOverrides); + } + + [Fact] + public async Task Resolve_instance_without_deps_does_not_pull_site_or_template() + { + var site = new Site("Plant West", "site-w") { Id = 3 }; + var template = new Template("ValveTpl") { Id = 41 }; + var instance = new Instance("Valve01") { Id = 70, SiteId = site.Id, TemplateId = template.Id }; + + StubInstance(instance); + StubSite(site); + StubSiteConnections(site.Id); + StubTemplate(template); + StubAllSharedScripts(); + StubAllExternalSystems(); + StubAllFolders(); + + var result = await Sut().ResolveAsync( + SelectInstances(new[] { instance.Id }, includeDeps: false), + CancellationToken.None); + + // The instance is present, but with deps off neither its site nor its template are pulled. + Assert.Single(result.Instances, i => i.UniqueName == "Valve01"); + Assert.Empty(result.Sites); + Assert.Empty(result.Templates); + } + + [Fact] + public async Task Resolve_dedups_instance_when_site_and_instance_both_selected() + { + var site = new Site("Plant East", "site-e") { Id = 4 }; + var template = new Template("MixerTpl") { Id = 42 }; + var instance = new Instance("Mixer01") { Id = 80, SiteId = site.Id, TemplateId = template.Id }; + + StubSite(site); + StubSiteConnections(site.Id); + StubSiteInstances(site.Id, instance); + StubInstance(instance); + StubTemplate(template); + StubAllSharedScripts(); + StubAllExternalSystems(); + StubAllFolders(); + + // Select the site AND the instance it owns — the instance must appear once. + var result = await Sut().ResolveAsync( + SelectInstances(new[] { instance.Id }, includeDeps: true, siteIds: new[] { site.Id }), + CancellationToken.None); + + Assert.Single(result.Instances, i => i.UniqueName == "Mixer01"); + Assert.Single(result.Sites, s => s.SiteIdentifier == "site-e"); + // The instance was loaded exactly once (site path), not re-loaded via the instance path. + await _templates.Received(1).GetInstanceByIdAsync(instance.Id, Arg.Any()); + } + + [Fact] + public async Task Resolve_orders_sites_connections_and_instances_deterministically() + { + // Two sites, out of identifier order; each with two connections out of name order; + // instances out of unique-name order. Result must be sorted. + var siteB = new Site("Beta", "site-b") { Id = 1 }; + var siteA = new Site("Alpha", "site-a") { Id = 2 }; + + var connB2 = new DataConnection("zeta", "OpcUa", siteB.Id) { Id = 10 }; + var connB1 = new DataConnection("alpha", "OpcUa", siteB.Id) { Id = 11 }; + var connA1 = new DataConnection("mike", "OpcUa", siteA.Id) { Id = 12 }; + + var instZ = new Instance("Zulu") { Id = 20, SiteId = siteA.Id, TemplateId = 0 }; + var instA = new Instance("Alpha") { Id = 21, SiteId = siteB.Id, TemplateId = 0 }; + + StubSite(siteB); + StubSite(siteA); + StubSiteConnections(siteB.Id, connB2, connB1); + StubSiteConnections(siteA.Id, connA1); + StubSiteInstances(siteB.Id, instA); + StubSiteInstances(siteA.Id, instZ); + StubInstance(instZ); + StubInstance(instA); + // TemplateId 0 → no template; GetTemplateWithChildrenAsync(0) returns null by default. + StubAllSharedScripts(); + StubAllExternalSystems(); + StubAllFolders(); + + var result = await Sut().ResolveAsync(SelectSites(new[] { siteB.Id, siteA.Id }), CancellationToken.None); + + // Sites by SiteIdentifier. + Assert.Equal(new[] { "site-a", "site-b" }, result.Sites.Select(s => s.SiteIdentifier).ToArray()); + // Connections by (SiteIdentifier, Name): site-a/mike, site-b/alpha, site-b/zeta. + Assert.Equal( + new[] { "mike", "alpha", "zeta" }, + result.DataConnections.Select(c => c.Name).ToArray()); + // Instances by UniqueName. + Assert.Equal(new[] { "Alpha", "Zulu" }, result.Instances.Select(i => i.UniqueName).ToArray()); + } + + [Fact] + public async Task Resolve_emits_manifest_entries_with_dependency_edges_for_site_scope() + { + var site = new Site("Plant North", "site-n") { Id = 1 }; + var conn = new DataConnection("opc-1", "OpcUa", site.Id) { Id = 5 }; + var template = new Template("PumpTpl") { Id = 9 }; + var instance = new Instance("Pump01") { Id = 30, SiteId = site.Id, TemplateId = template.Id }; + instance.ConnectionBindings.Add(new InstanceConnectionBinding("flow") { InstanceId = 30, DataConnectionId = conn.Id }); + + StubSite(site); + StubSiteConnections(site.Id, conn); + StubSiteInstances(site.Id, instance); + StubInstance(instance); + StubTemplate(template); + StubAllSharedScripts(); + StubAllExternalSystems(); + StubAllFolders(); + + var result = await Sut().ResolveAsync(SelectSites(new[] { site.Id }), CancellationToken.None); + + var siteEntry = Assert.Single(result.ContentManifest, e => e.Type == "Site"); + Assert.Equal("site-n", siteEntry.Name); + Assert.Empty(siteEntry.DependsOn); + + var connEntry = Assert.Single(result.ContentManifest, e => e.Type == "DataConnection"); + Assert.Equal("site-n/opc-1", connEntry.Name); + Assert.Contains("Site:site-n", connEntry.DependsOn); + + var instEntry = Assert.Single(result.ContentManifest, e => e.Type == "Instance"); + Assert.Equal("Pump01", instEntry.Name); + Assert.Contains("Template:PumpTpl", instEntry.DependsOn); + Assert.Contains("Site:site-n", instEntry.DependsOn); + Assert.Contains("DataConnection:site-n/opc-1", instEntry.DependsOn); + } }