feat(template-engine): contained names for composition-derived templates

A composition-derived template now stores its contained name — the
composition slot's InstanceName (e.g. "Pump"), unique only within its
owner — instead of the dotted global path ("Motor Controller.Pump").
The qualified hierarchical name is computed on read.

- TemplateNaming.QualifiedName: walks the OwnerCompositionId chain to
  build the dotted path; null-safe, cycle-guarded.
- TemplateConfiguration: the unique index on Template.Name becomes
  filtered (WHERE IsDerived = 0) — base templates stay globally unique;
  derived templates' uniqueness is the existing (TemplateId,
  InstanceName) index on TemplateComposition.
- Migration ContainedDerivedTemplateNames: rewrites derived rows to the
  contained name; Down rebuilds the dotted names via a recursive CTE
  before restoring the global index.
- TemplateService: composition create/rename store the contained name;
  the dotted-name collision pre-checks and cascade-rename are removed
  (a slot rename no longer touches nested derived templates).
- TemplateEdit: title shows the contained name; the qualified path is a
  breadcrumb subtitle; "composed inside" uses the owner's qualified name.

TDD: 4 TemplateNaming tests + updated composition tests. TemplateEngine
293, ConfigurationDatabase 114, CentralUI 316 green. Migration applied to
the dev cluster and verified in the browser (Motor Controller.Pump now
titled "Pump"; nested Motor Controller.Pump.TempSensor resolves).

Design: docs/plans/2026-05-18-contained-template-names-design.md
This commit is contained in:
Joseph Doherty
2026-05-18 17:50:30 -04:00
parent 2d4b287ab2
commit 06462a0100
11 changed files with 1662 additions and 139 deletions

View File

@@ -1,7 +1,7 @@
# Contained Names for Composition-Derived Templates — Design
**Date:** 2026-05-18
**Status:** Approved (brainstorming) — implementation to follow
**Status:** Implemented
## Context

View File

@@ -81,6 +81,18 @@ When a template composes a feature module, members from that module are addresse
- The composing template's own members (not from a module) have no prefix — they are top-level names.
- Naming collision detection operates on canonical names, so two modules can define the same member name as long as their module instance names differ.
### Derived template naming
A composition slot is materialized as its own *derived* template. A derived
template stores a **contained name** — the composition slot's instance name
(e.g. `Pump`), unique only within its owner. The **qualified name**
(`Motor Controller.Pump`, or `Motor Controller.Pump.TempSensor` when nested) is
*computed* on read by walking the owner-composition chain — it is not stored.
Only base (user-authored) templates are globally unique by name; a derived
template's uniqueness is the slot-name uniqueness within its owner. The Central
UI shows the contained name as the template title and the qualified path as a
breadcrumb.
## Override Granularity
Override and lock rules apply per entity type at the following granularity:

View File

