792f16c2d5
Attribute/alarm/script add (collision check) and update (parent-lock lookup) used GetAllTemplatesForAnalysisAsync, materialising every template, attribute, alarm and script in the library per edit. They now call the new ITemplateEngineRepository.GetTemplateAnalysisClosureAsync: the same untracked, body-free analysis projection, restricted to the template's inheritance chain plus the composition closure of every chain member - exactly what TemplateResolver and CollisionDetector walk (a composed module's own parent chain is never climbed, so it is not loaded). Adds use the edited template's closure, updates the parent's. The walk (Commons TemplateAnalysisClosure) loads one batch per closure depth, requests each id once, terminates on cycles and skips dangling edges. Evidence: - TemplateAnalysisClosureTests: for every template of a graph with chains, nested + diamond compositions, a derived module, cycles, dangling edges and colliding unrelated templates, ResolveAllMembers / DetectCollisions / FindMemberByCanonicalName answer identically from the closure and the whole graph (mutation-checked). - TemplateAnalysisClosureRepositoryTests (SQLite, recorded SQL): 6 templates / 12 attributes loaded instead of 47 / 94, in 4 queries (one per depth), untracked, script bodies never selected. - TemplateServiceTests: two new tests with the whole-graph load wired to throw (fail on the old service); every existing add/update test now runs against the closure via AnalysisClosureMock. ReconcileDescendantsAsync still loads the tracked full graph; not in scope. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
163 lines
7.8 KiB
C#
163 lines
7.8 KiB
C#
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests;
|
|
|
|
/// <summary>
|
|
/// Gitea #66: member add/update validation now runs the analysis walkers over a template's
|
|
/// inheritance chain + composition closure instead of the whole library. These tests pin that
|
|
/// the closure is (a) sufficient — every walker answer is identical to the whole-graph answer,
|
|
/// for every template in a graph with chains, nested and diamond compositions, a derived
|
|
/// module, cycles, dangling edges and unrelated look-alikes — and (b) actually smaller.
|
|
/// </summary>
|
|
public class TemplateAnalysisClosureTests
|
|
{
|
|
// Ids of the fixture graph.
|
|
private const int Grand = 1, Parent = 2, Child = 3, ModA = 4, ModB = 5, ModBBase = 6, Inner = 7;
|
|
private const int CycX = 8, CycY = 9, Dangling = 10;
|
|
private const int Twin = 18, TwinParent = 19, TwinGrand = 20;
|
|
|
|
private static readonly List<Template> Graph = BuildGraph();
|
|
|
|
private static List<Template> BuildGraph()
|
|
{
|
|
var memberId = 1000;
|
|
Template T(int id, string name, int? parent = null, params string[] attrs)
|
|
{
|
|
var t = new Template(name) { Id = id, ParentTemplateId = parent };
|
|
foreach (var a in attrs)
|
|
t.Attributes.Add(new TemplateAttribute(a) { Id = memberId++, TemplateId = id, IsLocked = a.StartsWith('L') });
|
|
return t;
|
|
}
|
|
|
|
void Compose(Template owner, Template module, string slot) =>
|
|
owner.Compositions.Add(new TemplateComposition(slot) { Id = memberId++, TemplateId = owner.Id, ComposedTemplateId = module.Id });
|
|
|
|
var grand = T(Grand, "Grand", null, "LSpeed", "Shared");
|
|
var parent = T(Parent, "Parent", Grand, "Flow");
|
|
var child = T(Child, "Child", Parent, "Shared", "m1.Level"); // overrides + a would-be collision
|
|
var modA = T(ModA, "ModA", null, "Level", "Temp");
|
|
var modB = T(ModB, "ModB", ModBBase, "Base1"); // derived module (placeholder row)
|
|
var modBBase = T(ModBBase, "ModBBase", null, "Base1", "OnlyInBase");
|
|
var inner = T(Inner, "Inner", null, "Deep");
|
|
modB.Alarms.Add(new TemplateAlarm("HiHi") { Id = memberId++, TemplateId = ModB });
|
|
modA.Scripts.Add(new TemplateScript("Calc", string.Empty) { Id = memberId++, TemplateId = ModA, IsLocked = true });
|
|
|
|
Compose(parent, modA, "m1");
|
|
Compose(child, modB, "m2");
|
|
Compose(child, modA, "m1b"); // diamond: ModA reached by two paths
|
|
Compose(modA, inner, "inner");
|
|
|
|
var cycX = T(CycX, "CycX", null, "X");
|
|
var cycY = T(CycY, "CycY", null, "Y");
|
|
Compose(cycX, cycY, "y");
|
|
Compose(cycY, cycX, "x"); // composition cycle
|
|
|
|
var dangling = T(Dangling, "Dangling", 998, "D");
|
|
dangling.Compositions.Add(new TemplateComposition("ghost") { Id = memberId++, TemplateId = Dangling, ComposedTemplateId = 999 });
|
|
|
|
// A template reached first as a module and then as a chain member: its parent must still load.
|
|
var twinGrand = T(TwinGrand, "TwinGrand", null, "G");
|
|
var twinParent = T(TwinParent, "TwinParent", TwinGrand, "P");
|
|
var twin = T(Twin, "Twin", TwinParent, "S");
|
|
Compose(twin, twinParent, "t");
|
|
|
|
var all = new List<Template> { grand, parent, child, modA, modB, modBBase, inner, cycX, cycY, dangling, twinGrand, twinParent, twin };
|
|
|
|
// Unrelated library content with colliding member names — must never be loaded.
|
|
for (var i = 0; i < 5; i++)
|
|
all.Add(T(11 + i, $"Unrelated{i}", i == 0 ? null : 11, "Shared", "Level", "m1.Level"));
|
|
|
|
return all;
|
|
}
|
|
|
|
private static Task<IReadOnlyList<Template>> InMemoryClosure(int id, List<IReadOnlyCollection<int>>? batches = null) =>
|
|
TemplateAnalysisClosure.CollectAsync(id, (ids, _) =>
|
|
{
|
|
batches?.Add(ids);
|
|
return Task.FromResult<IReadOnlyList<Template>>(Graph.Where(t => ids.Contains(t.Id)).ToList());
|
|
});
|
|
|
|
public static IEnumerable<object[]> EveryTemplate() => Graph.Select(t => new object[] { t.Id });
|
|
|
|
/// <summary>Resolver and collision detector give the same answer from the closure as from the whole graph.</summary>
|
|
/// <param name="templateId">Template under analysis.</param>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Theory]
|
|
[MemberData(nameof(EveryTemplate))]
|
|
public async Task Walkers_AnswerIdenticallyFromClosure(int templateId)
|
|
{
|
|
var closure = await InMemoryClosure(templateId);
|
|
var template = Graph.Single(t => t.Id == templateId);
|
|
|
|
Assert.Equal(
|
|
TemplateResolver.ResolveAllMembers(templateId, Graph),
|
|
TemplateResolver.ResolveAllMembers(templateId, closure));
|
|
Assert.Equal(
|
|
CollisionDetector.DetectCollisions(template, Graph),
|
|
CollisionDetector.DetectCollisions(template, closure));
|
|
|
|
foreach (var member in TemplateResolver.ResolveAllMembers(templateId, Graph))
|
|
{
|
|
Assert.Equal(
|
|
TemplateResolver.FindMemberByCanonicalName(member.CanonicalName, templateId, Graph),
|
|
TemplateResolver.FindMemberByCanonicalName(member.CanonicalName, templateId, closure));
|
|
}
|
|
}
|
|
|
|
/// <summary>The fixture really exercises a collision, so the equality above is not vacuous.</summary>
|
|
[Fact]
|
|
public void Fixture_ChildHasACompositionCollision()
|
|
{
|
|
var child = Graph.Single(t => t.Id == Child);
|
|
Assert.NotEmpty(CollisionDetector.DetectCollisions(child, Graph));
|
|
}
|
|
|
|
/// <summary>The closure is exactly the chain plus composition closure — no module ancestors, no unrelated templates.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Closure_IsChainPlusCompositionClosureOnly()
|
|
{
|
|
var closure = await InMemoryClosure(Child);
|
|
|
|
Assert.Equal(
|
|
new[] { Grand, Parent, Child, ModA, ModB, Inner }.OrderBy(i => i),
|
|
closure.Select(t => t.Id).OrderBy(i => i));
|
|
Assert.DoesNotContain(closure, t => t.Id == ModBBase); // a module's parent chain is never walked
|
|
Assert.True(closure.Count < Graph.Count);
|
|
}
|
|
|
|
/// <summary>A template reached as a module before it is reached as a chain member still expands its parent.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Closure_ModuleThatIsAlsoAChainMember_StillClimbsItsParent()
|
|
{
|
|
var closure = await InMemoryClosure(Twin);
|
|
Assert.Equal(new[] { Twin, TwinParent, TwinGrand }.OrderBy(i => i), closure.Select(t => t.Id).OrderBy(i => i));
|
|
}
|
|
|
|
/// <summary>Cycles terminate and dangling ids are skipped.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Closure_TerminatesOnCyclesAndSkipsDanglingEdges()
|
|
{
|
|
Assert.Equal(new[] { CycX, CycY }, (await InMemoryClosure(CycX)).Select(t => t.Id).OrderBy(i => i));
|
|
Assert.Equal(new[] { Dangling }, (await InMemoryClosure(Dangling)).Select(t => t.Id));
|
|
Assert.Empty(await InMemoryClosure(424242));
|
|
}
|
|
|
|
/// <summary>One batch per closure depth, and no id is ever requested twice.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Closure_LoadsOneBatchPerDepth_NeverRequestsAnIdTwice()
|
|
{
|
|
var batches = new List<IReadOnlyCollection<int>>();
|
|
await InMemoryClosure(Child, batches);
|
|
|
|
// Depth 0: Child; depth 1: Parent, ModB, ModA; depth 2: Grand, Inner.
|
|
Assert.Equal(3, batches.Count);
|
|
var requested = batches.SelectMany(b => b).ToList();
|
|
Assert.Equal(requested.Count, requested.Distinct().Count());
|
|
}
|
|
}
|