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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user