using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening; using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; using Template = ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template; using TemplateAttribute = ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute; namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// /// WP2.5: the flatten-session cache. Pins the two properties the bulk deploy path /// depends on — that N instances sharing a template chain load that chain ONCE per /// session, and that a template mutation (observed through the watermark) /// invalidates the memo so a session can never serve stale template state. /// public class FlattenSessionCacheTests { private const int TemplateId = 10; private const int ParentTemplateId = 9; private const int SiteId = 100; private readonly ITemplateEngineRepository _templateRepo = Substitute.For(); private readonly ISiteRepository _siteRepo = Substitute.For(); private readonly ISharedSchemaRepository _sharedSchemaRepo = Substitute.For(); private readonly TemplateGraphWatermark _watermark = new(); private readonly FlatteningPipeline _sut; public FlattenSessionCacheTests() { _sharedSchemaRepo.ListAsync(Arg.Any()).Returns([]); _templateRepo.GetAllSharedScriptsAsync(Arg.Any()).Returns([]); _siteRepo.GetDataConnectionsBySiteIdAsync(SiteId, Arg.Any()).Returns([]); _sut = new FlatteningPipeline( _templateRepo, _siteRepo, new FlatteningService(), new ValidationService(), new RevisionHashService(), _sharedSchemaRepo, _watermark); } /// /// Seeds a two-link inheritance chain (Tank -> TankBase) and /// instances that all derive from it. /// private void ArrangeSharedChain(int instanceCount) { var parent = new Template("TankBase") { Id = ParentTemplateId }; parent.Attributes.Add(new TemplateAttribute("Serial") { DataType = DataType.String, Value = "x" }); var template = new Template("Tank") { Id = TemplateId, ParentTemplateId = ParentTemplateId }; template.Attributes.Add(new TemplateAttribute("Temp") { DataType = DataType.Double, Value = "0" }); _templateRepo.GetTemplateWithChildrenAsync(TemplateId, Arg.Any()).Returns(template); _templateRepo.GetTemplateWithChildrenAsync(ParentTemplateId, Arg.Any()).Returns(parent); _templateRepo.GetCompositionsByTemplateIdAsync(Arg.Any(), Arg.Any()).Returns([]); for (var i = 1; i <= instanceCount; i++) { var instance = new Instance($"Tank-{i:00}") { Id = i, TemplateId = TemplateId, SiteId = SiteId }; _templateRepo.GetInstanceByIdAsync(i, Arg.Any()).Returns(instance); } } [Fact] public async Task SharedSession_TenInstancesOneTemplate_LoadsEachTemplateOnce() { const int instanceCount = 10; ArrangeSharedChain(instanceCount); var session = _sut.CreateSession(); for (var i = 1; i <= instanceCount; i++) { var result = await _sut.FlattenAndValidateAsync(i, CancellationToken.None, validateScripts: false, session); Assert.True(result.IsSuccess); } // Two templates in the chain, each read exactly once across ten flattens. await _templateRepo.Received(1).GetTemplateWithChildrenAsync(TemplateId, Arg.Any()); await _templateRepo.Received(1).GetTemplateWithChildrenAsync(ParentTemplateId, Arg.Any()); // The chain walk itself ran once, not once per instance. Assert.Equal(1, session.ChainLoads); Assert.Equal(2, session.TemplateLoads); // The session-global queries were hoisted out of the per-instance loop: // shared scripts + schema library once each, site connections once. await _templateRepo.Received(1).GetAllSharedScriptsAsync(Arg.Any()); await _sharedSchemaRepo.Received(1).ListAsync(Arg.Any()); await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(SiteId, Arg.Any()); } [Fact] public async Task NoSharedSession_EveryInstanceReloadsTheChain() { const int instanceCount = 4; ArrangeSharedChain(instanceCount); // Baseline: without a shared session each flatten gets its own private // one, which is the pre-WP2.5 behaviour the batch path improves on. for (var i = 1; i <= instanceCount; i++) { var result = await _sut.FlattenAndValidateAsync(i, CancellationToken.None, validateScripts: false); Assert.True(result.IsSuccess); } await _templateRepo.Received(instanceCount) .GetTemplateWithChildrenAsync(TemplateId, Arg.Any()); } [Fact] public async Task TemplateMutation_InvalidatesMemo_SoSessionNeverServesStaleState() { ArrangeSharedChain(instanceCount: 2); var session = _sut.CreateSession(); Assert.True((await _sut.FlattenAndValidateAsync(1, CancellationToken.None, validateScripts: false, session)).IsSuccess); // A template edit lands (the repository bumps the watermark on commit). _watermark.BumpTemplate(TemplateId); Assert.True((await _sut.FlattenAndValidateAsync(2, CancellationToken.None, validateScripts: false, session)).IsSuccess); // The edited template was re-read rather than served from the memo. The // untouched parent stayed cached — invalidation is per-template, not a // whole-session reset. await _templateRepo.Received(2).GetTemplateWithChildrenAsync(TemplateId, Arg.Any()); await _templateRepo.Received(1).GetTemplateWithChildrenAsync(ParentTemplateId, Arg.Any()); } [Fact] public async Task StructuralMutation_InvalidatesCachedChainMembership() { ArrangeSharedChain(instanceCount: 2); var session = _sut.CreateSession(); Assert.True((await _sut.FlattenAndValidateAsync(1, CancellationToken.None, validateScripts: false, session)).IsSuccess); Assert.Equal(1, session.ChainLoads); // A re-parent / composition change: chain MEMBERSHIP may now differ, so the // cached chain must be discarded even though each member's own version is // unchanged. _watermark.BumpTemplate(TemplateId, structural: true); Assert.True((await _sut.FlattenAndValidateAsync(2, CancellationToken.None, validateScripts: false, session)).IsSuccess); Assert.Equal(2, session.ChainLoads); } }