test(overrides): case-variant override names verified end to end through the flattener; UI single path gets the hint (ScadaBridge#105)

Re-verified #105 item 2 on the real write paths: ManagementActor bulk attribute
overrides (CLI set-overrides / management API) and InstanceService.SetAttributeOverrideAsync
(Central UI single editor) both match the template member ordinally and refuse a
case-variant name, so nothing is stored for the flattener to drop. Native-source
overrides already validate against the flattener's own output.

- New theory tests drive each path, feed whatever it stored into the real
  FlatteningService, and assert every stored override is applied (exact-case row is
  the positive control). Mutation: accepting case variants (OrdinalIgnoreCase) fails
  them with "stored ... but the flattener dropped it as unresolved".
- The did-you-mean hint moves to TemplateEngine.OverrideNameMatching and is now also
  on the UI single-attribute path; ambiguous matches get no hint.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-13 02:13:10 -05:00
parent bba3c348bc
commit 3a59c3f638
5 changed files with 164 additions and 5 deletions
@@ -1170,10 +1170,7 @@ public class ManagementActor : ReceiveActor
/// an ambiguous match gets no hint.
/// </summary>
internal static string CaseMismatchHint(string name, IEnumerable<string> candidates)
{
var matches = candidates.Where(c => string.Equals(c, name, StringComparison.OrdinalIgnoreCase)).Distinct(StringComparer.Ordinal).Take(2).ToList();
return matches.Count == 1 ? $" (names are case-sensitive; did you mean '{matches[0]}'?)" : string.Empty;
}
=> OverrideNameMatching.CaseMismatchHint(name, candidates);
private static async Task<object?> HandleSetInstanceOverrides(IServiceProvider sp, SetInstanceOverridesCommand cmd, AuthenticatedUser user)
{
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine;
/// <summary>
/// ScadaBridge#105: instance override names address a template member by its exact
/// (ordinal) name, because the flattener matches overrides ordinally and silently skips
/// one it cannot resolve. Every override write path (ManagementActor bulk attribute and
/// native-source commands, used by the CLI and the management API; and
/// <see cref="Services.InstanceService.SetAttributeOverrideAsync"/>, used by the Central UI)
/// therefore rejects a name that differs from a member only by case instead of storing a
/// dead override. This helper builds the shared "did you mean" hint for that rejection.
/// </summary>
public static class OverrideNameMatching
{
/// <summary>
/// Returns a hint naming the single candidate that equals <paramref name="name"/>
/// case-insensitively, or an empty string when there is no such candidate or the
/// case-insensitive match is ambiguous.
/// </summary>
/// <param name="name">The override name that did not match any member exactly.</param>
/// <param name="candidates">The member names the override could address.</param>
/// <returns>A hint suffix (leading space, no trailing period), or an empty string.</returns>
public static string CaseMismatchHint(string name, IEnumerable<string> candidates)
{
ArgumentNullException.ThrowIfNull(candidates);
var matches = candidates
.Where(c => string.Equals(c, name, StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.Ordinal)
.Take(2)
.ToList();
return matches.Count == 1 ? $" (names are case-sensitive; did you mean '{matches[0]}'?)" : string.Empty;
}
}
@@ -168,7 +168,9 @@ public class InstanceService
var templateAttr = templateAttrs.FirstOrDefault(a => a.Name == attributeName);
if (templateAttr == null)
return Result<InstanceAttributeOverride>.Failure(
$"Attribute '{attributeName}' does not exist in template {instance.TemplateId}. Cannot add new attributes via overrides.");
$"Attribute '{attributeName}' does not exist in template {instance.TemplateId}" +
OverrideNameMatching.CaseMismatchHint(attributeName, templateAttrs.Select(a => a.Name)) +
". Cannot add new attributes via overrides.");
if (templateAttr.IsLocked)
return Result<InstanceAttributeOverride>.Failure(