fix(template-engine): resolve TemplateEngine-015,016 — cascade-rename nested derived templates, correct composed-script ParentPath

This commit is contained in:
Joseph Doherty
2026-05-17 03:18:41 -04:00
parent 0135a6b2a6
commit d6221419c6
5 changed files with 177 additions and 14 deletions

View File

@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-17 | | Last reviewed | 2026-05-17 |
| Reviewer | claude-agent | | Reviewer | claude-agent |
| Commit reviewed | `39d737e` | | Commit reviewed | `39d737e` |
| Open findings | 2 | | Open findings | 0 |
## Summary ## Summary
@@ -674,7 +674,7 @@ verifies all three constraint categories are surfaced together.
|--|--| |--|--|
| Severity | Medium | | Severity | Medium |
| Category | Correctness & logic bugs | | Category | Correctness & logic bugs |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/TemplateService.cs:680` | | Location | `src/ScadaLink.TemplateEngine/TemplateService.cs:680` |
**Description** **Description**
@@ -719,7 +719,14 @@ two-level cascade rename.
**Resolution** **Resolution**
_Unresolved._ Resolved 2026-05-17 (commit `pending`): `RenameCompositionAsync` now recurses
into `derived.Compositions` via a new `CollectCascadeRenamesAsync` helper
(mirroring `CascadeDeleteDerivedAsync`), re-deriving each cascaded inner derived
template's name from its renamed parent and slot instance name, and runs the
same-name collision pre-check across every name in the cascade before any row
mutates. Regression tests:
`RenameComposition_CascadesRenameToNestedDerivedTemplates`,
`RenameComposition_NestedCascadeNameCollision_Fails`.
### TemplateEngine-016 — Composed-script `ScriptScope.ParentPath` is always empty, breaking `Parent.X` resolution for nested modules ### TemplateEngine-016 — Composed-script `ScriptScope.ParentPath` is always empty, breaking `Parent.X` resolution for nested modules
@@ -727,7 +734,7 @@ _Unresolved._
|--|--| |--|--|
| Severity | Medium | | Severity | Medium |
| Category | Correctness & logic bugs | | Category | Correctness & logic bugs |
| Status | Open | | Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs:750` | | Location | `src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs:750` |
**Description** **Description**
@@ -765,4 +772,11 @@ two-level composed script.
**Resolution** **Resolution**
_Unresolved._ Resolved 2026-05-17 (commit `pending`): threaded a `parentPath` parameter
through `ResolveComposedScriptsRecursive` — the top-level caller passes `""`
(a depth-1 composition's parent is the root template) and each nested call
passes the enclosing module's `prefix` — and the `ScriptScope` now sets
`ParentPath` to that value instead of a hard-coded `""`, so a depth-2 script's
`SelfPath = "Outer.Inner"` pairs with `ParentPath = "Outer"` and `Parent.X`
resolves against the real parent module. Regression test:
`Flatten_NestedComposedScript_ScopeCarriesCorrectParentPath`.

View File

@@ -714,7 +714,7 @@ public class FlatteningService
foreach (var composition in compositions) foreach (var composition in compositions)
ResolveComposedScriptsRecursive( ResolveComposedScriptsRecursive(
composition, composition.InstanceName, composition, composition.InstanceName, parentPath: "",
compositionMap, composedTemplateChains, scripts, scriptCanonicalById, compositionMap, composedTemplateChains, scripts, scriptCanonicalById,
new HashSet<int>()); new HashSet<int>());
} }
@@ -723,11 +723,17 @@ public class FlatteningService
/// <summary> /// <summary>
/// Recursively resolves the scripts of a composed module and every module /// Recursively resolves the scripts of a composed module and every module
/// nested inside it, path-qualifying each canonical name with the /// nested inside it, path-qualifying each canonical name with the
/// accumulated <paramref name="prefix"/>. /// accumulated <paramref name="prefix"/>. <paramref name="parentPath"/> is
/// the path of the enclosing module — empty for a depth-1 composition
/// (parent is the root template) and the enclosing module's
/// <c>prefix</c> for deeper nesting — and is carried into each script's
/// <see cref="ScriptScope"/> so a nested script's <c>Parent.X</c>
/// resolves against its real parent module.
/// </summary> /// </summary>
private static void ResolveComposedScriptsRecursive( private static void ResolveComposedScriptsRecursive(
TemplateComposition composition, TemplateComposition composition,
string prefix, string prefix,
string parentPath,
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap, IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains, IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
Dictionary<string, ResolvedScript> scripts, Dictionary<string, ResolvedScript> scripts,
@@ -747,7 +753,7 @@ public class FlatteningService
{ {
CanonicalName = canonicalName, CanonicalName = canonicalName,
Source = "Composed", Source = "Composed",
Scope = new Commons.Types.Scripts.ScriptScope(SelfPath: prefix, ParentPath: "") Scope = new Commons.Types.Scripts.ScriptScope(SelfPath: prefix, ParentPath: parentPath)
}; };
} }
} }
@@ -762,7 +768,7 @@ public class FlatteningService
foreach (var nested in nestedCompositions) foreach (var nested in nestedCompositions)
ResolveComposedScriptsRecursive( ResolveComposedScriptsRecursive(
nested, $"{prefix}.{nested.InstanceName}", nested, $"{prefix}.{nested.InstanceName}", parentPath: prefix,
compositionMap, composedTemplateChains, scripts, scriptCanonicalById, visited); compositionMap, composedTemplateChains, scripts, scriptCanonicalById, visited);
} }
} }

