fix(m9/T26a): HiLo per-setpoint merge in resolver (preview=deploy) + widen staleness comparison
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="TemplateResolver.BuildInheritanceChain"/>), 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
|
||||
/// <see cref="Flattening.FlatteningService"/>'s <c>ResolveInherited*</c> methods
|
||||
/// EXACTLY so the editor preview agrees with what a deploy would produce:
|
||||
/// walk base → derived (derived wins); an <c>IsInherited</c> placeholder row on
|
||||
/// walk base -> derived (derived wins); an <c>IsInherited</c> 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
|
||||
/// <c>LockedInDerived</c> flag is propagated so the editor can render the
|
||||
/// member read-only.
|
||||
/// overridden by a downstream template; an ancestor's <c>LockedInDerived</c>
|
||||
/// 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 <see cref="FlatteningService.MergeHiLoConfig"/>) so
|
||||
/// the resolved trigger config equals deploy's.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// 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) ──
|
||||
|
||||
/// <summary>The winning row for a member, plus where it came from.</summary>
|
||||
private sealed record Winner<T>(T Row, Template Origin, bool BaseLocked);
|
||||
// -- per-member-type winners (origin-tracking) --
|
||||
|
||||
/// <summary>
|
||||
/// Generic base → derived walk that picks the winning row per member name,
|
||||
/// The winning row for a member, plus where it came from. <c>EffectiveValue</c>
|
||||
/// 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
|
||||
/// <c>mergeEffective</c> on <see cref="ResolveWinners{T}"/>), so the resolver
|
||||
/// preview equals what the flattener produces on deploy.
|
||||
/// </summary>
|
||||
private sealed record Winner<T>(T Row, Template Origin, bool BaseLocked, string? EffectiveValue);
|
||||
|
||||
/// <summary>
|
||||
/// Generic base -> derived walk that picks the winning row per member name,
|
||||
/// replicating FlatteningService precedence:
|
||||
/// derived wins; an <c>IsInherited</c> placeholder does not shadow a live
|
||||
/// ancestor value; a locked member is not overridden downstream; the
|
||||
/// ancestor <c>LockedInDerived</c> flag is tracked.
|
||||
/// <para>
|
||||
/// <paramref name="valueOf"/> extracts the per-member effective value from a
|
||||
/// single row. <paramref name="mergeEffective"/> (optional) computes the
|
||||
/// effective value when a derived row OVERRIDES an existing winner -- for HiLo
|
||||
/// alarms this reuses <see cref="FlatteningService.MergeHiLoConfig"/>
|
||||
/// so a partial override (e.g. just <c>hi</c>) inherits the rest of the
|
||||
/// ancestor config, exactly as deploy does. When null, the winning row's own
|
||||
/// value is used verbatim (whole-replace).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static Dictionary<string, Winner<T>> ResolveWinners<T>(
|
||||
IReadOnlyList<Template> chain,
|
||||
@@ -91,11 +121,13 @@ public static class TemplateInheritanceResolver
|
||||
Func<T, string> nameOf,
|
||||
Func<T, bool> isInherited,
|
||||
Func<T, bool> isLocked,
|
||||
Func<T, bool> lockedInDerived)
|
||||
Func<T, bool> lockedInDerived,
|
||||
Func<T, string?> valueOf,
|
||||
Func<string?, T, string?>? mergeEffective = null)
|
||||
{
|
||||
var result = new Dictionary<string, Winner<T>>(StringComparer.Ordinal);
|
||||
|
||||
// chain is root-first; walk root → derived so the derived template
|
||||
// chain is root-first; walk root -> derived so the derived template
|
||||
// (last) wins, mirroring FlatteningService (which walks its
|
||||
// most-derived-first chain back-to-front for the same effect).
|
||||
foreach (var template in chain)
|
||||
@@ -122,74 +154,77 @@ public static class TemplateInheritanceResolver
|
||||
}
|
||||
|
||||
var baseLocked = (existing?.BaseLocked ?? false) || lockedInDerived(row);
|
||||
result[name] = new Winner<T>(row, template, baseLocked);
|
||||
// When a derived row overrides an existing winner, give the merge
|
||||
// hook a chance to fuse the two effective values (HiLo per-setpoint);
|
||||
// otherwise the overriding row's own value wins (whole-replace).
|
||||
var effective = existing != null && mergeEffective != null
|
||||
? mergeEffective(existing.EffectiveValue, row)
|
||||
: valueOf(row);
|
||||
result[name] = new Winner<T>(row, template, baseLocked, effective);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ResolvedTemplateMemberInfo> ResolveAttributes(
|
||||
IReadOnlyList<Template> chain, int selfId)
|
||||
{
|
||||
var winners = ResolveWinners(
|
||||
private static Dictionary<string, Winner<TemplateAttribute>> ResolveAttributeWinners(
|
||||
IReadOnlyList<Template> chain) =>
|
||||
ResolveWinners(
|
||||
chain,
|
||||
t => t.Attributes,
|
||||
a => a.Name,
|
||||
a => a.IsInherited,
|
||||
a => a.IsLocked,
|
||||
a => a.LockedInDerived);
|
||||
a => a.LockedInDerived,
|
||||
a => a.Value);
|
||||
|
||||
return Project(winners, selfId, w => w.Row.Value);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ResolvedTemplateMemberInfo> ResolveAlarms(
|
||||
IReadOnlyList<Template> chain, int selfId)
|
||||
{
|
||||
var winners = ResolveWinners(
|
||||
private static Dictionary<string, Winner<TemplateAlarm>> ResolveAlarmWinners(
|
||||
IReadOnlyList<Template> chain) =>
|
||||
// Winner.EffectiveValue carries the PER-SETPOINT MERGED trigger config so
|
||||
// a partial HiLo override (e.g. just `hi`) inherits the rest from the
|
||||
// ancestor -- reusing FlatteningService.MergeHiLoConfig so the resolver
|
||||
// preview equals deploy. Other trigger types whole-replace (mergeEffective
|
||||
// returns the derived row's own config unchanged).
|
||||
ResolveWinners(
|
||||
chain,
|
||||
t => t.Alarms,
|
||||
a => a.Name,
|
||||
a => a.IsInherited,
|
||||
a => a.IsLocked,
|
||||
a => a.LockedInDerived);
|
||||
a => a.LockedInDerived,
|
||||
a => a.TriggerConfiguration,
|
||||
(existingTrigger, derived) =>
|
||||
derived.TriggerType == AlarmTriggerType.HiLo
|
||||
? FlatteningService.MergeHiLoConfig(existingTrigger, derived.TriggerConfiguration)
|
||||
: derived.TriggerConfiguration);
|
||||
|
||||
// No single scalar value for an alarm; surface the priority as the preview value.
|
||||
return Project(winners, selfId, w => w.Row.PriorityLevel.ToString());
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ResolvedTemplateMemberInfo> ResolveScripts(
|
||||
IReadOnlyList<Template> chain, int selfId)
|
||||
{
|
||||
var winners = ResolveWinners(
|
||||
private static Dictionary<string, Winner<TemplateScript>> ResolveScriptWinners(
|
||||
IReadOnlyList<Template> chain) =>
|
||||
ResolveWinners(
|
||||
chain,
|
||||
t => t.Scripts,
|
||||
s => s.Name,
|
||||
s => s.IsInherited,
|
||||
s => s.IsLocked,
|
||||
s => s.LockedInDerived);
|
||||
s => s.LockedInDerived,
|
||||
s => s.Code);
|
||||
|
||||
return Project(winners, selfId, w => w.Row.Code);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ResolvedTemplateMemberInfo> ResolveNativeAlarmSources(
|
||||
IReadOnlyList<Template> chain, int selfId)
|
||||
{
|
||||
var winners = ResolveWinners(
|
||||
private static Dictionary<string, Winner<TemplateNativeAlarmSource>> ResolveNativeAlarmSourceWinners(
|
||||
IReadOnlyList<Template> chain) =>
|
||||
ResolveWinners(
|
||||
chain,
|
||||
t => t.NativeAlarmSources,
|
||||
s => s.Name,
|
||||
s => s.IsInherited,
|
||||
s => s.IsLocked,
|
||||
s => s.LockedInDerived);
|
||||
|
||||
return Project(winners, selfId, w => w.Row.SourceReference);
|
||||
}
|
||||
s => s.LockedInDerived,
|
||||
s => s.SourceReference);
|
||||
|
||||
private static IReadOnlyList<ResolvedTemplateMemberInfo> Project<T>(
|
||||
Dictionary<string, Winner<T>> winners,
|
||||
int selfId,
|
||||
Func<Winner<T>, string?> effectiveValue)
|
||||
Func<Winner<T>, string?> effectiveValue,
|
||||
Func<Winner<T>, string?> effectiveTriggerConfiguration)
|
||||
{
|
||||
return winners.Values
|
||||
.Select(w => new ResolvedTemplateMemberInfo
|
||||
@@ -200,7 +235,8 @@ public static class TemplateInheritanceResolver
|
||||
OriginTemplateName = w.Origin.Name,
|
||||
IsLocked = IsLockedOf(w.Row),
|
||||
IsBaseLocked = w.BaseLocked && w.Origin.Id != selfId,
|
||||
EffectiveValue = effectiveValue(w)
|
||||
EffectiveValue = effectiveValue(w),
|
||||
EffectiveTriggerConfiguration = effectiveTriggerConfiguration(w)
|
||||
})
|
||||
.OrderBy(m => m.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
@@ -224,32 +260,64 @@ public static class TemplateInheritanceResolver
|
||||
_ => false
|
||||
};
|
||||
|
||||
// ── staleness ──
|
||||
// -- staleness --
|
||||
|
||||
/// <summary>
|
||||
/// Compares the template's STORED member rows against the freshly-resolved
|
||||
/// set and counts how many INHERITED members drift: a freshly-resolved
|
||||
/// inherited member with no stored row, or whose stored placeholder value
|
||||
/// differs from the live resolved value. Own (non-inherited) members never
|
||||
/// count toward staleness — they are authoritative by definition.
|
||||
/// inherited member with no stored row, or whose stored placeholder differs
|
||||
/// from the freshly-resolved (effective) value. Own (non-inherited) members
|
||||
/// never count toward staleness -- they are authoritative by definition.
|
||||
/// <para>
|
||||
/// The comparison key per type is WIDE -- it covers the fields that actually
|
||||
/// matter, not a single scalar -- so a base change that leaves the previously
|
||||
/// compared scalar untouched (e.g. an alarm HiLo threshold change with the
|
||||
/// priority unchanged, or a native source ConnectionName/ConditionFilter
|
||||
/// change with the SourceReference unchanged) still registers as stale:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Attributes: <c>Value</c>.</description></item>
|
||||
/// <item><description>Alarms: <c>PriorityLevel</c> + (merged) <c>TriggerConfiguration</c> + <c>Description</c> + <c>OnTriggerScriptId</c>.</description></item>
|
||||
/// <item><description>Scripts: <c>Code</c>.</description></item>
|
||||
/// <item><description>NativeAlarmSources: <c>ConnectionName</c> + <c>SourceReference</c> + <c>ConditionFilter</c>.</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private static ResolvedTemplateStaleness ComputeStaleness(
|
||||
Template self,
|
||||
IReadOnlyList<ResolvedTemplateMemberInfo> attributes,
|
||||
IReadOnlyList<ResolvedTemplateMemberInfo> alarms,
|
||||
IReadOnlyList<ResolvedTemplateMemberInfo> scripts,
|
||||
IReadOnlyList<ResolvedTemplateMemberInfo> nativeAlarmSources)
|
||||
int selfId,
|
||||
Dictionary<string, Winner<TemplateAttribute>> attributeWinners,
|
||||
Dictionary<string, Winner<TemplateAlarm>> alarmWinners,
|
||||
Dictionary<string, Winner<TemplateScript>> scriptWinners,
|
||||
Dictionary<string, Winner<TemplateNativeAlarmSource>> nativeSourceWinners)
|
||||
{
|
||||
int differing = 0;
|
||||
|
||||
differing += CountDrift(attributes,
|
||||
self.Attributes.ToDictionary(a => a.Name, a => (string?)a.Value, StringComparer.Ordinal));
|
||||
differing += CountDrift(alarms,
|
||||
self.Alarms.ToDictionary(a => a.Name, a => (string?)a.PriorityLevel.ToString(), StringComparer.Ordinal));
|
||||
differing += CountDrift(scripts,
|
||||
self.Scripts.ToDictionary(s => s.Name, s => (string?)s.Code, StringComparer.Ordinal));
|
||||
differing += CountDrift(nativeAlarmSources,
|
||||
self.NativeAlarmSources.ToDictionary(s => s.Name, s => (string?)s.SourceReference, StringComparer.Ordinal));
|
||||
differing += CountDrift(
|
||||
attributeWinners, selfId,
|
||||
self.Attributes.ToDictionary(a => a.Name, a => AttributeKey(a.Value), StringComparer.Ordinal),
|
||||
w => AttributeKey(w.EffectiveValue));
|
||||
|
||||
differing += CountDrift(
|
||||
alarmWinners, selfId,
|
||||
self.Alarms.ToDictionary(
|
||||
a => a.Name,
|
||||
a => AlarmKey(a.PriorityLevel, a.TriggerConfiguration, a.Description, a.OnTriggerScriptId),
|
||||
StringComparer.Ordinal),
|
||||
// The resolved (winner) side uses the MERGED effective trigger config.
|
||||
w => AlarmKey(w.Row.PriorityLevel, w.EffectiveValue, w.Row.Description, w.Row.OnTriggerScriptId));
|
||||
|
||||
differing += CountDrift(
|
||||
scriptWinners, selfId,
|
||||
self.Scripts.ToDictionary(s => s.Name, s => ScriptKey(s.Code), StringComparer.Ordinal),
|
||||
w => ScriptKey(w.Row.Code));
|
||||
|
||||
differing += CountDrift(
|
||||
nativeSourceWinners, selfId,
|
||||
self.NativeAlarmSources.ToDictionary(
|
||||
s => s.Name,
|
||||
s => NativeSourceKey(s.ConnectionName, s.SourceReference, s.ConditionFilter),
|
||||
StringComparer.Ordinal),
|
||||
w => NativeSourceKey(w.Row.ConnectionName, w.Row.SourceReference, w.Row.ConditionFilter));
|
||||
|
||||
return new ResolvedTemplateStaleness
|
||||
{
|
||||
@@ -258,17 +326,24 @@ public static class TemplateInheritanceResolver
|
||||
};
|
||||
}
|
||||
|
||||
private static int CountDrift(
|
||||
IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
|
||||
IReadOnlyDictionary<string, string?> storedValuesByName)
|
||||
/// <summary>
|
||||
/// Counts inherited winners that drift from their stored placeholder: a
|
||||
/// winner whose origin is an ANCESTOR (inherited) and whose composite key has
|
||||
/// no stored counterpart, or differs from the stored one.
|
||||
/// </summary>
|
||||
private static int CountDrift<T>(
|
||||
Dictionary<string, Winner<T>> winners,
|
||||
int selfId,
|
||||
IReadOnlyDictionary<string, string> storedKeysByName,
|
||||
Func<Winner<T>, string> resolvedKey)
|
||||
{
|
||||
int count = 0;
|
||||
foreach (var m in resolved)
|
||||
foreach (var (name, w) in winners)
|
||||
{
|
||||
if (!m.IsInherited)
|
||||
continue; // own rows are authoritative — never stale
|
||||
if (w.Origin.Id == selfId)
|
||||
continue; // own rows are authoritative -- never stale
|
||||
|
||||
if (!storedValuesByName.TryGetValue(m.Name, out var storedValue))
|
||||
if (!storedKeysByName.TryGetValue(name, out var storedKey))
|
||||
{
|
||||
// Inherited member with no stored placeholder row (e.g. a base
|
||||
// member added after the derived template was created).
|
||||
@@ -276,10 +351,33 @@ public static class TemplateInheritanceResolver
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(storedValue, m.EffectiveValue, StringComparison.Ordinal))
|
||||
count++; // stored placeholder is stale vs. the live base value
|
||||
if (!string.Equals(storedKey, resolvedKey(w), StringComparison.Ordinal))
|
||||
count++; // stored placeholder is stale vs. the live (effective) base value
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
// -- composite comparison keys --
|
||||
// A unit-separator delimiter (U+001F, which never appears in JSON / names /
|
||||
// code) joins the fields; a distinct null marker (U+0000) ensures a null
|
||||
// field never collides with an empty one. The SAME normalization is applied
|
||||
// to both the stored side and the resolved side.
|
||||
private const string KeySep = "\u001F";
|
||||
private const string NullMarker = "\u0000";
|
||||
private static string Mark(string? s) => s ?? NullMarker;
|
||||
|
||||
private static string AttributeKey(string? value) => Mark(value);
|
||||
|
||||
private static string ScriptKey(string? code) => Mark(code);
|
||||
|
||||
private static string AlarmKey(int priorityLevel, string? triggerConfiguration, string? description, int? onTriggerScriptId) =>
|
||||
string.Join(KeySep,
|
||||
priorityLevel.ToString(),
|
||||
Mark(triggerConfiguration),
|
||||
Mark(description),
|
||||
onTriggerScriptId?.ToString() ?? NullMarker);
|
||||
|
||||
private static string NativeSourceKey(string? connectionName, string? sourceReference, string? conditionFilter) =>
|
||||
string.Join(KeySep, Mark(connectionName), Mark(sourceReference), Mark(conditionFilter));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user