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:
@@ -313,6 +313,59 @@ public class SandboxAttributeAccessor
|
||||
_ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Batch-write/wait helpers. These mirror the runtime AttributeAccessor
|
||||
// (SiteRuntime/Scripts/ScopeAccessors.cs) and the deploy-gate
|
||||
// ScriptCompileSurface member-for-member so instance scripts using them COMPILE
|
||||
// in the editor and pass Test Run analysis (follow-up #7) — previously the
|
||||
// sandbox omitted them and the editor false-flagged valid scripts with CS1061.
|
||||
// Execution needs the site's DCL batch path + event-driven attribute waiter,
|
||||
// for which the central Test Run sandbox has no transport, so each throws a
|
||||
// clearly-labelled ScriptSandboxException; the same code validates/deploys/runs
|
||||
// unchanged at a site.
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox stand-in for <c>AttributeAccessor.WriteBatchAndWaitAsync</c>: present
|
||||
/// for editor/compile parity, throws <see cref="ScriptSandboxException"/> when run
|
||||
/// in Test Run (no device batch-write transport here).
|
||||
/// </summary>
|
||||
public Task<bool> WriteBatchAndWaitAsync(
|
||||
IReadOnlyDictionary<string, object?> values, string flagKey, object? flagValue,
|
||||
string responseKey, object? responseValue, TimeSpan timeout)
|
||||
=> throw NotInSandbox(nameof(WriteBatchAndWaitAsync));
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox stand-in for <c>AttributeAccessor.WaitAsync</c> (value-equality form);
|
||||
/// see <see cref="WriteBatchAndWaitAsync"/>.
|
||||
/// </summary>
|
||||
public Task<bool> WaitAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false)
|
||||
=> throw NotInSandbox(nameof(WaitAsync));
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox stand-in for <c>AttributeAccessor.WaitAsync</c> (predicate form);
|
||||
/// see <see cref="WriteBatchAndWaitAsync"/>.
|
||||
/// </summary>
|
||||
public Task<bool> WaitAsync(string key, Func<object?, bool> predicate, TimeSpan timeout, bool requireGoodQuality = false)
|
||||
=> throw NotInSandbox(nameof(WaitAsync));
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox stand-in for <c>AttributeAccessor.WaitForAsync</c> (value-equality form);
|
||||
/// see <see cref="WriteBatchAndWaitAsync"/>.
|
||||
/// </summary>
|
||||
public Task<WaitResult> WaitForAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false)
|
||||
=> throw NotInSandbox(nameof(WaitForAsync));
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox stand-in for <c>AttributeAccessor.WaitForAsync</c> (predicate form);
|
||||
/// see <see cref="WriteBatchAndWaitAsync"/>.
|
||||
/// </summary>
|
||||
public Task<WaitResult> WaitForAsync(string key, Func<object?, bool> predicate, TimeSpan timeout, bool requireGoodQuality = false)
|
||||
=> throw NotInSandbox(nameof(WaitForAsync));
|
||||
|
||||
private static ScriptSandboxException NotInSandbox(string member) =>
|
||||
new($"{member}(...) drives live device tags and the site's event-driven " +
|
||||
"attribute waiter, which aren't available in the central Test Run sandbox — " +
|
||||
"deploy to a site to exercise batch-write/wait handshakes.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -33,8 +33,9 @@ public static class CollisionDetector
|
||||
var lookup = CycleDetector.BuildLookup(allTemplates);
|
||||
var allMembers = new List<ResolvedMember>();
|
||||
|
||||
// Collect direct (top-level) members
|
||||
CollectDirectMembers(template, prefix: null, originPrefix: template.Name, allMembers);
|
||||
// Collect direct (top-level) members. Inherited placeholder rows are skipped:
|
||||
// the inheritance walk below re-adds them under the parent's origin.
|
||||
CollectDirectMembers(template, prefix: null, originPrefix: template.Name, allMembers, skipInherited: true);
|
||||
|
||||
// Collect members from composed modules recursively
|
||||
foreach (var composition in template.Compositions)
|
||||
@@ -75,26 +76,41 @@ public static class CollisionDetector
|
||||
return collisions;
|
||||
}
|
||||
|
||||
/// <param name="skipInherited">
|
||||
/// When <c>true</c>, <c>IsInherited</c> placeholder rows are skipped. Those rows
|
||||
/// are materialized copies of members the template inherits from its parent
|
||||
/// chain, and they are ALSO re-added — under the parent's name — by
|
||||
/// <see cref="CollectInheritedMembers"/>. Counting both yielded two distinct
|
||||
/// origins for the same canonical name and a spurious collision, which blocked
|
||||
/// every attempt to add an attribute/composition to a derived template
|
||||
/// (follow-up #3). Pass <c>false</c> only for the composed-module walk, where
|
||||
/// placeholder rows are the sole representation of a derived module's inherited
|
||||
/// members (that walk does not climb the composed template's parent chain).
|
||||
/// </param>
|
||||
private static void CollectDirectMembers(
|
||||
Template template,
|
||||
string? prefix,
|
||||
string originPrefix,
|
||||
List<ResolvedMember> members)
|
||||
List<ResolvedMember> members,
|
||||
bool skipInherited)
|
||||
{
|
||||
foreach (var attr in template.Attributes)
|
||||
{
|
||||
if (skipInherited && attr.IsInherited) continue;
|
||||
var canonicalName = prefix == null ? attr.Name : $"{prefix}.{attr.Name}";
|
||||
members.Add(new ResolvedMember(canonicalName, "Attribute", originPrefix));
|
||||
}
|
||||
|
||||
foreach (var alarm in template.Alarms)
|
||||
{
|
||||
if (skipInherited && alarm.IsInherited) continue;
|
||||
var canonicalName = prefix == null ? alarm.Name : $"{prefix}.{alarm.Name}";
|
||||
members.Add(new ResolvedMember(canonicalName, "Alarm", originPrefix));
|
||||
}
|
||||
|
||||
foreach (var script in template.Scripts)
|
||||
{
|
||||
if (skipInherited && script.IsInherited) continue;
|
||||
var canonicalName = prefix == null ? script.Name : $"{prefix}.{script.Name}";
|
||||
members.Add(new ResolvedMember(canonicalName, "Script", originPrefix));
|
||||
}
|
||||
@@ -110,8 +126,11 @@ public static class CollisionDetector
|
||||
if (!visited.Add(template.Id))
|
||||
return;
|
||||
|
||||
// Add direct members of this composed template with the prefix
|
||||
CollectDirectMembers(template, prefix, $"module '{prefix}'", members);
|
||||
// Add direct members of this composed template with the prefix. Inherited
|
||||
// placeholder rows are KEPT here: the composed-module walk does not climb the
|
||||
// composed template's parent chain, so the placeholders are the only
|
||||
// representation of a derived module's inherited members.
|
||||
CollectDirectMembers(template, prefix, $"module '{prefix}'", members, skipInherited: false);
|
||||
|
||||
// Recurse into nested compositions
|
||||
foreach (var composition in template.Compositions)
|
||||
@@ -139,8 +158,10 @@ public static class CollisionDetector
|
||||
if (!visited.Add(parent.Id))
|
||||
return;
|
||||
|
||||
// Inherited direct members (no prefix)
|
||||
CollectDirectMembers(parent, prefix: null, $"parent '{parent.Name}'", members);
|
||||
// Inherited direct members (no prefix). Skip the parent's OWN inherited
|
||||
// placeholders too: the next recursion adds the grandparent's real rows, so
|
||||
// counting the parent's copies would re-introduce the collision one level up.
|
||||
CollectDirectMembers(parent, prefix: null, $"parent '{parent.Name}'", members, skipInherited: true);
|
||||
|
||||
// Inherited composed modules
|
||||
foreach (var composition in parent.Compositions)
|
||||
|
||||
Reference in New Issue
Block a user