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

@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-16 | | Last reviewed | 2026-05-16 |
| Reviewer | claude-agent | | Reviewer | claude-agent |
| Commit reviewed | `9c60592` | | Commit reviewed | `9c60592` |
| Open findings | 4 | | Open findings | 0 |
## Summary ## Summary
@@ -474,7 +474,7 @@ behaviour change; no regression test (documentation-only).
|--|--| |--|--|
| Severity | Low | | Severity | Low |
| Category | Documentation & comments | | Category | Documentation & comments |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/Flattening/RevisionHashService.cs:136` | | Location | `src/ScadaLink.TemplateEngine/Flattening/RevisionHashService.cs:136` |
**Description** **Description**
@@ -497,7 +497,18 @@ ordering of the `Hashable*` records (ideally enforced by a test).
**Resolution** **Resolution**
_Unresolved._ Resolved 2026-05-16 (commit `pending commit`): deleted the dead
`SortedPropertiesConverterFactory` (and removed it from `CanonicalJsonOptions`),
and replaced the misleading "sorted keys / consistent ordering" comments with an
explicit DETERMINISM CONTRACT note on the `RevisionHashService` class summary —
`System.Text.Json` serializes properties in CLR declaration order and does not
sort, so stable hashes rely on the private `Hashable*` records declaring their
properties alphabetically (collections are explicitly sorted by `CanonicalName`).
That manual ordering is now guarded by a regression test:
`RevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically`
(reflects over the nested `Hashable*` types and asserts ordinal-alphabetical
property declaration order), so adding a property out of order now fails the build's
test gate instead of silently changing every revision hash.
### TemplateEngine-012 — `DataType` enum naming diverges from the design doc ### TemplateEngine-012 — `DataType` enum naming diverges from the design doc
@@ -505,7 +516,7 @@ _Unresolved._
|--|--| |--|--|
| Severity | Low | | Severity | Low |
| Category | Design-document adherence | | Category | Design-document adherence |
| Status | Open | | Status | Deferred |
| Location | `src/ScadaLink.TemplateEngine/Validation/SemanticValidator.cs:18` | | Location | `src/ScadaLink.TemplateEngine/Validation/SemanticValidator.cs:18` |
**Description** **Description**
@@ -523,9 +534,30 @@ are numeric.
Update `docs/requirements/Component-TemplateEngine.md` to list the actual enum members, Update `docs/requirements/Component-TemplateEngine.md` to list the actual enum members,
or rename the enum to match the doc if "Integer" is the intended canonical name. or rename the enum to match the doc if "Integer" is the intended canonical name.
**Re-triage**
Verified against the source: the `DataType` enum is declared in
`src/ScadaLink.Commons/Types/Enums/DataType.cs` (`Boolean, Int32, Float, Double,
String, DateTime, Binary`) — **not** in the TemplateEngine module — and is consumed
across modules (`TemplateAttribute` entity, management command contracts). The only
in-module file the finding cites, `SemanticValidator.cs:18`, is confirmed **correct**:
`NumericDataTypes` already hard-codes the real enum names. Both remediation options
in the recommendation therefore land **outside** this module's resolution boundary
(`src/ScadaLink.TemplateEngine/**`): renaming the enum touches `ScadaLink.Commons`
(and every consumer of `DataType`), and the alternative — updating the design doc —
touches `docs/requirements/Component-TemplateEngine.md`. There is no in-module code
defect to fix. Re-triaged from Open to Deferred: the fix is a one-line design-doc
correction (list the actual seven enum members instead of "Boolean, Integer, Float,
String") that must be made by an agent owning the docs / Commons scope.
**Resolution** **Resolution**
_Unresolved._ Deferred 2026-05-16 (no commit): no in-module fix possible — see Re-triage. The
TemplateEngine code is correct as-is. FLAGGED for the docs owner: correct the
Attribute data-type list in `docs/requirements/Component-TemplateEngine.md` to match
`ScadaLink.Commons` `DataType` (`Boolean, Int32, Float, Double, String, DateTime,
Binary`). Renaming the enum is not recommended (cross-module churn for no behavioural
gain); the doc is the authoritative thing to fix.
### TemplateEngine-013 — `ToDictionary(t => t.Id)` throws on duplicate IDs; cycle detectors overload Id 0 as a sentinel ### TemplateEngine-013 — `ToDictionary(t => t.Id)` throws on duplicate IDs; cycle detectors overload Id 0 as a sentinel
@@ -533,7 +565,7 @@ _Unresolved._
|--|--| |--|--|
| Severity | Low | | Severity | Low |
| Category | Correctness & logic bugs | | Category | Correctness & logic bugs |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/CycleDetector.cs:30`, `src/ScadaLink.TemplateEngine/CycleDetector.cs:38` | | Location | `src/ScadaLink.TemplateEngine/CycleDetector.cs:30`, `src/ScadaLink.TemplateEngine/CycleDetector.cs:38` |
**Description** **Description**
@@ -556,7 +588,23 @@ special.
**Resolution** **Resolution**
_Unresolved._ Resolved 2026-05-16 (commit `pending commit`): added `CycleDetector.BuildLookup`,
a duplicate-tolerant Id-keyed lookup (`Dictionary` + `TryAdd`, first occurrence wins)
that replaces every `allTemplates.ToDictionary(t => t.Id)` in the static helpers —
`CycleDetector` (all three methods), `TemplateResolver.ResolveAllMembers`, and
`CollisionDetector.DetectCollisions` — so a list containing two templates with the
same `Id` (e.g. not-yet-saved templates carrying `Id 0`) no longer throws an
unhandled `ArgumentException`. Separately, the `0`-as-"no-parent" sentinel was
removed: `DetectInheritanceCycle` now walks the parent chain via the `int?`
`ParentTemplateId` (`HasValue` gates continuation), and `DetectCrossGraphCycle`
gates the proposed parent/composed edges on `HasValue` rather than `!= 0`, so a
template with a real `Id` of 0 is treated like any other node and cycles through it
are detected. Regression tests:
`CycleDetectorTests.DetectInheritanceCycle_DuplicateIdsInList_DoesNotThrow`,
`DetectCompositionCycle_DuplicateIdsInList_DoesNotThrow`,
`DetectCrossGraphCycle_DuplicateIdsInList_DoesNotThrow`,
`DetectInheritanceCycle_RealIdZero_StillDetectsCycle`,
`DetectInheritanceCycle_ParentChainThroughIdZero_DetectsCycle`.
### TemplateEngine-014 — Template-deletion constraint logic is duplicated and divergent ### TemplateEngine-014 — Template-deletion constraint logic is duplicated and divergent
@@ -564,7 +612,7 @@ _Unresolved._
|--|--| |--|--|
| Severity | Low | | Severity | Low |
| Category | Code organization & conventions | | Category | Code organization & conventions |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/TemplateService.cs:109`, `src/ScadaLink.TemplateEngine/Services/TemplateDeletionService.cs:27` | | Location | `src/ScadaLink.TemplateEngine/TemplateService.cs:109`, `src/ScadaLink.TemplateEngine/Services/TemplateDeletionService.cs:27` |
**Description** **Description**
@@ -586,4 +634,17 @@ vice versa) so the constraint logic lives in exactly one place.
**Resolution** **Resolution**
_Unresolved._ Resolved 2026-05-16 (commit `pending commit`): `TemplateService.DeleteTemplateAsync`
no longer re-implements the deletion-constraint rules — it now delegates the
constraint check and the delete to `TemplateDeletionService.DeleteTemplateAsync`
(the surviving single implementation, which already accumulates every blocking
reason rather than returning on the first failing category). `TemplateService`
retains only its audit-logging side effect: after a successful delete it writes the
`Delete` audit entry and calls `SaveChangesAsync` (the deletion service is unaware of
auditing and persists the delete itself, so the audit entry needs its own save).
The constraint logic now lives in exactly one place, so a future rule change cannot
drift between two implementations. Behavioural change: `DeleteTemplateAsync` now
reports all blocking reasons and uses `TemplateDeletionService`'s phrasing — the
affected `TemplateServiceTests` delete tests were updated to the unified messages,
and a regression test `DeleteTemplate_MultipleConstraints_ReportsAllNotJustFirst`
verifies all three constraint categories are surfaced together.

View File

@@ -27,7 +27,10 @@ public static class CollisionDetector
Template template, Template template,
IReadOnlyList<Template> allTemplates) IReadOnlyList<Template> allTemplates)
{ {
var lookup = allTemplates.ToDictionary(t => t.Id); // Duplicate-tolerant lookup (see CycleDetector.BuildLookup): a plain
// ToDictionary(t => t.Id) throws if two templates share an Id (e.g.
// not-yet-saved templates carrying Id 0).
var lookup = CycleDetector.BuildLookup(allTemplates);
var allMembers = new List<ResolvedMember>(); var allMembers = new List<ResolvedMember>();
// Collect direct (top-level) members // Collect direct (top-level) members

View File

@@ -9,6 +9,21 @@ namespace ScadaLink.TemplateEngine;
/// </summary> /// </summary>
public static class CycleDetector 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> /// <summary>
/// Checks whether setting <paramref name="parentId"/> as the parent of template /// Checks whether setting <paramref name="parentId"/> as the parent of template
/// <paramref name="templateId"/> would introduce an inheritance cycle. /// <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. // Walk the inheritance chain from the proposed parent upward.
// If we arrive back at templateId, there is a cycle. // 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 visited = new HashSet<int> { templateId };
var chain = new List<string>(); var chain = new List<string>();
var templateName = lookup.TryGetValue(templateId, out var tmpl) ? tmpl.Name : templateId.ToString(); var templateName = lookup.TryGetValue(templateId, out var tmpl) ? tmpl.Name : templateId.ToString();
chain.Add(templateName); chain.Add(templateName);
var currentId = parentId; // ParentTemplateId is int? — a missing value (not 0) means "no parent",
while (currentId != 0) // 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; break;
chain.Add(current.Name); chain.Add(current.Name);
if (visited.Contains(currentId)) if (visited.Contains(currentId.Value))
{ {
return $"Inheritance cycle detected: {string.Join(" -> ", chain)}."; return $"Inheritance cycle detected: {string.Join(" -> ", chain)}.";
} }
visited.Add(currentId); visited.Add(currentId.Value);
currentId = current.ParentTemplateId ?? 0; currentId = current.ParentTemplateId;
} }
return null; return null;
@@ -70,7 +87,7 @@ public static class CycleDetector
return $"Template '{selfName}' cannot compose itself."; 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. // BFS/DFS from composedTemplateId through all its compositions.
// If we reach templateId, that's a cycle. // If we reach templateId, that's a cycle.
@@ -115,7 +132,7 @@ public static class CycleDetector
int? proposedComposedTemplateId, int? proposedComposedTemplateId,
IReadOnlyList<Template> allTemplates) IReadOnlyList<Template> allTemplates)
{ {
var lookup = allTemplates.ToDictionary(t => t.Id); var lookup = BuildLookup(allTemplates);
// Build adjacency: for each template, collect all reachable templates // Build adjacency: for each template, collect all reachable templates
// via inheritance (parent) and composition edges. // via inheritance (parent) and composition edges.
@@ -124,11 +141,12 @@ public static class CycleDetector
var visited = new HashSet<int>(); var visited = new HashSet<int>();
var queue = new Queue<int>(); var queue = new Queue<int>();
// Seed with proposed targets // Seed with proposed targets. A null proposed id means "no edge"; a value
if (proposedParentId.HasValue && proposedParentId.Value != 0) // of 0 is a legitimate Id, so only HasValue gates enqueuing.
if (proposedParentId.HasValue)
queue.Enqueue(proposedParentId.Value); queue.Enqueue(proposedParentId.Value);
if (proposedComposedTemplateId.HasValue && proposedComposedTemplateId.Value != 0) if (proposedComposedTemplateId.HasValue)
queue.Enqueue(proposedComposedTemplateId.Value); queue.Enqueue(proposedComposedTemplateId.Value);
while (queue.Count > 0) while (queue.Count > 0)
@@ -146,8 +164,8 @@ public static class CycleDetector
if (!lookup.TryGetValue(currentId, out var current)) if (!lookup.TryGetValue(currentId, out var current))
continue; continue;
// Follow inheritance edge // Follow inheritance edge (int? — missing value means no parent)
if (current.ParentTemplateId.HasValue && current.ParentTemplateId.Value != 0) if (current.ParentTemplateId.HasValue)
queue.Enqueue(current.ParentTemplateId.Value); queue.Enqueue(current.ParentTemplateId.Value);
// Follow composition edges // Follow composition edges

View File

@@ -8,19 +8,24 @@ namespace ScadaLink.TemplateEngine.Flattening;
/// <summary> /// <summary>
/// Produces a deterministic SHA-256 hash of a FlattenedConfiguration. /// Produces a deterministic SHA-256 hash of a FlattenedConfiguration.
/// Same content always produces the same hash across runs by using /// Same content always produces the same hash across runs.
/// canonical JSON serialization with sorted keys. /// <para>
/// DETERMINISM CONTRACT: System.Text.Json serializes properties in CLR
/// declaration order, which it does NOT sort. Stable output therefore relies
/// on the private <c>Hashable*</c> records below declaring their properties
/// in alphabetical order. A contributor adding a property out of alphabetical
/// order would silently change every revision hash; the ordering is guarded
/// by <c>RevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically</c>.
/// Collections are explicitly sorted by <c>CanonicalName</c> before hashing.
/// </para>
/// </summary> /// </summary>
public class RevisionHashService public class RevisionHashService
{ {
private static readonly JsonSerializerOptions CanonicalJsonOptions = new() private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
{ {
// Sort properties alphabetically for determinism
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false, WriteIndented = false
// Ensure consistent ordering
Converters = { new SortedPropertiesConverterFactory() }
}; };
/// <summary> /// <summary>
@@ -84,7 +89,9 @@ public class RevisionHashService
return $"sha256:{Convert.ToHexStringLower(hashBytes)}"; return $"sha256:{Convert.ToHexStringLower(hashBytes)}";
} }
// Internal types for deterministic serialization (sorted property names via camelCase + alphabetical) // Internal types for deterministic serialization. Properties MUST be declared
// in alphabetical order — System.Text.Json emits them in declaration order and
// does not sort. See the DETERMINISM CONTRACT note on the class summary.
private sealed record HashableConfiguration private sealed record HashableConfiguration
{ {
public List<HashableAlarm> Alarms { get; init; } = []; public List<HashableAlarm> Alarms { get; init; } = [];
@@ -128,14 +135,3 @@ public class RevisionHashService
public string? TriggerType { get; init; } public string? TriggerType { get; init; }
} }
} }
/// <summary>
/// A JSON converter factory that ensures properties are serialized in alphabetical order
/// for deterministic output. Works with record types.
/// </summary>
internal class SortedPropertiesConverterFactory : JsonConverterFactory
{
public override bool CanConvert(Type typeToConvert) => false;
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) => null;
}

View File

@@ -39,7 +39,10 @@ public static class TemplateResolver
int templateId, int templateId,
IReadOnlyList<Template> allTemplates) IReadOnlyList<Template> allTemplates)
{ {
var lookup = allTemplates.ToDictionary(t => t.Id); // Duplicate-tolerant lookup (see CycleDetector.BuildLookup): a plain
// ToDictionary(t => t.Id) throws if two templates share an Id (e.g.
// not-yet-saved templates carrying Id 0).
var lookup = CycleDetector.BuildLookup(allTemplates);
if (!lookup.TryGetValue(templateId, out var template)) if (!lookup.TryGetValue(templateId, out var template))
return Array.Empty<ResolvedTemplateMember>(); return Array.Empty<ResolvedTemplateMember>();

View File

@@ -112,59 +112,18 @@ public class TemplateService
if (template == null) if (template == null)
return Result<bool>.Failure($"Template with ID {templateId} not found."); return Result<bool>.Failure($"Template with ID {templateId} not found.");
// Derived templates are owned by their composition row and must be removed // Deletion-constraint logic (instances / child / derived / composing
// by deleting the composition (which cascades) — block direct deletion. // templates) lives in exactly one place — TemplateDeletionService — so a
if (template.IsDerived) // future rule change cannot drift between two implementations
return Result<bool>.Failure( // (TemplateEngine-014). TemplateService owns only the audit-logging side
$"Cannot delete template '{template.Name}': it is a derived template. " + // effect, which the deletion service is unaware of.
"Remove the owning composition on its parent template instead."); var deletionService = new Services.TemplateDeletionService(_repository);
var deleteResult = await deletionService.DeleteTemplateAsync(templateId, cancellationToken);
if (deleteResult.IsFailure)
return deleteResult;
// Check for instances referencing this template // TemplateDeletionService already persisted the delete; the audit entry
var instances = await _repository.GetInstancesByTemplateIdAsync(templateId, cancellationToken); // is added to the change tracker here and needs its own SaveChangesAsync.
if (instances.Count > 0)
return Result<bool>.Failure(
$"Cannot delete template '{template.Name}': it is referenced by {instances.Count} instance(s).");
// Check for child templates inheriting from this template.
// Split derived vs. regular children — the message and remediation differ.
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var inheritors = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList();
var derivatives = inheritors.Where(t => t.IsDerived).ToList();
var regularChildren = inheritors.Where(t => !t.IsDerived).ToList();
if (regularChildren.Count > 0)
return Result<bool>.Failure(
$"Cannot delete template '{template.Name}': it is inherited by {regularChildren.Count} child template(s): " +
string.Join(", ", regularChildren.Select(c => $"'{c.Name}'")));
if (derivatives.Count > 0)
{
// Name each derivative by its owning parent template + composition slot.
var ownerCompIds = derivatives.Select(d => d.OwnerCompositionId).Where(id => id.HasValue).Select(id => id!.Value).ToHashSet();
var ownerLookup = allTemplates
.SelectMany(t => t.Compositions.Select(c => new { Owner = t, Composition = c }))
.Where(x => ownerCompIds.Contains(x.Composition.Id))
.ToDictionary(x => x.Composition.Id, x => $"'{x.Owner.Name}' (as '{x.Composition.InstanceName}')");
var details = derivatives
.Select(d => d.OwnerCompositionId.HasValue && ownerLookup.TryGetValue(d.OwnerCompositionId.Value, out var label)
? label
: $"'{d.Name}'");
return Result<bool>.Failure(
$"Cannot delete template '{template.Name}': it is the base of {derivatives.Count} derived template(s) used in: " +
string.Join(", ", details) + ". Remove those compositions first.");
}
// Check for templates composing this template
var composedBy = allTemplates
.Where(t => t.Compositions.Any(c => c.ComposedTemplateId == templateId))
.ToList();
if (composedBy.Count > 0)
return Result<bool>.Failure(
$"Cannot delete template '{template.Name}': it is composed by {composedBy.Count} template(s): " +
string.Join(", ", composedBy.Select(c => $"'{c.Name}'")));
await _repository.DeleteTemplateAsync(templateId, cancellationToken);
await _auditService.LogAsync(user, "Delete", "Template", templateId.ToString(), template.Name, null, cancellationToken); await _auditService.LogAsync(user, "Delete", "Template", templateId.ToString(), template.Name, null, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken); await _repository.SaveChangesAsync(cancellationToken);

View File

@@ -153,4 +153,77 @@ public class CycleDetectorTests
Assert.Null(result); Assert.Null(result);
} }
// ========================================================================
// TemplateEngine-013: robustness against duplicate Ids and Id 0
// ========================================================================
[Fact]
public void DetectInheritanceCycle_DuplicateIdsInList_DoesNotThrow()
{
// Two not-yet-saved templates both carry Id == 0. ToDictionary(t => t.Id)
// would throw ArgumentException; the detector must tolerate it.
var templateA = new Template("A") { Id = 0 };
var templateB = new Template("B") { Id = 0 };
var saved = new Template("Saved") { Id = 1 };
var all = new List<Template> { templateA, templateB, saved };
var ex = Record.Exception(() => CycleDetector.DetectInheritanceCycle(1, 0, all));
Assert.Null(ex);
}
[Fact]
public void DetectCompositionCycle_DuplicateIdsInList_DoesNotThrow()
{
var templateA = new Template("A") { Id = 0 };
var templateB = new Template("B") { Id = 0 };
var all = new List<Template> { templateA, templateB };
var ex = Record.Exception(() => CycleDetector.DetectCompositionCycle(1, 2, all));
Assert.Null(ex);
}
[Fact]
public void DetectCrossGraphCycle_DuplicateIdsInList_DoesNotThrow()
{
var templateA = new Template("A") { Id = 0 };
var templateB = new Template("B") { Id = 0 };
var all = new List<Template> { templateA, templateB };
var ex = Record.Exception(() => CycleDetector.DetectCrossGraphCycle(5, 1, 2, all));
Assert.Null(ex);
}
[Fact]
public void DetectInheritanceCycle_RealIdZero_StillDetectsCycle()
{
// A template legitimately stored with Id 0 (in-memory / test scenario):
// a self-inheritance attempt must still be detected, not skipped as
// "no parent" by a 0-as-sentinel overload.
var template = new Template("Zero") { Id = 0 };
var all = new List<Template> { template };
var result = CycleDetector.DetectInheritanceCycle(0, 0, all);
Assert.NotNull(result);
Assert.Contains("itself", result);
}
[Fact]
public void DetectInheritanceCycle_ParentChainThroughIdZero_DetectsCycle()
{
// Child(1) -> parent Zero(0) -> parent Child(1): a cycle running through
// a template whose real Id is 0 must be detected, not silently skipped.
var zero = new Template("Zero") { Id = 0, ParentTemplateId = 1 };
var child = new Template("Child") { Id = 1, ParentTemplateId = null };
var all = new List<Template> { zero, child };
var result = CycleDetector.DetectInheritanceCycle(1, 0, all);
Assert.NotNull(result);
Assert.Contains("cycle", result, StringComparison.OrdinalIgnoreCase);
}
} }

View File

@@ -99,6 +99,38 @@ public class RevisionHashServiceTests
Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2)); Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
} }
[Fact]
public void HashableRecords_PropertiesDeclaredAlphabetically()
{
// TemplateEngine-011: revision-hash determinism depends entirely on the
// private Hashable* records declaring their properties in alphabetical
// order (System.Text.Json emits properties in CLR declaration order and
// does not sort). This guards against a contributor silently changing
// every revision hash by adding a property out of order.
var nested = typeof(RevisionHashService)
.GetNestedTypes(System.Reflection.BindingFlags.NonPublic)
.Where(t => t.Name.StartsWith("Hashable"))
.ToList();
Assert.NotEmpty(nested);
foreach (var type in nested)
{
var propNames = type
.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
.Where(p => p.Name != "EqualityContract")
.Select(p => p.Name)
.ToList();
var sorted = propNames.OrderBy(n => n, StringComparer.Ordinal).ToList();
Assert.True(
propNames.SequenceEqual(sorted),
$"{type.Name} properties must be declared alphabetically. " +
$"Declared: [{string.Join(", ", propNames)}] Expected: [{string.Join(", ", sorted)}]");
}
}
private static FlattenedConfiguration CreateConfig(string instanceName, string tempValue) private static FlattenedConfiguration CreateConfig(string instanceName, string tempValue)
{ {
return new FlattenedConfiguration return new FlattenedConfiguration

View File

@@ -122,11 +122,13 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template); _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>())) _repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Instance> { new Instance("Pump1") { Id = 1, TemplateId = 1, SiteId = 1 } }); .ReturnsAsync(new List<Instance> { new Instance("Pump1") { Id = 1, TemplateId = 1, SiteId = 1 } });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
var result = await _service.DeleteTemplateAsync(1, "admin"); var result = await _service.DeleteTemplateAsync(1, "admin");
Assert.True(result.IsFailure); Assert.True(result.IsFailure);
Assert.Contains("referenced by", result.Error); Assert.Contains("instance(s) reference it", result.Error);
} }
[Fact] [Fact]
@@ -143,7 +145,7 @@ public class TemplateServiceTests
var result = await _service.DeleteTemplateAsync(1, "admin"); var result = await _service.DeleteTemplateAsync(1, "admin");
Assert.True(result.IsFailure); Assert.True(result.IsFailure);
Assert.Contains("inherited by", result.Error); Assert.Contains("child template(s) inherit from it", result.Error);
} }
[Fact] [Fact]
@@ -162,7 +164,36 @@ public class TemplateServiceTests
var result = await _service.DeleteTemplateAsync(1, "admin"); var result = await _service.DeleteTemplateAsync(1, "admin");
Assert.True(result.IsFailure); Assert.True(result.IsFailure);
Assert.Contains("composed by", result.Error); Assert.Contains("template(s) compose it", result.Error);
}
[Fact]
public async Task DeleteTemplate_MultipleConstraints_ReportsAllNotJustFirst()
{
// TemplateEngine-014: DeleteTemplateAsync delegates its constraint check
// to the single TemplateDeletionService implementation, which accumulates
// every blocking reason instead of returning on the first failing category.
var template = new Template("Busy") { Id = 1 };
var composer = new Template("Composer") { Id = 3 };
composer.Compositions.Add(new TemplateComposition("Module") { Id = 1, TemplateId = 3, ComposedTemplateId = 1 });
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Instance> { new Instance("Inst1") { Id = 1, TemplateId = 1, SiteId = 1 } });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template>
{
template,
new Template("Child") { Id = 2, ParentTemplateId = 1 },
composer
});
var result = await _service.DeleteTemplateAsync(1, "admin");
Assert.True(result.IsFailure);
Assert.Contains("instance(s) reference it", result.Error);
Assert.Contains("child template(s) inherit from it", result.Error);
Assert.Contains("template(s) compose it", result.Error);
} }
// ======================================================================== // ========================================================================