using System.Collections.Concurrent; using System.Reflection; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; 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.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services; namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import; /// /// Review 2026-10-21 04/P4 + 08/P1: one import used to load the WHOLE template /// library (attributes, alarms, scripts with bodies, compositions — tracked) up to /// nine times, two of them inside per-template loops, and to look every bundled /// instance up one query at a time. These tests count repository calls through a /// recording proxy so the per-session budget is pinned. /// public sealed class ImportRoundTripBudgetTests : IDisposable { private readonly ServiceProvider _provider; private readonly ConcurrentDictionary _calls = new(StringComparer.Ordinal); /// Initializes the test class with a counting template repository over an in-memory database. public ImportRoundTripBudgetTests() { var services = new ServiceCollection(); services.AddSingleton( new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider()); services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); var dbName = $"ImportRoundTripBudgetTests_{Guid.NewGuid()}"; services.AddDbContext(opts => opts .UseInMemoryDatabase(dbName) .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))); services.AddSingleton(); services.AddScoped(sp => CountingProxy.Wrap( new TemplateEngineRepository( sp.GetRequiredService(), sp.GetRequiredService()), _calls)); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddTransport(); _provider = services.BuildServiceProvider(); } /// Releases the resources used by this instance. public void Dispose() => _provider.Dispose(); private int Calls(string method) => _calls.TryGetValue(method, out var n) ? n : 0; /// /// Seeds a library of templates (a base, a module, and several composing / /// derived templates) plus a site with instances, exports the site closure with /// its templates, and returns the loaded session. /// private async Task SeedExportAndLoadAsync(int instanceCount) { await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var baseT = new Template("Base"); baseT.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" }); var module = new Template("Motor"); module.Attributes.Add(new TemplateAttribute("Speed") { DataType = DataType.Float, Value = "1.0" }); ctx.Templates.AddRange(baseT, module); await ctx.SaveChangesAsync(); var pump = new Template("Pump") { ParentTemplateId = baseT.Id }; pump.Compositions.Add(new TemplateComposition("motor1") { ComposedTemplateId = module.Id }); ctx.Templates.Add(pump); // Unrelated library templates: the import must not pay for them per item. for (var i = 0; i < 5; i++) ctx.Templates.Add(new Template($"Other{i}")); var site = new Site("Plant 1", "plant-1") { GrpcNodeAAddress = "http://plant-1-a:8083" }; ctx.Sites.Add(site); await ctx.SaveChangesAsync(); for (var i = 0; i < instanceCount; i++) { ctx.Instances.Add(new Instance($"Pump-{i:00}") { TemplateId = pump.Id, SiteId = site.Id }); } await ctx.SaveChangesAsync(); } Stream bundle; await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var siteIds = await ctx.Sites.Select(s => s.Id).ToListAsync(); bundle = await scope.ServiceProvider.GetRequiredService().ExportAsync( new ExportSelection( TemplateIds: Array.Empty(), SharedScriptIds: Array.Empty(), ExternalSystemIds: Array.Empty(), DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), SmtpConfigurationIds: Array.Empty(), ApiMethodIds: Array.Empty(), IncludeDependencies: true, SiteIds: siteIds), user: "alice", sourceEnvironment: "dev", passphrase: null, cancellationToken: CancellationToken.None); } await using var loadScope = _provider.CreateAsyncScope(); return (await loadScope.ServiceProvider.GetRequiredService() .LoadAsync(bundle, passphrase: null)).SessionId; } /// /// Verifies that preview + apply of a bundle carrying templates and instances /// never loads the full template graph, loads the library projection at most once /// per phase, and looks instances up in batches rather than per instance. /// /// A task that represents the asynchronous operation. [Fact] public async Task Preview_and_apply_load_the_template_library_once_per_phase_and_batch_instance_lookups() { var sessionId = await SeedExportAndLoadAsync(instanceCount: 6); _calls.Clear(); ImportPreview preview; await using (var scope = _provider.CreateAsyncScope()) { preview = await scope.ServiceProvider.GetRequiredService().PreviewAsync(sessionId); } Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker); Assert.Equal(0, Calls(nameof(ITemplateEngineRepository.GetAllTemplatesAsync))); Assert.Equal(0, Calls(nameof(ITemplateEngineRepository.GetInstanceByUniqueNameAsync))); Assert.Equal(0, Calls(nameof(ITemplateEngineRepository.GetTemplateByIdAsync))); Assert.True( Calls(nameof(ITemplateEngineRepository.GetAllTemplatesForAnalysisAsync)) + Calls(nameof(ITemplateEngineRepository.GetTemplateStubsAsync)) <= 2, "preview should load the library projection at most once per shape"); _calls.Clear(); var resolutions = preview.Items .Where(i => i.EntityType is "Template" or "Instance" or "Site") .Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Overwrite, null)) .ToList(); ImportResult result; await using (var scope = _provider.CreateAsyncScope()) { result = await scope.ServiceProvider.GetRequiredService() .ApplyAsync(sessionId, resolutions, user: "bob"); } Assert.True(result.Overwritten >= 6 + 3, $"expected the templates and instances to be overwritten, got {result.Overwritten}"); Assert.Equal(0, Calls(nameof(ITemplateEngineRepository.GetAllTemplatesAsync))); Assert.Equal(0, Calls(nameof(ITemplateEngineRepository.GetInstanceByUniqueNameAsync))); Assert.True(Calls(nameof(ITemplateEngineRepository.GetAllTemplatesForAnalysisAsync)) <= 1, "apply should load the analysis library at most once"); Assert.True(Calls(nameof(ITemplateEngineRepository.GetInstancesByUniqueNamesAsync)) <= 1, "apply should look the bundle's instances up in one batch"); } /// Records every interface call by method name, then forwards it to the real implementation. /// The proxied interface. public class CountingProxy : DispatchProxy where T : class { private T _inner = null!; private ConcurrentDictionary _calls = null!; /// Creates a counting proxy over . /// The real implementation. /// The shared call counter. /// The proxy. public static T Wrap(T inner, ConcurrentDictionary calls) { var proxy = Create>(); var self = (CountingProxy)(object)proxy; self._inner = inner; self._calls = calls; return proxy; } /// protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) { _calls.AddOrUpdate(targetMethod!.Name, 1, (_, n) => n + 1); try { return targetMethod.Invoke(_inner, args); } catch (TargetInvocationException ex) when (ex.InnerException is not null) { throw ex.InnerException; } } } }