fix(centralui): surface inherited compositions in the templates tree (followup #9)

The templates tree rendered a derived/composed member (e.g. LeftReactorSide,
derived from ReactorSide) as a flat leaf, omitting compositions it inherits
from its base (e.g. LeakTest composed onto ReactorSide). BuildCompositionLeavesFor
recursed only over a template's OWN composition rows; an inherited composition
row lives on the ancestor, and TemplateComposition has no IsInherited placeholder
(unlike attributes/alarms/scripts/native-sources), so the child's own Compositions
was empty. Same 'derived templates don't surface inherited members' family as
followups #1/#2, but for compositions. Deploy/flatten was always correct
(TemplateResolver.ResolveAllMembers walks the chain) — display-only.

Fix:
- BuildCompositionLeavesFor now renders the EFFECTIVE composition set (own +
  inherited) via EffectiveCompositionsFor, which walks the inheritance chain
  (leaf->root, child wins on InstanceName), mirroring the resolver.
- Inherited slots are flagged (TemplateTreeNode.IsInherited), badged 'inherited'
  in the label, and their context menu offers only 'Open composed template'
  (Rename/Delete edit the ancestor's slot, so suppressed on inherited nodes).
- The same inherited row can appear under several derived members (LeakTest under
  both LeftReactorSide and RightReactorSide), so composition nodes use a
  path-qualified KeyOverride to keep TreeView selection/expansion keys unique;
  recursion is cycle-guarded.

Tests: +1 bUnit (TemplatesPageTests.Renders_InheritedComposition_UnderDerivedComposedMember);
CentralUI suite 867 green; full solution builds 0/0.

Docs: Component-CentralUI.md (effective composition set in tree); known-issues
tracker #9 recorded + resolved.

Note: CentralUI change — shows on wonder-app-vd03 only after that host is redeployed.
This commit is contained in:
Joseph Doherty
2026-06-24 19:29:48 -04:00
parent 7747f25c9e
commit 1f261263b2
5 changed files with 147 additions and 11 deletions
@@ -129,6 +129,51 @@ public class TemplatesPageTests : BunitContext
Assert.Contains("bi-arrow-return-right", cut.Markup);
}
[Fact]
public void Renders_InheritedComposition_UnderDerivedComposedMember()
{
// followup #9: LeakTest is composed onto the BASE ReactorSide. LeftReactorSide is
// DERIVED from ReactorSide and composed into CvdReactor, so the tree must surface
// the INHERITED LeakTest slot under LeftReactorSide — the composition row lives on
// ReactorSide (no IsInherited placeholder on the child), so the builder must use
// the effective (own + inherited) composition set, mirroring the resolver.
var leakTest = new Template("LeakTest") { Id = 50 };
var reactorSide = new Template("ReactorSide") { Id = 7 };
reactorSide.Compositions.Add(
new TemplateComposition("LeakTest") { Id = 100, TemplateId = 7, ComposedTemplateId = 50 });
var leftReactorSide = new Template("LeftReactorSide") { Id = 8, ParentTemplateId = 7, IsDerived = true };
var cvdReactor = new Template("CvdReactor") { Id = 1 };
cvdReactor.Compositions.Add(
new TemplateComposition("LeftReactorSide") { Id = 200, TemplateId = 1, ComposedTemplateId = 8 });
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(
new List<Template> { cvdReactor, reactorSide, leftReactorSide, leakTest }));
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
var cut = Render<TemplatesPage>();
// Expand CvdReactor to reveal its composed member LeftReactorSide.
cut.FindAll("li[role='treeitem']")
.First(li => li.QuerySelector(".tv-label")?.TextContent.Trim() == "CvdReactor")
.QuerySelector(".tv-toggle")!.Click();
// LeftReactorSide must be expandable: before the fix it was a flat leaf with no
// toggle because its own Compositions are empty (LeakTest is inherited, not owned).
var leftLi = cut.FindAll("li[role='treeitem']")
.First(li => li.QuerySelector(".tv-label")?.TextContent.Trim() == "LeftReactorSide");
var leftToggle = leftLi.QuerySelector(".tv-toggle");
Assert.NotNull(leftToggle);
leftToggle!.Click();
// The inherited LeakTest slot now renders under LeftReactorSide, badged "inherited".
var leftLiAfter = cut.FindAll("li[role='treeitem']")
.First(li => li.QuerySelector(".tv-label")?.TextContent.Trim() == "LeftReactorSide");
Assert.Contains("LeakTest", leftLiAfter.TextContent);
Assert.Contains("inherited", leftLiAfter.TextContent);
}
[Fact]
public void SearchBox_IsPresentAndBound_ToTemplateFolderTreeFilter()
{