fix(template-engine): carry IsMesReportable onto derived-template placeholder alarms and through descendant reconcile; final-review doc fixes

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
This commit is contained in:
Joseph Doherty
2026-09-11 10:45:09 -04:00
parent 421d2d305e
commit 6ac95ea888
9 changed files with 185 additions and 4 deletions
@@ -285,6 +285,29 @@ public class CompatibilityTests
Assert.Null(deserialized!.AckTime);
}
[Fact]
public void BackwardCompat_AlarmStateChanged_MissingIsMesReportable_DefaultsToFalse()
{
// A frame minted by a node predating the MES-reportable flag omits the property
// entirely; it must deserialize to "not reported to MES", not fail. False is the
// safe default — an old site's alarms stay out of the MES alarm-status API until
// it is upgraded and stamps the flag itself.
var json = """
{
"InstanceUniqueName": "inst-1",
"AlarmName": "Tank01.Level.HiHi",
"State": 1,
"Priority": 900,
"Timestamp": "2026-08-01T00:00:00+00:00"
}
""";
var deserialized = JsonSerializer.Deserialize<AlarmStateChanged>(json, Options);
Assert.NotNull(deserialized);
Assert.False(deserialized!.IsMesReportable);
}
[Fact]
public void RoundTrip_HeartbeatMessage_Succeeds()
{
@@ -495,6 +495,43 @@ public class FlatteningServiceTests
Assert.Equal("{\"attributeName\":\"Temp\",\"high\":100}", alarm.TriggerConfiguration);
}
[Fact]
public void Flatten_MesReportable_DerivedChain_BaseValueWins()
{
// A stale IsInherited placeholder must not be able to un-report an alarm:
// the base's IsMesReportable is the resolved value down the chain.
var baseTemplate = CreateTemplate(2, "Sensor");
baseTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
{
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
PriorityLevel = 5,
IsMesReportable = true
});
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
derived.Alarms.Add(new TemplateAlarm("HighTemp")
{
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
PriorityLevel = 5,
IsInherited = true,
IsMesReportable = 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.True(alarm.IsMesReportable);
}
[Fact]
public void Flatten_OverriddenAlarmOnDerived_DerivedValueWins()
{
@@ -2046,4 +2046,108 @@ public class TemplateServiceTests
Assert.Contains(added, a => a.TemplateId == 7 && a.Name == "C" && !a.IsInherited); // base's own
Assert.Contains(added, a => a.TemplateId == 8 && a.Name == "C" && a.IsInherited); // child placeholder
}
[Fact]
public async Task CreateDerivedTemplateAsync_CopiesIsMesReportableOntoPlaceholder()
{
// Deriving a template (compose → BuildDerivedTemplate) materializes the
// base's alarms as IsInherited placeholders; the MES-reportable flag has
// to ride along or the derived template reads "not reportable" and an
// edit to any other field would persist that false.
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",
IsMesReportable = 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(moduleTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template, moduleTemplate });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(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);
var copied = Assert.Single(captured!.Alarms);
Assert.True(copied.IsInherited);
Assert.True(copied.IsMesReportable);
}
[Fact]
public async Task ReconcileDescendants_PropagatesIsMesReportableChange()
{
// Two shapes in one reconcile: an EXISTING placeholder that drifted
// (base flipped false→true after the derive) must be healed, and a base
// alarm added later must arrive on the child carrying the flag.
var baseT = BaseTemplate(7, "Base");
baseT.Alarms.Add(new TemplateAlarm("HighTemp")
{
Id = 70,
TemplateId = 7,
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
PriorityLevel = 5,
IsMesReportable = true
});
baseT.Alarms.Add(new TemplateAlarm("LowTemp")
{
Id = 71,
TemplateId = 7,
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"low\":10}",
PriorityLevel = 6,
IsMesReportable = true
});
var child = DerivedTemplate(8, "Child", 7);
child.Alarms.Add(new TemplateAlarm("HighTemp")
{
Id = 80,
TemplateId = 8,
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
PriorityLevel = 5,
IsInherited = true,
IsMesReportable = false // stale placeholder written before the base flipped
});
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var added = new List<TemplateAlarm>();
_repoMock.Setup(r => r.AddTemplateAlarmAsync(It.IsAny<TemplateAlarm>(), It.IsAny<CancellationToken>()))
.Callback<TemplateAlarm, CancellationToken>((a, _) => added.Add(a)).Returns(Task.CompletedTask);
var updated = new List<TemplateAlarm>();
_repoMock.Setup(r => r.UpdateTemplateAlarmAsync(It.IsAny<TemplateAlarm>(), It.IsAny<CancellationToken>()))
.Callback<TemplateAlarm, CancellationToken>((a, _) => updated.Add(a)).Returns(Task.CompletedTask);
await _service.ReconcileDescendantsAsync(7, "admin");
// Drift detected and healed on the existing placeholder.
var healed = Assert.Single(updated, a => a.Name == "HighTemp");
Assert.True(healed.IsMesReportable);
Assert.True(healed.IsInherited);
// Newly propagated member carries the flag.
var propagated = Assert.Single(added, a => a.Name == "LowTemp");
Assert.Equal(8, propagated.TemplateId);
Assert.True(propagated.IsInherited);
Assert.True(propagated.IsMesReportable);
}
}