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:
@@ -1170,10 +1170,7 @@ public class ManagementActor : ReceiveActor
|
|||||||
/// an ambiguous match gets no hint.
|
/// an ambiguous match gets no hint.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static string CaseMismatchHint(string name, IEnumerable<string> candidates)
|
internal static string CaseMismatchHint(string name, IEnumerable<string> candidates)
|
||||||
{
|
=> OverrideNameMatching.CaseMismatchHint(name, 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<object?> HandleSetInstanceOverrides(IServiceProvider sp, SetInstanceOverridesCommand cmd, AuthenticatedUser user)
|
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);
|
var templateAttr = templateAttrs.FirstOrDefault(a => a.Name == attributeName);
|
||||||
if (templateAttr == null)
|
if (templateAttr == null)
|
||||||
return Result<InstanceAttributeOverride>.Failure(
|
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)
|
if (templateAttr.IsLocked)
|
||||||
return Result<InstanceAttributeOverride>.Failure(
|
return Result<InstanceAttributeOverride>.Failure(
|
||||||
|
|||||||
@@ -2831,6 +2831,66 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
_templateRepo.DidNotReceiveWithAnyArgs().AddInstanceAttributeOverrideAsync(default!, default);
|
_templateRepo.DidNotReceiveWithAnyArgs().AddInstanceAttributeOverrideAsync(default!, default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ScadaBridge#105, end to end: drive the real ManagementActor bulk attribute override
|
||||||
|
/// path (the CLI <c>instance set-overrides</c> / management API path), capture whatever it
|
||||||
|
/// stores, and run the stored overrides through the real <see cref="ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.FlatteningService"/>.
|
||||||
|
/// Invariant: every override the management path accepts is applied by the flattener —
|
||||||
|
/// none is stored and then silently dropped as unresolved. The exact-case row is the
|
||||||
|
/// positive control (stored AND applied); the case-variant rows must be refused.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Setpoint", true)]
|
||||||
|
[InlineData("setpoint", false)]
|
||||||
|
[InlineData("SETPOINT", false)]
|
||||||
|
public void SetInstanceOverrides_EveryStoredOverride_IsAppliedByTheFlattener(string requestedName, bool accepted)
|
||||||
|
{
|
||||||
|
var template = new Template("PumpTemplate") { Id = 1 };
|
||||||
|
template.Attributes.Add(new TemplateAttribute("Setpoint") { Id = 1, TemplateId = 1, DataType = DataType.Double, Value = "0" });
|
||||||
|
var instance = new Instance("Pump6") { Id = 6, SiteId = 1, TemplateId = 1 };
|
||||||
|
_templateRepo.GetInstanceByIdAsync(6, Arg.Any<CancellationToken>()).Returns(instance);
|
||||||
|
_templateRepo.GetAttributesByTemplateIdAsync(1, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(template.Attributes.ToList());
|
||||||
|
_templateRepo.GetOverridesByInstanceIdAsync(6, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<Commons.Entities.Instances.InstanceAttributeOverride>());
|
||||||
|
var stored = new List<Commons.Entities.Instances.InstanceAttributeOverride>();
|
||||||
|
_templateRepo
|
||||||
|
.When(r => r.AddInstanceAttributeOverrideAsync(Arg.Any<Commons.Entities.Instances.InstanceAttributeOverride>(), Arg.Any<CancellationToken>()))
|
||||||
|
.Do(ci => stored.Add(ci.Arg<Commons.Entities.Instances.InstanceAttributeOverride>()));
|
||||||
|
|
||||||
|
var actor = CreateActor();
|
||||||
|
actor.Tell(Envelope(new SetInstanceOverridesCommand(6, new Dictionary<string, string?> { [requestedName] = "42" }), "Deployer"));
|
||||||
|
var response = ExpectMsg<object>(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
// Feed what the management path persisted into the real flattener.
|
||||||
|
foreach (var o in stored) instance.AttributeOverrides.Add(o);
|
||||||
|
var flattened = new ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.FlatteningService().Flatten(
|
||||||
|
instance, [template],
|
||||||
|
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||||
|
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||||
|
new Dictionary<int, DataConnection>());
|
||||||
|
Assert.True(flattened.IsSuccess, flattened.IsFailure ? flattened.Error : null);
|
||||||
|
foreach (var o in stored)
|
||||||
|
Assert.True(
|
||||||
|
flattened.Value.Attributes.Any(a => a.CanonicalName == o.AttributeName && a.Source == "Override"),
|
||||||
|
$"Override '{o.AttributeName}' was stored by the management path but the flattener dropped it as unresolved.");
|
||||||
|
|
||||||
|
var setpoint = Assert.Single(flattened.Value.Attributes, a => a.CanonicalName == "Setpoint");
|
||||||
|
if (accepted)
|
||||||
|
{
|
||||||
|
Assert.IsType<ManagementSuccess>(response);
|
||||||
|
Assert.Equal("42", setpoint.Value);
|
||||||
|
Assert.Equal("Override", setpoint.Source);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var error = Assert.IsType<ManagementError>(response);
|
||||||
|
Assert.Contains("did you mean 'Setpoint'", error.Error);
|
||||||
|
Assert.Empty(stored);
|
||||||
|
Assert.Equal("0", setpoint.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>ScadaBridge#105: a bulk native-source override differing only by case is rejected, nothing stored.</summary>
|
/// <summary>ScadaBridge#105: a bulk native-source override differing only by case is rejected, nothing stored.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SetInstanceNativeAlarmSourceOverrides_CaseVariantName_IsRejectedWithHint()
|
public void SetInstanceNativeAlarmSourceOverrides_CaseVariantName_IsRejectedWithHint()
|
||||||
|
|||||||
@@ -133,6 +133,74 @@ public class InstanceServiceTests
|
|||||||
Assert.Equal("99", result.Value.OverrideValue);
|
Assert.Equal("99", result.Value.OverrideValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ScadaBridge#105, end to end for the single-attribute path the Central UI uses
|
||||||
|
/// (<see cref="InstanceService.SetAttributeOverrideAsync"/>): whatever it stores must be
|
||||||
|
/// applied by the real flattener. The exact-case row is the positive control; a name
|
||||||
|
/// differing from the member only by case is refused with a hint and nothing is stored.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="requestedName">The override name the caller sends.</param>
|
||||||
|
/// <param name="accepted">Whether the write is expected to succeed.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Threshold", true)]
|
||||||
|
[InlineData("threshold", false)]
|
||||||
|
[InlineData("THRESHOLD", false)]
|
||||||
|
public async Task SetAttributeOverride_EveryStoredOverride_IsAppliedByTheFlattener(string requestedName, bool accepted)
|
||||||
|
{
|
||||||
|
var template = new Template("Base") { Id = 1 };
|
||||||
|
template.Attributes.Add(new TemplateAttribute("Threshold") { TemplateId = 1, DataType = DataType.Double, Value = "50.0" });
|
||||||
|
var instance = new Instance("Inst1") { Id = 1, TemplateId = 1, SiteId = 1 };
|
||||||
|
_repoMock.Setup(r => r.GetInstanceByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(instance);
|
||||||
|
_repoMock.Setup(r => r.GetAttributesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(template.Attributes.ToList());
|
||||||
|
_repoMock.Setup(r => r.GetOverridesByInstanceIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<InstanceAttributeOverride>());
|
||||||
|
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
|
||||||
|
var stored = new List<InstanceAttributeOverride>();
|
||||||
|
_repoMock.Setup(r => r.AddInstanceAttributeOverrideAsync(It.IsAny<InstanceAttributeOverride>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<InstanceAttributeOverride, CancellationToken>((o, _) => stored.Add(o))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var result = await _sut.SetAttributeOverrideAsync(1, requestedName, "75.0", "admin");
|
||||||
|
|
||||||
|
foreach (var o in stored) instance.AttributeOverrides.Add(o);
|
||||||
|
var flattened = new ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.FlatteningService().Flatten(
|
||||||
|
instance, [template],
|
||||||
|
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||||
|
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||||
|
new Dictionary<int, ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection>());
|
||||||
|
Assert.True(flattened.IsSuccess);
|
||||||
|
foreach (var o in stored)
|
||||||
|
Assert.True(
|
||||||
|
flattened.Value.Attributes.Any(a => a.CanonicalName == o.AttributeName && a.Source == "Override"),
|
||||||
|
$"Override '{o.AttributeName}' was stored but the flattener dropped it as unresolved.");
|
||||||
|
|
||||||
|
var threshold = Assert.Single(flattened.Value.Attributes, a => a.CanonicalName == "Threshold");
|
||||||
|
if (accepted)
|
||||||
|
{
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal("75.0", threshold.Value);
|
||||||
|
Assert.Equal("Override", threshold.Source);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("did you mean 'Threshold'", result.Error);
|
||||||
|
Assert.Empty(stored);
|
||||||
|
Assert.Equal("50.0", threshold.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ScadaBridge#105: an ambiguous case-insensitive match (two candidates differing only by case) gets no hint.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void CaseMismatchHint_NamesOnlyAnUnambiguousMatch()
|
||||||
|
{
|
||||||
|
Assert.Contains("did you mean 'Setpoint'", OverrideNameMatching.CaseMismatchHint("setpoint", ["Setpoint", "Other"]));
|
||||||
|
Assert.Equal(string.Empty, OverrideNameMatching.CaseMismatchHint("setpoint", ["Setpoint", "SETPOINT"]));
|
||||||
|
Assert.Equal(string.Empty, OverrideNameMatching.CaseMismatchHint("missing", ["Setpoint"]));
|
||||||
|
}
|
||||||
|
|
||||||
// --- SetAlarmOverrideAsync validation regression ---
|
// --- SetAlarmOverrideAsync validation regression ---
|
||||||
|
|
||||||
private static Template TemplateWithAlarms(int id, params TemplateAlarm[] alarms)
|
private static Template TemplateWithAlarms(int id, params TemplateAlarm[] alarms)
|
||||||
|
|||||||
Reference in New Issue
Block a user