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

315 lines
14 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
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.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests;
/// <summary>
/// Gitea #86: <c>TemplateService.ReconcileDescendantsAsync</c> now reconciles against the
/// template's derived subtree + inheritance chain instead of the whole tracked library. These
/// tests pin that the closure is (a) sufficient — for every template of a graph with a deep
/// subtree, an ancestor chain above the edited template, a composed module, unrelated look-alike
/// trees, a parent cycle and a dangling parent, the reconcile issues exactly the same writes and
/// the resolver answers identically for every descendant — and (b) actually smaller, loading
/// nothing beyond one probe when there are no descendants.
/// </summary>
public class TemplateReconcileClosureTests
{
internal const int Root = 1, Base = 2, Mid = 3, Leaf1 = 4, Leaf2 = 5, Leaf3 = 6, GreatLeaf = 7, Mod = 8;
internal const int U1 = 9, U2 = 10, CycA = 11, CycB = 12, CycC = 13, Dang = 14, DangKid = 15;
internal static readonly JsonSerializerOptions Json = new()
{
ReferenceHandler = ReferenceHandler.IgnoreCycles,
WriteIndented = false,
};
/// <summary>Builds a fresh, independently mutable copy of the fixture graph.</summary>
internal static List<Template> BuildGraph()
{
var memberId = 1000;
Template T(int id, string name, int? parent = null)
=> new(name) { Id = id, ParentTemplateId = parent };
void Attr(Template t, string name, string? value, bool inherited = false, bool locked = false) =>
t.Attributes.Add(new TemplateAttribute(name)
{
Id = memberId++, TemplateId = t.Id, Value = value, DataType = DataType.String,
IsInherited = inherited, IsLocked = locked,
});
void Script(Template t, string name, string code, bool inherited = false) =>
t.Scripts.Add(new TemplateScript(name, code)
{
Id = memberId++, TemplateId = t.Id, TriggerType = "Startup", IsInherited = inherited,
});
void Alarm(Template t, string name, int priority, bool inherited = false) =>
t.Alarms.Add(new TemplateAlarm(name)
{
Id = memberId++, TemplateId = t.Id, PriorityLevel = priority,
TriggerType = AlarmTriggerType.ValueMatch, IsInherited = inherited,
});
var root = T(Root, "Root");
Attr(root, "R", "root"); // inherited from ABOVE the edited template
Script(root, "Init", "root-body");
var @base = T(Base, "Base", Root);
Attr(@base, "A", "1");
Attr(@base, "L", "locked", locked: true);
Attr(@base, "New", "added-after-derivation"); // no placeholder anywhere yet
Script(@base, "Calc", "base-body-v2");
Alarm(@base, "Hi", 700);
@base.Compositions.Add(new TemplateComposition("m") { Id = memberId++, TemplateId = Base, ComposedTemplateId = Mod });
var mid = T(Mid, "Mid", Base);
Attr(mid, "A", "mid-override"); // own override, never touched
Attr(mid, "R", "stale-root", inherited: true);
Attr(mid, "L", "locked", inherited: true);
Script(mid, "Calc", "base-body-v1", inherited: true); // stale body
Alarm(mid, "Hi", 500, inherited: true); // stale priority
var leaf1 = T(Leaf1, "Leaf1", Mid);
Attr(leaf1, "A", "1", inherited: true); // stale: Mid overrides A
Attr(leaf1, "Gone", "orphan", inherited: true); // orphan placeholder
var leaf2 = T(Leaf2, "Leaf2", Mid);
Attr(leaf2, "L", "override-attempt"); // locked upstream
var leaf3 = T(Leaf3, "Leaf3", Base);
var greatLeaf = T(GreatLeaf, "GreatLeaf", Leaf1);
Script(greatLeaf, "Calc", "own-body");
var mod = T(Mod, "Mod");
Attr(mod, "ModOnly", "x");
// Unrelated look-alike tree: same member names, must never be loaded.
var u1 = T(U1, "U1");
Attr(u1, "A", "u");
Attr(u1, "New", "u");
var u2 = T(U2, "U2", U1);
Attr(u2, "A", "stale", inherited: true);
// Parent cycle with a hanging child, and a dangling parent edge.
var cycA = T(CycA, "CycA", CycB);
Attr(cycA, "CA", "a");
var cycB = T(CycB, "CycB", CycA);
Attr(cycB, "CB", "b");
var cycC = T(CycC, "CycC", CycA);
var dang = T(Dang, "Dang", 998);
Attr(dang, "D", "d");
var dangKid = T(DangKid, "DangKid", Dang);
return new List<Template>
{
root, @base, mid, leaf1, leaf2, leaf3, greatLeaf, mod, u1, u2, cycA, cycB, cycC, dang, dangKid,
};
}
internal static Task<IReadOnlyList<Template>> Closure(
IReadOnlyList<Template> graph,
int id,
List<string>? calls = null) =>
TemplateReconcileClosure.CollectAsync(
id,
(parentIds, _) =>
{
calls?.Add("children:" + string.Join(",", parentIds.OrderBy(i => i)));
return Task.FromResult<IReadOnlyList<Template>>(
graph.Where(t => t.ParentTemplateId is int p && parentIds.Contains(p)).ToList());
},
(ids, _) =>
{
calls?.Add("ids:" + string.Join(",", ids.OrderBy(i => i)));
return Task.FromResult<IReadOnlyList<Template>>(graph.Where(t => ids.Contains(t.Id)).ToList());
});
// Independent re-statement of the service's descendant walk over a given template list.
private static List<int> Descendants(int rootId, IReadOnlyList<Template> all)
{
var result = new List<int>();
var seen = new HashSet<int> { rootId };
var queue = new Queue<int>(new[] { rootId });
while (queue.Count > 0)
{
var id = queue.Dequeue();
foreach (var child in all.Where(t => t.ParentTemplateId == id))
{
if (seen.Add(child.Id))
{
result.Add(child.Id);
queue.Enqueue(child.Id);
}
}
}
return result;
}
/// <summary>Every fixture template id.</summary>
/// <returns>One row per template.</returns>
public static IEnumerable<object[]> EveryTemplate() => BuildGraph().Select(t => new object[] { t.Id });
/// <summary>
/// The closure holds the same descendant set as the whole graph, and the inheritance resolver
/// gives every descendant the identical resolved member set from it.
/// </summary>
/// <param name="templateId">The edited template.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[MemberData(nameof(EveryTemplate))]
public async Task Resolver_AnswersIdenticallyForEveryDescendant(int templateId)
{
var graph = BuildGraph();
var closure = await Closure(graph, templateId);
var wholeDescendants = Descendants(templateId, graph);
Assert.Equal(wholeDescendants.OrderBy(i => i), Descendants(templateId, closure).OrderBy(i => i));
foreach (var d in wholeDescendants)
{
Assert.Equal(
JsonSerializer.Serialize(TemplateInheritanceResolver.Resolve(d, graph), Json),
JsonSerializer.Serialize(TemplateInheritanceResolver.Resolve(d, closure), Json));
}
}
/// <summary>
/// End to end through <see cref="TemplateService.ReconcileDescendantsAsync"/>: the closure-backed
/// reconcile issues exactly the writes (adds / updates / deletes / audit / save) the old
/// whole-graph reconcile did, for every template of the fixture.
/// </summary>
/// <param name="templateId">The edited template.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[MemberData(nameof(EveryTemplate))]
public async Task Reconcile_IssuesIdenticalWrites_FromClosureAndWholeGraph(int templateId)
{
var expected = await RecordReconcileWrites(templateId, useClosure: false);
var actual = await RecordReconcileWrites(templateId, useClosure: true);
Assert.Equal(expected, actual);
}
/// <summary>The equivalence above is not vacuous: editing Base really adds, updates and removes rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Fixture_ReconcilingBase_WritesAddsUpdatesAndDeletes()
{
var writes = await RecordReconcileWrites(Base, useClosure: true);
Assert.Contains(writes, w => w.StartsWith("AddTemplateAttributeAsync", StringComparison.Ordinal));
Assert.Contains(writes, w => w.StartsWith("UpdateTemplateAttributeAsync", StringComparison.Ordinal));
Assert.Contains(writes, w => w.StartsWith("UpdateTemplateScriptAsync", StringComparison.Ordinal));
Assert.Contains(writes, w => w.StartsWith("UpdateTemplateAlarmAsync", StringComparison.Ordinal));
Assert.Contains(writes, w => w.StartsWith("DeleteTemplateAttributeAsync", StringComparison.Ordinal));
// Root's member reaches Mid only through Base's own chain — the ancestor walk is load-bearing.
Assert.Contains(writes, w => w.StartsWith("UpdateTemplateAttributeAsync", StringComparison.Ordinal)
&& w.Contains("\"Value\":\"root\"", StringComparison.Ordinal));
}
/// <summary>The closure is the subtree plus the chain — no module, no unrelated tree, no siblings.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Closure_IsSubtreePlusChainOnly()
{
var graph = BuildGraph();
var closure = await Closure(graph, Base);
Assert.Equal(
new[] { Root, Base, Mid, Leaf1, Leaf2, Leaf3, GreatLeaf },
closure.Select(t => t.Id).OrderBy(i => i));
Assert.True(closure.Count < graph.Count);
// A mid-tree edit loads its own subtree and chain, not its siblings.
Assert.Equal(
new[] { Root, Base, Mid, Leaf1, Leaf2, GreatLeaf },
(await Closure(graph, Mid)).Select(t => t.Id).OrderBy(i => i));
}
/// <summary>A template with no children costs one children probe and loads nothing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Closure_NoDescendants_OneProbeAndEmpty()
{
var calls = new List<string>();
Assert.Empty(await Closure(BuildGraph(), Leaf2, calls));
Assert.Equal(new[] { "children:5" }, calls);
Assert.Empty(await Closure(BuildGraph(), 424242));
}
/// <summary>One children batch per subtree depth, one id batch per ancestor level, no id twice.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Closure_LoadsOneBatchPerDepth()
{
var calls = new List<string>();
await Closure(BuildGraph(), Base, calls);
Assert.Equal(
new[] { "children:2", "children:3,6", "children:4,5", "children:7", "ids:2", "ids:1" },
calls);
}
/// <summary>Parent cycles terminate and a dangling parent ends the chain.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Closure_TerminatesOnCyclesAndDanglingParents()
{
var graph = BuildGraph();
Assert.Equal(new[] { CycA, CycB, CycC }, (await Closure(graph, CycA)).Select(t => t.Id).OrderBy(i => i));
Assert.Equal(new[] { Dang, DangKid }, (await Closure(graph, Dang)).Select(t => t.Id).OrderBy(i => i));
}
/// <summary>
/// Runs the reconcile for <paramref name="templateId"/> on a fresh copy of the graph and returns
/// every write the service issued, rendered as sorted strings (sibling order is not part of the
/// contract — each descendant is reconciled independently).
/// </summary>
private static async Task<List<string>> RecordReconcileWrites(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("reconcile loaded the whole tracked graph"));
repo.Setup(r => r.GetTemplateReconcileClosureAsync(templateId, It.IsAny<CancellationToken>()))
.Returns((int id, CancellationToken _) => Closure(graph, id));
}
else
{
repo.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(graph);
}
var service = new TemplateService(repo.Object, audit.Object);
if (useClosure)
await service.ReconcileDescendantsAsync(templateId, "tester");
else
await service.ReconcileDescendantsAsync(templateId, "tester", default, graph);
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))) + ")";
var writes = 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))
.OrderBy(s => s, StringComparer.Ordinal)
.ToList();
return writes;
}
}