@@ -236,7 +236,7 @@
<a href="/design/templates/@_baseTemplate.Id"><code>@_baseTemplate.Name</code></a>
@if (_ownerTemplate != null && _ownerComposition != null)
{
<span class="ms-1">— composed inside <a href="/design/templates/@_ownerTemplate.Id"><code>@_ownerTemplate.Name</code></a> as <code>@_ownerComposition.InstanceName</code>.</span>
<span class="ms-1">— composed inside <a href="/design/templates/@_ownerTemplate.Id"><code>@QualifiedTemplateName(_ownerTemplate)</code></a> as <code>@_ownerComposition.InstanceName</code>.</span>
}
</div>
</div>
@@ -248,6 +248,12 @@
{
<span class="text-muted ms-2">inherits @(_templates.FirstOrDefault(t => t.Id == _selectedTemplate.ParentTemplateId)?.Name)</span>
}
@if (_selectedTemplate.IsDerived)
{
@* Derived templates store a contained name; show the full
qualified path as a breadcrumb subtitle. *@
<div class="text-muted small font-monospace">@QualifiedTemplateName(_selectedTemplate)</div>
}
</div>
<div>
<button class="btn btn-outline-info btn-sm me-1" @onclick="RunValidation" disabled="@_validating">
@@ -1701,6 +1707,18 @@
else { _toast.ShowError(result.Error); }
}
/// <summary>
/// Computes a template's qualified (hierarchical) name from the loaded
/// template set — the stored name for a base template, the dotted
/// owner-chain path for a composition-derived one.
/// </summary>
private string QualifiedTemplateName(Template template)
{
var byId = _templates.ToDictionary(t => t.Id);
var compById = _templates.SelectMany(t => t.Compositions).ToDictionary(c => c.Id);
return TemplateNaming.QualifiedName(template, byId, compById);
}
// ---- Editor metadata builders ----
private async Task<IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext>> BuildChildContextsAsync(

View File

@@ -17,7 +17,12 @@ public class TemplateConfiguration : IEntityTypeConfiguration<Template>
builder.Property(t => t.Description)
.HasMaxLength(2000);
builder.HasIndex(t => t.Name).IsUnique();
// Only base (user-authored) templates are globally unique by name.
// Derived templates store their *contained* name (the composition slot's
// InstanceName), unique only within the owner — enforced by the
// (TemplateId, InstanceName) index on TemplateComposition — so they are
// excluded from this index via a filter.
builder.HasIndex(t => t.Name).IsUnique().HasFilter("[IsDerived] = 0");
// Self-referencing parent template (inheritance)
builder.HasOne<Template>()

View File

@@ -0,0 +1,81 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <summary>
/// Moves composition-derived templates to AVEVA-style contained names: a
/// derived template stores only its slot name (e.g. <c>Pump</c>), not the
/// dotted qualified path (<c>Motor Controller.Pump</c>). The qualified name
/// is computed on read by walking the OwnerComposition chain. The unique
/// index on Template.Name becomes filtered to base templates only —
/// derived templates' uniqueness is the (TemplateId, InstanceName) index on
/// TemplateComposition.
/// </summary>
public partial class ContainedDerivedTemplateNames : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Drop the global unique index first: derived rows are about to be
// renamed to contained names that may duplicate one another or a
// base template.
migrationBuilder.DropIndex(
name: "IX_Templates_Name",
table: "Templates");
// Collapse every derived template's dotted name to its contained
// name — the owning composition slot's InstanceName.
migrationBuilder.Sql(@"
UPDATE t
SET t.Name = c.InstanceName
FROM Templates t
INNER JOIN TemplateCompositions c ON c.Id = t.OwnerCompositionId
WHERE t.IsDerived = 1;");
// Recreate the uniqueness guarantee for base templates only.
migrationBuilder.CreateIndex(
name: "IX_Templates_Name",
table: "Templates",
column: "Name",
unique: true,
filter: "[IsDerived] = 0");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Templates_Name",
table: "Templates");
// Rebuild the dotted qualified names so the global unique index can
// be restored — derived templates' contained names are not globally
// unique. The recursive CTE walks the OwnerComposition chain down
// from each base template.
migrationBuilder.Sql(@"
WITH q AS (
SELECT t.Id, CAST(t.Name AS NVARCHAR(MAX)) AS Qualified
FROM Templates t
WHERE t.IsDerived = 0
UNION ALL
SELECT t.Id, CAST(q.Qualified + N'.' + c.InstanceName AS NVARCHAR(MAX))
FROM Templates t
INNER JOIN TemplateCompositions c ON c.Id = t.OwnerCompositionId
INNER JOIN q ON q.Id = c.TemplateId
)
UPDATE t
SET t.Name = q.Qualified
FROM Templates t
INNER JOIN q ON q.Id = t.Id
WHERE t.IsDerived = 1;");
migrationBuilder.CreateIndex(
name: "IX_Templates_Name",
table: "Templates",
column: "Name",
unique: true);
}
}
}

View File

@@ -900,7 +900,8 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.HasIndex("FolderId");
b.HasIndex("Name")
.IsUnique();
.IsUnique()
.HasFilter("[IsDerived] = 0");
b.HasIndex("ParentTemplateId");

View File

