fix(templateengine+centralui): resolve follow-ups #3 (derived-template collisions) and #7 (sandbox batch/wait surface)

#3 — CollisionDetector counted a derived template's IsInherited placeholder
rows as a distinct origin from the parent members the inheritance walk
re-adds, reporting a spurious "Naming collision" for every inherited row and
blocking any attribute/composition add to a derived template. CollectDirectMembers
now skips IsInherited rows on the direct-template and inherited-parent walks;
it keeps them for the composed-module walk, where placeholders are the sole
representation of a derived module's inherited members (that walk does not
climb the composed template's parent chain).

#7 — SandboxAttributeAccessor (Central UI Test-Run host) omitted
WriteBatchAndWaitAsync / WaitAsync / WaitForAsync, so the editor false-flagged
valid instance scripts with CS1061 even though `template validate` and the
deploy gate accept them. Added the five overloads mirroring the runtime
AttributeAccessor; they throw a labelled ScriptSandboxException if run in
Test Run (the central sandbox has no device-batch / event-waiter transport).

Tests: +3 CollisionDetector unit + 1 end-to-end TemplateService (derived add
now succeeds); +2 ScriptAnalysisService diagnose-clean. Each new test verified
to fail without its fix with the exact user-facing symptom. Full suites green
(TemplateEngine.Tests 438, CentralUI.Tests 866).

Docs: Component-TemplateEngine.md (inherited-placeholder collision rule),
Component-ScriptAnalysis.md (third sandbox surface + its compile-clean guard),
known-issues tracker #3/#7 marked resolved and the minor note promoted to #8.
This commit is contained in:
Joseph Doherty
2026-06-24 15:03:27 -04:00
parent 1a647cf1c4
commit b3f6833b36
8 changed files with 254 additions and 12 deletions
@@ -220,6 +220,37 @@ public class TemplateServiceTests
Assert.Equal(1, result.Value.TemplateId);
}
[Fact]
public async Task AddAttribute_ToDerivedTemplateWithInheritedPlaceholders_Succeeds()
{
// Follow-up #3 (end-to-end). Adding a member to a derived template used to
// fail: CloneTemplateWithNewAttribute carries the child's IsInherited
// placeholder rows into DetectCollisions, which counted each one twice (child
// origin + the parent the inheritance walk re-adds) and reported a spurious
// "Naming collision" for every inherited attribute — there was no supported
// API path to extend a derived template. The new attribute must now save.
var parent = new Template("ReactorSide") { Id = 7 };
parent.Attributes.Add(new TemplateAttribute("DeltaVac") { Id = 70, TemplateId = 7, DataType = DataType.Float });
parent.Attributes.Add(new TemplateAttribute("ResultType") { Id = 71, TemplateId = 7, DataType = DataType.String });
var child = new Template("LeftReactorSide") { Id = 8, ParentTemplateId = 7 };
child.Attributes.Add(new TemplateAttribute("DeltaVac") { Id = 80, TemplateId = 8, DataType = DataType.Float, IsInherited = true });
child.Attributes.Add(new TemplateAttribute("ResultType") { Id = 81, TemplateId = 8, DataType = DataType.String, IsInherited = true });
_repoMock.Setup(r => r.GetTemplateByIdAsync(8, It.IsAny<CancellationToken>())).ReturnsAsync(child);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { parent, child });
var attr = new TemplateAttribute("MoveInType") { DataType = DataType.String, Value = "" };
var result = await _service.AddAttributeAsync(8, attr, "admin");
// Guard the message: Result.Error throws on a success result, so only read
// it when the add actually failed (which is the case this test regresses).
Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null);
Assert.Equal("MoveInType", result.Value.Name);
Assert.Equal(8, result.Value.TemplateId);
}
[Fact]
public async Task AddAttribute_DuplicateName_Fails()
{