fix(template-engine): resolve TemplateEngine-011,013,014 — remove dead converter, duplicate-id-safe cycle detection, unified deletion logic; TemplateEngine-012 deferred

This commit is contained in:
Joseph Doherty
2026-05-16 22:32:30 -04:00
parent 9e2416b34c
commit adb5e75ec3
9 changed files with 274 additions and 98 deletions

View File

@@ -9,6 +9,21 @@ namespace ScadaLink.TemplateEngine;
/// </summary>
public static class CycleDetector
{
/// <summary>
/// Builds an Id-keyed lookup that tolerates duplicate Ids in the input list
/// (e.g. multiple not-yet-saved templates all carrying Id 0). On a duplicate
/// the first occurrence wins — graph walks only need one representative node
/// per Id, and a real cycle through any duplicate would still be reachable.
/// A plain <c>ToDictionary(t =&gt; t.Id)</c> would instead throw ArgumentException.
/// </summary>
internal static Dictionary<int, Template> BuildLookup(IReadOnlyList<Template> allTemplates)
{
var lookup = new Dictionary<int, Template>();
foreach (var t in allTemplates)
lookup.TryAdd(t.Id, t);
return lookup;
}
/// <summary>
/// Checks whether setting <paramref name="parentId"/> as the parent of template
/// <paramref name="templateId"/> would introduce an inheritance cycle.
@@ -27,28 +42,30 @@ public static class CycleDetector
// Walk the inheritance chain from the proposed parent upward.
// If we arrive back at templateId, there is a cycle.
var lookup = allTemplates.ToDictionary(t => t.Id);
var lookup = BuildLookup(allTemplates);
var visited = new HashSet<int> { templateId };
var chain = new List<string>();
var templateName = lookup.TryGetValue(templateId, out var tmpl) ? tmpl.Name : templateId.ToString();
chain.Add(templateName);
var currentId = parentId;
while (currentId != 0)
// ParentTemplateId is int? — a missing value (not 0) means "no parent",
// so a template with a real Id of 0 is walked like any other node.
int? currentId = parentId;
while (currentId.HasValue)
{
if (!lookup.TryGetValue(currentId, out var current))
if (!lookup.TryGetValue(currentId.Value, out var current))
break;
chain.Add(current.Name);
if (visited.Contains(currentId))
if (visited.Contains(currentId.Value))
{
return $"Inheritance cycle detected: {string.Join(" -> ", chain)}.";
}
visited.Add(currentId);
currentId = current.ParentTemplateId ?? 0;
visited.Add(currentId.Value);
currentId = current.ParentTemplateId;
}
return null;
@@ -70,7 +87,7 @@ public static class CycleDetector
return $"Template '{selfName}' cannot compose itself.";
}
var lookup = allTemplates.ToDictionary(t => t.Id);
var lookup = BuildLookup(allTemplates);
// BFS/DFS from composedTemplateId through all its compositions.
// If we reach templateId, that's a cycle.
@@ -115,7 +132,7 @@ public static class CycleDetector
int? proposedComposedTemplateId,
IReadOnlyList<Template> allTemplates)
{
var lookup = allTemplates.ToDictionary(t => t.Id);
var lookup = BuildLookup(allTemplates);
// Build adjacency: for each template, collect all reachable templates
// via inheritance (parent) and composition edges.
@@ -124,11 +141,12 @@ public static class CycleDetector
var visited = new HashSet<int>();
var queue = new Queue<int>();
// Seed with proposed targets
if (proposedParentId.HasValue && proposedParentId.Value != 0)
// Seed with proposed targets. A null proposed id means "no edge"; a value
// of 0 is a legitimate Id, so only HasValue gates enqueuing.
if (proposedParentId.HasValue)
queue.Enqueue(proposedParentId.Value);
if (proposedComposedTemplateId.HasValue && proposedComposedTemplateId.Value != 0)
if (proposedComposedTemplateId.HasValue)
queue.Enqueue(proposedComposedTemplateId.Value);
while (queue.Count > 0)
@@ -146,8 +164,8 @@ public static class CycleDetector
if (!lookup.TryGetValue(currentId, out var current))
continue;
// Follow inheritance edge
if (current.ParentTemplateId.HasValue && current.ParentTemplateId.Value != 0)
// Follow inheritance edge (int? — missing value means no parent)
if (current.ParentTemplateId.HasValue)
queue.Enqueue(current.ParentTemplateId.Value);
// Follow composition edges