feat(m9/T26a): read-only inheritance resolve service + GetResolvedTemplateMembersCommand
This commit is contained in:
@@ -0,0 +1,117 @@
|
|||||||
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only response for <see cref="GetResolvedTemplateMembersCommand"/>: the
|
||||||
|
/// EFFECTIVE inherited member set for a template, computed fresh from the full
|
||||||
|
/// inheritance chain (root → leaf, arbitrary depth) and annotated per member
|
||||||
|
/// with its origin and lock state — plus a staleness summary comparing the
|
||||||
|
/// template's STORED member rows against this freshly-resolved set.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The precedence used to compute the effective member set MIRRORS
|
||||||
|
/// <c>FlatteningService.ResolveInherited*</c> exactly (derived wins;
|
||||||
|
/// <c>IsInherited</c> placeholders never shadow the live base value; locked
|
||||||
|
/// members flagged), so the editor preview agrees with what a deploy would
|
||||||
|
/// produce. This is the AUTHORING view only — it never mutates stored rows and
|
||||||
|
/// is not on the deploy path.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Member values surfaced here are the inheritance-resolved values BEFORE any
|
||||||
|
/// composition or instance override is applied — composition is a separate
|
||||||
|
/// concern owned by the instance flattener, and there are no instances in the
|
||||||
|
/// template-authoring context.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record ResolvedTemplateMembers
|
||||||
|
{
|
||||||
|
/// <summary>The template the member set was resolved for.</summary>
|
||||||
|
public int TemplateId { get; init; }
|
||||||
|
/// <summary>The template's name.</summary>
|
||||||
|
public string TemplateName { get; init; } = string.Empty;
|
||||||
|
/// <summary>The immediate parent template id, or null when this is a root template.</summary>
|
||||||
|
public int? ParentTemplateId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The effective resolved attributes (own + transitively inherited).</summary>
|
||||||
|
public IReadOnlyList<ResolvedTemplateMemberInfo> Attributes { get; init; } = [];
|
||||||
|
/// <summary>The effective resolved alarms (own + transitively inherited).</summary>
|
||||||
|
public IReadOnlyList<ResolvedTemplateMemberInfo> Alarms { get; init; } = [];
|
||||||
|
/// <summary>The effective resolved scripts (own + transitively inherited).</summary>
|
||||||
|
public IReadOnlyList<ResolvedTemplateMemberInfo> Scripts { get; init; } = [];
|
||||||
|
/// <summary>The effective resolved native alarm source bindings (own + transitively inherited).</summary>
|
||||||
|
public IReadOnlyList<ResolvedTemplateMemberInfo> NativeAlarmSources { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Staleness summary: whether the template's stored member rows differ from
|
||||||
|
/// this freshly-resolved set (e.g. a base member added after the derived
|
||||||
|
/// template was created, or a multi-level inherited member not present in
|
||||||
|
/// the stored rows), and how many inherited members differ.
|
||||||
|
/// </summary>
|
||||||
|
public ResolvedTemplateStaleness Staleness { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One effective member in a <see cref="ResolvedTemplateMembers"/> set,
|
||||||
|
/// annotated with where its winning definition came from and its lock state.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ResolvedTemplateMemberInfo
|
||||||
|
{
|
||||||
|
/// <summary>The member name (bare; inheritance-resolved members are never path-qualified).</summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the winning definition lives on an ANCESTOR template rather
|
||||||
|
/// than on the resolved template itself — i.e. this member is inherited.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsInherited { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The id of the template that supplied the winning definition.</summary>
|
||||||
|
public int OriginTemplateId { get; init; }
|
||||||
|
/// <summary>The name of the template that supplied the winning definition.</summary>
|
||||||
|
public string OriginTemplateName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the member is locked from override (<c>IsLocked</c> on the
|
||||||
|
/// winning row) — a derived template cannot change it.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsLocked { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when an ANCESTOR marked this member <c>LockedInDerived</c> — the
|
||||||
|
/// derived template may not override it (rendered read-only with a lock in
|
||||||
|
/// the editor). Distinct from <see cref="IsLocked"/>: a base may forbid
|
||||||
|
/// override (<c>LockedInDerived</c>) without the member itself being a
|
||||||
|
/// locked composition member.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsBaseLocked { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The effective value for the member, type-appropriate: attribute value,
|
||||||
|
/// script code, alarm priority/trigger summary, or native-source reference.
|
||||||
|
/// Provided so the editor preview shows the live (post-resolution) value
|
||||||
|
/// without re-walking the chain itself. Null when the member type carries
|
||||||
|
/// no single scalar value.
|
||||||
|
/// </summary>
|
||||||
|
public string? EffectiveValue { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Staleness summary comparing a template's STORED member rows against the
|
||||||
|
/// freshly-resolved inherited member set.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ResolvedTemplateStaleness
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// True when the stored derived rows differ from the freshly-resolved set —
|
||||||
|
/// the base changed after the derived template was created, so the editor
|
||||||
|
/// should surface a "base changed" banner.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsStale { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The number of inherited members whose freshly-resolved state differs
|
||||||
|
/// from what the stored rows reflect (missing rows, added base members,
|
||||||
|
/// changed inherited values). This is the count the editor banner shows.
|
||||||
|
/// </summary>
|
||||||
|
public int DifferingMemberCount { get; init; }
|
||||||
|
}
|
||||||
@@ -7,6 +7,16 @@ public record UpdateTemplateCommand(int TemplateId, string Name, string? Descrip
|
|||||||
public record DeleteTemplateCommand(int TemplateId);
|
public record DeleteTemplateCommand(int TemplateId);
|
||||||
public record ValidateTemplateCommand(int TemplateId);
|
public record ValidateTemplateCommand(int TemplateId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only authoring query (M9/T26a): returns the EFFECTIVE inherited member
|
||||||
|
/// set for a template — computed fresh from the full inheritance chain
|
||||||
|
/// (arbitrary depth), annotated per member with origin + lock state — plus a
|
||||||
|
/// staleness summary comparing the template's stored rows against the resolved
|
||||||
|
/// set. Feeds the template editor's inheritance preview + base-change banner;
|
||||||
|
/// never mutates stored rows. Response: <see cref="ResolvedTemplateMembers"/>.
|
||||||
|
/// </summary>
|
||||||
|
public record GetResolvedTemplateMembersCommand(int TemplateId);
|
||||||
|
|
||||||
// Template member operations
|
// Template member operations
|
||||||
public record AddTemplateAttributeCommand(int TemplateId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked, string? ElementDataType = null);
|
public record AddTemplateAttributeCommand(int TemplateId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked, string? ElementDataType = null);
|
||||||
public record UpdateTemplateAttributeCommand(int AttributeId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked, string? ElementDataType = null);
|
public record UpdateTemplateAttributeCommand(int AttributeId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked, string? ElementDataType = null);
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ public class ManagementActor : ReceiveActor
|
|||||||
UpdateTemplateCommand cmd => await HandleUpdateTemplate(sp, cmd, user.Username),
|
UpdateTemplateCommand cmd => await HandleUpdateTemplate(sp, cmd, user.Username),
|
||||||
DeleteTemplateCommand cmd => await HandleDeleteTemplate(sp, cmd, user.Username),
|
DeleteTemplateCommand cmd => await HandleDeleteTemplate(sp, cmd, user.Username),
|
||||||
ValidateTemplateCommand cmd => await HandleValidateTemplate(sp, cmd),
|
ValidateTemplateCommand cmd => await HandleValidateTemplate(sp, cmd),
|
||||||
|
GetResolvedTemplateMembersCommand cmd => await HandleGetResolvedTemplateMembers(sp, cmd),
|
||||||
|
|
||||||
// Template members
|
// Template members
|
||||||
AddTemplateAttributeCommand cmd => await HandleAddAttribute(sp, cmd, user.Username),
|
AddTemplateAttributeCommand cmd => await HandleAddAttribute(sp, cmd, user.Username),
|
||||||
@@ -599,6 +600,20 @@ public class ManagementActor : ReceiveActor
|
|||||||
return validationResult;
|
return validationResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only authoring resolve (M9/T26a): returns the effective inherited
|
||||||
|
/// member set for a template — computed fresh from the full inheritance
|
||||||
|
/// chain — plus a staleness summary. Loads every template (children
|
||||||
|
/// eager-loaded) so the resolver can walk an arbitrary-depth chain; never
|
||||||
|
/// mutates stored rows.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<object?> HandleGetResolvedTemplateMembers(IServiceProvider sp, GetResolvedTemplateMembersCommand cmd)
|
||||||
|
{
|
||||||
|
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||||
|
var allTemplates = await repo.GetAllTemplatesAsync();
|
||||||
|
return TemplateInheritanceResolver.Resolve(cmd.TemplateId, allTemplates);
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// Template folder handlers
|
// Template folder handlers
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||||
|
|
||||||
|
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
|
||||||
|
/// <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.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// 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
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class TemplateInheritanceResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the effective inherited member set for <paramref name="templateId"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="templateId">The template to resolve members for.</param>
|
||||||
|
/// <param name="allTemplates">The complete set of templates (for chain lookups).</param>
|
||||||
|
/// <returns>
|
||||||
|
/// The resolved member set (attributes / alarms / scripts / native alarm
|
||||||
|
/// sources), each annotated with origin + lock state, plus the staleness
|
||||||
|
/// summary. Returns an empty set when the template id is not found.
|
||||||
|
/// </returns>
|
||||||
|
public static ResolvedTemplateMembers Resolve(int templateId, IReadOnlyList<Template> allTemplates)
|
||||||
|
{
|
||||||
|
// Duplicate-tolerant lookup (matches TemplateResolver.ResolveAllMembers):
|
||||||
|
// an Id of 0 is a valid node (import-staging / not-yet-saved rows).
|
||||||
|
var lookup = CycleDetector.BuildLookup(allTemplates);
|
||||||
|
if (!lookup.TryGetValue(templateId, out var self))
|
||||||
|
return new ResolvedTemplateMembers { TemplateId = templateId };
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
var staleness = ComputeStaleness(self, attributes, alarms, scripts, nativeAlarmSources);
|
||||||
|
|
||||||
|
return new ResolvedTemplateMembers
|
||||||
|
{
|
||||||
|
TemplateId = templateId,
|
||||||
|
TemplateName = self.Name,
|
||||||
|
ParentTemplateId = self.ParentTemplateId,
|
||||||
|
Attributes = attributes,
|
||||||
|
Alarms = alarms,
|
||||||
|
Scripts = scripts,
|
||||||
|
NativeAlarmSources = nativeAlarmSources,
|
||||||
|
Staleness = staleness
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static Dictionary<string, Winner<T>> ResolveWinners<T>(
|
||||||
|
IReadOnlyList<Template> chain,
|
||||||
|
Func<Template, IEnumerable<T>> select,
|
||||||
|
Func<T, string> nameOf,
|
||||||
|
Func<T, bool> isInherited,
|
||||||
|
Func<T, bool> isLocked,
|
||||||
|
Func<T, bool> lockedInDerived)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, Winner<T>>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
foreach (var row in select(template))
|
||||||
|
{
|
||||||
|
var name = nameOf(row);
|
||||||
|
if (result.TryGetValue(name, out var existing))
|
||||||
|
{
|
||||||
|
// A locked ancestor row is not overridden by a downstream template.
|
||||||
|
if (isLocked(existing.Row))
|
||||||
|
{
|
||||||
|
if (lockedInDerived(row))
|
||||||
|
result[name] = existing with { BaseLocked = true };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// IsInherited placeholders never shadow the live ancestor row.
|
||||||
|
if (isInherited(row))
|
||||||
|
{
|
||||||
|
if (lockedInDerived(row))
|
||||||
|
result[name] = existing with { BaseLocked = true };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseLocked = (existing?.BaseLocked ?? false) || lockedInDerived(row);
|
||||||
|
result[name] = new Winner<T>(row, template, baseLocked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<ResolvedTemplateMemberInfo> ResolveAttributes(
|
||||||
|
IReadOnlyList<Template> chain, int selfId)
|
||||||
|
{
|
||||||
|
var winners = ResolveWinners(
|
||||||
|
chain,
|
||||||
|
t => t.Attributes,
|
||||||
|
a => a.Name,
|
||||||
|
a => a.IsInherited,
|
||||||
|
a => a.IsLocked,
|
||||||
|
a => a.LockedInDerived);
|
||||||
|
|
||||||
|
return Project(winners, selfId, w => w.Row.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<ResolvedTemplateMemberInfo> ResolveAlarms(
|
||||||
|
IReadOnlyList<Template> chain, int selfId)
|
||||||
|
{
|
||||||
|
var winners = ResolveWinners(
|
||||||
|
chain,
|
||||||
|
t => t.Alarms,
|
||||||
|
a => a.Name,
|
||||||
|
a => a.IsInherited,
|
||||||
|
a => a.IsLocked,
|
||||||
|
a => a.LockedInDerived);
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
chain,
|
||||||
|
t => t.Scripts,
|
||||||
|
s => s.Name,
|
||||||
|
s => s.IsInherited,
|
||||||
|
s => s.IsLocked,
|
||||||
|
s => s.LockedInDerived);
|
||||||
|
|
||||||
|
return Project(winners, selfId, w => w.Row.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<ResolvedTemplateMemberInfo> ResolveNativeAlarmSources(
|
||||||
|
IReadOnlyList<Template> chain, int selfId)
|
||||||
|
{
|
||||||
|
var winners = 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<ResolvedTemplateMemberInfo> Project<T>(
|
||||||
|
Dictionary<string, Winner<T>> winners,
|
||||||
|
int selfId,
|
||||||
|
Func<Winner<T>, string?> effectiveValue)
|
||||||
|
{
|
||||||
|
return winners.Values
|
||||||
|
.Select(w => new ResolvedTemplateMemberInfo
|
||||||
|
{
|
||||||
|
Name = NameOf(w.Row),
|
||||||
|
IsInherited = w.Origin.Id != selfId,
|
||||||
|
OriginTemplateId = w.Origin.Id,
|
||||||
|
OriginTemplateName = w.Origin.Name,
|
||||||
|
IsLocked = IsLockedOf(w.Row),
|
||||||
|
IsBaseLocked = w.BaseLocked && w.Origin.Id != selfId,
|
||||||
|
EffectiveValue = effectiveValue(w)
|
||||||
|
})
|
||||||
|
.OrderBy(m => m.Name, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NameOf<T>(T row) => row switch
|
||||||
|
{
|
||||||
|
TemplateAttribute a => a.Name,
|
||||||
|
TemplateAlarm a => a.Name,
|
||||||
|
TemplateScript s => s.Name,
|
||||||
|
TemplateNativeAlarmSource s => s.Name,
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool IsLockedOf<T>(T row) => row switch
|
||||||
|
{
|
||||||
|
TemplateAttribute a => a.IsLocked,
|
||||||
|
TemplateAlarm a => a.IsLocked,
|
||||||
|
TemplateScript s => s.IsLocked,
|
||||||
|
TemplateNativeAlarmSource s => s.IsLocked,
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 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.
|
||||||
|
/// </summary>
|
||||||
|
private static ResolvedTemplateStaleness ComputeStaleness(
|
||||||
|
Template self,
|
||||||
|
IReadOnlyList<ResolvedTemplateMemberInfo> attributes,
|
||||||
|
IReadOnlyList<ResolvedTemplateMemberInfo> alarms,
|
||||||
|
IReadOnlyList<ResolvedTemplateMemberInfo> scripts,
|
||||||
|
IReadOnlyList<ResolvedTemplateMemberInfo> nativeAlarmSources)
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
|
||||||
|
return new ResolvedTemplateStaleness
|
||||||
|
{
|
||||||
|
IsStale = differing > 0,
|
||||||
|
DifferingMemberCount = differing
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountDrift(
|
||||||
|
IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
|
||||||
|
IReadOnlyDictionary<string, string?> storedValuesByName)
|
||||||
|
{
|
||||||
|
int count = 0;
|
||||||
|
foreach (var m in resolved)
|
||||||
|
{
|
||||||
|
if (!m.IsInherited)
|
||||||
|
continue; // own rows are authoritative — never stale
|
||||||
|
|
||||||
|
if (!storedValuesByName.TryGetValue(m.Name, out var storedValue))
|
||||||
|
{
|
||||||
|
// Inherited member with no stored placeholder row (e.g. a base
|
||||||
|
// member added after the derived template was created).
|
||||||
|
count++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(storedValue, m.EffectiveValue, StringComparison.Ordinal))
|
||||||
|
count++; // stored placeholder is stale vs. the live base value
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the read-only authoring resolve service (M9/T26a). The resolved
|
||||||
|
/// inherited member set MUST agree with what <see cref="FlatteningService"/>
|
||||||
|
/// produces on deploy — the precedence is exercised both directly and via a
|
||||||
|
/// cross-check against the flattener.
|
||||||
|
/// </summary>
|
||||||
|
public class TemplateInheritanceResolverTests
|
||||||
|
{
|
||||||
|
private static TemplateAttribute Attr(string name, int id, int templateId, string? value = null,
|
||||||
|
bool isInherited = false, bool isLocked = false, bool lockedInDerived = false) =>
|
||||||
|
new(name)
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
TemplateId = templateId,
|
||||||
|
DataType = DataType.String,
|
||||||
|
Value = value,
|
||||||
|
IsInherited = isInherited,
|
||||||
|
IsLocked = isLocked,
|
||||||
|
LockedInDerived = lockedInDerived
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── (a) A→B→C chain: resolving C returns members inherited transitively from A ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_ThreeLevelChain_IncludesTransitivelyInheritedGrandparentMember()
|
||||||
|
{
|
||||||
|
var a = new Template("A") { Id = 1 };
|
||||||
|
a.Attributes.Add(Attr("FromGrandparent", id: 10, templateId: 1, value: "gp"));
|
||||||
|
|
||||||
|
var b = new Template("B") { Id = 2, ParentTemplateId = 1 };
|
||||||
|
b.Attributes.Add(Attr("FromParent", id: 20, templateId: 2, value: "p"));
|
||||||
|
|
||||||
|
var c = new Template("C") { Id = 3, ParentTemplateId = 2 };
|
||||||
|
c.Attributes.Add(Attr("FromChild", id: 30, templateId: 3, value: "c"));
|
||||||
|
|
||||||
|
var resolved = TemplateInheritanceResolver.Resolve(3, new[] { a, b, c });
|
||||||
|
|
||||||
|
Assert.Equal(3, resolved.Attributes.Count);
|
||||||
|
|
||||||
|
var fromGp = Assert.Single(resolved.Attributes, m => m.Name == "FromGrandparent");
|
||||||
|
Assert.True(fromGp.IsInherited);
|
||||||
|
Assert.Equal(1, fromGp.OriginTemplateId);
|
||||||
|
Assert.Equal("A", fromGp.OriginTemplateName);
|
||||||
|
Assert.Equal("gp", fromGp.EffectiveValue);
|
||||||
|
|
||||||
|
var fromParent = Assert.Single(resolved.Attributes, m => m.Name == "FromParent");
|
||||||
|
Assert.True(fromParent.IsInherited);
|
||||||
|
Assert.Equal("B", fromParent.OriginTemplateName);
|
||||||
|
|
||||||
|
var fromChild = Assert.Single(resolved.Attributes, m => m.Name == "FromChild");
|
||||||
|
Assert.False(fromChild.IsInherited);
|
||||||
|
Assert.Equal(3, fromChild.OriginTemplateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── (b) a base member ADDED to A after C was created appears in C's resolved set ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_BaseMemberAddedAfterDerivedCreated_AppearsInResolvedSet()
|
||||||
|
{
|
||||||
|
// C was created carrying a single inherited placeholder for "Original".
|
||||||
|
// "AddedLater" was then added to the grandparent A; it has NO stored row
|
||||||
|
// on C, yet must surface in C's freshly-resolved set.
|
||||||
|
var a = new Template("A") { Id = 1 };
|
||||||
|
a.Attributes.Add(Attr("Original", id: 10, templateId: 1, value: "v1"));
|
||||||
|
a.Attributes.Add(Attr("AddedLater", id: 11, templateId: 1, value: "new"));
|
||||||
|
|
||||||
|
var b = new Template("B") { Id = 2, ParentTemplateId = 1 };
|
||||||
|
|
||||||
|
var c = new Template("C") { Id = 3, ParentTemplateId = 2 };
|
||||||
|
// Stale placeholder for the one member C knew about at creation time.
|
||||||
|
c.Attributes.Add(Attr("Original", id: 30, templateId: 3, value: "v1", isInherited: true));
|
||||||
|
|
||||||
|
var resolved = TemplateInheritanceResolver.Resolve(3, new[] { a, b, c });
|
||||||
|
|
||||||
|
Assert.Contains(resolved.Attributes, m => m.Name == "AddedLater" && m.EffectiveValue == "new" && m.IsInherited);
|
||||||
|
Assert.Contains(resolved.Attributes, m => m.Name == "Original");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── (c) a locked member is flagged ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_LockedAndBaseLockedMembers_Flagged()
|
||||||
|
{
|
||||||
|
var baseT = new Template("Base") { Id = 1 };
|
||||||
|
baseT.Attributes.Add(Attr("Locked", id: 10, templateId: 1, value: "x", isLocked: true));
|
||||||
|
baseT.Attributes.Add(Attr("BaseLocked", id: 11, templateId: 1, value: "y", lockedInDerived: true));
|
||||||
|
|
||||||
|
var derived = new Template("Derived") { Id = 2, ParentTemplateId = 1 };
|
||||||
|
|
||||||
|
var resolved = TemplateInheritanceResolver.Resolve(2, new[] { baseT, derived });
|
||||||
|
|
||||||
|
var locked = Assert.Single(resolved.Attributes, m => m.Name == "Locked");
|
||||||
|
Assert.True(locked.IsLocked);
|
||||||
|
|
||||||
|
var baseLocked = Assert.Single(resolved.Attributes, m => m.Name == "BaseLocked");
|
||||||
|
Assert.True(baseLocked.IsBaseLocked);
|
||||||
|
Assert.True(baseLocked.IsInherited);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── (d) own override wins over inherited ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_OwnOverride_WinsOverInherited()
|
||||||
|
{
|
||||||
|
var baseT = new Template("Base") { Id = 1 };
|
||||||
|
baseT.Attributes.Add(Attr("Speed", id: 10, templateId: 1, value: "100"));
|
||||||
|
|
||||||
|
var derived = new Template("Derived") { Id = 2, ParentTemplateId = 1 };
|
||||||
|
// Explicit override (IsInherited=false) — must win, value 200, origin = derived.
|
||||||
|
derived.Attributes.Add(Attr("Speed", id: 20, templateId: 2, value: "200", isInherited: false));
|
||||||
|
|
||||||
|
var resolved = TemplateInheritanceResolver.Resolve(2, new[] { baseT, derived });
|
||||||
|
|
||||||
|
var speed = Assert.Single(resolved.Attributes, m => m.Name == "Speed");
|
||||||
|
Assert.Equal("200", speed.EffectiveValue);
|
||||||
|
Assert.Equal(2, speed.OriginTemplateId);
|
||||||
|
Assert.False(speed.IsInherited);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_InheritedPlaceholder_DoesNotShadowLiveBaseValue()
|
||||||
|
{
|
||||||
|
// Derived carries an IsInherited placeholder whose value is stale ("old").
|
||||||
|
// The base value has since changed to "fresh"; the live base value wins.
|
||||||
|
var baseT = new Template("Base") { Id = 1 };
|
||||||
|
baseT.Attributes.Add(Attr("Speed", id: 10, templateId: 1, value: "fresh"));
|
||||||
|
|
||||||
|
var derived = new Template("Derived") { Id = 2, ParentTemplateId = 1 };
|
||||||
|
derived.Attributes.Add(Attr("Speed", id: 20, templateId: 2, value: "old", isInherited: true));
|
||||||
|
|
||||||
|
var resolved = TemplateInheritanceResolver.Resolve(2, new[] { baseT, derived });
|
||||||
|
|
||||||
|
var speed = Assert.Single(resolved.Attributes, m => m.Name == "Speed");
|
||||||
|
Assert.Equal("fresh", speed.EffectiveValue);
|
||||||
|
Assert.True(speed.IsInherited);
|
||||||
|
Assert.Equal(1, speed.OriginTemplateId); // base supplied the live value
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── (e) staleness summary true when stored rows differ; false when in sync ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_StalenessSummary_TrueWhenBaseAddedMemberMissingFromStoredRows()
|
||||||
|
{
|
||||||
|
var baseT = new Template("Base") { Id = 1 };
|
||||||
|
baseT.Attributes.Add(Attr("Original", id: 10, templateId: 1, value: "v"));
|
||||||
|
baseT.Attributes.Add(Attr("AddedLater", id: 11, templateId: 1, value: "new"));
|
||||||
|
|
||||||
|
var derived = new Template("Derived") { Id = 2, ParentTemplateId = 1 };
|
||||||
|
// Only the original placeholder is stored; "AddedLater" is missing → stale.
|
||||||
|
derived.Attributes.Add(Attr("Original", id: 20, templateId: 2, value: "v", 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_FalseWhenStoredRowsInSync()
|
||||||
|
{
|
||||||
|
var baseT = new Template("Base") { Id = 1 };
|
||||||
|
baseT.Attributes.Add(Attr("Speed", id: 10, templateId: 1, value: "100"));
|
||||||
|
|
||||||
|
var derived = new Template("Derived") { Id = 2, ParentTemplateId = 1 };
|
||||||
|
// Stored placeholder matches the live base value exactly.
|
||||||
|
derived.Attributes.Add(Attr("Speed", id: 20, templateId: 2, value: "100", isInherited: true));
|
||||||
|
|
||||||
|
var resolved = TemplateInheritanceResolver.Resolve(2, new[] { baseT, derived });
|
||||||
|
|
||||||
|
Assert.False(resolved.Staleness.IsStale);
|
||||||
|
Assert.Equal(0, resolved.Staleness.DifferingMemberCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_StalenessSummary_TrueWhenStoredInheritedValueIsStale()
|
||||||
|
{
|
||||||
|
var baseT = new Template("Base") { Id = 1 };
|
||||||
|
baseT.Attributes.Add(Attr("Speed", id: 10, templateId: 1, value: "fresh"));
|
||||||
|
|
||||||
|
var derived = new Template("Derived") { Id = 2, ParentTemplateId = 1 };
|
||||||
|
derived.Attributes.Add(Attr("Speed", id: 20, templateId: 2, value: "old", isInherited: true));
|
||||||
|
|
||||||
|
var resolved = TemplateInheritanceResolver.Resolve(2, new[] { baseT, derived });
|
||||||
|
|
||||||
|
Assert.True(resolved.Staleness.IsStale);
|
||||||
|
Assert.Equal(1, resolved.Staleness.DifferingMemberCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── (f) a composition-derived template resolves sanely ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_CompositionDerivedTemplate_ResolvesOwnAndInheritedMembers()
|
||||||
|
{
|
||||||
|
// IsDerived templates back a composition slot; they still inherit from a
|
||||||
|
// base via ParentTemplateId. The resolver must handle them like any
|
||||||
|
// other derived template (inheritance only — composition is the
|
||||||
|
// flattener's concern).
|
||||||
|
var baseT = new Template("ComposedBase") { Id = 1 };
|
||||||
|
baseT.Attributes.Add(Attr("Pressure", id: 10, templateId: 1, value: "10"));
|
||||||
|
|
||||||
|
var derived = new Template("Slot") { Id = 2, ParentTemplateId = 1, IsDerived = true, OwnerCompositionId = 99 };
|
||||||
|
derived.Attributes.Add(Attr("LocalTweak", id: 20, templateId: 2, value: "t"));
|
||||||
|
|
||||||
|
var resolved = TemplateInheritanceResolver.Resolve(2, new[] { baseT, derived });
|
||||||
|
|
||||||
|
Assert.Equal(2, resolved.TemplateId);
|
||||||
|
Assert.Contains(resolved.Attributes, m => m.Name == "Pressure" && m.IsInherited);
|
||||||
|
Assert.Contains(resolved.Attributes, m => m.Name == "LocalTweak" && !m.IsInherited);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_UnknownTemplate_ReturnsEmptySet()
|
||||||
|
{
|
||||||
|
var resolved = TemplateInheritanceResolver.Resolve(999, Array.Empty<Template>());
|
||||||
|
Assert.Empty(resolved.Attributes);
|
||||||
|
Assert.False(resolved.Staleness.IsStale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cross-member-type coverage: alarms, scripts, native alarm sources ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_AllMemberTypes_TransitivelyInherited()
|
||||||
|
{
|
||||||
|
var a = new Template("A") { Id = 1 };
|
||||||
|
a.Attributes.Add(Attr("Attr", id: 10, templateId: 1, value: "v"));
|
||||||
|
a.Alarms.Add(new TemplateAlarm("Alarm") { Id = 11, TemplateId = 1, PriorityLevel = 5, TriggerType = AlarmTriggerType.ValueMatch });
|
||||||
|
a.Scripts.Add(new TemplateScript("Script", "code") { Id = 12, TemplateId = 1 });
|
||||||
|
a.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Native") { Id = 13, TemplateId = 1, ConnectionName = "conn", SourceReference = "ns=2;s=Alarm" });
|
||||||
|
|
||||||
|
var b = new Template("B") { Id = 2, ParentTemplateId = 1 };
|
||||||
|
var c = new Template("C") { Id = 3, ParentTemplateId = 2 };
|
||||||
|
|
||||||
|
var resolved = TemplateInheritanceResolver.Resolve(3, new[] { a, b, c });
|
||||||
|
|
||||||
|
Assert.Contains(resolved.Attributes, m => m.Name == "Attr" && m.IsInherited && m.OriginTemplateName == "A");
|
||||||
|
Assert.Contains(resolved.Alarms, m => m.Name == "Alarm" && m.IsInherited && m.OriginTemplateName == "A");
|
||||||
|
Assert.Contains(resolved.Scripts, m => m.Name == "Script" && m.IsInherited && m.OriginTemplateName == "A");
|
||||||
|
Assert.Contains(resolved.NativeAlarmSources, m => m.Name == "Native" && m.IsInherited && m.OriginTemplateName == "A");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cross-check: the resolved inherited values MUST equal the flattener's
|
||||||
|
// output for a no-override instance over the same chain. This is the
|
||||||
|
// guarantee that the editor preview agrees with deploy. ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_AgreesWithFlatteningService_ForNoOverrideInstance()
|
||||||
|
{
|
||||||
|
var a = new Template("A") { Id = 1 };
|
||||||
|
a.Attributes.Add(Attr("Speed", id: 10, templateId: 1, value: "100"));
|
||||||
|
a.Attributes.Add(Attr("BaseOnly", id: 11, templateId: 1, value: "b"));
|
||||||
|
|
||||||
|
var b = new Template("B") { Id = 2, ParentTemplateId = 1 };
|
||||||
|
// Override Speed; inherited placeholder for BaseOnly (stale value to prove live base wins).
|
||||||
|
b.Attributes.Add(Attr("Speed", id: 20, templateId: 2, value: "200", isInherited: false));
|
||||||
|
b.Attributes.Add(Attr("BaseOnly", id: 21, templateId: 2, value: "stale", isInherited: true));
|
||||||
|
|
||||||
|
var c = new Template("C") { Id = 3, ParentTemplateId = 2 };
|
||||||
|
c.Attributes.Add(Attr("ChildOnly", id: 30, templateId: 3, value: "c"));
|
||||||
|
|
||||||
|
var all = new[] { a, b, c };
|
||||||
|
|
||||||
|
// Resolver view.
|
||||||
|
var resolved = TemplateInheritanceResolver.Resolve(3, all);
|
||||||
|
|
||||||
|
// Flattener view: most-derived-first chain, no overrides, no compositions.
|
||||||
|
var instance = new Instance("inst") { Id = 1, TemplateId = 3, SiteId = 1 };
|
||||||
|
var chain = new List<Template> { c, b, a }; // 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);
|
||||||
|
|
||||||
|
// Every flattened attribute value must equal the resolver's effective value.
|
||||||
|
foreach (var fa in flat.Value.Attributes)
|
||||||
|
{
|
||||||
|
var r = Assert.Single(resolved.Attributes, m => m.Name == fa.CanonicalName);
|
||||||
|
Assert.Equal(fa.Value, r.EffectiveValue);
|
||||||
|
}
|
||||||
|
Assert.Equal(flat.Value.Attributes.Count, resolved.Attributes.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user