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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user