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:
@@ -371,6 +371,110 @@ public class FlatteningServiceTests
|
||||
Assert.Equal("return base;", script.Code);
|
||||
}
|
||||
|
||||
// ── TemplateEngine-002: per-slot alarm override ────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InheritedAlarmOnDerived_BaseValueWins()
|
||||
{
|
||||
var baseTemplate = CreateTemplate(2, "Sensor");
|
||||
baseTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
|
||||
PriorityLevel = 5
|
||||
});
|
||||
|
||||
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
|
||||
derived.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":999}",
|
||||
PriorityLevel = 99,
|
||||
IsInherited = true
|
||||
});
|
||||
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[derived, baseTemplate],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var alarm = result.Value.Alarms.First(a => a.CanonicalName == "HighTemp");
|
||||
Assert.Equal(5, alarm.PriorityLevel);
|
||||
Assert.Equal("{\"attributeName\":\"Temp\",\"high\":100}", alarm.TriggerConfiguration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_OverriddenAlarmOnDerived_DerivedValueWins()
|
||||
{
|
||||
var baseTemplate = CreateTemplate(2, "Sensor");
|
||||
baseTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
|
||||
PriorityLevel = 5
|
||||
});
|
||||
|
||||
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
|
||||
derived.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":120}",
|
||||
PriorityLevel = 42,
|
||||
IsInherited = false
|
||||
});
|
||||
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[derived, baseTemplate],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var alarm = result.Value.Alarms.First(a => a.CanonicalName == "HighTemp");
|
||||
Assert.Equal(42, alarm.PriorityLevel);
|
||||
Assert.Equal("{\"attributeName\":\"Temp\",\"high\":120}", alarm.TriggerConfiguration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_LockedInDerivedAlarmOverride_Fails()
|
||||
{
|
||||
var baseTemplate = CreateTemplate(2, "Sensor");
|
||||
baseTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
|
||||
PriorityLevel = 5,
|
||||
LockedInDerived = true
|
||||
});
|
||||
|
||||
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
|
||||
derived.Alarms.Add(new TemplateAlarm("HighTemp")
|
||||
{
|
||||
TriggerType = AlarmTriggerType.RangeViolation,
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":120}",
|
||||
PriorityLevel = 42,
|
||||
IsInherited = false
|
||||
});
|
||||
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[derived, baseTemplate],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("LockedInDerived", result.Error);
|
||||
Assert.Contains("HighTemp", result.Error);
|
||||
}
|
||||
|
||||
// ── TemplateEngine-001: deep composition nesting ───────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user