From ca6e5da34b89bf51a14c6a232215ef0f3308b34a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 12:51:58 -0400 Subject: [PATCH] fix(m9/T26a): HiLo per-setpoint merge in resolver (preview=deploy) + widen staleness comparison --- .../Management/ResolvedTemplateMembers.cs | 10 + .../TemplateInheritanceResolver.cs | 258 ++++++++++++------ .../TemplateInheritanceResolverTests.cs | 209 ++++++++++++++ 3 files changed, 397 insertions(+), 80 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ResolvedTemplateMembers.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ResolvedTemplateMembers.cs index abb99456..545aae2e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ResolvedTemplateMembers.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ResolvedTemplateMembers.cs @@ -93,6 +93,16 @@ public sealed record ResolvedTemplateMemberInfo /// no single scalar value. /// public string? EffectiveValue { get; init; } + + /// + /// For ALARM members only: the effective (inheritance-resolved) trigger + /// configuration JSON — for HiLo alarms this is the per-setpoint MERGED + /// config (a derived template overriding only hi inherits the base + /// lo), identical to what the flattener produces on deploy, so the + /// editor can render the effective HiLo thresholds. Null for non-alarm + /// members and for alarms with no trigger configuration. + /// + public string? EffectiveTriggerConfiguration { get; init; } } /// diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateInheritanceResolver.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateInheritanceResolver.cs index 098aa955..013f8114 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateInheritanceResolver.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateInheritanceResolver.cs @@ -1,12 +1,14 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening; namespace ZB.MOM.WW.ScadaBridge.TemplateEngine; /// /// Read-only AUTHORING resolver (M9/T26a). Given a template id and the full /// template lookup, computes the EFFECTIVE inherited member set fresh from the -/// whole inheritance chain (root → leaf, arbitrary depth, cycle-guarded via +/// whole inheritance chain (root -> leaf, arbitrary depth, cycle-guarded via /// ), annotated per member /// with origin (own / inherited-from-X) and lock state, plus a staleness /// summary comparing the template's STORED member rows against the resolved set. @@ -15,16 +17,18 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine; /// The inheritance precedence here MIRRORS /// 's ResolveInherited* methods /// EXACTLY so the editor preview agrees with what a deploy would produce: -/// walk base → derived (derived wins); an IsInherited placeholder row on +/// walk base -> derived (derived wins); an IsInherited placeholder row on /// a derived template never shadows the live base value (it only contributes a /// row when no ancestor defines the member); a locked member, once seen, is not -/// overridden by a downstream template; and an ancestor's -/// LockedInDerived flag is propagated so the editor can render the -/// member read-only. +/// overridden by a downstream template; an ancestor's LockedInDerived +/// flag is propagated so the editor can render the member read-only; and a +/// partial HiLo alarm override is MERGED per-setpoint against the inherited +/// trigger config (reusing ) so +/// the resolved trigger config equals deploy's. /// /// /// -/// This is inheritance only — composition and instance overrides are the +/// This is inheritance only -- composition and instance overrides are the /// instance flattener's concern (there are no instances in the /// template-authoring context). It NEVER mutates stored rows and is not on the /// deploy path. @@ -50,15 +54,25 @@ public static class TemplateInheritanceResolver if (!lookup.TryGetValue(templateId, out var self)) return new ResolvedTemplateMembers { TemplateId = templateId }; - // Root → leaf (derived last). The OWN template is the last element. + // Root -> leaf (derived last). The OWN template is the last element. var chain = TemplateResolver.BuildInheritanceChain(templateId, lookup); - var attributes = ResolveAttributes(chain, templateId); - var alarms = ResolveAlarms(chain, templateId); - var scripts = ResolveScripts(chain, templateId); - var nativeAlarmSources = ResolveNativeAlarmSources(chain, templateId); + // Resolve winners ONCE per member type so projection and staleness share a + // single source of truth (the same merged effective state). + var attributeWinners = ResolveAttributeWinners(chain); + var alarmWinners = ResolveAlarmWinners(chain); + var scriptWinners = ResolveScriptWinners(chain); + var nativeSourceWinners = ResolveNativeAlarmSourceWinners(chain); - var staleness = ComputeStaleness(self, attributes, alarms, scripts, nativeAlarmSources); + var attributes = Project(attributeWinners, templateId, w => w.EffectiveValue, _ => null); + // The DTO EffectiveValue keeps surfacing the priority as the at-a-glance + // preview; EffectiveTriggerConfiguration carries the merged trigger config. + var alarms = Project(alarmWinners, templateId, w => w.Row.PriorityLevel.ToString(), w => w.EffectiveValue); + var scripts = Project(scriptWinners, templateId, w => w.EffectiveValue, _ => null); + var nativeAlarmSources = Project(nativeSourceWinners, templateId, w => w.EffectiveValue, _ => null); + + var staleness = ComputeStaleness( + self, templateId, attributeWinners, alarmWinners, scriptWinners, nativeSourceWinners); return new ResolvedTemplateMembers { @@ -73,17 +87,33 @@ public static class TemplateInheritanceResolver }; } - // ── per-member-type winners (origin-tracking) ── - - /// The winning row for a member, plus where it came from. - private sealed record Winner(T Row, Template Origin, bool BaseLocked); + // -- per-member-type winners (origin-tracking) -- /// - /// Generic base → derived walk that picks the winning row per member name, + /// The winning row for a member, plus where it came from. EffectiveValue + /// is the resolved scalar/effective value for the member type -- usually the + /// winning row's own field, but for HiLo alarms it is the PER-SETPOINT + /// MERGED trigger configuration accumulated down the chain (see + /// mergeEffective on ), so the resolver + /// preview equals what the flattener produces on deploy. + /// + private sealed record Winner(T Row, Template Origin, bool BaseLocked, string? EffectiveValue); + + /// + /// Generic base -> derived walk that picks the winning row per member name, /// replicating FlatteningService precedence: /// derived wins; an IsInherited placeholder does not shadow a live /// ancestor value; a locked member is not overridden downstream; the /// ancestor LockedInDerived flag is tracked. + /// + /// extracts the per-member effective value from a + /// single row. (optional) computes the + /// effective value when a derived row OVERRIDES an existing winner -- for HiLo + /// alarms this reuses + /// so a partial override (e.g. just hi) inherits the rest of the + /// ancestor config, exactly as deploy does. When null, the winning row's own + /// value is used verbatim (whole-replace). + /// /// private static Dictionary> ResolveWinners( IReadOnlyList