fix(template-engine): resolve TemplateEngine-015,016 — cascade-rename nested derived templates, correct composed-script ParentPath
This commit is contained in:
@@ -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`.
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user