fix(templates): gate resolver HiLo-merge on both-HiLo to match flattener (#262)

Change `mergeEffective` delegate in `ResolveWinners<T>` from `Func<string?, T, string?>`
to `Func<Winner<T>, T, string?>` so the alarm hook can inspect the existing winner's row.
Gate the per-setpoint merge in `ResolveAlarmWinners` on both sides being HiLo
(`existingWinner.Row.TriggerType == HiLo && derived.TriggerType == HiLo`), matching
`FlatteningService.ResolveInheritedAlarms` exactly. Base non-HiLo + derived HiLo now
falls through to whole-replace (derived config verbatim) — the same path the flattener
takes. Preview-only fix; the deploy path is unchanged.

Add test: `Resolve_BaseNonHiLo_DerivedHiLo_DerivedConfigWinsVerbatim` — asserts
resolver and flattener agree when base is ValueMatch and derived overrides to HiLo.
This commit is contained in:
Joseph Doherty
2026-06-19 01:49:25 -04:00
parent 5585d7ba51
commit a1eed1c2ab
2 changed files with 74 additions and 9 deletions
@@ -355,6 +355,66 @@ public class TemplateInheritanceResolverTests
Assert.Contains("\"hi\":95", resolvedAlarm.EffectiveTriggerConfiguration);
}
// ── I1b: base NON-HiLo alarm overridden by a derived HiLo alarm — the resolver
// must NOT attempt the per-setpoint merge (guard matches the flattener's
// both-HiLo gate). Derived config must win verbatim (whole-replace). ──
[Fact]
public void Resolve_BaseNonHiLo_DerivedHiLo_DerivedConfigWinsVerbatim()
{
// Base uses a non-HiLo trigger type (ValueMatch, JSON-object config).
// Derived overrides to HiLo. The resolver must take the same path the
// flattener takes: derived config wins verbatim (no per-setpoint merge).
var baseT = new Template("Base") { Id = 1 };
baseT.Alarms.Add(new TemplateAlarm("Level")
{
Id = 10,
TemplateId = 1,
PriorityLevel = 5,
TriggerType = AlarmTriggerType.ValueMatch,
TriggerConfiguration = "{\"value\":42}"
});
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}",
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: no merge expected (base non-HiLo), derived config verbatim.
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
// result — derived config verbatim, not a merge of base-keys + derived-keys.
Assert.Equal(flatAlarm.TriggerConfiguration, resolvedAlarm.EffectiveTriggerConfiguration);
// Sanity: the derived config came through unchanged; the base config key
// ("value") must NOT appear (it would if the merge hook fired incorrectly).
Assert.Equal("{\"hi\":90,\"lo\":10}", resolvedAlarm.EffectiveTriggerConfiguration);
Assert.DoesNotContain("\"value\"", resolvedAlarm.EffectiveTriggerConfiguration ?? "");
}
// ── I2: staleness must flag drift on the fields that actually matter, not
// just one scalar per type. ──