feat(templateengine+centralui): resolve follow-ups #1/#2 — inherited-member propagation & resync
Derived templates store IsInherited placeholder rows mirroring inherited members, but a base member added/changed/removed AFTER a child was derived never reached the child — leaving the editor's editable tabs incomplete (#1) and stored rows drifted from the resolved set (#2). Fix (one order-independent reconcile, two entry points): - Auto-propagation: every attribute/alarm/script add/update/delete now reconciles the template's derived subtree (TemplateService.ReconcileDescendantsAsync), hooked into all member-mutating paths incl. native-alarm-source CRUD in the ManagementActor. - Resync: ResyncInheritedMembersAsync repairs a template + its subtree on demand — materialize missing placeholders, re-sync drifted ones, remove orphans, across attributes/alarms/scripts/native sources. Exposed as management ResyncInheritedMembersCommand (Designer-gated, audited) → CLI `template resync-members` → a Resync button on the editor's staleness banner. Reconcile drives off TemplateInheritanceResolver (same precedence + HiLo merge as deploy), only ever touches IsInherited placeholders (never an authored override), and matches the staleness comparison keys so the banner clears. BuildDerivedTemplate now also materializes native-source placeholders at compose time (previously omitted → any inherited native source was perpetually stale). Tests: +8 TemplateServiceTests (materialize / drift-update / orphan-remove / override-untouched / base-cascade / multi-type / direct-propagate / end-to-end add) + 1 ManagementService test fix (native-source add resolves TemplateService). Affected suites green: TemplateEngine 446, ManagementService 230, CentralUI 866, CLI 333, Transport 127, ConfigurationDatabase 307; full solution builds 0/0. Docs: Component-TemplateEngine.md "Inherited-Member Propagation & Resync"; CLI README `template resync-members`; known-issues tracker #1/#2 resolved.
This commit is contained in:
@@ -3,14 +3,17 @@
|
|||||||
**Status:** PARTIALLY RESOLVED · **Found:** 2026-06-24 · **Context:** live ops session on `wonder-app-vd03` (CvdReactor / Z28061 / Z28061Sim) — renaming the template, adding the LeakTest module, and adding MoveInType to the MESReceiver children.
|
**Status:** PARTIALLY RESOLVED · **Found:** 2026-06-24 · **Context:** live ops session on `wonder-app-vd03` (CvdReactor / Z28061 / Z28061Sim) — renaming the template, adding the LeakTest module, and adding MoveInType to the MESReceiver children.
|
||||||
**Components:** Central UI (#9), Template Engine (#1), CLI (#19), Configuration Database (#17)
|
**Components:** Central UI (#9), Template Engine (#1), CLI (#19), Configuration Database (#17)
|
||||||
|
|
||||||
**Resolved:** #3 (collision detector) and #7 (sandbox compile surface) fixed on branch `fix/followups-3-7` (2026-06-24). Open: #1, #2, #4, #5, #6, #8.
|
**Resolved:** #3 (collision detector) and #7 (sandbox compile surface) on branch `fix/followups-3-7`; #1 + #2 (inherited-member propagation & resync) on branch `fix/followups-1-2` (2026-06-24). Open: #4, #5, #6, #8.
|
||||||
|
|
||||||
Issues are listed worst-first. Severities are author estimates. None caused data loss; the runtime/flattened config and deployed instances are correct.
|
Issues are listed worst-first. Severities are author estimates. None caused data loss; the runtime/flattened config and deployed instances are correct.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Template editor omits inherited-but-unmaterialized base attributes (user-reported)
|
## 1. Template editor omits inherited-but-unmaterialized base attributes (user-reported)
|
||||||
**Severity:** Medium · **Components:** Central UI (#9), Template Engine (#1)
|
**Severity:** Medium · **Components:** Central UI (#9), Template Engine (#1) · **✅ RESOLVED 2026-06-24 (branch `fix/followups-1-2`)**
|
||||||
|
|
||||||
|
**Fix:** shared root cause with #2 — see #2's fix. Once a template's inherited rows are materialized (auto-propagation going forward, or the Resync action for already-stale templates), the editor's editable Attributes/Alarms/Scripts tabs list them. The "base changed" banner is now actionable: it carries a **Resync inherited members** button (`TemplateEdit.razor`) that calls `TemplateService.ResyncInheritedMembersAsync` and reloads. The read-only "Effective inherited set" preview is retained.
|
||||||
|
|
||||||
|
|
||||||
**Symptom:** On `/design/templates` → `LeftMESReceiver`, the **Attributes** tab does not list `MoveInType`. Same for `RightMESReceiver`. (Also missing from the list: all `MoveOut*` and `ScanStateCmd`.)
|
**Symptom:** On `/design/templates` → `LeftMESReceiver`, the **Attributes** tab does not list `MoveInType`. Same for `RightMESReceiver`. (Also missing from the list: all `MoveOut*` and `ScanStateCmd`.)
|
||||||
|
|
||||||
@@ -23,7 +26,10 @@ Issues are listed worst-first. Severities are author estimates. None caused data
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 2. Derived templates carry incomplete/stale `IsInherited` row sets
|
## 2. Derived templates carry incomplete/stale `IsInherited` row sets
|
||||||
**Severity:** Medium · **Components:** Template Engine (#1), Configuration Database (#17)
|
**Severity:** Medium · **Components:** Template Engine (#1), Configuration Database (#17) · **✅ RESOLVED 2026-06-24 (branch `fix/followups-1-2`)**
|
||||||
|
|
||||||
|
**Fix:** root-cause fix — derived templates' stored inherited rows are now kept in sync two ways. (1) **Auto-propagation:** adding/updating/removing a member on a template now reconciles its entire derived subtree (`TemplateService.ReconcileDescendantsAsync`, called from every member-mutating path incl. native-alarm-source CRUD in the ManagementActor). (2) **Resync:** `ResyncInheritedMembersAsync` (CLI `template resync-members`, management `ResyncInheritedMembersCommand`, Designer-gated, audited; UI banner button) repairs a template + its subtree on demand — materializing missing placeholders, re-syncing drifted ones, removing orphans, across attributes/alarms/scripts/native sources. `BuildDerivedTemplate` also now materializes native-source placeholders at compose time (previously omitted, which made any inherited native source perpetually stale). Authored overrides are never touched. Covered by `TemplateServiceTests` (materialize / drift-update / orphan-remove / override-untouched / base-cascade / multi-type / propagation / end-to-end add). Documented in `Component-TemplateEngine.md` → "Inherited-Member Propagation & Resync".
|
||||||
|
|
||||||
|
|
||||||
**Symptom:** `LeftMESReceiver`/`RightMESReceiver` (parent=3) have 12 stored attribute rows vs the base's 26. By contrast `LeftReactorSide`/`RightReactorSide` (parent=7) mirror the full 61. So derived row-sets are inconsistent.
|
**Symptom:** `LeftMESReceiver`/`RightMESReceiver` (parent=3) have 12 stored attribute rows vs the base's 26. By contrast `LeftReactorSide`/`RightReactorSide` (parent=7) mirror the full 61. So derived row-sets are inconsistent.
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,15 @@ The resolved result carries, per member:
|
|||||||
|
|
||||||
The resolver is consumed only by the Central UI `TemplateEdit` page. It is not part of the flattening pipeline and is not called during deployment.
|
The resolver is consumed only by the Central UI `TemplateEdit` page. It is not part of the flattening pipeline and is not called during deployment.
|
||||||
|
|
||||||
|
## Inherited-Member Propagation & Resync
|
||||||
|
|
||||||
|
A derived template stores `IsInherited` **placeholder rows** mirroring every member it inherits (attributes, alarms, scripts, native alarm sources). These placeholders are what the editor's editable member tabs render and what the staleness summary above compares against. They are materialized when the derived template is created (compose / inherit), and kept in sync by two mechanisms so the stored rows never drift incomplete:
|
||||||
|
|
||||||
|
- **Auto-propagation.** Whenever a member is **added, updated, or removed** on a template, the change is propagated to that template's entire derived subtree: a missing inherited placeholder is materialized, a drifted one is re-synced to the live effective value, and an orphaned one (its base member removed) is deleted. This runs automatically as part of the member-mutating commands, so children stay complete going forward.
|
||||||
|
- **Resync (operator repair).** `ResyncInheritedMembersAsync` (CLI `template resync-members`, and a **Resync** button on the editor's "base changed" banner) reconciles a template **and its subtree** on demand — repairing templates that drifted before auto-propagation existed (e.g. base members added after a child was derived). Resyncing a base repairs every derivation; resyncing a leaf repairs just it.
|
||||||
|
|
||||||
|
Both delegate to one **order-independent reconcile** that compares a template's stored inherited rows against the inheritance resolver's effective set (the same precedence + HiLo merge the editor preview and deploy use) and only ever touches `IsInherited` placeholder rows — never an authored override (`IsInherited == false`). Because the resolver ignores placeholder rows when picking winners, reconciling one template never changes what another resolves, so the operation needs no particular ordering. The effective value mirrored into each placeholder matches the staleness comparison key per member type, so after a reconcile the "base changed" banner clears. Reconcile is best-effort housekeeping for the *stored authoring rows*: a deploy always re-resolves the chain fresh regardless, so a not-yet-resynced template still deploys correctly.
|
||||||
|
|
||||||
## Diff Calculation
|
## Diff Calculation
|
||||||
|
|
||||||
The Template Engine can compare:
|
The Template Engine can compare:
|
||||||
|
|||||||
@@ -29,10 +29,29 @@ public static class TemplateCommands
|
|||||||
command.Add(BuildNativeAlarmSource(urlOption, formatOption, usernameOption, passwordOption));
|
command.Add(BuildNativeAlarmSource(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildScript(urlOption, formatOption, usernameOption, passwordOption));
|
command.Add(BuildScript(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildComposition(urlOption, formatOption, usernameOption, passwordOption));
|
command.Add(BuildComposition(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
|
command.Add(BuildResyncMembers(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
|
|
||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Command BuildResyncMembers(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||||
|
{
|
||||||
|
var idOption = new Option<int>("--id") { Description = "Template ID (its derived subtree is included)", Required = true };
|
||||||
|
var cmd = new Command("resync-members")
|
||||||
|
{
|
||||||
|
Description = "Resync inherited members onto a template and its derived subtree " +
|
||||||
|
"(materialize missing, re-sync drifted, remove orphaned)"
|
||||||
|
};
|
||||||
|
cmd.Add(idOption);
|
||||||
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
|
{
|
||||||
|
var id = result.GetValue(idOption);
|
||||||
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
|
result, urlOption, formatOption, usernameOption, passwordOption, new ResyncInheritedMembersCommand(id));
|
||||||
|
});
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||||
{
|
{
|
||||||
var cmd = new Command("list") { Description = "List all templates" };
|
var cmd = new Command("list") { Description = "List all templates" };
|
||||||
|
|||||||
@@ -403,6 +403,18 @@ scadabridge --url <url> template composition delete --id <int>
|
|||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
| `--id` | yes | Composition ID to remove |
|
| `--id` | yes | Composition ID to remove |
|
||||||
|
|
||||||
|
#### `template resync-members`
|
||||||
|
|
||||||
|
Reconcile a template's stored *inherited* member rows (and those of its whole derived subtree) with the resolved effective inherited set: materialize missing inherited placeholders (e.g. base members added after a derived template was created), re-sync drifted ones to the live base value, and remove orphaned ones. After a resync the template editor's member tabs are complete and the "Base template changed" banner clears. Authored overrides are never touched. Base member changes auto-propagate going forward; this command repairs templates that drifted before that was in place. Returns the per-subtree change counts (templates changed, members added/updated/removed).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scadabridge --url <url> template resync-members --id <int>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Required | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `--id` | yes | Template ID (its derived subtree is included) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `instance` — Manage instances
|
### `instance` — Manage instances
|
||||||
|
|||||||
@@ -288,18 +288,29 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* M9-T26b: read-only base-changed banner. Informational only — surfaced
|
@* Base-changed banner (follow-up #1/#2). Surfaced when the freshly-resolved
|
||||||
when the freshly-resolved inherited set differs from this template's
|
inherited set differs from this template's stored copy (a base member
|
||||||
stored copy (a multi-level inherited member, or a base member added
|
added/changed/removed after this template was created, or a multi-level
|
||||||
after this template was created). No action button: a deploy already
|
inherited member). The Resync action materializes the missing inherited
|
||||||
resolves fresh, so this is purely an authoring heads-up. *@
|
rows, re-syncs drifted ones, and removes orphans on this template and its
|
||||||
|
derived subtree — so the editable tabs below become complete and the
|
||||||
|
banner clears. (A deploy already resolves fresh regardless.) *@
|
||||||
@if (_resolved?.Staleness.IsStale == true)
|
@if (_resolved?.Staleness.IsStale == true)
|
||||||
{
|
{
|
||||||
<div class="alert alert-info py-2 mb-3" role="status">
|
<div class="alert alert-info d-flex align-items-center justify-content-between py-2 mb-3" role="status">
|
||||||
|
<div>
|
||||||
<i class="bi bi-info-circle me-1"></i>
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
<strong>Base template changed</strong> —
|
<strong>Base template changed</strong> —
|
||||||
@_resolved.Staleness.DifferingMemberCount inherited member(s) differ from this template's stored copy.
|
@_resolved.Staleness.DifferingMemberCount inherited member(s) differ from this template's stored copy.
|
||||||
The effective set above reflects the live base; a deploy resolves fresh.
|
Resync to bring this template's stored members in line with the base.
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-primary ms-3 text-nowrap" @onclick="ResyncInheritedMembers" disabled="@_resyncing">
|
||||||
|
@if (_resyncing)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
|
||||||
|
}
|
||||||
|
Resync inherited members
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -944,6 +955,38 @@
|
|||||||
else _toast.ShowError(result.Error);
|
else _toast.ShowError(result.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool _resyncing;
|
||||||
|
|
||||||
|
// Follow-up #1/#2: materialize/refresh this template's (and its subtree's)
|
||||||
|
// inherited member rows so the editable tabs are complete and the
|
||||||
|
// base-changed banner clears. In-process TemplateService call (the page is
|
||||||
|
// already RequireDesign-gated, matching the management command's role gate).
|
||||||
|
private async Task ResyncInheritedMembers()
|
||||||
|
{
|
||||||
|
if (_resyncing) return;
|
||||||
|
_resyncing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await GetCurrentUserAsync();
|
||||||
|
var result = await TemplateService.ResyncInheritedMembersAsync(Id, user);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
var r = result.Value;
|
||||||
|
_toast.ShowSuccess(
|
||||||
|
$"Resynced inherited members — {r.MembersAdded} added, {r.MembersUpdated} updated, {r.MembersRemoved} removed across {r.TemplatesChanged} template(s).");
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_toast.ShowError(result.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_resyncing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Alarms Tab ----
|
// ---- Alarms Tab ----
|
||||||
private RenderFragment RenderAlarmsTab() => __builder =>
|
private RenderFragment RenderAlarmsTab() => __builder =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -105,6 +105,23 @@ public sealed record ResolvedTemplateMemberInfo
|
|||||||
public string? EffectiveTriggerConfiguration { get; init; }
|
public string? EffectiveTriggerConfiguration { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of a <see cref="ResyncInheritedMembersCommand"/>: how many derived
|
||||||
|
/// templates were brought into sync and how many inherited member rows were
|
||||||
|
/// added / updated / removed across the reconciled subtree.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ResyncInheritedMembersResult
|
||||||
|
{
|
||||||
|
/// <summary>Number of templates in the subtree that had at least one inherited-row change.</summary>
|
||||||
|
public int TemplatesChanged { get; init; }
|
||||||
|
/// <summary>Inherited placeholder rows materialized (members missing from the stored set).</summary>
|
||||||
|
public int MembersAdded { get; init; }
|
||||||
|
/// <summary>Inherited placeholder rows re-synced to the live effective value.</summary>
|
||||||
|
public int MembersUpdated { get; init; }
|
||||||
|
/// <summary>Orphaned inherited placeholder rows removed (the base no longer defines them).</summary>
|
||||||
|
public int MembersRemoved { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Staleness summary comparing a template's STORED member rows against the
|
/// Staleness summary comparing a template's STORED member rows against the
|
||||||
/// freshly-resolved inherited member set.
|
/// freshly-resolved inherited member set.
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ public record ValidateTemplateCommand(int TemplateId);
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public record GetResolvedTemplateMembersCommand(int TemplateId);
|
public record GetResolvedTemplateMembersCommand(int TemplateId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reconciles a template's STORED inherited member rows with the resolved
|
||||||
|
/// effective inherited set, for the template and its whole derived subtree:
|
||||||
|
/// materializes missing inherited placeholders (e.g. base members added after a
|
||||||
|
/// derived template was created), re-syncs drifted placeholders to the live
|
||||||
|
/// effective value, and removes orphaned inherited rows. After a resync the
|
||||||
|
/// editor's editable member tabs are complete and the "base changed" staleness
|
||||||
|
/// banner clears. Designer-gated, audited. Response:
|
||||||
|
/// <see cref="ResyncInheritedMembersResult"/>.
|
||||||
|
/// </summary>
|
||||||
|
public record ResyncInheritedMembersCommand(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);
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ public class ManagementActor : ReceiveActor
|
|||||||
or AddTemplateNativeAlarmSourceCommand or UpdateTemplateNativeAlarmSourceCommand or DeleteTemplateNativeAlarmSourceCommand
|
or AddTemplateNativeAlarmSourceCommand or UpdateTemplateNativeAlarmSourceCommand or DeleteTemplateNativeAlarmSourceCommand
|
||||||
or AddTemplateScriptCommand or UpdateTemplateScriptCommand or DeleteTemplateScriptCommand
|
or AddTemplateScriptCommand or UpdateTemplateScriptCommand or DeleteTemplateScriptCommand
|
||||||
or AddTemplateCompositionCommand or DeleteTemplateCompositionCommand
|
or AddTemplateCompositionCommand or DeleteTemplateCompositionCommand
|
||||||
|
or ResyncInheritedMembersCommand
|
||||||
or CreateSharedScriptCommand or UpdateSharedScriptCommand or DeleteSharedScriptCommand
|
or CreateSharedScriptCommand or UpdateSharedScriptCommand or DeleteSharedScriptCommand
|
||||||
or CreateSharedSchemaCommand or UpdateSharedSchemaCommand or DeleteSharedSchemaCommand
|
or CreateSharedSchemaCommand or UpdateSharedSchemaCommand or DeleteSharedSchemaCommand
|
||||||
or CreateDatabaseConnectionDefCommand or UpdateDatabaseConnectionDefCommand or DeleteDatabaseConnectionDefCommand
|
or CreateDatabaseConnectionDefCommand or UpdateDatabaseConnectionDefCommand or DeleteDatabaseConnectionDefCommand
|
||||||
@@ -253,6 +254,7 @@ public class ManagementActor : ReceiveActor
|
|||||||
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),
|
GetResolvedTemplateMembersCommand cmd => await HandleGetResolvedTemplateMembers(sp, cmd),
|
||||||
|
ResyncInheritedMembersCommand cmd => await HandleResyncInheritedMembers(sp, cmd, user.Username),
|
||||||
|
|
||||||
// Template members
|
// Template members
|
||||||
AddTemplateAttributeCommand cmd => await HandleAddAttribute(sp, cmd, user.Username),
|
AddTemplateAttributeCommand cmd => await HandleAddAttribute(sp, cmd, user.Username),
|
||||||
@@ -261,9 +263,9 @@ public class ManagementActor : ReceiveActor
|
|||||||
AddTemplateAlarmCommand cmd => await HandleAddAlarm(sp, cmd, user.Username),
|
AddTemplateAlarmCommand cmd => await HandleAddAlarm(sp, cmd, user.Username),
|
||||||
UpdateTemplateAlarmCommand cmd => await HandleUpdateAlarm(sp, cmd, user.Username),
|
UpdateTemplateAlarmCommand cmd => await HandleUpdateAlarm(sp, cmd, user.Username),
|
||||||
DeleteTemplateAlarmCommand cmd => await HandleDeleteAlarm(sp, cmd, user.Username),
|
DeleteTemplateAlarmCommand cmd => await HandleDeleteAlarm(sp, cmd, user.Username),
|
||||||
AddTemplateNativeAlarmSourceCommand cmd => await HandleAddNativeAlarmSource(sp, cmd),
|
AddTemplateNativeAlarmSourceCommand cmd => await HandleAddNativeAlarmSource(sp, cmd, user.Username),
|
||||||
UpdateTemplateNativeAlarmSourceCommand cmd => await HandleUpdateNativeAlarmSource(sp, cmd),
|
UpdateTemplateNativeAlarmSourceCommand cmd => await HandleUpdateNativeAlarmSource(sp, cmd, user.Username),
|
||||||
DeleteTemplateNativeAlarmSourceCommand cmd => await HandleDeleteNativeAlarmSource(sp, cmd),
|
DeleteTemplateNativeAlarmSourceCommand cmd => await HandleDeleteNativeAlarmSource(sp, cmd, user.Username),
|
||||||
ListTemplateNativeAlarmSourcesCommand cmd => await HandleListNativeAlarmSources(sp, cmd),
|
ListTemplateNativeAlarmSourcesCommand cmd => await HandleListNativeAlarmSources(sp, cmd),
|
||||||
AddTemplateScriptCommand cmd => await HandleAddScript(sp, cmd, user.Username),
|
AddTemplateScriptCommand cmd => await HandleAddScript(sp, cmd, user.Username),
|
||||||
UpdateTemplateScriptCommand cmd => await HandleUpdateScript(sp, cmd, user.Username),
|
UpdateTemplateScriptCommand cmd => await HandleUpdateScript(sp, cmd, user.Username),
|
||||||
@@ -645,6 +647,20 @@ public class ManagementActor : ReceiveActor
|
|||||||
return TemplateInheritanceResolver.Resolve(cmd.TemplateId, allTemplates);
|
return TemplateInheritanceResolver.Resolve(cmd.TemplateId, allTemplates);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resync inherited members (follow-up #1/#2): reconciles a template's stored
|
||||||
|
/// inherited rows (and its derived subtree's) with the resolved effective set —
|
||||||
|
/// materializing missing placeholders, re-syncing drifted ones, and removing
|
||||||
|
/// orphans — so the editor's editable tabs are complete and the staleness
|
||||||
|
/// banner clears. Designer-gated; the service owns the audit row.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<object?> HandleResyncInheritedMembers(IServiceProvider sp, ResyncInheritedMembersCommand cmd, string user)
|
||||||
|
{
|
||||||
|
var svc = sp.GetRequiredService<TemplateService>();
|
||||||
|
var result = await svc.ResyncInheritedMembersAsync(cmd.TemplateId, user);
|
||||||
|
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// Template folder handlers
|
// Template folder handlers
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -2226,7 +2242,7 @@ public class ManagementActor : ReceiveActor
|
|||||||
|
|
||||||
// ── Native alarm source bindings (read-only mirror; repository-direct CRUD) ──
|
// ── Native alarm source bindings (read-only mirror; repository-direct CRUD) ──
|
||||||
|
|
||||||
private static async Task<object?> HandleAddNativeAlarmSource(IServiceProvider sp, AddTemplateNativeAlarmSourceCommand cmd)
|
private static async Task<object?> HandleAddNativeAlarmSource(IServiceProvider sp, AddTemplateNativeAlarmSourceCommand cmd, string user)
|
||||||
{
|
{
|
||||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||||
var source = new TemplateNativeAlarmSource(cmd.Name)
|
var source = new TemplateNativeAlarmSource(cmd.Name)
|
||||||
@@ -2240,10 +2256,13 @@ public class ManagementActor : ReceiveActor
|
|||||||
};
|
};
|
||||||
await repo.AddTemplateNativeAlarmSourceAsync(source);
|
await repo.AddTemplateNativeAlarmSourceAsync(source);
|
||||||
await repo.SaveChangesAsync();
|
await repo.SaveChangesAsync();
|
||||||
|
// Propagate the new source to derived descendants (#1/#2). Native-source
|
||||||
|
// CRUD lives here (not TemplateService), so call the propagation directly.
|
||||||
|
await sp.GetRequiredService<TemplateService>().ReconcileDescendantsAsync(cmd.TemplateId, user);
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<object?> HandleUpdateNativeAlarmSource(IServiceProvider sp, UpdateTemplateNativeAlarmSourceCommand cmd)
|
private static async Task<object?> HandleUpdateNativeAlarmSource(IServiceProvider sp, UpdateTemplateNativeAlarmSourceCommand cmd, string user)
|
||||||
{
|
{
|
||||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||||
var source = await repo.GetTemplateNativeAlarmSourceByIdAsync(cmd.NativeAlarmSourceId)
|
var source = await repo.GetTemplateNativeAlarmSourceByIdAsync(cmd.NativeAlarmSourceId)
|
||||||
@@ -2256,14 +2275,21 @@ public class ManagementActor : ReceiveActor
|
|||||||
source.IsLocked = cmd.IsLocked;
|
source.IsLocked = cmd.IsLocked;
|
||||||
await repo.UpdateTemplateNativeAlarmSourceAsync(source);
|
await repo.UpdateTemplateNativeAlarmSourceAsync(source);
|
||||||
await repo.SaveChangesAsync();
|
await repo.SaveChangesAsync();
|
||||||
|
// Propagate the changed source to derived descendants (#1/#2).
|
||||||
|
await sp.GetRequiredService<TemplateService>().ReconcileDescendantsAsync(source.TemplateId, user);
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<object?> HandleDeleteNativeAlarmSource(IServiceProvider sp, DeleteTemplateNativeAlarmSourceCommand cmd)
|
private static async Task<object?> HandleDeleteNativeAlarmSource(IServiceProvider sp, DeleteTemplateNativeAlarmSourceCommand cmd, string user)
|
||||||
{
|
{
|
||||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||||
|
// Capture the owning template before delete so descendants can be reconciled.
|
||||||
|
var source = await repo.GetTemplateNativeAlarmSourceByIdAsync(cmd.NativeAlarmSourceId);
|
||||||
await repo.DeleteTemplateNativeAlarmSourceAsync(cmd.NativeAlarmSourceId);
|
await repo.DeleteTemplateNativeAlarmSourceAsync(cmd.NativeAlarmSourceId);
|
||||||
await repo.SaveChangesAsync();
|
await repo.SaveChangesAsync();
|
||||||
|
// Remove the now-orphaned inherited placeholder from derived descendants (#1/#2).
|
||||||
|
if (source != null)
|
||||||
|
await sp.GetRequiredService<TemplateService>().ReconcileDescendantsAsync(source.TemplateId, user);
|
||||||
return cmd.NativeAlarmSourceId;
|
return cmd.NativeAlarmSourceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
@@ -302,6 +303,9 @@ public class TemplateService
|
|||||||
await _auditService.LogAsync(user, "Create", "TemplateAttribute", attribute.Id.ToString(), attribute.Name, attribute, cancellationToken);
|
await _auditService.LogAsync(user, "Create", "TemplateAttribute", attribute.Id.ToString(), attribute.Name, attribute, cancellationToken);
|
||||||
await _repository.SaveChangesAsync(cancellationToken);
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Propagate the new member to derived descendants (#1/#2).
|
||||||
|
await ReconcileDescendantsAsync(templateId, user, cancellationToken);
|
||||||
|
|
||||||
return Result<TemplateAttribute>.Success(attribute);
|
return Result<TemplateAttribute>.Success(attribute);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,6 +400,9 @@ public class TemplateService
|
|||||||
await _auditService.LogAsync(user, "Update", "TemplateAttribute", attributeId.ToString(), existing.Name, existing, cancellationToken);
|
await _auditService.LogAsync(user, "Update", "TemplateAttribute", attributeId.ToString(), existing.Name, existing, cancellationToken);
|
||||||
await _repository.SaveChangesAsync(cancellationToken);
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Propagate the changed effective value to derived descendants (#1/#2).
|
||||||
|
await ReconcileDescendantsAsync(existing.TemplateId, user, cancellationToken);
|
||||||
|
|
||||||
return Result<TemplateAttribute>.Success(existing);
|
return Result<TemplateAttribute>.Success(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,6 +438,9 @@ public class TemplateService
|
|||||||
await _auditService.LogAsync(user, "Delete", "TemplateAttribute", attributeId.ToString(), attribute.Name, null, cancellationToken);
|
await _auditService.LogAsync(user, "Delete", "TemplateAttribute", attributeId.ToString(), attribute.Name, null, cancellationToken);
|
||||||
await _repository.SaveChangesAsync(cancellationToken);
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Remove the now-orphaned inherited placeholder from derived descendants (#1/#2).
|
||||||
|
await ReconcileDescendantsAsync(attribute.TemplateId, user, cancellationToken);
|
||||||
|
|
||||||
return Result<bool>.Success(true);
|
return Result<bool>.Success(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,6 +536,9 @@ public class TemplateService
|
|||||||
await _auditService.LogAsync(user, "Create", "TemplateAlarm", alarm.Id.ToString(), alarm.Name, alarm, cancellationToken);
|
await _auditService.LogAsync(user, "Create", "TemplateAlarm", alarm.Id.ToString(), alarm.Name, alarm, cancellationToken);
|
||||||
await _repository.SaveChangesAsync(cancellationToken);
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Propagate the new member to derived descendants (#1/#2).
|
||||||
|
await ReconcileDescendantsAsync(templateId, user, cancellationToken);
|
||||||
|
|
||||||
return Result<TemplateAlarm>.Success(alarm);
|
return Result<TemplateAlarm>.Success(alarm);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,6 +621,9 @@ public class TemplateService
|
|||||||
await _auditService.LogAsync(user, "Update", "TemplateAlarm", alarmId.ToString(), existing.Name, existing, cancellationToken);
|
await _auditService.LogAsync(user, "Update", "TemplateAlarm", alarmId.ToString(), existing.Name, existing, cancellationToken);
|
||||||
await _repository.SaveChangesAsync(cancellationToken);
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Propagate the changed effective value to derived descendants (#1/#2).
|
||||||
|
await ReconcileDescendantsAsync(existing.TemplateId, user, cancellationToken);
|
||||||
|
|
||||||
return Result<TemplateAlarm>.Success(existing);
|
return Result<TemplateAlarm>.Success(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -642,6 +658,9 @@ public class TemplateService
|
|||||||
await _auditService.LogAsync(user, "Delete", "TemplateAlarm", alarmId.ToString(), alarm.Name, null, cancellationToken);
|
await _auditService.LogAsync(user, "Delete", "TemplateAlarm", alarmId.ToString(), alarm.Name, null, cancellationToken);
|
||||||
await _repository.SaveChangesAsync(cancellationToken);
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Remove the now-orphaned inherited placeholder from derived descendants (#1/#2).
|
||||||
|
await ReconcileDescendantsAsync(alarm.TemplateId, user, cancellationToken);
|
||||||
|
|
||||||
return Result<bool>.Success(true);
|
return Result<bool>.Success(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -687,6 +706,9 @@ public class TemplateService
|
|||||||
await _auditService.LogAsync(user, "Create", "TemplateScript", script.Id.ToString(), script.Name, script, cancellationToken);
|
await _auditService.LogAsync(user, "Create", "TemplateScript", script.Id.ToString(), script.Name, script, cancellationToken);
|
||||||
await _repository.SaveChangesAsync(cancellationToken);
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Propagate the new member to derived descendants (#1/#2).
|
||||||
|
await ReconcileDescendantsAsync(templateId, user, cancellationToken);
|
||||||
|
|
||||||
return Result<TemplateScript>.Success(script);
|
return Result<TemplateScript>.Success(script);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,6 +792,9 @@ public class TemplateService
|
|||||||
await _auditService.LogAsync(user, "Update", "TemplateScript", scriptId.ToString(), existing.Name, existing, cancellationToken);
|
await _auditService.LogAsync(user, "Update", "TemplateScript", scriptId.ToString(), existing.Name, existing, cancellationToken);
|
||||||
await _repository.SaveChangesAsync(cancellationToken);
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Propagate the changed effective value to derived descendants (#1/#2).
|
||||||
|
await ReconcileDescendantsAsync(existing.TemplateId, user, cancellationToken);
|
||||||
|
|
||||||
return Result<TemplateScript>.Success(existing);
|
return Result<TemplateScript>.Success(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -804,6 +829,9 @@ public class TemplateService
|
|||||||
await _auditService.LogAsync(user, "Delete", "TemplateScript", scriptId.ToString(), script.Name, null, cancellationToken);
|
await _auditService.LogAsync(user, "Delete", "TemplateScript", scriptId.ToString(), script.Name, null, cancellationToken);
|
||||||
await _repository.SaveChangesAsync(cancellationToken);
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Remove the now-orphaned inherited placeholder from derived descendants (#1/#2).
|
||||||
|
await ReconcileDescendantsAsync(script.TemplateId, user, cancellationToken);
|
||||||
|
|
||||||
return Result<bool>.Success(true);
|
return Result<bool>.Success(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1090,9 +1118,450 @@ public class TemplateService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Native alarm sources follow the same inherit/override/lock model as the
|
||||||
|
// other member types (TemplateNativeAlarmSource), and the inheritance
|
||||||
|
// resolver + staleness summary count them — so materialize their inherited
|
||||||
|
// placeholders here too, or a freshly-composed derived template reports
|
||||||
|
// stale for every inherited native source (follow-up #1/#2).
|
||||||
|
foreach (var src in baseTemplate.NativeAlarmSources)
|
||||||
|
{
|
||||||
|
derived.NativeAlarmSources.Add(new TemplateNativeAlarmSource(src.Name)
|
||||||
|
{
|
||||||
|
Description = src.Description,
|
||||||
|
ConnectionName = src.ConnectionName,
|
||||||
|
SourceReference = src.SourceReference,
|
||||||
|
ConditionFilter = src.ConditionFilter,
|
||||||
|
IsLocked = src.IsLocked,
|
||||||
|
IsInherited = true,
|
||||||
|
LockedInDerived = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return derived;
|
return derived;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Inherited-member propagation & resync (follow-up #1/#2)
|
||||||
|
// ========================================================================
|
||||||
|
//
|
||||||
|
// A derived template stores IsInherited placeholder rows mirroring the
|
||||||
|
// members it inherits. BuildDerivedTemplate materializes them at compose time,
|
||||||
|
// but a member ADDED / UPDATED / REMOVED on a base AFTER the derivation
|
||||||
|
// existed never reached the children — so derived row-sets drifted incomplete
|
||||||
|
// (the editor's editable tabs missed inherited members; #1) and stored rows
|
||||||
|
// diverged from the resolved set (#2). Two mechanisms keep them in sync:
|
||||||
|
// • ReconcileDescendantsAsync — called automatically after every base member
|
||||||
|
// mutation, propagates the change to the whole derived subtree.
|
||||||
|
// • ResyncInheritedMembersAsync — an explicit operator action (CLI + UI)
|
||||||
|
// that repairs an already-stale template and its subtree.
|
||||||
|
// Both delegate to one order-independent reconcile that only ever touches
|
||||||
|
// IsInherited placeholder rows (never an authored override), which the
|
||||||
|
// inheritance resolver ignores when picking winners — so reconciling one
|
||||||
|
// template never changes what another resolves.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reconciles the stored inherited member rows of a template and its entire
|
||||||
|
/// derived subtree against the resolved effective inherited set: materializes
|
||||||
|
/// missing placeholders, re-syncs drifted ones, and removes orphaned ones.
|
||||||
|
/// This is the operator-facing repair action behind the CLI
|
||||||
|
/// <c>template resync-members</c> command and the editor's "Resync" button.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="templateId">The template to resync (its subtree is included).</param>
|
||||||
|
/// <param name="user">Username for the audit row.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A result carrying the per-subtree change counts.</returns>
|
||||||
|
public async Task<Result<ResyncInheritedMembersResult>> ResyncInheritedMembersAsync(
|
||||||
|
int templateId,
|
||||||
|
string user,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var all = await _repository.GetAllTemplatesAsync(cancellationToken) ?? Array.Empty<Template>();
|
||||||
|
var byId = BuildTemplateLookup(all);
|
||||||
|
if (!byId.TryGetValue(templateId, out var root))
|
||||||
|
return Result<ResyncInheritedMembersResult>.Failure($"Template with ID {templateId} not found.");
|
||||||
|
|
||||||
|
// Reconcile the target itself AND its subtree: resyncing a base repairs
|
||||||
|
// every derivation; resyncing a leaf repairs just it.
|
||||||
|
var targets = new List<int> { templateId };
|
||||||
|
targets.AddRange(GetDescendantIdsBreadthFirst(templateId, all));
|
||||||
|
|
||||||
|
var total = new ReconcileCounts();
|
||||||
|
var changed = 0;
|
||||||
|
foreach (var id in targets)
|
||||||
|
{
|
||||||
|
var counts = await ReconcileInheritedRowsAsync(id, all, byId, cancellationToken);
|
||||||
|
if (counts.Any) changed++;
|
||||||
|
total.Add(counts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total.Any)
|
||||||
|
{
|
||||||
|
await _auditService.LogAsync(
|
||||||
|
user, "Resync", "Template", templateId.ToString(), root.Name,
|
||||||
|
new { templatesChanged = changed, total.Added, total.Updated, total.Removed },
|
||||||
|
cancellationToken);
|
||||||
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result<ResyncInheritedMembersResult>.Success(new ResyncInheritedMembersResult
|
||||||
|
{
|
||||||
|
TemplatesChanged = changed,
|
||||||
|
MembersAdded = total.Added,
|
||||||
|
MembersUpdated = total.Updated,
|
||||||
|
MembersRemoved = total.Removed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Propagates a base member change to every derived descendant by reconciling
|
||||||
|
/// their inherited rows. Called automatically after a base member is added,
|
||||||
|
/// updated, or deleted. The base template itself is NOT reconciled — it is the
|
||||||
|
/// author of the change. A no-op (and a single cheap query) when the template
|
||||||
|
/// has no descendants, which is the common case.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="templateId">The template whose member set changed.</param>
|
||||||
|
/// <param name="user">Username for the audit row.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
public async Task ReconcileDescendantsAsync(
|
||||||
|
int templateId,
|
||||||
|
string user,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var all = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||||
|
var descendants = GetDescendantIdsBreadthFirst(templateId, all);
|
||||||
|
if (descendants.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var byId = BuildTemplateLookup(all);
|
||||||
|
var total = new ReconcileCounts();
|
||||||
|
foreach (var id in descendants)
|
||||||
|
total.Add(await ReconcileInheritedRowsAsync(id, all, byId, cancellationToken));
|
||||||
|
|
||||||
|
if (total.Any)
|
||||||
|
{
|
||||||
|
var name = byId.TryGetValue(templateId, out var t) ? t.Name : templateId.ToString();
|
||||||
|
await _auditService.LogAsync(
|
||||||
|
user, "Resync", "Template", templateId.ToString(), name,
|
||||||
|
new { propagatedFrom = templateId, total.Added, total.Updated, total.Removed },
|
||||||
|
cancellationToken);
|
||||||
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reconciles ONE template's stored IsInherited rows against its resolved
|
||||||
|
/// effective inherited set (the same resolver the editor preview + staleness
|
||||||
|
/// banner use). Adds missing placeholders, re-syncs drifted ones, removes
|
||||||
|
/// orphans. Authored override rows (<c>IsInherited == false</c>) are never
|
||||||
|
/// touched. Repository writes are queued; the caller owns SaveChanges.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<ReconcileCounts> ReconcileInheritedRowsAsync(
|
||||||
|
int templateId,
|
||||||
|
IReadOnlyList<Template> all,
|
||||||
|
Dictionary<int, Template> byId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var counts = new ReconcileCounts();
|
||||||
|
if (!byId.TryGetValue(templateId, out var child) || child.ParentTemplateId == null)
|
||||||
|
return counts; // root templates inherit nothing
|
||||||
|
|
||||||
|
var resolved = TemplateInheritanceResolver.Resolve(templateId, all);
|
||||||
|
|
||||||
|
await ReconcileAttributesAsync(child, resolved.Attributes, byId, counts, cancellationToken);
|
||||||
|
await ReconcileAlarmsAsync(child, resolved.Alarms, byId, counts, cancellationToken);
|
||||||
|
await ReconcileScriptsAsync(child, resolved.Scripts, byId, counts, cancellationToken);
|
||||||
|
await ReconcileNativeSourcesAsync(child, resolved.NativeAlarmSources, byId, counts, cancellationToken);
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReconcileAttributesAsync(
|
||||||
|
Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
|
||||||
|
Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var inherited = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var m in resolved)
|
||||||
|
{
|
||||||
|
if (!m.IsInherited) continue; // own / override rows are authoritative
|
||||||
|
inherited.Add(m.Name);
|
||||||
|
if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
|
||||||
|
var win = origin.Attributes.FirstOrDefault(a => a.Name == m.Name);
|
||||||
|
if (win == null) continue;
|
||||||
|
|
||||||
|
var existing = child.Attributes.FirstOrDefault(a => a.Name == m.Name);
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
await _repository.AddTemplateAttributeAsync(new TemplateAttribute(m.Name)
|
||||||
|
{
|
||||||
|
TemplateId = child.Id,
|
||||||
|
Value = m.EffectiveValue,
|
||||||
|
DataType = win.DataType,
|
||||||
|
ElementDataType = win.ElementDataType,
|
||||||
|
Description = win.Description,
|
||||||
|
DataSourceReference = win.DataSourceReference,
|
||||||
|
IsLocked = win.IsLocked,
|
||||||
|
IsInherited = true,
|
||||||
|
LockedInDerived = false,
|
||||||
|
}, ct);
|
||||||
|
counts.Added++;
|
||||||
|
}
|
||||||
|
else if (existing.IsInherited && (
|
||||||
|
existing.Value != m.EffectiveValue ||
|
||||||
|
existing.DataType != win.DataType ||
|
||||||
|
existing.ElementDataType != win.ElementDataType ||
|
||||||
|
existing.Description != win.Description ||
|
||||||
|
existing.DataSourceReference != win.DataSourceReference ||
|
||||||
|
existing.IsLocked != win.IsLocked))
|
||||||
|
{
|
||||||
|
existing.Value = m.EffectiveValue;
|
||||||
|
existing.DataType = win.DataType;
|
||||||
|
existing.ElementDataType = win.ElementDataType;
|
||||||
|
existing.Description = win.Description;
|
||||||
|
existing.DataSourceReference = win.DataSourceReference;
|
||||||
|
existing.IsLocked = win.IsLocked;
|
||||||
|
await _repository.UpdateTemplateAttributeAsync(existing, ct);
|
||||||
|
counts.Updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var orphan in child.Attributes.Where(a => a.IsInherited && !inherited.Contains(a.Name)).ToList())
|
||||||
|
{
|
||||||
|
await _repository.DeleteTemplateAttributeAsync(orphan.Id, ct);
|
||||||
|
counts.Removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReconcileAlarmsAsync(
|
||||||
|
Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
|
||||||
|
Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var inherited = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var m in resolved)
|
||||||
|
{
|
||||||
|
if (!m.IsInherited) continue;
|
||||||
|
inherited.Add(m.Name);
|
||||||
|
if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
|
||||||
|
var win = origin.Alarms.FirstOrDefault(a => a.Name == m.Name);
|
||||||
|
if (win == null) continue;
|
||||||
|
|
||||||
|
// EffectiveTriggerConfiguration is the per-setpoint MERGED HiLo config
|
||||||
|
// (mirrors the flattener), so the placeholder matches what a deploy and
|
||||||
|
// the staleness comparison produce.
|
||||||
|
var existing = child.Alarms.FirstOrDefault(a => a.Name == m.Name);
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
await _repository.AddTemplateAlarmAsync(new TemplateAlarm(m.Name)
|
||||||
|
{
|
||||||
|
TemplateId = child.Id,
|
||||||
|
Description = win.Description,
|
||||||
|
PriorityLevel = win.PriorityLevel,
|
||||||
|
IsLocked = win.IsLocked,
|
||||||
|
TriggerType = win.TriggerType,
|
||||||
|
TriggerConfiguration = m.EffectiveTriggerConfiguration,
|
||||||
|
OnTriggerScriptId = win.OnTriggerScriptId,
|
||||||
|
IsInherited = true,
|
||||||
|
LockedInDerived = false,
|
||||||
|
}, ct);
|
||||||
|
counts.Added++;
|
||||||
|
}
|
||||||
|
else if (existing.IsInherited && (
|
||||||
|
existing.PriorityLevel != win.PriorityLevel ||
|
||||||
|
existing.TriggerConfiguration != m.EffectiveTriggerConfiguration ||
|
||||||
|
existing.Description != win.Description ||
|
||||||
|
existing.OnTriggerScriptId != win.OnTriggerScriptId ||
|
||||||
|
existing.TriggerType != win.TriggerType ||
|
||||||
|
existing.IsLocked != win.IsLocked))
|
||||||
|
{
|
||||||
|
existing.PriorityLevel = win.PriorityLevel;
|
||||||
|
existing.TriggerConfiguration = m.EffectiveTriggerConfiguration;
|
||||||
|
existing.Description = win.Description;
|
||||||
|
existing.OnTriggerScriptId = win.OnTriggerScriptId;
|
||||||
|
existing.TriggerType = win.TriggerType;
|
||||||
|
existing.IsLocked = win.IsLocked;
|
||||||
|
await _repository.UpdateTemplateAlarmAsync(existing, ct);
|
||||||
|
counts.Updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var orphan in child.Alarms.Where(a => a.IsInherited && !inherited.Contains(a.Name)).ToList())
|
||||||
|
{
|
||||||
|
await _repository.DeleteTemplateAlarmAsync(orphan.Id, ct);
|
||||||
|
counts.Removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReconcileScriptsAsync(
|
||||||
|
Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
|
||||||
|
Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var inherited = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var m in resolved)
|
||||||
|
{
|
||||||
|
if (!m.IsInherited) continue;
|
||||||
|
inherited.Add(m.Name);
|
||||||
|
if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
|
||||||
|
var win = origin.Scripts.FirstOrDefault(s => s.Name == m.Name);
|
||||||
|
if (win == null) continue;
|
||||||
|
|
||||||
|
var existing = child.Scripts.FirstOrDefault(s => s.Name == m.Name);
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
await _repository.AddTemplateScriptAsync(new TemplateScript(m.Name, win.Code)
|
||||||
|
{
|
||||||
|
TemplateId = child.Id,
|
||||||
|
IsLocked = win.IsLocked,
|
||||||
|
TriggerType = win.TriggerType,
|
||||||
|
TriggerConfiguration = win.TriggerConfiguration,
|
||||||
|
ParameterDefinitions = win.ParameterDefinitions,
|
||||||
|
ReturnDefinition = win.ReturnDefinition,
|
||||||
|
MinTimeBetweenRuns = win.MinTimeBetweenRuns,
|
||||||
|
ExecutionTimeoutSeconds = win.ExecutionTimeoutSeconds,
|
||||||
|
IsInherited = true,
|
||||||
|
LockedInDerived = false,
|
||||||
|
}, ct);
|
||||||
|
counts.Added++;
|
||||||
|
}
|
||||||
|
else if (existing.IsInherited && (
|
||||||
|
existing.Code != win.Code ||
|
||||||
|
existing.TriggerType != win.TriggerType ||
|
||||||
|
existing.TriggerConfiguration != win.TriggerConfiguration ||
|
||||||
|
existing.ParameterDefinitions != win.ParameterDefinitions ||
|
||||||
|
existing.ReturnDefinition != win.ReturnDefinition ||
|
||||||
|
existing.MinTimeBetweenRuns != win.MinTimeBetweenRuns ||
|
||||||
|
existing.ExecutionTimeoutSeconds != win.ExecutionTimeoutSeconds ||
|
||||||
|
existing.IsLocked != win.IsLocked))
|
||||||
|
{
|
||||||
|
existing.Code = win.Code;
|
||||||
|
existing.TriggerType = win.TriggerType;
|
||||||
|
existing.TriggerConfiguration = win.TriggerConfiguration;
|
||||||
|
existing.ParameterDefinitions = win.ParameterDefinitions;
|
||||||
|
existing.ReturnDefinition = win.ReturnDefinition;
|
||||||
|
existing.MinTimeBetweenRuns = win.MinTimeBetweenRuns;
|
||||||
|
existing.ExecutionTimeoutSeconds = win.ExecutionTimeoutSeconds;
|
||||||
|
existing.IsLocked = win.IsLocked;
|
||||||
|
await _repository.UpdateTemplateScriptAsync(existing, ct);
|
||||||
|
counts.Updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var orphan in child.Scripts.Where(s => s.IsInherited && !inherited.Contains(s.Name)).ToList())
|
||||||
|
{
|
||||||
|
await _repository.DeleteTemplateScriptAsync(orphan.Id, ct);
|
||||||
|
counts.Removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReconcileNativeSourcesAsync(
|
||||||
|
Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
|
||||||
|
Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var inherited = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var m in resolved)
|
||||||
|
{
|
||||||
|
if (!m.IsInherited) continue;
|
||||||
|
inherited.Add(m.Name);
|
||||||
|
if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
|
||||||
|
var win = origin.NativeAlarmSources.FirstOrDefault(s => s.Name == m.Name);
|
||||||
|
if (win == null) continue;
|
||||||
|
|
||||||
|
var existing = child.NativeAlarmSources.FirstOrDefault(s => s.Name == m.Name);
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
await _repository.AddTemplateNativeAlarmSourceAsync(new TemplateNativeAlarmSource(m.Name)
|
||||||
|
{
|
||||||
|
TemplateId = child.Id,
|
||||||
|
Description = win.Description,
|
||||||
|
ConnectionName = win.ConnectionName,
|
||||||
|
SourceReference = win.SourceReference,
|
||||||
|
ConditionFilter = win.ConditionFilter,
|
||||||
|
IsLocked = win.IsLocked,
|
||||||
|
IsInherited = true,
|
||||||
|
LockedInDerived = false,
|
||||||
|
}, ct);
|
||||||
|
counts.Added++;
|
||||||
|
}
|
||||||
|
else if (existing.IsInherited && (
|
||||||
|
existing.ConnectionName != win.ConnectionName ||
|
||||||
|
existing.SourceReference != win.SourceReference ||
|
||||||
|
existing.ConditionFilter != win.ConditionFilter ||
|
||||||
|
existing.Description != win.Description ||
|
||||||
|
existing.IsLocked != win.IsLocked))
|
||||||
|
{
|
||||||
|
existing.ConnectionName = win.ConnectionName;
|
||||||
|
existing.SourceReference = win.SourceReference;
|
||||||
|
existing.ConditionFilter = win.ConditionFilter;
|
||||||
|
existing.Description = win.Description;
|
||||||
|
existing.IsLocked = win.IsLocked;
|
||||||
|
await _repository.UpdateTemplateNativeAlarmSourceAsync(existing, ct);
|
||||||
|
counts.Updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var orphan in child.NativeAlarmSources.Where(s => s.IsInherited && !inherited.Contains(s.Name)).ToList())
|
||||||
|
{
|
||||||
|
await _repository.DeleteTemplateNativeAlarmSourceAsync(orphan.Id, ct);
|
||||||
|
counts.Removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Breadth-first list of every template that (transitively) derives from
|
||||||
|
/// <paramref name="rootId"/>, excluding the root itself. Cycle-guarded.
|
||||||
|
/// </summary>
|
||||||
|
private static List<int> GetDescendantIdsBreadthFirst(int rootId, IReadOnlyList<Template> all)
|
||||||
|
{
|
||||||
|
if (all is null) return new List<int>();
|
||||||
|
var childrenByParent = new Dictionary<int, List<int>>();
|
||||||
|
foreach (var t in all)
|
||||||
|
{
|
||||||
|
if (t.ParentTemplateId is { } pid)
|
||||||
|
{
|
||||||
|
if (!childrenByParent.TryGetValue(pid, out var list))
|
||||||
|
childrenByParent[pid] = list = new List<int>();
|
||||||
|
list.Add(t.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<int>();
|
||||||
|
var seen = new HashSet<int> { rootId };
|
||||||
|
var queue = new Queue<int>();
|
||||||
|
if (childrenByParent.TryGetValue(rootId, out var direct))
|
||||||
|
foreach (var c in direct) queue.Enqueue(c);
|
||||||
|
|
||||||
|
while (queue.Count > 0)
|
||||||
|
{
|
||||||
|
var id = queue.Dequeue();
|
||||||
|
if (!seen.Add(id)) continue;
|
||||||
|
result.Add(id);
|
||||||
|
if (childrenByParent.TryGetValue(id, out var kids))
|
||||||
|
foreach (var c in kids) queue.Enqueue(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<int, Template> BuildTemplateLookup(IReadOnlyList<Template> all)
|
||||||
|
{
|
||||||
|
var byId = new Dictionary<int, Template>(all.Count);
|
||||||
|
foreach (var t in all) byId[t.Id] = t; // DB ids are unique; last-wins is harmless
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Running tally of inherited-row changes during a reconcile.</summary>
|
||||||
|
private sealed class ReconcileCounts
|
||||||
|
{
|
||||||
|
public int Added;
|
||||||
|
public int Updated;
|
||||||
|
public int Removed;
|
||||||
|
public bool Any => Added > 0 || Updated > 0 || Removed > 0;
|
||||||
|
|
||||||
|
public void Add(ReconcileCounts other)
|
||||||
|
{
|
||||||
|
Added += other.Added;
|
||||||
|
Updated += other.Updated;
|
||||||
|
Removed += other.Removed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// WP-7: Path-Qualified Canonical Naming (via TemplateResolver)
|
// WP-7: Path-Qualified Canonical Naming (via TemplateResolver)
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|||||||
@@ -2397,6 +2397,10 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void AddTemplateNativeAlarmSource_WithDesignRole_ReturnsSuccess()
|
public void AddTemplateNativeAlarmSource_WithDesignRole_ReturnsSuccess()
|
||||||
{
|
{
|
||||||
|
// Native-source add now propagates inherited rows to derived descendants,
|
||||||
|
// so the handler resolves TemplateService (no-op here: the substitute repo's
|
||||||
|
// GetAllTemplatesAsync yields no descendants).
|
||||||
|
_services.AddScoped<TemplateService>();
|
||||||
var actor = CreateActor();
|
var actor = CreateActor();
|
||||||
var envelope = Envelope(
|
var envelope = Envelope(
|
||||||
new AddTemplateNativeAlarmSourceCommand(1, "Pressure", "Opc", "ns=2;s=T01", null, "desc", false),
|
new AddTemplateNativeAlarmSourceCommand(1, "Pressure", "Opc", "ns=2;s=T01", null, "desc", false),
|
||||||
|
|||||||
@@ -1631,4 +1631,241 @@ public class TemplateServiceTests
|
|||||||
Assert.Equal("123", auditedEntityId);
|
Assert.Equal("123", auditedEntityId);
|
||||||
Assert.NotEqual("0", auditedEntityId);
|
Assert.NotEqual("0", auditedEntityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Follow-up #1/#2: inherited-member propagation & resync
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
private static Template BaseTemplate(int id, string name) => new(name) { Id = id };
|
||||||
|
private static Template DerivedTemplate(int id, string name, int parentId) =>
|
||||||
|
new(name) { Id = id, ParentTemplateId = parentId, IsDerived = true };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resync_MaterializesMissingInheritedAttribute()
|
||||||
|
{
|
||||||
|
// The reported #1/#2 shape: a base member added after the child was derived
|
||||||
|
// never reached the child's stored rows. Resync materializes it.
|
||||||
|
var baseT = BaseTemplate(7, "Base");
|
||||||
|
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" });
|
||||||
|
baseT.Attributes.Add(new TemplateAttribute("B") { Id = 71, TemplateId = 7, DataType = DataType.String, Value = "x" });
|
||||||
|
|
||||||
|
var child = DerivedTemplate(8, "Child", 7);
|
||||||
|
child.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.Float, Value = "1", IsInherited = true });
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { baseT, child });
|
||||||
|
var added = new List<TemplateAttribute>();
|
||||||
|
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var result = await _service.ResyncInheritedMembersAsync(8, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(1, result.Value.MembersAdded);
|
||||||
|
Assert.Equal(1, result.Value.TemplatesChanged);
|
||||||
|
var b = Assert.Single(added);
|
||||||
|
Assert.Equal("B", b.Name);
|
||||||
|
Assert.Equal(8, b.TemplateId);
|
||||||
|
Assert.True(b.IsInherited);
|
||||||
|
Assert.Equal("x", b.Value);
|
||||||
|
Assert.Equal(DataType.String, b.DataType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resync_RemovesOrphanedInheritedRow()
|
||||||
|
{
|
||||||
|
var baseT = BaseTemplate(7, "Base");
|
||||||
|
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" });
|
||||||
|
|
||||||
|
var child = DerivedTemplate(8, "Child", 7);
|
||||||
|
child.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.Float, Value = "1", IsInherited = true });
|
||||||
|
// Orphan: an inherited placeholder whose base member was removed.
|
||||||
|
child.Attributes.Add(new TemplateAttribute("Gone") { Id = 81, TemplateId = 8, DataType = DataType.String, Value = "old", IsInherited = true });
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { baseT, child });
|
||||||
|
var deleted = new List<int>();
|
||||||
|
_repoMock.Setup(r => r.DeleteTemplateAttributeAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<int, CancellationToken>((id, _) => deleted.Add(id))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var result = await _service.ResyncInheritedMembersAsync(8, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(1, result.Value.MembersRemoved);
|
||||||
|
Assert.Equal(81, Assert.Single(deleted));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resync_UpdatesDriftedInheritedRow()
|
||||||
|
{
|
||||||
|
var baseT = BaseTemplate(7, "Base");
|
||||||
|
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.String, Value = "NEW" });
|
||||||
|
|
||||||
|
var child = DerivedTemplate(8, "Child", 7);
|
||||||
|
var childA = new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.String, Value = "OLD", IsInherited = true };
|
||||||
|
child.Attributes.Add(childA);
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { baseT, child });
|
||||||
|
var updated = new List<TemplateAttribute>();
|
||||||
|
_repoMock.Setup(r => r.UpdateTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<TemplateAttribute, CancellationToken>((a, _) => updated.Add(a))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var result = await _service.ResyncInheritedMembersAsync(8, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(1, result.Value.MembersUpdated);
|
||||||
|
Assert.Same(childA, Assert.Single(updated));
|
||||||
|
Assert.Equal("NEW", childA.Value); // re-synced in place to the live base value
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resync_LeavesAuthoredOverridesUntouched()
|
||||||
|
{
|
||||||
|
var baseT = BaseTemplate(7, "Base");
|
||||||
|
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.String, Value = "base" });
|
||||||
|
|
||||||
|
var child = DerivedTemplate(8, "Child", 7);
|
||||||
|
// Authored override (IsInherited == false) with a divergent value.
|
||||||
|
child.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.String, Value = "override", IsInherited = false });
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { baseT, child });
|
||||||
|
|
||||||
|
var result = await _service.ResyncInheritedMembersAsync(8, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(0, result.Value.MembersAdded);
|
||||||
|
Assert.Equal(0, result.Value.MembersUpdated);
|
||||||
|
Assert.Equal(0, result.Value.MembersRemoved);
|
||||||
|
_repoMock.Verify(r => r.UpdateTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
_repoMock.Verify(r => r.DeleteTemplateAttributeAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resync_OnBase_CascadesToAllDerivedChildren()
|
||||||
|
{
|
||||||
|
var baseT = BaseTemplate(7, "ReactorSide");
|
||||||
|
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" });
|
||||||
|
baseT.Attributes.Add(new TemplateAttribute("B") { Id = 71, TemplateId = 7, DataType = DataType.String, Value = "x" });
|
||||||
|
|
||||||
|
var left = DerivedTemplate(8, "Left", 7);
|
||||||
|
left.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.Float, Value = "1", IsInherited = true });
|
||||||
|
var right = DerivedTemplate(9, "Right", 7);
|
||||||
|
right.Attributes.Add(new TemplateAttribute("A") { Id = 90, TemplateId = 9, DataType = DataType.Float, Value = "1", IsInherited = true });
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { baseT, left, right });
|
||||||
|
var added = new List<TemplateAttribute>();
|
||||||
|
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
// Resyncing the base repairs the whole subtree.
|
||||||
|
var result = await _service.ResyncInheritedMembersAsync(7, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(2, result.Value.MembersAdded); // B materialized on Left and Right
|
||||||
|
Assert.Equal(2, result.Value.TemplatesChanged);
|
||||||
|
Assert.Contains(added, a => a.TemplateId == 8 && a.Name == "B" && a.IsInherited);
|
||||||
|
Assert.Contains(added, a => a.TemplateId == 9 && a.Name == "B" && a.IsInherited);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resync_MaterializesMissingInheritedScriptAndNativeSource()
|
||||||
|
{
|
||||||
|
// Scripts and native alarm sources use the same inherit model and are
|
||||||
|
// counted by the staleness summary, so reconcile must materialize them too.
|
||||||
|
var baseT = BaseTemplate(7, "Base");
|
||||||
|
baseT.Scripts.Add(new TemplateScript("OnStart", "return 1;") { Id = 70, TemplateId = 7, TriggerType = "Startup" });
|
||||||
|
baseT.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Src") { Id = 75, TemplateId = 7, ConnectionName = "opc", SourceReference = "ns=2;s=X" });
|
||||||
|
|
||||||
|
var child = DerivedTemplate(8, "Child", 7); // no inherited rows yet
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { baseT, child });
|
||||||
|
var addedScripts = new List<TemplateScript>();
|
||||||
|
var addedSources = new List<TemplateNativeAlarmSource>();
|
||||||
|
_repoMock.Setup(r => r.AddTemplateScriptAsync(It.IsAny<TemplateScript>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<TemplateScript, CancellationToken>((s, _) => addedScripts.Add(s)).Returns(Task.CompletedTask);
|
||||||
|
_repoMock.Setup(r => r.AddTemplateNativeAlarmSourceAsync(It.IsAny<TemplateNativeAlarmSource>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<TemplateNativeAlarmSource, CancellationToken>((s, _) => addedSources.Add(s)).Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var result = await _service.ResyncInheritedMembersAsync(8, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(2, result.Value.MembersAdded);
|
||||||
|
var script = Assert.Single(addedScripts);
|
||||||
|
Assert.Equal("OnStart", script.Name);
|
||||||
|
Assert.Equal("return 1;", script.Code);
|
||||||
|
Assert.True(script.IsInherited);
|
||||||
|
Assert.Equal(8, script.TemplateId);
|
||||||
|
var src = Assert.Single(addedSources);
|
||||||
|
Assert.Equal("Src", src.Name);
|
||||||
|
Assert.Equal("ns=2;s=X", src.SourceReference);
|
||||||
|
Assert.True(src.IsInherited);
|
||||||
|
Assert.Equal(8, src.TemplateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReconcileDescendants_PropagatesNewBaseMemberToChild()
|
||||||
|
{
|
||||||
|
// Base already carries the new member C; reconciling its descendants
|
||||||
|
// materializes C onto the child (the engine behind auto-propagation).
|
||||||
|
var baseT = BaseTemplate(7, "Base");
|
||||||
|
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" });
|
||||||
|
baseT.Attributes.Add(new TemplateAttribute("C") { Id = 72, TemplateId = 7, DataType = DataType.String, Value = "c" });
|
||||||
|
|
||||||
|
var child = DerivedTemplate(8, "Child", 7);
|
||||||
|
child.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.Float, Value = "1", IsInherited = true });
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { baseT, child });
|
||||||
|
var added = new List<TemplateAttribute>();
|
||||||
|
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a)).Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
await _service.ReconcileDescendantsAsync(7, "admin");
|
||||||
|
|
||||||
|
var c = Assert.Single(added);
|
||||||
|
Assert.Equal("C", c.Name);
|
||||||
|
Assert.Equal(8, c.TemplateId);
|
||||||
|
Assert.True(c.IsInherited);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddAttribute_PropagatesNewMemberToDerivedChild()
|
||||||
|
{
|
||||||
|
// End-to-end: adding a member to a base auto-propagates an inherited
|
||||||
|
// placeholder to its derived children (#1/#2).
|
||||||
|
var baseT = BaseTemplate(7, "Base");
|
||||||
|
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" });
|
||||||
|
var child = DerivedTemplate(8, "Child", 7);
|
||||||
|
child.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.Float, Value = "1", IsInherited = true });
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(baseT);
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { baseT, child });
|
||||||
|
|
||||||
|
var added = new List<TemplateAttribute>();
|
||||||
|
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<TemplateAttribute, CancellationToken>((a, _) =>
|
||||||
|
{
|
||||||
|
added.Add(a);
|
||||||
|
// Simulate persistence so the post-add reconcile re-fetch sees the
|
||||||
|
// base's new member and propagates it down.
|
||||||
|
if (a.TemplateId == 7) baseT.Attributes.Add(a);
|
||||||
|
})
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var newAttr = new TemplateAttribute("C") { DataType = DataType.String, Value = "c" };
|
||||||
|
var result = await _service.AddAttributeAsync(7, newAttr, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Contains(added, a => a.TemplateId == 7 && a.Name == "C" && !a.IsInherited); // base's own
|
||||||
|
Assert.Contains(added, a => a.TemplateId == 8 && a.Name == "C" && a.IsInherited); // child placeholder
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user