feat(template-engine): resolve TemplateEngine-002 — per-slot alarm override for derived templates

Adds IsInherited/LockedInDerived to the TemplateAlarm entity (mirroring the
attribute/script override model), an EF migration, base-alarm copy-on-derive,
inherited-alarm flattening skip, and LockedInDerived override-rejection validation.
This commit is contained in:
Joseph Doherty
2026-05-16 20:12:24 -04:00
parent bc548e1447
commit 305b42ea6d
9 changed files with 1700 additions and 21 deletions

View File

@@ -566,6 +566,123 @@ public class TemplateServiceTests
Assert.Contains("locked by base template 'Sensor'", result.Error);
}
[Fact]
public async Task AddComposition_CopiesAlarmsAsInherited()
{
var moduleTemplate = new Template("Module") { Id = 2 };
moduleTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
{
Id = 30,
TemplateId = 2,
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
PriorityLevel = 5,
Description = "Too hot"
});
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 });
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.NotNull(captured);
Assert.Single(captured!.Alarms);
var copied = captured.Alarms.First();
Assert.Equal("HighTemp", copied.Name);
Assert.True(copied.IsInherited);
Assert.False(copied.LockedInDerived);
Assert.Equal(AlarmTriggerType.RangeViolation, copied.TriggerType);
Assert.Equal(5, copied.PriorityLevel);
Assert.Equal("Too hot", copied.Description);
}
[Fact]
public async Task UpdateAlarm_LockedInDerivedBase_RejectsOnDerived()
{
var existing = new TemplateAlarm("HighTemp")
{
Id = 300,
TemplateId = 77,
TriggerType = AlarmTriggerType.RangeViolation,
PriorityLevel = 5,
IsInherited = true
};
var baseTemplate = new Template("Sensor") { Id = 2 };
baseTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
{
Id = 30,
TemplateId = 2,
TriggerType = AlarmTriggerType.RangeViolation,
LockedInDerived = true
});
var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };
_repoMock.Setup(r => r.GetTemplateAlarmByIdAsync(300, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
var proposed = new TemplateAlarm("HighTemp")
{
TriggerType = AlarmTriggerType.RangeViolation,
PriorityLevel = 99,
IsInherited = false
};
var result = await _service.UpdateAlarmAsync(300, proposed, "admin");
Assert.True(result.IsFailure);
Assert.Contains("locked by base template 'Sensor'", result.Error);
}
[Fact]
public async Task UpdateAlarm_DerivedOverride_PersistsIsInheritedFalse()
{
var existing = new TemplateAlarm("HighTemp")
{
Id = 300,
TemplateId = 77,
TriggerType = AlarmTriggerType.RangeViolation,
PriorityLevel = 5,
IsInherited = true
};
var baseTemplate = new Template("Sensor") { Id = 2 };
baseTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
{
Id = 30,
TemplateId = 2,
TriggerType = AlarmTriggerType.RangeViolation
});
var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };
_repoMock.Setup(r => r.GetTemplateAlarmByIdAsync(300, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
var proposed = new TemplateAlarm("HighTemp")
{
TriggerType = AlarmTriggerType.RangeViolation,
PriorityLevel = 99,
IsInherited = false
};
var result = await _service.UpdateAlarmAsync(300, proposed, "admin");
Assert.True(result.IsSuccess);
Assert.False(result.Value.IsInherited);
Assert.Equal(99, result.Value.PriorityLevel);
}
[Fact]
public async Task UpdateAttribute_DerivedOverride_PersistsIsInheritedFalse()
{