fix(m9/T26a): HiLo per-setpoint merge in resolver (preview=deploy) + widen staleness comparison

This commit is contained in:
Joseph Doherty
2026-06-18 12:51:58 -04:00
parent 10c08dd309
commit ca6e5da34b
3 changed files with 397 additions and 80 deletions
@@ -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);
}
}