@@ -0,0 +1,53 @@
using ScadaLink.Commons.Entities.Templates;
namespace ScadaLink.TemplateEngine;
/// <summary>
/// Resolves the hierarchical ("qualified") name of a composition-derived
/// template. A derived template stores only its <em>contained</em> name — the
/// owning composition slot's <c>InstanceName</c>, unique only within that owner.
/// The qualified path (<c>Owner.Slot.Slot…</c>) is computed on demand by
/// walking the <see cref="Template.OwnerCompositionId"/> chain up to the base
/// template.
/// </summary>
public static class TemplateNaming
{
/// <summary>
/// Returns the dotted hierarchical name of <paramref name="template"/>. For
/// a base (non-derived) template this is just its stored name. The walk is
/// null-safe: if any owner link is missing from the supplied lookups it
/// stops and falls back to the stored contained name, and a cycle (which
/// the composition graph should never contain) is broken defensively.
/// </summary>
public static string QualifiedName(
Template template,
IReadOnlyDictionary<int, Template> byId,
IReadOnlyDictionary<int, TemplateComposition> compById)
{
ArgumentNullException.ThrowIfNull(template);
ArgumentNullException.ThrowIfNull(byId);
ArgumentNullException.ThrowIfNull(compById);
return Resolve(template, byId, compById, new HashSet<int>());
}
private static string Resolve(
Template template,
IReadOnlyDictionary<int, Template> byId,
IReadOnlyDictionary<int, TemplateComposition> compById,
HashSet<int> visited)
{
// Base template, broken owner link, or a cycle → the stored name is the
// best (and contained) answer.
if (!template.IsDerived
|| template.OwnerCompositionId is not { } compId
|| !compById.TryGetValue(compId, out var composition)
|| !byId.TryGetValue(composition.TemplateId, out var owner)
|| !visited.Add(template.Id))
{
return template.Name;
}
return $"{Resolve(owner, byId, compById, visited)}.{composition.InstanceName}";
}
}

View File

