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);
+ }
}