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:
Joseph Doherty
2026-06-24 15:51:26 -04:00
parent b3f6833b36
commit 2b5949320c
11 changed files with 873 additions and 19 deletions
@@ -2397,6 +2397,10 @@ public class ManagementActorTests : TestKit, IDisposable
[Fact]
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 envelope = Envelope(
new AddTemplateNativeAlarmSourceCommand(1, "Pressure", "Opc", "ns=2;s=T01", null, "desc", false),
@@ -1631,4 +1631,241 @@ public class TemplateServiceTests
Assert.Equal("123", 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
}
}