fix(transport): template diffs resolve folder/base/composition names — unchanged structured templates classify Identical again

#05-T23. ArtifactDiff.CompareTemplate gains optional folderNameById /
templateNameById maps; FolderNameOf / BaseTemplateNameOf / CompositionTargetNameOf
resolve the existing template's FolderId / ParentTemplateId / composition
ComposedTemplateId to real names (falling back to the <id:N> placeholder only
when a map is absent or misses, keeping existing unit-test callers valid).
PreviewAsync builds templateNameById from GetAllTemplatesAsync (parent/composition
targets can be any template, not just bundle-named ones) and threads both maps
through. Fixes spurious Modified on every re-import of a foldered/derived/composed
template. End-to-end preview test + two ArtifactDiff unit tests.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 02:22:44 -04:00
parent 523e670579
commit 92e713f1a1
4 changed files with 128 additions and 15 deletions
@@ -50,8 +50,19 @@ public sealed class ArtifactDiff
/// </summary>
/// <param name="incoming">The incoming template from the bundle.</param>
/// <param name="existing">The existing template in the database, or null if new.</param>
/// <param name="folderNameById">Optional folder-id→name map so an existing template's
/// <c>FolderId</c> resolves to the same name the bundle carries; without it, folder
/// identity falls back to a <c>&lt;id:N&gt;</c> placeholder that never matches a bundle
/// name (spurious Modified on every re-import — #05-T23).</param>
/// <param name="templateNameById">Optional template-id→name map resolving the existing
/// template's <c>ParentTemplateId</c> (base) and each composition's <c>ComposedTemplateId</c>
/// to names, with the same placeholder fallback semantics.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareTemplate(TemplateDto incoming, Template? existing)
public ImportPreviewItem CompareTemplate(
TemplateDto incoming,
Template? existing,
IReadOnlyDictionary<int, string>? folderNameById = null,
IReadOnlyDictionary<int, string>? templateNameById = null)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null)
@@ -61,8 +72,8 @@ public sealed class ArtifactDiff
var changes = new List<FieldChange>();
AddIfDifferent(changes, "Description", existing.Description, incoming.Description);
AddIfDifferent(changes, "FolderName", FolderNameOf(existing), incoming.FolderName);
AddIfDifferent(changes, "BaseTemplateName", BaseTemplateNameOf(existing), incoming.BaseTemplateName);
AddIfDifferent(changes, "FolderName", FolderNameOf(existing, folderNameById), incoming.FolderName);
AddIfDifferent(changes, "BaseTemplateName", BaseTemplateNameOf(existing, templateNameById), incoming.BaseTemplateName);
// Bundles reference the alarm on-trigger script by name; resolve the
// persisted FK back to a name over this template's own scripts so the
@@ -104,7 +115,7 @@ public sealed class ArtifactDiff
incoming.Compositions,
e => e.InstanceName,
i => i.InstanceName,
(e, i) => CompositionTargetNameOf(e) == i.ComposedTemplateName,
(e, i) => CompositionTargetNameOf(e, templateNameById) == i.ComposedTemplateName,
"Compositions",
changes);
@@ -665,24 +676,28 @@ public sealed class ArtifactDiff
&& e.ParameterDefinitions == i.ParameterDefinitions
&& e.ReturnDefinition == i.ReturnDefinition;
private static string? FolderNameOf(Template t)
private static string? FolderNameOf(Template t, IReadOnlyDictionary<int, string>? folderNameById)
{
// Templates carry only a FK to the folder; the EntitySerializer projects
// it to a name. The diff doesn't have access to a folder lookup at
// CompareTemplate scope, so fall back to "<id:N>" when only the id is
// known. The PreviewAsync caller can pass a hydrated Template (via
// GetTemplateWithChildrenAsync) and the folder name typically isn't on
// it — this branch is a deliberate best-effort.
return t.FolderId is null ? null : $"<id:{t.FolderId}>";
// it to a name. When PreviewAsync supplies a folder-id→name map (#05-T23)
// the id resolves to the real name so an unchanged folder assignment reads
// Identical; without the map (or on a miss) we fall back to "<id:N>", which
// keeps the older unit-test callers valid but never matches a bundle name.
if (t.FolderId is null) return null;
if (folderNameById != null && folderNameById.TryGetValue(t.FolderId.Value, out var name)) return name;
return $"<id:{t.FolderId}>";
}
private static string? BaseTemplateNameOf(Template t)
private static string? BaseTemplateNameOf(Template t, IReadOnlyDictionary<int, string>? templateNameById)
{
return t.ParentTemplateId is null ? null : $"<id:{t.ParentTemplateId}>";
if (t.ParentTemplateId is null) return null;
if (templateNameById != null && templateNameById.TryGetValue(t.ParentTemplateId.Value, out var name)) return name;
return $"<id:{t.ParentTemplateId}>";
}
private static string CompositionTargetNameOf(TemplateComposition comp)
private static string CompositionTargetNameOf(TemplateComposition comp, IReadOnlyDictionary<int, string>? templateNameById)
{
if (templateNameById != null && templateNameById.TryGetValue(comp.ComposedTemplateId, out var name)) return name;
return $"<id:{comp.ComposedTemplateId}>";
}
@@ -392,10 +392,17 @@ public sealed class BundleImporter : IBundleImporter
.ConfigureAwait(false);
var hydratedByName = hydratedTemplates
.ToDictionary(t => t.Name, t => t, StringComparer.Ordinal);
// #05-T23: a template's ParentTemplateId (base) and each composition's
// ComposedTemplateId point at ANY template in the DB (not just the ones
// whose names the bundle carries), so resolve names through the full
// template set. Without this map the diff compares "<id:N>" placeholders
// against real bundle names and reports spurious Modified.
var allTemplateStubs = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
var templateNameById = allTemplateStubs.ToDictionary(t => t.Id, t => t.Name);
foreach (var tDto in content.Templates)
{
hydratedByName.TryGetValue(tDto.Name, out var existing);
items.Add(_diff.CompareTemplate(tDto, existing));
items.Add(_diff.CompareTemplate(tDto, existing, folderNameById, templateNameById));
}
// ---- SharedScripts ----