feat(templates): phase 2 — derive-on-compose for new compositions
AddCompositionAsync creates a derived Template ("<parent>.<slot>") that
inherits from the base via ParentTemplateId. Base attributes and scripts
are copied with IsInherited=true so the derived template carries its own
override-able rows. The composition row points at the derived template,
and the derived's OwnerCompositionId back-refs the composition for cascade
delete.
DeleteCompositionAsync cascade-deletes the owned derived template.
DeleteTemplateAsync blocks direct deletion of derived templates and
distinguishes derivatives from regular children, listing slot owners
("'Pump' (as 'TempSensor')") in the error.
Composing a derived template is rejected — only bases can be composed.
Existing compositions still resolve until phase 3 migrates them.
This commit is contained in:
@@ -288,9 +288,11 @@ public class TemplateServiceTests
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task AddComposition_Success()
|
||||
public async Task AddComposition_Success_DerivesTemplate()
|
||||
{
|
||||
var moduleTemplate = new Template("Module") { Id = 2 };
|
||||
moduleTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { Id = 10, TemplateId = 2, Value = "75", DataType = DataType.Float });
|
||||
moduleTemplate.Scripts.Add(new TemplateScript("Compute", "return 1;") { Id = 20, TemplateId = 2 });
|
||||
var template = new Template("Parent") { Id = 1 };
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
@@ -298,11 +300,123 @@ public class TemplateServiceTests
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { template, moduleTemplate });
|
||||
|
||||
Template? captured = null;
|
||||
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<Template, CancellationToken>((t, _) => captured = t)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("myModule", result.Value.InstanceName);
|
||||
Assert.Equal(2, result.Value.ComposedTemplateId);
|
||||
Assert.NotNull(captured);
|
||||
Assert.True(captured!.IsDerived);
|
||||
Assert.Equal("Parent.myModule", captured.Name);
|
||||
Assert.Equal(2, captured.ParentTemplateId);
|
||||
Assert.Single(captured.Attributes);
|
||||
Assert.True(captured.Attributes.First().IsInherited);
|
||||
Assert.Equal("SetPoint", captured.Attributes.First().Name);
|
||||
Assert.Single(captured.Scripts);
|
||||
Assert.True(captured.Scripts.First().IsInherited);
|
||||
_repoMock.Verify(r => r.AddTemplateCompositionAsync(It.IsAny<TemplateComposition>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddComposition_BaseIsDerived_Fails()
|
||||
{
|
||||
var derivedBase = new Template("Sensor") { Id = 2, IsDerived = true };
|
||||
var template = new Template("Parent") { Id = 1 };
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(derivedBase);
|
||||
|
||||
var result = await _service.AddCompositionAsync(1, 2, "slot", "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("derived template", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddComposition_DerivedNameCollision_Fails()
|
||||
{
|
||||
var existing = new Template("Parent.myModule") { Id = 99 };
|
||||
var moduleTemplate = new Template("Module") { Id = 2 };
|
||||
var template = new Template("Parent") { Id = 1 };
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { template, moduleTemplate, existing });
|
||||
|
||||
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("already exists", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteComposition_CascadesDerivedTemplate()
|
||||
{
|
||||
var composition = new TemplateComposition("slot") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
||||
var derived = new Template("Parent.slot") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
||||
|
||||
var result = await _service.DeleteCompositionAsync(50, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
_repoMock.Verify(r => r.DeleteTemplateCompositionAsync(50, It.IsAny<CancellationToken>()), Times.Once);
|
||||
_repoMock.Verify(r => r.DeleteTemplateAsync(77, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteComposition_LegacyDirectBase_DoesNotCascade()
|
||||
{
|
||||
var composition = new TemplateComposition("slot") { Id = 60, TemplateId = 1, ComposedTemplateId = 5 };
|
||||
var baseTemplate = new Template("Module") { Id = 5, IsDerived = false };
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(60, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(5, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
||||
|
||||
var result = await _service.DeleteCompositionAsync(60, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
_repoMock.Verify(r => r.DeleteTemplateCompositionAsync(60, It.IsAny<CancellationToken>()), Times.Once);
|
||||
_repoMock.Verify(r => r.DeleteTemplateAsync(5, It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTemplate_DerivedTemplate_DirectDeleteBlocked()
|
||||
{
|
||||
var derived = new Template("Parent.slot") { Id = 77, IsDerived = true };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
||||
|
||||
var result = await _service.DeleteTemplateAsync(77, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("derived template", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTemplate_BaseWithDerivatives_BlockedWithSlotNames()
|
||||
{
|
||||
var baseTemplate = new Template("Sensor") { Id = 5 };
|
||||
var parent = new Template("Pump") { Id = 1 };
|
||||
var composition = new TemplateComposition("TempSensor") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
||||
parent.Compositions.Add(composition);
|
||||
var derived = new Template("Pump.TempSensor") { Id = 77, ParentTemplateId = 5, IsDerived = true, OwnerCompositionId = 50 };
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(5, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
||||
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(5, It.IsAny<CancellationToken>())).ReturnsAsync(new List<Instance>());
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { baseTemplate, parent, derived });
|
||||
|
||||
var result = await _service.DeleteTemplateAsync(5, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("derived", result.Error);
|
||||
Assert.Contains("'Pump' (as 'TempSensor')", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user