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.
|
||||
/// </summary>
|
||||
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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user