Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/AnalysisClosureMock.cs
T
Joseph Doherty 6070cc2b9d perf(templates): resync and resolved-members read only the chain/subtree (ScadaBridge#89)
The last two whole-library template reads are gone. Both fed the
inheritance-only TemplateInheritanceResolver, which walks one template's
parent chain and never follows compositions.

- TemplateService.ResyncInheritedMembersAsync (operator repair, CLI + editor)
  now reads #86's GetTemplateReconcileClosureAsync (derived subtree + the
  template's chain, tracked). When nothing derives from the template the
  closure is empty, so it falls back to the new tracked
  GetTemplateChainAsync - resyncing a leaf reads only its own chain.
- ManagementActor.HandleGetResolvedTemplateMembers now reads
  GetTemplateChainAsync(id, trackChanges: false): the chain alone, untracked,
  with script bodies (the resolver projects script code, so #66's body-free
  analysis closure would not be equivalent).

The chain walk is factored out of TemplateReconcileClosure into
CollectChainAsync (one load per level, stops on parent cycles and dangling
parents) and shared by both.

Evidence:
- TemplateChainReadTests (#86 fixture): for every template plus an unknown
  id, the closure-backed resync issues exactly the writes, audit row and
  result of the whole-graph resync with GetAllTemplatesAsync wired to throw;
  the resolver answers identically from the chain and the whole graph.
  17 of these fail against the old service; 13 fail with the leaf chain
  fallback removed (mutation-checked).
- TemplateChainRepositoryTests (SQLite, recorded SQL): a 4-deep chain loads
  4 templates / 8 attributes instead of 46 / 92, one split-query set per
  level, untracked on request and tracked for the resync.
- ResolvedTemplateMembersReadTests: the actor answers from the untracked
  chain read and never calls GetAllTemplatesAsync (fails on the old actor).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:30:26 -05:00

80 lines
4.0 KiB
C#

using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests;
/// <summary>
/// Gitea #66: member add/update now asks the repository for a template's analysis closure
/// rather than the whole analysis graph. Test classes keep seeding the whole graph through
/// <c>GetAllTemplatesForAnalysisAsync</c>; this wires <c>GetTemplateAnalysisClosureAsync</c>
/// to serve the real closure walk over that seeded graph, so every existing scenario runs the
/// service against the reduced set — the same set the production repository returns.
/// </summary>
internal static class AnalysisClosureMock
{
/// <summary>Wires the closure method to filter whatever the full-graph setup returns.</summary>
/// <param name="repoMock">The repository mock.</param>
/// <param name="closureRequests">Optional sink recording each requested closure root.</param>
public static void ServeClosureFromFullGraph(
this Mock<ITemplateEngineRepository> repoMock,
List<int>? closureRequests = null)
{
repoMock
.Setup(r => r.GetTemplateAnalysisClosureAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.Returns((int templateId, CancellationToken ct) =>
{
closureRequests?.Add(templateId);
return TemplateAnalysisClosure.CollectAsync(
templateId,
async (ids, token) =>
{
var all = await repoMock.Object.GetAllTemplatesForAnalysisAsync(token)
?? Array.Empty<Template>();
return all.Where(t => ids.Contains(t.Id)).ToList();
},
ct);
});
}
/// <summary>
/// Gitea #86: the descendant reconcile now asks for the reconcile closure (derived subtree +
/// the template's chain) instead of <c>GetAllTemplatesAsync</c>. Wires it to run the real
/// closure walk over whatever the full tracked-graph setup returns, so every existing
/// propagation scenario exercises the service against the reduced set. Also serves the
/// chain-only read the inherited-member resync falls back to for a leaf (Gitea #89).
/// </summary>
/// <param name="repoMock">The repository mock.</param>
/// <param name="closureRequests">Optional sink recording each requested closure root.</param>
public static void ServeReconcileClosureFromFullGraph(
this Mock<ITemplateEngineRepository> repoMock,
List<int>? closureRequests = null)
{
async Task<IReadOnlyList<Template>> All(CancellationToken token) =>
await repoMock.Object.GetAllTemplatesAsync(token) ?? Array.Empty<Template>();
repoMock
.Setup(r => r.GetTemplateReconcileClosureAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.Returns((int templateId, CancellationToken ct) =>
{
closureRequests?.Add(templateId);
return TemplateReconcileClosure.CollectAsync(
templateId,
async (parentIds, token) => (await All(token))
.Where(t => t.ParentTemplateId is int p && parentIds.Contains(p)).ToList(),
async (ids, token) => (await All(token)).Where(t => ids.Contains(t.Id)).ToList(),
ct);
});
// Gitea #89: the resync of a template nobody derives from falls back to its chain.
repoMock
.Setup(r => r.GetTemplateChainAsync(It.IsAny<int>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.Returns((int templateId, bool _, CancellationToken ct) =>
TemplateReconcileClosure.CollectChainAsync(
templateId,
async (ids, token) => (await All(token)).Where(t => ids.Contains(t.Id)).ToList(),
ct));
}
}