feat(transport): carry IsMesReportable / IsMesReportableOverride through bundles (round-trip guard extended)

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
This commit is contained in:
Joseph Doherty
2026-09-11 10:00:00 -04:00
parent af10af1573
commit 43973d34de
8 changed files with 178 additions and 7 deletions
@@ -491,7 +491,8 @@ public sealed class ArtifactDiff
e => e.AlarmCanonicalName,
i => i.AlarmCanonicalName,
(e, i) => e.TriggerConfigurationOverride == i.TriggerConfigurationOverride
&& e.PriorityLevelOverride == i.PriorityLevelOverride,
&& e.PriorityLevelOverride == i.PriorityLevelOverride
&& e.IsMesReportableOverride == i.IsMesReportableOverride,
"AlarmOverrides",
changes);
@@ -2172,6 +2172,7 @@ public sealed class BundleImporter : IBundleImporter
TriggerConfiguration = al.TriggerConfiguration,
IsLocked = al.IsLocked,
LockedInDerived = al.LockedInDerived,
IsMesReportable = al.IsMesReportable,
});
}
foreach (var s in dto.Scripts)
@@ -2395,6 +2396,7 @@ public sealed class BundleImporter : IBundleImporter
current.TriggerConfiguration = alarmDto.TriggerConfiguration;
current.IsLocked = alarmDto.IsLocked;
current.LockedInDerived = alarmDto.LockedInDerived;
current.IsMesReportable = alarmDto.IsMesReportable;
current.OnTriggerScriptId = null; // re-resolved post-flush.
await _templateRepo.UpdateTemplateAlarmAsync(current, ct).ConfigureAwait(false);
await _auditService.LogAsync(
@@ -2413,6 +2415,7 @@ public sealed class BundleImporter : IBundleImporter
current.TriggerConfiguration,
current.IsLocked,
current.LockedInDerived,
current.IsMesReportable,
OnTriggerScriptName = alarmDto.OnTriggerScriptName,
},
ct).ConfigureAwait(false);
@@ -2427,6 +2430,7 @@ public sealed class BundleImporter : IBundleImporter
TriggerConfiguration = alarmDto.TriggerConfiguration,
IsLocked = alarmDto.IsLocked,
LockedInDerived = alarmDto.LockedInDerived,
IsMesReportable = alarmDto.IsMesReportable,
};
ex.Alarms.Add(newAlarm);
await _auditService.LogAsync(
@@ -2445,6 +2449,7 @@ public sealed class BundleImporter : IBundleImporter
newAlarm.TriggerConfiguration,
newAlarm.IsLocked,
newAlarm.LockedInDerived,
newAlarm.IsMesReportable,
OnTriggerScriptName = alarmDto.OnTriggerScriptName,
},
ct).ConfigureAwait(false);
@@ -4360,6 +4365,7 @@ public sealed class BundleImporter : IBundleImporter
{
TriggerConfigurationOverride = o.TriggerConfigurationOverride,
PriorityLevelOverride = o.PriorityLevelOverride,
IsMesReportableOverride = o.IsMesReportableOverride,
});
}
foreach (var o in dto.NativeAlarmSourceOverrides)
@@ -4913,6 +4919,7 @@ public sealed class BundleImporter : IBundleImporter
// at this single-template-scope validation step the bare name
// IS the canonical name, so just pass it through.
OnTriggerScriptCanonicalName = string.IsNullOrEmpty(al.OnTriggerScriptName) ? null : al.OnTriggerScriptName,
IsMesReportable = al.IsMesReportable,
Source = "Template",
});
}
@@ -61,6 +61,7 @@ internal static class TemplateChildEquality
&& e.TriggerConfiguration == i.TriggerConfiguration
&& e.IsLocked == i.IsLocked
&& e.LockedInDerived == i.LockedInDerived
&& e.IsMesReportable == i.IsMesReportable
&& scriptNameById(e.OnTriggerScriptId) == i.OnTriggerScriptName;
/// <summary>
@@ -218,7 +218,10 @@ public sealed record TemplateAlarmDto(
bool IsLocked,
string? OnTriggerScriptName,
// Derived-lock flag; additive trailing field (see TemplateAttributeDto).
bool LockedInDerived = false);
bool LockedInDerived = false,
// Whether this alarm is reported to MES; additive trailing field, false on
// bundles written before it existed (see TemplateAttributeDto).
bool IsMesReportable = false);
public sealed record TemplateScriptDto(
string Name,
@@ -395,7 +398,10 @@ public sealed record InstanceAttributeOverrideDto(
public sealed record InstanceAlarmOverrideDto(
string AlarmCanonicalName,
string? TriggerConfigurationOverride,
int? PriorityLevelOverride);
int? PriorityLevelOverride,
// Per-instance override of the alarm's MES-reportable flag; additive trailing
// field, null ("keep inherited") on bundles written before it existed.
bool? IsMesReportableOverride = null);
public sealed record InstanceNativeAlarmSourceOverrideDto(
string SourceCanonicalName,
@@ -77,7 +77,8 @@ public sealed class EntitySerializer
// name comes through as null and the importer leaves the
// FK null on the imported alarm.
OnTriggerScriptName: a.OnTriggerScriptId is { } sid && scriptNameById.TryGetValue(sid, out var sn) ? sn : null,
LockedInDerived: a.LockedInDerived)).ToList(),
LockedInDerived: a.LockedInDerived,
IsMesReportable: a.IsMesReportable)).ToList(),
Scripts: t.Scripts.Select(s => new TemplateScriptDto(
Name: s.Name,
Code: s.Code,
@@ -285,7 +286,8 @@ public sealed class EntitySerializer
AlarmOverrides: inst.AlarmOverrides.Select(o => new InstanceAlarmOverrideDto(
AlarmCanonicalName: o.AlarmCanonicalName,
TriggerConfigurationOverride: o.TriggerConfigurationOverride,
PriorityLevelOverride: o.PriorityLevelOverride)).ToList(),
PriorityLevelOverride: o.PriorityLevelOverride,
IsMesReportableOverride: o.IsMesReportableOverride)).ToList(),
NativeAlarmSourceOverrides: inst.NativeAlarmSourceOverrides.Select(o => new InstanceNativeAlarmSourceOverrideDto(
SourceCanonicalName: o.SourceCanonicalName,
ConnectionNameOverride: o.ConnectionNameOverride,
@@ -354,6 +356,7 @@ public sealed class EntitySerializer
TriggerConfiguration = al.TriggerConfiguration,
IsLocked = al.IsLocked,
LockedInDerived = al.LockedInDerived,
IsMesReportable = al.IsMesReportable,
});
}
foreach (var s in dto.Scripts)
@@ -607,6 +610,7 @@ public sealed class EntitySerializer
InstanceId = inst.Id,
TriggerConfigurationOverride = o.TriggerConfigurationOverride,
PriorityLevelOverride = o.PriorityLevelOverride,
IsMesReportableOverride = o.IsMesReportableOverride,
});
}
foreach (var o in dto.NativeAlarmSourceOverrides)
@@ -238,6 +238,7 @@ public sealed class RoundTripEquivalenceTests : IDisposable
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = "{\"threshold\":100}",
LockedInDerived = true,
IsMesReportable = true,
});
tpl.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Boiler")
{
@@ -809,6 +810,9 @@ public sealed class RoundTripEquivalenceTests : IDisposable
{
TriggerConfigurationOverride = "{\"hi\":90}",
PriorityLevelOverride = 7,
// Non-default for a bool? (whose default is null), so the reflection
// sweep fails loudly if the DTO drops the field.
IsMesReportableOverride = false,
});
instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("NativeSrc")
{
@@ -396,6 +396,53 @@ public sealed class ArtifactDiffTests
ChangeFor(item, "Alarms.A"); // OnTriggerScriptName diverged — previously missed.
}
[Fact]
public void CompareTemplate_AlarmIsMesReportableChange_IsModified()
{
var existing = MakeTemplate("T1");
existing.Alarms.Add(new TemplateAlarm("A")
{
TriggerType = AlarmTriggerType.RangeViolation,
IsMesReportable = false,
});
var incoming = MakeTemplateDto("T1", alarms:
[
// every other alarm field identical; only IsMesReportable differs.
new TemplateAlarmDto("A", null, 0, AlarmTriggerType.RangeViolation, null, false, null,
IsMesReportable: true),
]);
var item = _diff.CompareTemplate(incoming, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
ChangeFor(item, "Alarms.A"); // IsMesReportable diverged.
}
[Fact]
public void CompareInstance_AlarmOverrideIsMesReportableChange_IsModified()
{
var existing = new Instance("inst-1") { State = InstanceState.Enabled };
existing.AlarmOverrides.Add(new InstanceAlarmOverride("HiAlarm")
{
TriggerConfigurationOverride = "{\"hi\":90}",
PriorityLevelOverride = 7,
IsMesReportableOverride = null,
});
var dto = MakeInstanceDto("inst-1", state: InstanceState.Enabled, alarmOverrides:
[
// identical apart from the MES-reportable override.
new InstanceAlarmOverrideDto("HiAlarm", "{\"hi\":90}", 7, IsMesReportableOverride: false),
]);
var item = _diff.CompareInstance(dto, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
var modified = ChangeFor(item, "AlarmOverrides.HiAlarm");
Assert.Equal("<modified>", modified.GetProperty("oldValue").GetString());
Assert.Equal("<modified>", modified.GetProperty("newValue").GetString());
}
// ============ M8: CompareSite ============
[Fact]
@@ -699,7 +746,8 @@ public sealed class ArtifactDiffTests
string template = "Tmpl",
string site = "site-a",
InstanceState state = InstanceState.Enabled,
IReadOnlyList<InstanceAttributeOverrideDto>? attributeOverrides = null) =>
IReadOnlyList<InstanceAttributeOverrideDto>? attributeOverrides = null,
IReadOnlyList<InstanceAlarmOverrideDto>? alarmOverrides = null) =>
new(
UniqueName: uniqueName,
TemplateName: template,
@@ -707,7 +755,7 @@ public sealed class ArtifactDiffTests
AreaName: null,
State: state,
AttributeOverrides: attributeOverrides ?? Array.Empty<InstanceAttributeOverrideDto>(),
AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
AlarmOverrides: alarmOverrides ?? Array.Empty<InstanceAlarmOverrideDto>(),
NativeAlarmSourceOverrides: Array.Empty<InstanceNativeAlarmSourceOverrideDto>(),
ConnectionBindings: Array.Empty<InstanceConnectionBindingDto>());
}
@@ -294,6 +294,106 @@ public sealed class EntitySerializerTests
Assert.False(Assert.Single(template.Scripts).LockedInDerived);
}
[Fact]
public void Roundtrip_preserves_IsMesReportable_on_alarm_and_instance_override()
{
var basic = new Template("Basic") { Id = 1 };
basic.Alarms.Add(new TemplateAlarm("High")
{
Id = 1,
TemplateId = 1,
TriggerType = AlarmTriggerType.RangeViolation,
IsMesReportable = true,
});
var instance = new Instance("Pump-01") { Id = 1, TemplateId = 1, SiteId = 1 };
instance.AlarmOverrides.Add(new InstanceAlarmOverride("High")
{
InstanceId = 1,
PriorityLevelOverride = 7,
IsMesReportableOverride = false,
});
var aggregate = MakeEmptyAggregate() with
{
Templates = new[] { basic },
Sites = new[] { new Site("Site A", "site-a") { Id = 1 } },
Instances = new[] { instance },
};
var sut = new EntitySerializer();
var dto = sut.ToBundleContent(aggregate);
Assert.True(Assert.Single(Assert.Single(dto.Templates).Alarms).IsMesReportable);
Assert.False(Assert.Single(Assert.Single(dto.Instances).AlarmOverrides).IsMesReportableOverride);
var restored = sut.FromBundleContent(dto);
Assert.True(Assert.Single(Assert.Single(restored.Templates).Alarms).IsMesReportable);
Assert.False(Assert.Single(Assert.Single(restored.Instances).AlarmOverrides).IsMesReportableOverride);
}
[Fact]
public void OldForm_bundle_json_without_IsMesReportable_deserializes_to_false_and_null()
{
// A bundle written before the MES-reportable flag existed: the property is
// absent from the template alarm and from the instance alarm override. The
// DTO fields are additive trailing parameters, so the alarm flag must land
// on false and the override on null (= "keep inherited"), never throw.
const string json = """
{
"TemplateFolders": [],
"Templates": [
{
"Name": "Legacy",
"FolderName": null,
"BaseTemplateName": null,
"Description": null,
"Attributes": [],
"Alarms": [
{ "Name": "Alm", "Description": null, "PriorityLevel": 1,
"TriggerType": "RangeViolation", "TriggerConfiguration": null, "IsLocked": false,
"OnTriggerScriptName": null, "LockedInDerived": true }
],
"Scripts": [],
"Compositions": []
}
],
"SharedScripts": [],
"ExternalSystems": [],
"DatabaseConnections": [],
"NotificationLists": [],
"SmtpConfigs": [],
"ApiMethods": [],
"Instances": [
{
"UniqueName": "Pump-01",
"TemplateName": "Legacy",
"SiteIdentifier": "site-a",
"AreaName": null,
"State": "Enabled",
"AttributeOverrides": [],
"AlarmOverrides": [
{ "AlarmCanonicalName": "Alm", "TriggerConfigurationOverride": null,
"PriorityLevelOverride": 3 }
],
"NativeAlarmSourceOverrides": [],
"ConnectionBindings": []
}
]
}
""";
var content = JsonSerializer.Deserialize<BundleContentDto>(json, BundleJsonOptions.Default)!;
var alarm = Assert.Single(Assert.Single(content.Templates).Alarms);
Assert.True(alarm.LockedInDerived); // pre-existing trailing field still read
Assert.False(alarm.IsMesReportable); // new trailing field defaults
var over = Assert.Single(Assert.Single(content.Instances).AlarmOverrides);
Assert.Equal(3, over.PriorityLevelOverride);
Assert.Null(over.IsMesReportableOverride);
}
[Fact]
public void Roundtrip_template_folder_preserves_hierarchy()
{