From 6257c9e20ed084c1a369314240ae25596932aaf5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 21 Aug 2026 06:04:13 -0400 Subject: [PATCH] =?UTF-8?q?feat(deploymentmanager):=20IFlatteningPipeline.?= =?UTF-8?q?PreviewAsync=20=E2=80=94=20flatten=20a=20template+site=20withou?= =?UTF-8?q?t=20an=20instance=20row=20(create-page=20bindings)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH --- .../FlatteningPipeline.cs | 122 ++++++++++++------ .../IFlatteningPipeline.cs | 15 +++ .../FlatteningPipelinePreviewTests.cs | 97 ++++++++++++++ 3 files changed, 195 insertions(+), 39 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelinePreviewTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlatteningPipeline.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlatteningPipeline.cs index 953b9124..335348f2 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlatteningPipeline.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlatteningPipeline.cs @@ -81,47 +81,11 @@ public class FlatteningPipeline : IFlatteningPipeline if (instance == null) return Result.Failure($"Instance with ID {instanceId} not found."); - Task LoadTemplate(int id) => - _templateRepo.GetTemplateWithChildrenAsync(id, cancellationToken); - - // Build template chain - var templateChain = await session.GetChainAsync(instance.TemplateId, LoadTemplate); - if (templateChain.Count == 0) + var graph = await LoadTemplateGraphAsync(instance.TemplateId, session, cancellationToken); + if (graph is null) return Result.Failure("Template chain is empty."); - // Build composition maps, walking nested compositions so the flattener - // can resolve composed-of-composed attributes / alarms / scripts (e.g. - // a parent that composes Pump where Pump itself composes AlarmSensor - // produces "Pump.AlarmSensor.SensorReading"). - var compositionMap = new Dictionary>(); - var composedChains = new Dictionary>(); - var processedTemplateIds = new HashSet(); - var pendingChains = new Queue>(); - - pendingChains.Enqueue(templateChain); - while (pendingChains.Count > 0) - { - var chain = pendingChains.Dequeue(); - foreach (var template in chain) - { - if (!processedTemplateIds.Add(template.Id)) continue; - - var compositions = await session.GetCompositionsAsync( - template.Id, - id => _templateRepo.GetCompositionsByTemplateIdAsync(id, cancellationToken)); - if (compositions.Count == 0) continue; - - compositionMap[template.Id] = compositions; - foreach (var comp in compositions) - { - if (composedChains.ContainsKey(comp.ComposedTemplateId)) continue; - - var composedChain = await session.GetChainAsync(comp.ComposedTemplateId, LoadTemplate); - composedChains[comp.ComposedTemplateId] = composedChain; - pendingChains.Enqueue(composedChain); - } - } - } + var (templateChain, compositionMap, composedChains) = graph.Value; // Load data connections for the site var dataConnections = await session.GetDataConnectionsAsync( @@ -216,6 +180,86 @@ public class FlatteningPipeline : IFlatteningPipeline new FlatteningPipelineResult(config, hash, validation)); } + /// + public async Task> PreviewAsync( + int templateId, + int siteId, + CancellationToken cancellationToken = default) + { + var session = CreateSession(); + + var graph = await LoadTemplateGraphAsync(templateId, session, cancellationToken); + if (graph is null) + return Result.Failure($"Template {templateId} not found or its chain is empty."); + + var dataConnections = await session.GetDataConnectionsAsync( + siteId, + id => LoadDataConnectionsAsync(id, cancellationToken)); + + // A stand-in instance with no overrides and no bindings: the flattener sees + // exactly what a freshly created instance would, and nothing is persisted. + var preview = new Commons.Entities.Instances.Instance("(preview)") + { + TemplateId = templateId, + SiteId = siteId + }; + + return _flatteningService.Flatten( + preview, graph.Value.Chain, graph.Value.CompositionMap, graph.Value.ComposedChains, dataConnections); + } + + /// + /// Loads the template inheritance chain for plus the + /// composition maps, walking nested compositions so the flattener can resolve + /// composed-of-composed members (e.g. a parent composing Pump where Pump itself + /// composes AlarmSensor produces "Pump.AlarmSensor.SensorReading"). + /// Returns when the chain is empty (unknown template). + /// + private async Task<(IReadOnlyList Chain, + Dictionary> CompositionMap, + Dictionary> ComposedChains)?> + LoadTemplateGraphAsync(int templateId, FlattenSession session, CancellationToken cancellationToken) + { + Task LoadTemplate(int id) => + _templateRepo.GetTemplateWithChildrenAsync(id, cancellationToken); + + var templateChain = await session.GetChainAsync(templateId, LoadTemplate); + if (templateChain.Count == 0) + return null; + + var compositionMap = new Dictionary>(); + var composedChains = new Dictionary>(); + var processedTemplateIds = new HashSet(); + var pendingChains = new Queue>(); + + pendingChains.Enqueue(templateChain); + while (pendingChains.Count > 0) + { + var chain = pendingChains.Dequeue(); + foreach (var template in chain) + { + if (!processedTemplateIds.Add(template.Id)) continue; + + var compositions = await session.GetCompositionsAsync( + template.Id, + id => _templateRepo.GetCompositionsByTemplateIdAsync(id, cancellationToken)); + if (compositions.Count == 0) continue; + + compositionMap[template.Id] = compositions; + foreach (var comp in compositions) + { + if (composedChains.ContainsKey(comp.ComposedTemplateId)) continue; + + var composedChain = await session.GetChainAsync(comp.ComposedTemplateId, LoadTemplate); + composedChains[comp.ComposedTemplateId] = composedChain; + pendingChains.Enqueue(composedChain); + } + } + } + + return (templateChain, compositionMap, composedChains); + } + private async Task> LoadDataConnectionsAsync( int siteId, CancellationToken cancellationToken) diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/IFlatteningPipeline.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/IFlatteningPipeline.cs index 17a5f5f5..c930f98f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/IFlatteningPipeline.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/IFlatteningPipeline.cs @@ -39,6 +39,21 @@ public interface IFlatteningPipeline bool validateScripts = true, FlattenSession? session = null); + /// + /// Flattens as if an instance with no overrides and no + /// bindings existed at . Attribute list only — no validation, + /// no revision hash, nothing persisted. Used by the Create Instance page to show the + /// module groups before the instance row exists. + /// + /// Id of the template to preview. + /// Id of the target site, whose data connections resolve references. + /// Cancellation token. + /// The flattened configuration of the hypothetical instance, or a failure result. + Task> PreviewAsync( + int templateId, + int siteId, + CancellationToken cancellationToken = default); + /// /// Creates a fresh bound to this pipeline's /// watermark, for a caller that wants to share one across a batch of diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelinePreviewTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelinePreviewTests.cs new file mode 100644 index 00000000..f74962fe --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelinePreviewTests.cs @@ -0,0 +1,97 @@ +using NSubstitute; +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.Types.Enums; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; +using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening; +using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; + +namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; + +/// +/// Pins PreviewAsync: the Create Instance page must see exactly the attribute +/// set a freshly created, un-overridden instance flattens to — including composed +/// module members — without any instance row existing. +/// +public class FlatteningPipelinePreviewTests +{ + private const int TemplateId = 10, ModuleTemplateId = 11, SiteId = 20, InstanceId = 30; + + private readonly ITemplateEngineRepository _templateRepo = Substitute.For(); + private readonly ISiteRepository _siteRepo = Substitute.For(); + private readonly ISharedSchemaRepository _sharedSchemaRepo = Substitute.For(); + private readonly FlatteningPipeline _sut; + + public FlatteningPipelinePreviewTests() + { + _sharedSchemaRepo.ListAsync(Arg.Any()).Returns([]); + _sut = new FlatteningPipeline(_templateRepo, _siteRepo, new FlatteningService(), new ValidationService(), + new RevisionHashService(), _sharedSchemaRepo, new TemplateGraphWatermark()); + + // Parent template with one direct data-sourced attribute, composing a module with one. + var module = new Template("Receiver") { Id = ModuleTemplateId }; + module.Attributes.Add(new TemplateAttribute("MoveInFlag") + { + DataType = DataType.Boolean, + DataSourceReference = "MoveInFlag" + }); + var parent = new Template("Line") { Id = TemplateId }; + parent.Attributes.Add(new TemplateAttribute("Running") + { + DataType = DataType.Boolean, + DataSourceReference = "Running" + }); + var composition = new TemplateComposition("Left") + { + TemplateId = TemplateId, + ComposedTemplateId = ModuleTemplateId + }; + + _templateRepo.GetTemplateWithChildrenAsync(TemplateId, Arg.Any()).Returns(parent); + _templateRepo.GetTemplateWithChildrenAsync(ModuleTemplateId, Arg.Any()).Returns(module); + _templateRepo.GetCompositionsByTemplateIdAsync(TemplateId, Arg.Any()).Returns([composition]); + _templateRepo.GetCompositionsByTemplateIdAsync(ModuleTemplateId, Arg.Any()).Returns([]); + _templateRepo.GetAllSharedScriptsAsync(Arg.Any()).Returns([]); + _templateRepo.GetInstanceByIdAsync(InstanceId, Arg.Any()) + .Returns(new Instance("Line-01") { Id = InstanceId, TemplateId = TemplateId, SiteId = SiteId }); + _siteRepo.GetDataConnectionsBySiteIdAsync(SiteId, Arg.Any()) + .Returns([new DataConnection("MxGateway", "MxGateway", SiteId) { Id = 1 }]); + } + + [Fact] + public async Task Preview_matches_flatten_of_fresh_instance() + { + var preview = await _sut.PreviewAsync(TemplateId, SiteId); + var real = await _sut.FlattenAndValidateAsync(InstanceId, validateScripts: false); + + // Result.Error throws on a success, so the message is only read on failure. + Assert.True(preview.IsSuccess, preview.IsSuccess ? "" : preview.Error); + Assert.True(real.IsSuccess, real.IsSuccess ? "" : real.Error); + + var expected = real.Value.Configuration.Attributes + .Select(a => (a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference)) + .OrderBy(x => x.Item1, StringComparer.Ordinal).ToList(); + var actual = preview.Value.Attributes + .Select(a => (a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference)) + .OrderBy(x => x.Item1, StringComparer.Ordinal).ToList(); + + Assert.Equal(expected, actual); + Assert.Contains(actual, a => a.Item1 == "Left.MoveInFlag"); + } + + [Fact] + public async Task Preview_unknown_template_fails() + { + var r = await _sut.PreviewAsync(999, SiteId); + Assert.True(r.IsFailure); + } + + [Fact] + public async Task Preview_never_touches_instance_repository() + { + await _sut.PreviewAsync(TemplateId, SiteId); + await _templateRepo.DidNotReceive().GetInstanceByIdAsync(Arg.Any(), Arg.Any()); + } +}