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