Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateChainReadTests.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

170 lines
8.5 KiB
C#

using System.Text.Json;
using Moq;
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.Types.Templates;
using static ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.TemplateReconcileClosureTests;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests;
/// <summary>
/// Gitea #89: the last two whole-library template reads — the operator resync
/// (<see cref="TemplateService.ResyncInheritedMembersAsync"/>) and the read-only resolved-members
/// query — now read only what the inheritance resolver walks: the resync reuses #86's reconcile
/// closure (subtree + chain), falling back to the template's own chain when nothing derives from
/// it, and resolved-members reads the chain alone. Pinned against the #86 fixture (deep subtree,
/// ancestors above the edited template, a composed module, look-alike unrelated trees, a parent
/// cycle, a dangling parent).
/// </summary>
public class TemplateChainReadTests
{
private static Task<IReadOnlyList<Template>> Chain(
IReadOnlyList<Template> graph,
int id,
List<string>? calls = null) =>
TemplateReconcileClosure.CollectChainAsync(
id,
(ids, _) =>
{
calls?.Add("ids:" + string.Join(",", ids.OrderBy(i => i)));
return Task.FromResult<IReadOnlyList<Template>>(graph.Where(t => ids.Contains(t.Id)).ToList());
});
/// <summary>Every fixture template id, plus one that does not exist.</summary>
/// <returns>One row per id.</returns>
public static IEnumerable<object[]> EveryTemplateAndUnknown() =>
BuildGraph().Select(t => new object[] { t.Id }).Append(new object[] { 424242 });
/// <summary>
/// The resolved-members query answers identically from the chain alone and from the whole
/// library, for every template (resolved set + staleness, serialized).
/// </summary>
/// <param name="templateId">The template resolved.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[MemberData(nameof(EveryTemplateAndUnknown))]
public async Task ResolvedMembers_AnswerIdentically_FromChainAndWholeGraph(int templateId)
{
var graph = BuildGraph();
var chain = await Chain(graph, templateId);
Assert.Equal(
JsonSerializer.Serialize(TemplateInheritanceResolver.Resolve(templateId, graph), Json),
JsonSerializer.Serialize(TemplateInheritanceResolver.Resolve(templateId, chain), Json));
}
/// <summary>The chain is the template and its ancestors — one load per level, nothing else.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Chain_IsTemplateAndAncestorsOnly_OneLoadPerLevel()
{
var calls = new List<string>();
var chain = await Chain(BuildGraph(), GreatLeaf, calls);
Assert.Equal(new[] { GreatLeaf, Leaf1, Mid, Base, Root }, chain.Select(t => t.Id));
Assert.Equal(new[] { "ids:7", "ids:4", "ids:3", "ids:2", "ids:1" }, calls);
Assert.DoesNotContain(chain, t => t.Id == Mod || t.Id == U1);
}
/// <summary>A parent cycle and a dangling parent both end the chain; an unknown id loads nothing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Chain_TerminatesOnCyclesDanglingParentsAndUnknownIds()
{
var graph = BuildGraph();
Assert.Equal(new[] { CycC, CycA, CycB }, (await Chain(graph, CycC)).Select(t => t.Id));
Assert.Equal(new[] { DangKid, Dang }, (await Chain(graph, DangKid)).Select(t => t.Id));
Assert.Empty(await Chain(graph, 424242));
}
/// <summary>
/// End to end through <see cref="TemplateService.ResyncInheritedMembersAsync"/>: resyncing from
/// the closure (with the whole-library load wired to throw) issues exactly the writes, audit and
/// result of the old whole-graph resync, for every template and for an unknown id.
/// </summary>
/// <param name="templateId">The template resynced.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[MemberData(nameof(EveryTemplateAndUnknown))]
public async Task Resync_IssuesIdenticalWritesAndResult_FromClosureAndWholeGraph(int templateId)
{
var expected = await RecordResync(templateId, useClosure: false);
var actual = await RecordResync(templateId, useClosure: true);
Assert.Equal(expected, actual);
}
/// <summary>
/// The equivalence is not vacuous: resyncing the leaf <c>GreatLeaf</c> (empty reconcile closure,
/// so the chain fallback is load-bearing) materializes the members it inherits from four levels
/// up, resyncing <c>Leaf1</c> removes its orphan, and resyncing <c>Base</c> repairs the subtree.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Fixture_ResyncWritesRepairs_ForLeafAndSubtree()
{
var leaf = await RecordResync(GreatLeaf, useClosure: true);
Assert.Contains(leaf, w => w.StartsWith("AddTemplateAttributeAsync", StringComparison.Ordinal));
Assert.Contains(leaf, w => w.StartsWith("Result:Success", StringComparison.Ordinal));
var leaf1 = await RecordResync(Leaf1, useClosure: true);
Assert.Contains(leaf1, w => w.StartsWith("DeleteTemplateAttributeAsync", StringComparison.Ordinal));
var subtree = await RecordResync(Base, useClosure: true);
Assert.Contains(subtree, w => w.StartsWith("UpdateTemplateScriptAsync", StringComparison.Ordinal));
Assert.Contains(subtree, w => w.StartsWith("UpdateTemplateAlarmAsync", StringComparison.Ordinal));
}
/// <summary>
/// Runs the resync on a fresh copy of the graph and returns every write the service issued, the
/// audit row and the result, rendered as sorted strings.
/// </summary>
private static async Task<List<string>> RecordResync(int templateId, bool useClosure)
{
var graph = BuildGraph();
var repo = new Mock<ITemplateEngineRepository>();
var audit = new Mock<IAuditService>();
repo.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
if (useClosure)
{
repo.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("resync loaded the whole tracked graph"));
repo.Setup(r => r.GetTemplateReconcileClosureAsync(templateId, It.IsAny<CancellationToken>()))
.Returns((int id, CancellationToken _) => Closure(graph, id));
// The resync writes through what it loads: the chain fallback must be the tracked read.
repo.Setup(r => r.GetTemplateChainAsync(templateId, true, It.IsAny<CancellationToken>()))
.Returns((int id, bool _, CancellationToken _) => Chain(graph, id));
}
else
{
repo.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(graph);
repo.Setup(r => r.GetTemplateReconcileClosureAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(graph);
repo.Setup(r => r.GetTemplateChainAsync(It.IsAny<int>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(graph);
}
var service = new TemplateService(repo.Object, audit.Object);
var result = await service.ResyncInheritedMembersAsync(templateId, "tester");
static string Render(Moq.IInvocation i) =>
i.Method.Name + "(" + string.Join(", ", i.Arguments
.Where(a => a is not CancellationToken)
.Select(a => JsonSerializer.Serialize(a, Json))) + ")";
return repo.Invocations
.Where(i => i.Method.Name.StartsWith("Add", StringComparison.Ordinal)
|| i.Method.Name.StartsWith("Update", StringComparison.Ordinal)
|| i.Method.Name.StartsWith("Delete", StringComparison.Ordinal)
|| i.Method.Name == nameof(ITemplateEngineRepository.SaveChangesAsync))
.Select(Render)
.Concat(audit.Invocations.Select(Render))
.Append(result.IsSuccess
? "Result:Success " + JsonSerializer.Serialize(result.Value, Json)
: "Result:Failure " + result.Error)
.OrderBy(s => s, StringComparer.Ordinal)
.ToList();
}
}