fix(m9/T26a): HiLo per-setpoint merge in resolver (preview=deploy) + widen staleness comparison
This commit is contained in:
@@ -93,6 +93,16 @@ public sealed record ResolvedTemplateMemberInfo
|
|||||||
/// no single scalar value.
|
/// no single scalar value.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? EffectiveValue { get; init; }
|
public string? EffectiveValue { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>hi</c> inherits the base
|
||||||
|
/// <c>lo</c>), 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.
|
||||||
|
/// </summary>
|
||||||
|
public string? EffectiveTriggerConfiguration { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
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;
|
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read-only AUTHORING resolver (M9/T26a). Given a template id and the full
|
/// Read-only AUTHORING resolver (M9/T26a). Given a template id and the full
|
||||||
/// template lookup, computes the EFFECTIVE inherited member set fresh from the
|
/// 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
|
/// <see cref="TemplateResolver.BuildInheritanceChain"/>), annotated per member
|
||||||
/// with origin (own / inherited-from-X) and lock state, plus a staleness
|
/// with origin (own / inherited-from-X) and lock state, plus a staleness
|
||||||
/// summary comparing the template's STORED member rows against the resolved set.
|
/// 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
|
/// The inheritance precedence here MIRRORS
|
||||||
/// <see cref="Flattening.FlatteningService"/>'s <c>ResolveInherited*</c> methods
|
/// <see cref="Flattening.FlatteningService"/>'s <c>ResolveInherited*</c> methods
|
||||||
/// EXACTLY so the editor preview agrees with what a deploy would produce:
|
/// 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
|
/// 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
|
/// row when no ancestor defines the member); a locked member, once seen, is not
|
||||||
/// overridden by a downstream template; and an ancestor's
|
/// overridden by a downstream template; an ancestor's <c>LockedInDerived</c>
|
||||||
/// <c>LockedInDerived</c> flag is propagated so the editor can render the
|
/// flag is propagated so the editor can render the member read-only; and a
|
||||||
/// member read-only.
|
/// 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>
|
||||||
///
|
///
|
||||||
/// <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
|
/// instance flattener's concern (there are no instances in the
|
||||||
/// template-authoring context). It NEVER mutates stored rows and is not on the
|
/// template-authoring context). It NEVER mutates stored rows and is not on the
|
||||||
/// deploy path.
|
/// deploy path.
|
||||||
@@ -50,15 +54,25 @@ public static class TemplateInheritanceResolver
|
|||||||
if (!lookup.TryGetValue(templateId, out var self))
|
if (!lookup.TryGetValue(templateId, out var self))
|
||||||
return new ResolvedTemplateMembers { TemplateId = templateId };
|
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 chain = TemplateResolver.BuildInheritanceChain(templateId, lookup);
|
||||||
|
|
||||||
var attributes = ResolveAttributes(chain, templateId);
|
// Resolve winners ONCE per member type so projection and staleness share a
|
||||||
var alarms = ResolveAlarms(chain, templateId);
|
// single source of truth (the same merged effective state).
|
||||||
var scripts = ResolveScripts(chain, templateId);
|
var attributeWinners = ResolveAttributeWinners(chain);
|
||||||
var nativeAlarmSources = ResolveNativeAlarmSources(chain, templateId);
|
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
|
return new ResolvedTemplateMembers
|
||||||
{
|
{
|
||||||
@@ -73,17 +87,33 @@ public static class TemplateInheritanceResolver
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── per-member-type winners (origin-tracking) ──
|
// -- 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);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <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:
|
/// replicating FlatteningService precedence:
|
||||||
/// derived wins; an <c>IsInherited</c> placeholder does not shadow a live
|
/// derived wins; an <c>IsInherited</c> placeholder does not shadow a live
|
||||||
/// ancestor value; a locked member is not overridden downstream; the
|
/// ancestor value; a locked member is not overridden downstream; the
|
||||||
/// ancestor <c>LockedInDerived</c> flag is tracked.
|
/// 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>
|
/// </summary>
|
||||||
private static Dictionary<string, Winner<T>> ResolveWinners<T>(
|
private static Dictionary<string, Winner<T>> ResolveWinners<T>(
|
||||||
IReadOnlyList<Template> chain,
|
IReadOnlyList<Template> chain,
|
||||||
@@ -91,11 +121,13 @@ public static class TemplateInheritanceResolver
|
|||||||
Func<T, string> nameOf,
|
Func<T, string> nameOf,
|
||||||
Func<T, bool> isInherited,
|
Func<T, bool> isInherited,
|
||||||
Func<T, bool> isLocked,
|
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);
|
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
|
// (last) wins, mirroring FlatteningService (which walks its
|
||||||
// most-derived-first chain back-to-front for the same effect).
|
// most-derived-first chain back-to-front for the same effect).
|
||||||
foreach (var template in chain)
|
foreach (var template in chain)
|
||||||
@@ -122,74 +154,77 @@ public static class TemplateInheritanceResolver
|
|||||||
}
|
}
|
||||||
|
|
||||||
var baseLocked = (existing?.BaseLocked ?? false) || lockedInDerived(row);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<ResolvedTemplateMemberInfo> ResolveAttributes(
|
private static Dictionary<string, Winner<TemplateAttribute>> ResolveAttributeWinners(
|
||||||
IReadOnlyList<Template> chain, int selfId)
|
IReadOnlyList<Template> chain) =>
|
||||||
{
|
ResolveWinners(
|
||||||
var winners = ResolveWinners(
|
|
||||||
chain,
|
chain,
|
||||||
t => t.Attributes,
|
t => t.Attributes,
|
||||||
a => a.Name,
|
a => a.Name,
|
||||||
a => a.IsInherited,
|
a => a.IsInherited,
|
||||||
a => a.IsLocked,
|
a => a.IsLocked,
|
||||||
a => a.LockedInDerived);
|
a => a.LockedInDerived,
|
||||||
|
a => a.Value);
|
||||||
|
|
||||||
return Project(winners, selfId, w => w.Row.Value);
|
private static Dictionary<string, Winner<TemplateAlarm>> ResolveAlarmWinners(
|
||||||
}
|
IReadOnlyList<Template> chain) =>
|
||||||
|
// Winner.EffectiveValue carries the PER-SETPOINT MERGED trigger config so
|
||||||
private static IReadOnlyList<ResolvedTemplateMemberInfo> ResolveAlarms(
|
// a partial HiLo override (e.g. just `hi`) inherits the rest from the
|
||||||
IReadOnlyList<Template> chain, int selfId)
|
// ancestor -- reusing FlatteningService.MergeHiLoConfig so the resolver
|
||||||
{
|
// preview equals deploy. Other trigger types whole-replace (mergeEffective
|
||||||
var winners = ResolveWinners(
|
// returns the derived row's own config unchanged).
|
||||||
|
ResolveWinners(
|
||||||
chain,
|
chain,
|
||||||
t => t.Alarms,
|
t => t.Alarms,
|
||||||
a => a.Name,
|
a => a.Name,
|
||||||
a => a.IsInherited,
|
a => a.IsInherited,
|
||||||
a => a.IsLocked,
|
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.
|
private static Dictionary<string, Winner<TemplateScript>> ResolveScriptWinners(
|
||||||
return Project(winners, selfId, w => w.Row.PriorityLevel.ToString());
|
IReadOnlyList<Template> chain) =>
|
||||||
}
|
ResolveWinners(
|
||||||
|
|
||||||
private static IReadOnlyList<ResolvedTemplateMemberInfo> ResolveScripts(
|
|
||||||
IReadOnlyList<Template> chain, int selfId)
|
|
||||||
{
|
|
||||||
var winners = ResolveWinners(
|
|
||||||
chain,
|
chain,
|
||||||
t => t.Scripts,
|
t => t.Scripts,
|
||||||
s => s.Name,
|
s => s.Name,
|
||||||
s => s.IsInherited,
|
s => s.IsInherited,
|
||||||
s => s.IsLocked,
|
s => s.IsLocked,
|
||||||
s => s.LockedInDerived);
|
s => s.LockedInDerived,
|
||||||
|
s => s.Code);
|
||||||
|
|
||||||
return Project(winners, selfId, w => w.Row.Code);
|
private static Dictionary<string, Winner<TemplateNativeAlarmSource>> ResolveNativeAlarmSourceWinners(
|
||||||
}
|
IReadOnlyList<Template> chain) =>
|
||||||
|
ResolveWinners(
|
||||||
private static IReadOnlyList<ResolvedTemplateMemberInfo> ResolveNativeAlarmSources(
|
|
||||||
IReadOnlyList<Template> chain, int selfId)
|
|
||||||
{
|
|
||||||
var winners = ResolveWinners(
|
|
||||||
chain,
|
chain,
|
||||||
t => t.NativeAlarmSources,
|
t => t.NativeAlarmSources,
|
||||||
s => s.Name,
|
s => s.Name,
|
||||||
s => s.IsInherited,
|
s => s.IsInherited,
|
||||||
s => s.IsLocked,
|
s => s.IsLocked,
|
||||||
s => s.LockedInDerived);
|
s => s.LockedInDerived,
|
||||||
|
s => s.SourceReference);
|
||||||
return Project(winners, selfId, w => w.Row.SourceReference);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IReadOnlyList<ResolvedTemplateMemberInfo> Project<T>(
|
private static IReadOnlyList<ResolvedTemplateMemberInfo> Project<T>(
|
||||||
Dictionary<string, Winner<T>> winners,
|
Dictionary<string, Winner<T>> winners,
|
||||||
int selfId,
|
int selfId,
|
||||||
Func<Winner<T>, string?> effectiveValue)
|
Func<Winner<T>, string?> effectiveValue,
|
||||||
|
Func<Winner<T>, string?> effectiveTriggerConfiguration)
|
||||||
{
|
{
|
||||||
return winners.Values
|
return winners.Values
|
||||||
.Select(w => new ResolvedTemplateMemberInfo
|
.Select(w => new ResolvedTemplateMemberInfo
|
||||||
@@ -200,7 +235,8 @@ public static class TemplateInheritanceResolver
|
|||||||
OriginTemplateName = w.Origin.Name,
|
OriginTemplateName = w.Origin.Name,
|
||||||
IsLocked = IsLockedOf(w.Row),
|
IsLocked = IsLockedOf(w.Row),
|
||||||
IsBaseLocked = w.BaseLocked && w.Origin.Id != selfId,
|
IsBaseLocked = w.BaseLocked && w.Origin.Id != selfId,
|
||||||
EffectiveValue = effectiveValue(w)
|
EffectiveValue = effectiveValue(w),
|
||||||
|
EffectiveTriggerConfiguration = effectiveTriggerConfiguration(w)
|
||||||
})
|
})
|
||||||
.OrderBy(m => m.Name, StringComparer.Ordinal)
|
.OrderBy(m => m.Name, StringComparer.Ordinal)
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -224,32 +260,64 @@ public static class TemplateInheritanceResolver
|
|||||||
_ => false
|
_ => false
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── staleness ──
|
// -- staleness --
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compares the template's STORED member rows against the freshly-resolved
|
/// Compares the template's STORED member rows against the freshly-resolved
|
||||||
/// set and counts how many INHERITED members drift: a freshly-resolved
|
/// set and counts how many INHERITED members drift: a freshly-resolved
|
||||||
/// inherited member with no stored row, or whose stored placeholder value
|
/// inherited member with no stored row, or whose stored placeholder differs
|
||||||
/// differs from the live resolved value. Own (non-inherited) members never
|
/// from the freshly-resolved (effective) value. Own (non-inherited) members
|
||||||
/// count toward staleness — they are authoritative by definition.
|
/// 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>
|
/// </summary>
|
||||||
private static ResolvedTemplateStaleness ComputeStaleness(
|
private static ResolvedTemplateStaleness ComputeStaleness(
|
||||||
Template self,
|
Template self,
|
||||||
IReadOnlyList<ResolvedTemplateMemberInfo> attributes,
|
int selfId,
|
||||||
IReadOnlyList<ResolvedTemplateMemberInfo> alarms,
|
Dictionary<string, Winner<TemplateAttribute>> attributeWinners,
|
||||||
IReadOnlyList<ResolvedTemplateMemberInfo> scripts,
|
Dictionary<string, Winner<TemplateAlarm>> alarmWinners,
|
||||||
IReadOnlyList<ResolvedTemplateMemberInfo> nativeAlarmSources)
|
Dictionary<string, Winner<TemplateScript>> scriptWinners,
|
||||||
|
Dictionary<string, Winner<TemplateNativeAlarmSource>> nativeSourceWinners)
|
||||||
{
|
{
|
||||||
int differing = 0;
|
int differing = 0;
|
||||||
|
|
||||||
differing += CountDrift(attributes,
|
differing += CountDrift(
|
||||||
self.Attributes.ToDictionary(a => a.Name, a => (string?)a.Value, StringComparer.Ordinal));
|
attributeWinners, selfId,
|
||||||
differing += CountDrift(alarms,
|
self.Attributes.ToDictionary(a => a.Name, a => AttributeKey(a.Value), StringComparer.Ordinal),
|
||||||
self.Alarms.ToDictionary(a => a.Name, a => (string?)a.PriorityLevel.ToString(), StringComparer.Ordinal));
|
w => AttributeKey(w.EffectiveValue));
|
||||||
differing += CountDrift(scripts,
|
|
||||||
self.Scripts.ToDictionary(s => s.Name, s => (string?)s.Code, StringComparer.Ordinal));
|
differing += CountDrift(
|
||||||
differing += CountDrift(nativeAlarmSources,
|
alarmWinners, selfId,
|
||||||
self.NativeAlarmSources.ToDictionary(s => s.Name, s => (string?)s.SourceReference, StringComparer.Ordinal));
|
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
|
return new ResolvedTemplateStaleness
|
||||||
{
|
{
|
||||||
@@ -258,17 +326,24 @@ public static class TemplateInheritanceResolver
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int CountDrift(
|
/// <summary>
|
||||||
IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
|
/// Counts inherited winners that drift from their stored placeholder: a
|
||||||
IReadOnlyDictionary<string, string?> storedValuesByName)
|
/// 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;
|
int count = 0;
|
||||||
foreach (var m in resolved)
|
foreach (var (name, w) in winners)
|
||||||
{
|
{
|
||||||
if (!m.IsInherited)
|
if (w.Origin.Id == selfId)
|
||||||
continue; // own rows are authoritative — never stale
|
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
|
// Inherited member with no stored placeholder row (e.g. a base
|
||||||
// member added after the derived template was created).
|
// member added after the derived template was created).
|
||||||
@@ -276,10 +351,33 @@ public static class TemplateInheritanceResolver
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.Equals(storedValue, m.EffectiveValue, StringComparison.Ordinal))
|
if (!string.Equals(storedKey, resolvedKey(w), StringComparison.Ordinal))
|
||||||
count++; // stored placeholder is stale vs. the live base value
|
count++; // stored placeholder is stale vs. the live (effective) base value
|
||||||
}
|
}
|
||||||
|
|
||||||
return count;
|
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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,4 +290,213 @@ public class TemplateInheritanceResolverTests
|
|||||||
}
|
}
|
||||||
Assert.Equal(flat.Value.Attributes.Count, resolved.Attributes.Count);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user