refactor(transport): single-source template child equality — diff and overwrite-sync can no longer drift

New TemplateChildEquality is the one place "has this template child changed?" is
decided, comparing the COMPLETE writable field set per entity (attributes now incl.
ElementDataType + LockedInDerived; alarms incl. the on-trigger script by name +
LockedInDerived; scripts incl. MinTimeBetweenRuns/ExecutionTimeoutSeconds +
LockedInDerived; native sources per T5). ArtifactDiff.CompareTemplate and all four
BundleImporter.SyncTemplate*Async predicates now route through it — closing the
class of bug where Preview reported Identical for an artifact an Overwrite would
mutate. The attribute sync passes a value-normalised DTO so List-value re-imports
stay idempotent; the alarm on-trigger change now surfaces in both diff and sync.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-09 17:17:18 -04:00
parent 33cf577718
commit 38edfefc42
4 changed files with 228 additions and 73 deletions
@@ -150,6 +150,91 @@ public sealed class ArtifactDiffTests
Assert.Contains(("add", "new2"), hunks);
}
// ============ #05-T6: single-source child equality — diff sees the full field set ============
[Fact]
public void CompareTemplate_AttributeElementDataTypeChange_IsModified()
{
var existing = MakeTemplate("T1");
existing.Attributes.Add(new TemplateAttribute("L")
{
DataType = DataType.List,
ElementDataType = DataType.Int32,
Value = "[1,2]",
});
var incoming = MakeTemplateDto("T1", attributes:
[
new TemplateAttributeDto("L", "[1,2]", DataType.List, false, null, null, DataType.String),
]);
var item = _diff.CompareTemplate(incoming, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
ChangeFor(item, "Attributes.L"); // ElementDataType diverged — previously missed.
}
[Fact]
public void CompareTemplate_LockedInDerivedChange_IsModified()
{
var existing = MakeTemplate("T1");
existing.Attributes.Add(new TemplateAttribute("L")
{
DataType = DataType.Int32,
Value = "1",
LockedInDerived = false,
});
var incoming = MakeTemplateDto("T1", attributes:
[
new TemplateAttributeDto("L", "1", DataType.Int32, false, null, null, null, LockedInDerived: true),
]);
var item = _diff.CompareTemplate(incoming, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
ChangeFor(item, "Attributes.L"); // LockedInDerived diverged — previously missed.
}
[Fact]
public void CompareTemplate_ScriptExecutionTimeoutChange_IsModified()
{
var existing = MakeTemplate("T1");
existing.Scripts.Add(new TemplateScript("S", "return 1;") { ExecutionTimeoutSeconds = 30 });
var incoming = MakeTemplateDto("T1", scripts:
[
// identical code; only ExecutionTimeoutSeconds differs.
new TemplateScriptDto("S", "return 1;", null, null, null, null, false, null, ExecutionTimeoutSeconds: 60),
]);
var item = _diff.CompareTemplate(incoming, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
ChangeFor(item, "Scripts.S"); // ExecutionTimeoutSeconds diverged — previously missed.
}
[Fact]
public void CompareTemplate_AlarmOnTriggerScriptChange_IsModified()
{
var existing = MakeTemplate("T1");
existing.Scripts.Add(new TemplateScript("Run", "return 1;") { Id = 1 });
existing.Alarms.Add(new TemplateAlarm("A")
{
TriggerType = AlarmTriggerType.RangeViolation,
OnTriggerScriptId = 1, // resolves to "Run"
});
var incoming = MakeTemplateDto("T1",
scripts: [new TemplateScriptDto("Run", "return 1;", null, null, null, null, false, null)],
alarms:
[
// same alarm fields but no on-trigger script (was "Run").
new TemplateAlarmDto("A", null, 0, AlarmTriggerType.RangeViolation, null, false, OnTriggerScriptName: null),
]);
var item = _diff.CompareTemplate(incoming, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
ChangeFor(item, "Alarms.A"); // OnTriggerScriptName diverged — previously missed.
}
// ============ M8: CompareSite ============
[Fact]
@@ -433,14 +518,18 @@ public sealed class ArtifactDiffTests
private static Commons.Entities.Templates.Template MakeTemplate(string name) =>
new(name);
private static TemplateDto MakeTemplateDto(string name, IReadOnlyList<TemplateScriptDto>? scripts = null) =>
private static TemplateDto MakeTemplateDto(
string name,
IReadOnlyList<TemplateScriptDto>? scripts = null,
IReadOnlyList<TemplateAttributeDto>? attributes = null,
IReadOnlyList<TemplateAlarmDto>? alarms = null) =>
new(
Name: name,
FolderName: null,
BaseTemplateName: null,
Description: null,
Attributes: Array.Empty<TemplateAttributeDto>(),
Alarms: Array.Empty<TemplateAlarmDto>(),
Attributes: attributes ?? Array.Empty<TemplateAttributeDto>(),
Alarms: alarms ?? Array.Empty<TemplateAlarmDto>(),
Scripts: scripts ?? Array.Empty<TemplateScriptDto>(),
Compositions: Array.Empty<TemplateCompositionDto>());