fix(templates): cascade child compositions when composing a composite

When the user composes a template that already has compositions of its
own (e.g. \$Sensor → Probe1 slot), only the outer derived was created
— the source's children weren't replicated. AddCompositionAsync now
walks the source's composition graph and creates a parallel derived for
every slot it encounters, each linked back through ParentTemplateId so
override chains stay intact (\$Probe → \$Sensor.Probe1 → \$Pump.TempSensor.Probe1).

The cascade pre-flights every name it would create — a deep collision
aborts before any rows mutate. Internal helper
CreateCascadedCompositionAsync skips the "base templates only" check
since it operates on the source side which may legitimately reference
derived rows.
This commit is contained in:
Joseph Doherty
2026-05-12 09:57:07 -04:00
parent 1f86945d46
commit 4f90f952d0
2 changed files with 103 additions and 9 deletions

View File

@@ -321,6 +321,49 @@ public class TemplateServiceTests
_repoMock.Verify(r => r.AddTemplateCompositionAsync(It.IsAny<TemplateComposition>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task AddComposition_CascadesChildCompositions()
{
// $Probe (base) → $Sensor.Probe1 (derived) ← $Sensor composes "Probe1"
// Composing $Sensor into $Pump as "TempSensor" should produce:
// $Pump.TempSensor (derived from $Sensor)
// $Pump.TempSensor.Probe1 (derived from $Sensor.Probe1)
// plus a composition row on $Pump.TempSensor pointing at the new inner derived.
var probe = new Template("Probe") { Id = 10 };
var sensorProbe1 = new Template("Sensor.Probe1") { Id = 11, IsDerived = true, ParentTemplateId = 10, OwnerCompositionId = 1 };
var sensor = new Template("Sensor") { Id = 2 };
sensor.Compositions.Add(new TemplateComposition("Probe1") { Id = 1, TemplateId = 2, ComposedTemplateId = 11 });
var pump = new Template("Pump") { Id = 1 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(pump);
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(sensor);
_repoMock.Setup(r => r.GetTemplateByIdAsync(11, It.IsAny<CancellationToken>())).ReturnsAsync(sensorProbe1);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { pump, sensor, probe, sensorProbe1 });
var captured = new List<Template>();
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
.Callback<Template, CancellationToken>((t, _) => captured.Add(t))
.Returns(Task.CompletedTask);
var capturedCompositions = new List<TemplateComposition>();
_repoMock.Setup(r => r.AddTemplateCompositionAsync(It.IsAny<TemplateComposition>(), It.IsAny<CancellationToken>()))
.Callback<TemplateComposition, CancellationToken>((c, _) => capturedCompositions.Add(c))
.Returns(Task.CompletedTask);
var result = await _service.AddCompositionAsync(1, 2, "TempSensor", "admin");
Assert.True(result.IsSuccess);
Assert.Equal(2, captured.Count);
Assert.Equal("Pump.TempSensor", captured[0].Name);
Assert.Equal(2, captured[0].ParentTemplateId);
Assert.Equal("Pump.TempSensor.Probe1", captured[1].Name);
Assert.Equal(11, captured[1].ParentTemplateId);
Assert.Equal(2, capturedCompositions.Count);
Assert.Equal("TempSensor", capturedCompositions[0].InstanceName);
Assert.Equal("Probe1", capturedCompositions[1].InstanceName);
}
[Fact]
public async Task AddComposition_BaseIsDerived_Fails()
{