View File

@@ -705,12 +705,28 @@ public class TemplateService
{ {
var newDerivedName = $"{owner.Name}.{newInstanceName}"; var newDerivedName = $"{owner.Name}.{newInstanceName}";
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken); var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
if (allTemplates.Any(t => t.Id != derived.Id && t.Name == newDerivedName))
return Result<TemplateComposition>.Failure(
$"Cannot rename derived template to '{newDerivedName}': a template with that name already exists.");
derived.Name = newDerivedName; // The cascade of derived templates created by AddComposition follows a
await _repository.UpdateTemplateAsync(derived, cancellationToken); // dotted path (Pump.TempSensor and the nested Pump.TempSensor.Probe1).
// Renaming the slot must rename every derived template in that cascade
// so the dotted-path naming invariant holds — pre-check every new name
// the cascade will introduce before any row mutates.
var renames = new List<(Template Template, string NewName)>();
await CollectCascadeRenamesAsync(derived, newDerivedName, renames, cancellationToken);
var renamedIds = renames.Select(r => r.Template.Id).ToHashSet();
foreach (var (_, newName) in renames)
{
if (allTemplates.Any(t => !renamedIds.Contains(t.Id) && t.Name == newName))
return Result<TemplateComposition>.Failure(
$"Cannot rename derived template to '{newName}': a template with that name already exists.");
}
foreach (var (template, newName) in renames)
{
template.Name = newName;
await _repository.UpdateTemplateAsync(template, cancellationToken);
}
} }
composition.InstanceName = newInstanceName; composition.InstanceName = newInstanceName;
@@ -747,6 +763,30 @@ public class TemplateService
return Result<bool>.Success(true); return Result<bool>.Success(true);
} }
/// <summary>
/// Recursively collects the (template, new name) pairs for a renamed derived
/// template and every cascaded inner derived template beneath it. Each inner
/// derived's new name is re-derived from its renamed parent and the slot's
/// instance name (mirroring the cascade <see cref="CreateCascadedCompositionAsync"/>
/// builds and the recursion in <see cref="CascadeDeleteDerivedAsync"/>).
/// </summary>
private async Task CollectCascadeRenamesAsync(
Template derived,
string newName,
List<(Template Template, string NewName)> renames,
CancellationToken cancellationToken)
{
renames.Add((derived, newName));
foreach (var child in derived.Compositions.ToList())
{
var childDerived = await _repository.GetTemplateByIdAsync(child.ComposedTemplateId, cancellationToken);
if (childDerived != null && childDerived.IsDerived && childDerived.OwnerCompositionId == child.Id)
await CollectCascadeRenamesAsync(
childDerived, $"{newName}.{child.InstanceName}", renames, cancellationToken);
}
}
/// <summary> /// <summary>
/// Recursively deletes a derived template along with the cascade of inner /// Recursively deletes a derived template along with the cascade of inner
/// derived templates the compose flow created. Each composition row on the /// derived templates the compose flow created. Each composition row on the

View File