@@ -598,20 +598,9 @@ public class TemplateService
if (collisions.Count > 0)
return Result<TemplateComposition>.Failure(string.Join(" ", collisions));
// Derived template name uses dot-separated path: "<parent>.<slot>". The
// cascade may create additional derived templates one level per slot
// (composing $Sensor with a Probe1 slot into $Pump produces both
// $Pump.TempSensor and $Pump.TempSensor.Probe1). Pre-check every name
// the cascade is about to introduce so a deep collision aborts before
// any rows mutate.
var byId = allTemplates.ToDictionary(t => t.Id);
var cascadeNames = EnumerateCascadeNames(template.Name, instanceName, baseTemplate, byId).ToList();
var existingNames = allTemplates.Select(t => t.Name).ToHashSet(StringComparer.Ordinal);
var nameCollision = cascadeNames.FirstOrDefault(n => existingNames.Contains(n));
if (nameCollision != null)
return Result<TemplateComposition>.Failure(
$"Cannot create derived template '{nameCollision}': a template with that name already exists.");
// No global name pre-check: derived templates store their contained
// (slot) name, which need only be unique within the owner — and that is
// already enforced above and by the (TemplateId, InstanceName) index.
var composition = await CreateCascadedCompositionAsync(template, baseTemplate, instanceName, user, cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateComposition", "0", instanceName, composition, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
@@ -633,8 +622,10 @@ public class TemplateService
string user,
CancellationToken cancellationToken)
{
var derivedName = $"{outerTemplate.Name}.{instanceName}";
var derived = BuildDerivedTemplate(source, derivedName);
// A derived template stores its contained name — the slot's instance
// name, unique within its owner. The qualified path is computed on read
// (see TemplateNaming.QualifiedName).
var derived = BuildDerivedTemplate(source, instanceName);
await _repository.AddTemplateAsync(derived, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
@@ -664,19 +655,6 @@ public class TemplateService
return composition;
}
private static IEnumerable<string> EnumerateCascadeNames(
string outerName, string instanceName, Template source, IReadOnlyDictionary<int, Template> byId)
{
var derivedName = $"{outerName}.{instanceName}";
yield return derivedName;
foreach (var comp in source.Compositions)
{
if (!byId.TryGetValue(comp.ComposedTemplateId, out var child)) continue;
foreach (var name in EnumerateCascadeNames(derivedName, comp.InstanceName, child, byId))
yield return name;
}
}
public async Task<Result<TemplateComposition>> RenameCompositionAsync(
int compositionId,
string newInstanceName,
@@ -700,33 +678,15 @@ public class TemplateService
return Result<TemplateComposition>.Failure(
$"Slot name '{newInstanceName}' already exists on '{owner.Name}'.");
// The derived template stores the slot's contained name, so renaming
// the slot renames just that one template. Nested derived templates
// keep their own contained names — an ancestor slot rename never
// touches them, and the qualified path is recomputed on read.
var derived = await _repository.GetTemplateByIdAsync(composition.ComposedTemplateId, cancellationToken);
if (derived != null && derived.IsDerived && derived.OwnerCompositionId == compositionId)
{
var newDerivedName = $"{owner.Name}.{newInstanceName}";
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
// The cascade of derived templates created by AddComposition follows a
// dotted path (Pump.TempSensor and the nested Pump.TempSensor.Probe1).
// Renaming the slot must rename every derived template in that cascade
// so the dotted-path naming invariant holds — pre-check every new name
// the cascade will introduce before any row mutates.
var renames = new List<(Template Template, string NewName)>();
await CollectCascadeRenamesAsync(derived, newDerivedName, renames, cancellationToken);
var renamedIds = renames.Select(r => r.Template.Id).ToHashSet();
foreach (var (_, newName) in renames)
{
if (allTemplates.Any(t => !renamedIds.Contains(t.Id) && t.Name == newName))
return Result<TemplateComposition>.Failure(
$"Cannot rename derived template to '{newName}': a template with that name already exists.");
}
foreach (var (template, newName) in renames)
{
template.Name = newName;
await _repository.UpdateTemplateAsync(template, cancellationToken);
}
derived.Name = newInstanceName;
await _repository.UpdateTemplateAsync(derived, cancellationToken);
}
composition.InstanceName = newInstanceName;
@@ -763,30 +723,6 @@ public class TemplateService
return Result<bool>.Success(true);
}
/// <summary>
/// Recursively collects the (template, new name) pairs for a renamed derived
/// template and every cascaded inner derived template beneath it. Each inner
/// derived's new name is re-derived from its renamed parent and the slot's
/// instance name (mirroring the cascade <see cref="CreateCascadedCompositionAsync"/>
/// builds and the recursion in <see cref="CascadeDeleteDerivedAsync"/>).
/// </summary>
private async Task CollectCascadeRenamesAsync(
Template derived,
string newName,
List<(Template Template, string NewName)> renames,
CancellationToken cancellationToken)
{
renames.Add((derived, newName));
foreach (var child in derived.Compositions.ToList())
{
var childDerived = await _repository.GetTemplateByIdAsync(child.ComposedTemplateId, cancellationToken);
if (childDerived != null && childDerived.IsDerived && childDerived.OwnerCompositionId == child.Id)
await CollectCascadeRenamesAsync(
childDerived, $"{newName}.{child.InstanceName}", renames, cancellationToken);
}
}
/// <summary>
/// Recursively deletes a derived template along with the cascade of inner
/// derived templates the compose flow created. Each composition row on the

View File

@@ -0,0 +1,87 @@
using ScadaLink.Commons.Entities.Templates;
namespace ScadaLink.TemplateEngine.Tests;
/// <summary>
/// Coverage for <see cref="TemplateNaming.QualifiedName"/> — the computed
/// hierarchical name of a composition-derived template. Derived templates store
/// only their contained name (the composition slot's <c>InstanceName</c>); the
/// dotted path is resolved on read by walking the <c>OwnerCompositionId</c> chain.
/// </summary>
public class TemplateNamingTests
{
private static (Dictionary<int, Template> byId, Dictionary<int, TemplateComposition> compById)
BuildGraph(params Template[] templates)
{
var byId = templates.ToDictionary(t => t.Id);
var compById = templates
.SelectMany(t => t.Compositions)
.ToDictionary(c => c.Id);
return (byId, compById);
}
[Fact]
public void QualifiedName_BaseTemplate_IsJustItsName()
{
var motorController = new Template("Motor Controller") { Id = 4 };
var (byId, compById) = BuildGraph(motorController);
Assert.Equal("Motor Controller", TemplateNaming.QualifiedName(motorController, byId, compById));
}
[Fact]
public void QualifiedName_OneLevelDerived_PrefixesTheOwner()
{
// Motor Controller composes the Pump template into a slot named "Pump".
var motorController = new Template("Motor Controller") { Id = 4 };
motorController.Compositions.Add(
new TemplateComposition("Pump") { Id = 1014, TemplateId = 4, ComposedTemplateId = 2018 });
var derivedPump = new Template("Pump")
{
Id = 2018, IsDerived = true, OwnerCompositionId = 1014
};
var (byId, compById) = BuildGraph(motorController, derivedPump);
Assert.Equal("Motor Controller.Pump", TemplateNaming.QualifiedName(derivedPump, byId, compById));
}
[Fact]
public void QualifiedName_NestedDerived_WalksTheWholeChain()
{
// Motor Controller -> Pump slot -> TempSensor slot.
var motorController = new Template("Motor Controller") { Id = 4 };
motorController.Compositions.Add(
new TemplateComposition("Pump") { Id = 1014, TemplateId = 4, ComposedTemplateId = 2018 });
var derivedPump = new Template("Pump")
{
Id = 2018, IsDerived = true, OwnerCompositionId = 1014
};
derivedPump.Compositions.Add(
new TemplateComposition("TempSensor") { Id = 1015, TemplateId = 2018, ComposedTemplateId = 2019 });
var derivedTempSensor = new Template("TempSensor")
{
Id = 2019, IsDerived = true, OwnerCompositionId = 1015
};
var (byId, compById) = BuildGraph(motorController, derivedPump, derivedTempSensor);
Assert.Equal(
"Motor Controller.Pump.TempSensor",
TemplateNaming.QualifiedName(derivedTempSensor, byId, compById));
}
[Fact]
public void QualifiedName_DerivedWithMissingOwnerLink_FallsBackToStoredName()
{
// Defensive: a derived template whose owner composition is not in the
// lookup must not throw — it falls back to the stored contained name.
var orphan = new Template("TempSensor")
{
Id = 2019, IsDerived = true, OwnerCompositionId = 9999
};
var (byId, compById) = BuildGraph(orphan);
Assert.Equal("TempSensor", TemplateNaming.QualifiedName(orphan, byId, compById));
}
}

View File

@@ -359,7 +359,7 @@ public class TemplateServiceTests
Assert.Equal("myModule", result.Value.InstanceName);
Assert.NotNull(captured);
Assert.True(captured!.IsDerived);
Assert.Equal("Parent.myModule", captured.Name);
Assert.Equal("myModule", captured.Name); // contained (slot) name, not the qualified path
Assert.Equal(2, captured.ParentTemplateId);
Assert.Single(captured.Attributes);
Assert.True(captured.Attributes.First().IsInherited);
@@ -372,13 +372,13 @@ public class TemplateServiceTests
[Fact]
public async Task AddComposition_CascadesChildCompositions()
{
// $Probe (base) → $Sensor.Probe1 (derived) ← $Sensor composes "Probe1"
// Composing $Sensor into $Pump as "TempSensor" should produce:
// $Pump.TempSensor (derived from $Sensor)
// $Pump.TempSensor.Probe1 (derived from $Sensor.Probe1)
// plus a composition row on $Pump.TempSensor pointing at the new inner derived.
// $Probe (base) → $Sensor's "Probe1" derived ← $Sensor composes "Probe1"
// Composing $Sensor into $Pump as "TempSensor" should produce two derived
// templates whose stored names are their *contained* names (the slot
// names) — "TempSensor" and "Probe1" — plus a composition row on the
// TempSensor derived pointing at the new inner derived.
var probe = new Template("Probe") { Id = 10 };
var sensorProbe1 = new Template("Sensor.Probe1") { Id = 11, IsDerived = true, ParentTemplateId = 10, OwnerCompositionId = 1 };
var sensorProbe1 = new Template("Probe1") { Id = 11, IsDerived = true, ParentTemplateId = 10, OwnerCompositionId = 1 };
var sensor = new Template("Sensor") { Id = 2 };
sensor.Compositions.Add(new TemplateComposition("Probe1") { Id = 1, TemplateId = 2, ComposedTemplateId = 11 });
var pump = new Template("Pump") { Id = 1 };
@@ -403,9 +403,9 @@ public class TemplateServiceTests
Assert.True(result.IsSuccess);
Assert.Equal(2, captured.Count);
Assert.Equal("Pump.TempSensor", captured[0].Name);
Assert.Equal("TempSensor", captured[0].Name);
Assert.Equal(2, captured[0].ParentTemplateId);
Assert.Equal("Pump.TempSensor.Probe1", captured[1].Name);
Assert.Equal("Probe1", captured[1].Name);
Assert.Equal(11, captured[1].ParentTemplateId);
Assert.Equal(2, capturedCompositions.Count);
Assert.Equal("TempSensor", capturedCompositions[0].InstanceName);
@@ -428,9 +428,13 @@ public class TemplateServiceTests
}
[Fact]
public async Task AddComposition_DerivedNameCollision_Fails()
public async Task AddComposition_DerivedContainedName_NeedNotBeGloballyUnique()
{
var existing = new Template("Parent.myModule") { Id = 99 };
// A derived template's stored name is its contained (slot) name, which
// is only unique within its owner — it may freely coincide with an
// unrelated base template's name. Composing a module as the slot
// "myModule" succeeds even though a base template "myModule" exists.
var existing = new Template("myModule") { Id = 99 };
var moduleTemplate = new Template("Module") { Id = 2 };
var template = new Template("Parent") { Id = 1 };
@@ -439,92 +443,67 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template, moduleTemplate, existing });
var captured = new List<Template>();
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
.Callback<Template, CancellationToken>((t, _) => captured.Add(t))
.Returns(Task.CompletedTask);
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
Assert.True(result.IsFailure);
Assert.Contains("already exists", result.Error);
Assert.True(result.IsSuccess);
Assert.Single(captured);
Assert.Equal("myModule", captured[0].Name);
}
[Fact]
public async Task RenameComposition_RenamesSlotAndDerivedTemplate()
{
// The derived template's stored name is the slot's contained name, so
// renaming the slot renames the derived template to the new short name.
var composition = new TemplateComposition("OldSlot") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
var owner = new Template("Pump") { Id = 1 };
owner.Compositions.Add(composition);
var derived = new Template("Pump.OldSlot") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
var derived = new Template("OldSlot") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { owner, derived });
var result = await _service.RenameCompositionAsync(50, "NewSlot", "admin");
Assert.True(result.IsSuccess);
Assert.Equal("NewSlot", result.Value.InstanceName);
Assert.Equal("Pump.NewSlot", derived.Name);
Assert.Equal("NewSlot", derived.Name);
}
[Fact]
public async Task RenameComposition_CascadesRenameToNestedDerivedTemplates()
public async Task RenameComposition_RenamesOnlyTheSlotsOwnDerivedTemplate()
{
// Pump.TempSensor is the slot-owned derived; Pump.TempSensor.Probe1 is a
// cascaded inner derived under it. Renaming the TempSensor slot to
// MainSensor must rename BOTH derived templates so the dotted-path
// naming invariant holds: Pump.MainSensor and Pump.MainSensor.Probe1.
// With contained names, a nested derived template's name is its own
// slot name and is unaffected by renaming an ancestor slot. Renaming
// the TempSensor slot to MainSensor renames only that derived template;
// the inner "Probe1" derived is left untouched.
var innerComp = new TemplateComposition("Probe1") { Id = 51, TemplateId = 77, ComposedTemplateId = 78 };
var innerDerived = new Template("Pump.TempSensor.Probe1") { Id = 78, IsDerived = true, OwnerCompositionId = 51, ParentTemplateId = 11 };
var innerDerived = new Template("Probe1") { Id = 78, IsDerived = true, OwnerCompositionId = 51, ParentTemplateId = 11 };
var composition = new TemplateComposition("TempSensor") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
var owner = new Template("Pump") { Id = 1 };
owner.Compositions.Add(composition);
var derived = new Template("Pump.TempSensor") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
var derived = new Template("TempSensor") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
derived.Compositions.Add(innerComp);
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
_repoMock.Setup(r => r.GetTemplateByIdAsync(78, It.IsAny<CancellationToken>())).ReturnsAsync(innerDerived);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { owner, derived, innerDerived });
var result = await _service.RenameCompositionAsync(50, "MainSensor", "admin");
Assert.True(result.IsSuccess);
Assert.Equal("MainSensor", result.Value.InstanceName);
Assert.Equal("Pump.MainSensor", derived.Name);
Assert.Equal("Pump.MainSensor.Probe1", innerDerived.Name);
Assert.Equal("MainSensor", derived.Name);
Assert.Equal("Probe1", innerDerived.Name);
_repoMock.Verify(r => r.UpdateTemplateAsync(
It.Is<Template>(t => t.Id == 78), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task RenameComposition_NestedCascadeNameCollision_Fails()
{
// A pre-existing template occupies the name the nested cascade would
// produce (Pump.MainSensor.Probe1). The rename must abort before any
// row mutates, so the full cascade name set must be pre-checked.
var innerComp = new TemplateComposition("Probe1") { Id = 51, TemplateId = 77, ComposedTemplateId = 78 };
var innerDerived = new Template("Pump.TempSensor.Probe1") { Id = 78, IsDerived = true, OwnerCompositionId = 51, ParentTemplateId = 11 };
var composition = new TemplateComposition("TempSensor") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
var owner = new Template("Pump") { Id = 1 };
owner.Compositions.Add(composition);
var derived = new Template("Pump.TempSensor") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
derived.Compositions.Add(innerComp);
var collider = new Template("Pump.MainSensor.Probe1") { Id = 99 };
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
_repoMock.Setup(r => r.GetTemplateByIdAsync(78, It.IsAny<CancellationToken>())).ReturnsAsync(innerDerived);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { owner, derived, innerDerived, collider });
var result = await _service.RenameCompositionAsync(50, "MainSensor", "admin");
Assert.True(result.IsFailure);
Assert.Contains("already exists", result.Error);
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Never);
It.Is<Template>(t => t.Id == 78), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]