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; using ZB.MOM.WW.ScadaBridge.Transport.Export; namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Export; public sealed class DependencyResolverTests { private readonly ITemplateEngineRepository _templates = Substitute.For(); 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, _sites); private static ExportSelection SelectTemplates(params int[] ids) => new( TemplateIds: ids, SharedScriptIds: Array.Empty(), ExternalSystemIds: Array.Empty(), DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), SmtpConfigurationIds: Array.Empty(), ApiMethodIds: Array.Empty(), IncludeDependencies: true); private static ExportSelection SelectApiMethods(params int[] ids) => new( TemplateIds: Array.Empty(), SharedScriptIds: Array.Empty(), ExternalSystemIds: 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); } private void StubAllSharedScripts(params SharedScript[] scripts) { _templates.GetAllSharedScriptsAsync(Arg.Any()).Returns(scripts); } private void StubAllExternalSystems(params ExternalSystemDefinition[] systems) { _externalSystems.GetAllExternalSystemsAsync(Arg.Any()).Returns(systems); foreach (var es in systems) { _externalSystems .GetMethodsByExternalSystemIdAsync(es.Id, Arg.Any()) .Returns(Array.Empty()); } } private void StubAllFolders(params TemplateFolder[] folders) { _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() { var baseT = new Template("Base") { Id = 10 }; var composing = new Template("Top") { Id = 11 }; composing.Compositions.Add(new TemplateComposition("slot") { TemplateId = 11, ComposedTemplateId = 10 }); StubTemplate(composing); StubTemplate(baseT); StubAllSharedScripts(); StubAllExternalSystems(); StubAllFolders(); var result = await Sut().ResolveAsync(SelectTemplates(11), CancellationToken.None); Assert.Equal(2, result.Templates.Count); Assert.Contains(result.Templates, t => t.Id == 10); Assert.Contains(result.Templates, t => t.Id == 11); } [Fact] public async Task Resolve_includes_shared_script_referenced_by_template() { var shared = new SharedScript("UtilHelper", "return 42;") { Id = 100 }; var t = new Template("UsesUtil") { Id = 1 }; t.Scripts.Add(new TemplateScript("body", "var x = UtilHelper(); return x;") { TemplateId = 1 }); StubTemplate(t); StubAllSharedScripts(shared, new SharedScript("OtherScript", "return 0;") { Id = 101 }); StubAllExternalSystems(); StubAllFolders(); var result = await Sut().ResolveAsync(SelectTemplates(1), CancellationToken.None); Assert.Single(result.SharedScripts); Assert.Equal("UtilHelper", result.SharedScripts[0].Name); } [Fact] public async Task Resolve_includes_external_system_referenced_by_template() { var es = new ExternalSystemDefinition("ErpSystem", "https://erp", "ApiKey") { Id = 7 }; var t = new Template("UsesErp") { Id = 2 }; t.Scripts.Add(new TemplateScript("call", "ErpSystem.Call(\"x\");") { TemplateId = 2 }); StubTemplate(t); StubAllSharedScripts(); StubAllExternalSystems(es, new ExternalSystemDefinition("Other", "https://o", "Basic") { Id = 8 }); StubAllFolders(); var result = await Sut().ResolveAsync(SelectTemplates(2), CancellationToken.None); Assert.Single(result.ExternalSystems); Assert.Equal("ErpSystem", result.ExternalSystems[0].Name); } [Fact] public async Task Resolve_includes_api_method_shared_script_dependency() { var shared = new SharedScript("Validator", "return true;") { Id = 50 }; var method = new ApiMethod("submit", "var ok = Validator(input); return ok;") { Id = 5 }; _inboundApi.GetApiMethodByIdAsync(5, Arg.Any()).Returns(method); StubAllSharedScripts(shared); StubAllExternalSystems(); StubAllFolders(); var result = await Sut().ResolveAsync(SelectApiMethods(5), CancellationToken.None); Assert.Single(result.ApiMethods); Assert.Single(result.SharedScripts); Assert.Equal("Validator", result.SharedScripts[0].Name); } [Fact] public async Task Resolve_handles_diamond_dependency_without_duplication() { // A composes B and C; both B and C compose D. Selection={A}. D must appear once. var d = new Template("D") { Id = 4 }; var b = new Template("B") { Id = 2 }; b.Compositions.Add(new TemplateComposition("d-in-b") { TemplateId = 2, ComposedTemplateId = 4 }); var c = new Template("C") { Id = 3 }; c.Compositions.Add(new TemplateComposition("d-in-c") { TemplateId = 3, ComposedTemplateId = 4 }); var a = new Template("A") { Id = 1 }; a.Compositions.Add(new TemplateComposition("b-in-a") { TemplateId = 1, ComposedTemplateId = 2 }); a.Compositions.Add(new TemplateComposition("c-in-a") { TemplateId = 1, ComposedTemplateId = 3 }); StubTemplate(a); StubTemplate(b); StubTemplate(c); StubTemplate(d); StubAllSharedScripts(); StubAllExternalSystems(); StubAllFolders(); var result = await Sut().ResolveAsync(SelectTemplates(1), CancellationToken.None); Assert.Equal(4, result.Templates.Count); Assert.Single(result.Templates, t => t.Id == 4); } [Fact] public async Task Resolve_includes_template_folder_for_each_selected_template() { var root = new TemplateFolder("Root") { Id = 1, ParentFolderId = null }; var child = new TemplateFolder("Child") { Id = 2, ParentFolderId = 1 }; var grand = new TemplateFolder("Grand") { Id = 3, ParentFolderId = 2 }; var t = new Template("T") { Id = 99, FolderId = 3 }; StubTemplate(t); StubAllSharedScripts(); StubAllExternalSystems(); StubAllFolders(root, child, grand, new TemplateFolder("Unrelated") { Id = 4, ParentFolderId = null }); var result = await Sut().ResolveAsync(SelectTemplates(99), CancellationToken.None); Assert.Equal(3, result.TemplateFolders.Count); // Root-first ordering: depth 0, 1, 2. Assert.Equal("Root", result.TemplateFolders[0].Name); Assert.Equal("Child", result.TemplateFolders[1].Name); Assert.Equal("Grand", result.TemplateFolders[2].Name); } [Fact] public async Task Resolve_returns_topological_order_base_before_derived() { // Top composes Middle, Middle composes Leaf. Order must be Leaf, Middle, Top. var leaf = new Template("Leaf") { Id = 30 }; var middle = new Template("Middle") { Id = 20 }; middle.Compositions.Add(new TemplateComposition("l") { TemplateId = 20, ComposedTemplateId = 30 }); var top = new Template("Top") { Id = 10 }; top.Compositions.Add(new TemplateComposition("m") { TemplateId = 10, ComposedTemplateId = 20 }); StubTemplate(top); StubTemplate(middle); StubTemplate(leaf); StubAllSharedScripts(); StubAllExternalSystems(); StubAllFolders(); var result = await Sut().ResolveAsync(SelectTemplates(10), CancellationToken.None); Assert.Equal(3, result.Templates.Count); Assert.Equal("Leaf", result.Templates[0].Name); 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); } }