@@ -624,4 +624,46 @@ public class FlatteningServiceTests
var alarm = result.Value.Alarms.First(a => a.CanonicalName == "MainPump.PumpFault"); var alarm = result.Value.Alarms.First(a => a.CanonicalName == "MainPump.PumpFault");
Assert.Equal("MainPump.PumpAlarmHandler", alarm.OnTriggerScriptCanonicalName); Assert.Equal("MainPump.PumpAlarmHandler", alarm.OnTriggerScriptCanonicalName);
} }
// ── TemplateEngine-016: composed-script ScriptScope.ParentPath ─────────
[Fact]
public void Flatten_NestedComposedScript_ScopeCarriesCorrectParentPath()
{
// Station composes Pump (level 1); Pump composes Motor (level 2).
// The depth-1 script's parent is the root template (ParentPath "");
// the depth-2 script's parent is the Pump module (ParentPath "MainPump").
var motor = CreateTemplate(3, "Motor");
motor.Scripts.Add(new TemplateScript("MonitorMotor", "// m") { Id = 70 });
var pump = CreateTemplate(2, "Pump");
pump.Scripts.Add(new TemplateScript("MonitorPump", "// p") { Id = 71 });
var station = CreateTemplate(1, "Station");
var compositions = new Dictionary<int, IReadOnlyList<TemplateComposition>>
{
[1] = new List<TemplateComposition> { new("MainPump") { ComposedTemplateId = 2 } },
[2] = new List<TemplateComposition> { new("DriveMotor") { ComposedTemplateId = 3 } },
};
var composedChains = new Dictionary<int, IReadOnlyList<Template>>
{
[2] = [pump], [3] = [motor],
};
var instance = CreateInstance();
var result = _sut.Flatten(instance, [station], compositions, composedChains,
new Dictionary<int, DataConnection>());
Assert.True(result.IsSuccess);
var depth1 = result.Value.Scripts.First(s => s.CanonicalName == "MainPump.MonitorPump");
Assert.Equal("MainPump", depth1.Scope.SelfPath);
Assert.Equal("", depth1.Scope.ParentPath);
var depth2 = result.Value.Scripts.First(s => s.CanonicalName == "MainPump.DriveMotor.MonitorMotor");
Assert.Equal("MainPump.DriveMotor", depth2.Scope.SelfPath);
// Parent module of a depth-2 script is the enclosing Pump module.
Assert.Equal("MainPump", depth2.Scope.ParentPath);
}
} }

View File

@@ -466,6 +466,67 @@ public class TemplateServiceTests
Assert.Equal("Pump.NewSlot", derived.Name); Assert.Equal("Pump.NewSlot", derived.Name);
} }
[Fact]
public async Task RenameComposition_CascadesRenameToNestedDerivedTemplates()
{
// Pump.TempSensor is the slot-owned derived; Pump.TempSensor.Probe1 is a
// cascaded inner derived under it. Renaming the TempSensor slot to
// MainSensor must rename BOTH derived templates so the dotted-path
// naming invariant holds: Pump.MainSensor and Pump.MainSensor.Probe1.
var innerComp = new TemplateComposition("Probe1") { Id = 51, TemplateId = 77, ComposedTemplateId = 78 };
var innerDerived = new Template("Pump.TempSensor.Probe1") { Id = 78, IsDerived = true, OwnerCompositionId = 51, ParentTemplateId = 11 };
var composition = new TemplateComposition("TempSensor") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
var owner = new Template("Pump") { Id = 1 };
owner.Compositions.Add(composition);
var derived = new Template("Pump.TempSensor") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
derived.Compositions.Add(innerComp);
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
_repoMock.Setup(r => r.GetTemplateByIdAsync(78, It.IsAny<CancellationToken>())).ReturnsAsync(innerDerived);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { owner, derived, innerDerived });
var result = await _service.RenameCompositionAsync(50, "MainSensor", "admin");
Assert.True(result.IsSuccess);
Assert.Equal("MainSensor", result.Value.InstanceName);
Assert.Equal("Pump.MainSensor", derived.Name);
Assert.Equal("Pump.MainSensor.Probe1", innerDerived.Name);
_repoMock.Verify(r => r.UpdateTemplateAsync(
It.Is<Template>(t => t.Id == 78), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task RenameComposition_NestedCascadeNameCollision_Fails()
{
// A pre-existing template occupies the name the nested cascade would
// produce (Pump.MainSensor.Probe1). The rename must abort before any
// row mutates, so the full cascade name set must be pre-checked.
var innerComp = new TemplateComposition("Probe1") { Id = 51, TemplateId = 77, ComposedTemplateId = 78 };
var innerDerived = new Template("Pump.TempSensor.Probe1") { Id = 78, IsDerived = true, OwnerCompositionId = 51, ParentTemplateId = 11 };
var composition = new TemplateComposition("TempSensor") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
var owner = new Template("Pump") { Id = 1 };
owner.Compositions.Add(composition);
var derived = new Template("Pump.TempSensor") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
derived.Compositions.Add(innerComp);
var collider = new Template("Pump.MainSensor.Probe1") { Id = 99 };
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
_repoMock.Setup(r => r.GetTemplateByIdAsync(78, It.IsAny<CancellationToken>())).ReturnsAsync(innerDerived);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { owner, derived, innerDerived, collider });
var result = await _service.RenameCompositionAsync(50, "MainSensor", "admin");
Assert.True(result.IsFailure);
Assert.Contains("already exists", result.Error);
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact] [Fact]
public async Task RenameComposition_DuplicateName_Fails() public async Task RenameComposition_DuplicateName_Fails()
{ {