fix(m9/T26a): HiLo per-setpoint merge in resolver (preview=deploy) + widen staleness comparison

This commit is contained in:
Joseph Doherty
2026-06-18 12:51:58 -04:00
parent 10c08dd309
commit ca6e5da34b
3 changed files with 397 additions and 80 deletions
@@ -290,4 +290,213 @@ public class TemplateInheritanceResolverTests
}
Assert.Equal(flat.Value.Attributes.Count, resolved.Attributes.Count);
}
// ── I1: HiLo per-setpoint merge — the resolved alarm's effective
// TriggerConfiguration MUST equal the FlatteningService-merged result for
// a derived template that overrides only one setpoint (preview = deploy). ──
[Fact]
public void Resolve_AgreesWithFlatteningService_ForPartialHiLoOverride()
{
// Base HiLo alarm carries both hi + lo. Derived overrides ONLY hi and
// inherits lo. The resolver must surface the MERGED effective trigger
// config (lo inherited, hi overridden) — identical to what deploy
// produces via the flattener's MergeHiLoConfig.
var baseT = new Template("Base") { Id = 1 };
baseT.Alarms.Add(new TemplateAlarm("Level")
{
Id = 10,
TemplateId = 1,
PriorityLevel = 5,
TriggerType = AlarmTriggerType.HiLo,
TriggerConfiguration = "{\"hi\":90,\"lo\":10}"
});
var derived = new Template("Derived") { Id = 2, ParentTemplateId = 1 };
// Partial override: only hi changes; lo must be inherited from base.
derived.Alarms.Add(new TemplateAlarm("Level")
{
Id = 20,
TemplateId = 2,
PriorityLevel = 5,
TriggerType = AlarmTriggerType.HiLo,
TriggerConfiguration = "{\"hi\":95}",
IsInherited = false
});
var all = new[] { baseT, derived };
// Resolver view.
var resolved = TemplateInheritanceResolver.Resolve(2, all);
var resolvedAlarm = Assert.Single(resolved.Alarms, m => m.Name == "Level");
// Flattener view: most-derived-first chain, no overrides, no compositions.
var instance = new Instance("inst") { Id = 1, TemplateId = 2, SiteId = 1 };
var chain = new List<Template> { derived, baseT }; // most-derived first
var flat = new FlatteningService().Flatten(
instance,
chain,
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
new Dictionary<int, DataConnection>());
Assert.True(flat.IsSuccess, flat.IsFailure ? flat.Error : null);
var flatAlarm = Assert.Single(flat.Value.Alarms, a => a.CanonicalName == "Level");
// The resolver's effective TriggerConfiguration MUST equal the flattener's
// merged output verbatim — the key regression guard for preview = deploy.
Assert.Equal(flatAlarm.TriggerConfiguration, resolvedAlarm.EffectiveTriggerConfiguration);
// And sanity: the merge actually happened (lo inherited, hi overridden).
Assert.Equal(
FlatteningService.MergeHiLoConfig("{\"hi\":90,\"lo\":10}", "{\"hi\":95}"),
resolvedAlarm.EffectiveTriggerConfiguration);
Assert.Contains("\"lo\":10", resolvedAlarm.EffectiveTriggerConfiguration);
Assert.Contains("\"hi\":95", resolvedAlarm.EffectiveTriggerConfiguration);
}
// ── I2: staleness must flag drift on the fields that actually matter, not
// just one scalar per type. ──
[Fact]
public void Resolve_StalenessSummary_TrueWhenBaseAlarmTriggerConfigChanged_PriorityUnchanged()
{
// Base alarm threshold changed (TriggerConfiguration), PriorityLevel is
// unchanged. The derived placeholder still carries the OLD threshold, so
// staleness must flag it even though the priority scalar matches.
var baseT = new Template("Base") { Id = 1 };
baseT.Alarms.Add(new TemplateAlarm("Level")
{
Id = 10,
TemplateId = 1,
PriorityLevel = 5,
TriggerType = AlarmTriggerType.HiLo,
TriggerConfiguration = "{\"hi\":95,\"lo\":10}"
});
var derived = new Template("Derived") { Id = 2, ParentTemplateId = 1 };
derived.Alarms.Add(new TemplateAlarm("Level")
{
Id = 20,
TemplateId = 2,
PriorityLevel = 5, // priority matches the base — only the threshold drifted
TriggerType = AlarmTriggerType.HiLo,
TriggerConfiguration = "{\"hi\":90,\"lo\":10}", // stale threshold
IsInherited = true
});
var resolved = TemplateInheritanceResolver.Resolve(2, new[] { baseT, derived });
Assert.True(resolved.Staleness.IsStale);
Assert.Equal(1, resolved.Staleness.DifferingMemberCount);
}
[Fact]
public void Resolve_StalenessSummary_TrueWhenNativeSourceConnectionNameChanged_SourceReferenceUnchanged()
{
var baseT = new Template("Base") { Id = 1 };
baseT.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Src")
{
Id = 10,
TemplateId = 1,
ConnectionName = "newConn",
SourceReference = "ns=2;s=Alarm",
ConditionFilter = null
});
var derived = new Template("Derived") { Id = 2, ParentTemplateId = 1 };
derived.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Src")
{
Id = 20,
TemplateId = 2,
ConnectionName = "oldConn", // ConnectionName drifted
SourceReference = "ns=2;s=Alarm", // SourceReference unchanged
ConditionFilter = null,
IsInherited = true
});
var resolved = TemplateInheritanceResolver.Resolve(2, new[] { baseT, derived });
Assert.True(resolved.Staleness.IsStale);
Assert.Equal(1, resolved.Staleness.DifferingMemberCount);
}
[Fact]
public void Resolve_StalenessSummary_TrueWhenNativeSourceConditionFilterChanged_SourceReferenceUnchanged()
{
var baseT = new Template("Base") { Id = 1 };
baseT.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Src")
{
Id = 10,
TemplateId = 1,
ConnectionName = "conn",
SourceReference = "ns=2;s=Alarm",
ConditionFilter = "severity>500"
});
var derived = new Template("Derived") { Id = 2, ParentTemplateId = 1 };
derived.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Src")
{
Id = 20,
TemplateId = 2,
ConnectionName = "conn",
SourceReference = "ns=2;s=Alarm", // unchanged
ConditionFilter = null, // ConditionFilter drifted
IsInherited = true
});
var resolved = TemplateInheritanceResolver.Resolve(2, new[] { baseT, derived });
Assert.True(resolved.Staleness.IsStale);
Assert.Equal(1, resolved.Staleness.DifferingMemberCount);
}
[Fact]
public void Resolve_StalenessSummary_FalseWhenAlarmAndNativeSourceInSync()
{
// Guard against false-positives from the widened comparison: when the
// stored placeholders match the resolved (merged) values, nothing is stale.
var baseT = new Template("Base") { Id = 1 };
baseT.Alarms.Add(new TemplateAlarm("Level")
{
Id = 10,
TemplateId = 1,
PriorityLevel = 5,
TriggerType = AlarmTriggerType.HiLo,
TriggerConfiguration = "{\"hi\":90,\"lo\":10}"
});
baseT.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Src")
{
Id = 11,
TemplateId = 1,
ConnectionName = "conn",
SourceReference = "ns=2;s=Alarm",
ConditionFilter = "severity>500"
});
var derived = new Template("Derived") { Id = 2, ParentTemplateId = 1 };
derived.Alarms.Add(new TemplateAlarm("Level")
{
Id = 20,
TemplateId = 2,
PriorityLevel = 5,
TriggerType = AlarmTriggerType.HiLo,
TriggerConfiguration = "{\"hi\":90,\"lo\":10}", // in sync
IsInherited = true
});
derived.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Src")
{
Id = 21,
TemplateId = 2,
ConnectionName = "conn",
SourceReference = "ns=2;s=Alarm",
ConditionFilter = "severity>500", // in sync
IsInherited = true
});
var resolved = TemplateInheritanceResolver.Resolve(2, new[] { baseT, derived });
Assert.False(resolved.Staleness.IsStale);
Assert.Equal(0, resolved.Staleness.DifferingMemberCount);
}
}