fix(transport): export reads instance children once, site connections once per site, composed templates in batches (review 2026-10-21 04/P5)
This commit is contained in:
@@ -7,6 +7,7 @@ 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.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Export;
|
||||
|
||||
@@ -76,9 +77,28 @@ public sealed class DependencyResolverTests
|
||||
IncludeDependencies: true,
|
||||
SmsConfigurationIds: ids);
|
||||
|
||||
// Templates registered via StubTemplate. Backs both the by-id accessor and the
|
||||
// batched stub + by-name accessors the resolver uses for closure expansion
|
||||
// (review 2026-10-21 04/P5).
|
||||
private readonly List<Template> _stubbedTemplates = new();
|
||||
|
||||
private void StubTemplate(Template t)
|
||||
{
|
||||
_templates.GetTemplateWithChildrenAsync(t.Id, Arg.Any<CancellationToken>()).Returns(t);
|
||||
if (_stubbedTemplates.Count == 0)
|
||||
{
|
||||
_templates.GetTemplateStubsAsync(Arg.Any<CancellationToken>()).Returns(_ =>
|
||||
(IReadOnlyList<TemplateStub>)_stubbedTemplates
|
||||
.Select(x => new TemplateStub(x.Id, x.Name, x.ParentTemplateId, x.FolderId, x.IsDerived, x.OwnerCompositionId))
|
||||
.ToList());
|
||||
_templates.GetTemplatesWithChildrenAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
var names = ci.Arg<IEnumerable<string>>().ToHashSet(StringComparer.Ordinal);
|
||||
return (IReadOnlyList<Template>)_stubbedTemplates.Where(x => names.Contains(x.Name)).ToList();
|
||||
});
|
||||
}
|
||||
_stubbedTemplates.Add(t);
|
||||
}
|
||||
|
||||
private void StubAllSharedScripts(params SharedScript[] scripts)
|
||||
@@ -114,7 +134,7 @@ public sealed class DependencyResolverTests
|
||||
|
||||
private void StubSiteInstances(int siteId, params Instance[] instances)
|
||||
{
|
||||
_sites.GetInstancesBySiteIdAsync(siteId, Arg.Any<CancellationToken>()).Returns(instances);
|
||||
_templates.GetInstancesBySiteIdAsync(siteId, Arg.Any<CancellationToken>()).Returns(instances);
|
||||
}
|
||||
|
||||
// Stubs the per-instance child getters on the template repo so the resolver can
|
||||
@@ -442,8 +462,9 @@ public sealed class DependencyResolverTests
|
||||
|
||||
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<CancellationToken>());
|
||||
// The instance came from the site load (which already includes its children)
|
||||
// and was not re-loaded via the instance path (review 2026-10-21 04/P5).
|
||||
await _templates.DidNotReceive().GetInstanceByIdAsync(instance.Id, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving orders sites, connections and instances deterministically.</summary>
|
||||
@@ -524,4 +545,94 @@ public sealed class DependencyResolverTests
|
||||
Assert.Contains("Site:site-n", instEntry.DependsOn);
|
||||
Assert.Contains("DataConnection:site-n/opc-1", instEntry.DependsOn);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Review 2026-10-21 04/P5 — export round trips.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a directly-selected instance is loaded once, with its children
|
||||
/// from the same query, and the four per-collection child loads are not issued.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Resolve_instance_does_not_reload_its_child_collections()
|
||||
{
|
||||
var site = new Site("Plant", "site-p") { Id = 5 };
|
||||
var instance = new Instance("Pump01") { Id = 70, SiteId = site.Id, TemplateId = 0 };
|
||||
StubSite(site);
|
||||
StubInstance(instance);
|
||||
StubAllSharedScripts();
|
||||
StubAllExternalSystems();
|
||||
StubAllFolders();
|
||||
|
||||
var result = await Sut().ResolveAsync(SelectInstances(new[] { instance.Id }), CancellationToken.None);
|
||||
|
||||
Assert.Single(result.Instances);
|
||||
await _templates.Received(1).GetInstanceByIdAsync(instance.Id, Arg.Any<CancellationToken>());
|
||||
await _templates.DidNotReceive().GetOverridesByInstanceIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||
await _templates.DidNotReceive().GetAlarmOverridesByInstanceIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||
await _templates.DidNotReceive().GetNativeAlarmSourceOverridesByInstanceIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||
await _templates.DidNotReceive().GetBindingsByInstanceIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a site's data connections are read once per export, not once per
|
||||
/// instance that binds one.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Resolve_site_reads_its_data_connections_once()
|
||||
{
|
||||
var site = new Site("Plant", "site-p") { Id = 6 };
|
||||
var conn = new DataConnection("opc", "OpcUa", site.Id) { Id = 61 };
|
||||
var instances = Enumerable.Range(0, 3).Select(i =>
|
||||
{
|
||||
var inst = new Instance($"Pump{i}") { Id = 600 + i, SiteId = site.Id, TemplateId = 0 };
|
||||
inst.ConnectionBindings.Add(new InstanceConnectionBinding("flow") { InstanceId = inst.Id, DataConnectionId = conn.Id });
|
||||
return inst;
|
||||
}).ToArray();
|
||||
StubSite(site);
|
||||
StubSiteConnections(site.Id, conn);
|
||||
StubSiteInstances(site.Id, instances);
|
||||
StubAllSharedScripts();
|
||||
StubAllExternalSystems();
|
||||
StubAllFolders();
|
||||
|
||||
var result = await Sut().ResolveAsync(SelectSites(new[] { site.Id }), CancellationToken.None);
|
||||
|
||||
Assert.Equal(3, result.Instances.Count);
|
||||
Assert.Single(result.DataConnections);
|
||||
await _sites.Received(1).GetDataConnectionsBySiteIdAsync(site.Id, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the modules a template composes are loaded in one batch rather
|
||||
/// than one by-id load each.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Resolve_batches_composed_template_loads()
|
||||
{
|
||||
var root = new Template("Root") { Id = 1 };
|
||||
var modules = Enumerable.Range(0, 3).Select(i => new Template($"Module{i}") { Id = 10 + i }).ToArray();
|
||||
foreach (var m in modules)
|
||||
{
|
||||
root.Compositions.Add(new TemplateComposition($"slot{m.Id}") { TemplateId = root.Id, ComposedTemplateId = m.Id });
|
||||
}
|
||||
StubTemplate(root);
|
||||
foreach (var m in modules) StubTemplate(m);
|
||||
StubAllSharedScripts();
|
||||
StubAllExternalSystems();
|
||||
StubAllFolders();
|
||||
|
||||
var result = await Sut().ResolveAsync(SelectTemplates(root.Id), CancellationToken.None);
|
||||
|
||||
Assert.Equal(4, result.Templates.Count);
|
||||
foreach (var m in modules)
|
||||
{
|
||||
await _templates.DidNotReceive().GetTemplateWithChildrenAsync(m.Id, Arg.Any<CancellationToken>());
|
||||
}
|
||||
await _templates.Received(1).GetTemplatesWithChildrenAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user