fix(management): case-variant instance override names are rejected with a did-you-mean hint (ScadaBridge#105)
Verified the ManagementActor bulk attribute override and single/bulk native alarm source override paths match names ordinally and already reject a case-variant name (nothing stored); the error now names the single case-insensitive match. Tests pin the rejection. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -1140,6 +1140,18 @@ public class ManagementActor : ReceiveActor
|
|||||||
: throw new ManagementCommandException(result.Error);
|
: throw new ManagementCommandException(result.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ScadaBridge#105: override names are matched exactly (the flattener is ordinal), so a name
|
||||||
|
/// that differs from a member only by case is rejected, never stored. When exactly one
|
||||||
|
/// member matches case-insensitively, name it in the error so the caller can correct it;
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<object?> HandleSetInstanceOverrides(IServiceProvider sp, SetInstanceOverridesCommand cmd, AuthenticatedUser user)
|
private static async Task<object?> HandleSetInstanceOverrides(IServiceProvider sp, SetInstanceOverridesCommand cmd, AuthenticatedUser user)
|
||||||
{
|
{
|
||||||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||||||
@@ -1163,8 +1175,8 @@ public class ManagementActor : ReceiveActor
|
|||||||
{
|
{
|
||||||
if (!attrsByName.TryGetValue(attrName, out var templateAttr))
|
if (!attrsByName.TryGetValue(attrName, out var templateAttr))
|
||||||
throw new ManagementCommandException(
|
throw new ManagementCommandException(
|
||||||
$"Attribute '{attrName}' does not exist in template {instance.TemplateId}. " +
|
$"Attribute '{attrName}' does not exist in template {instance.TemplateId}" +
|
||||||
"No overrides were applied.");
|
CaseMismatchHint(attrName, attrsByName.Keys) + ". No overrides were applied.");
|
||||||
if (templateAttr.IsLocked)
|
if (templateAttr.IsLocked)
|
||||||
throw new ManagementCommandException(
|
throw new ManagementCommandException(
|
||||||
$"Attribute '{attrName}' is locked and cannot be overridden. No overrides were applied.");
|
$"Attribute '{attrName}' is locked and cannot be overridden. No overrides were applied.");
|
||||||
@@ -1288,7 +1300,9 @@ public class ManagementActor : ReceiveActor
|
|||||||
.FirstOrDefault(s => string.Equals(s.CanonicalName, cmd.SourceCanonicalName, StringComparison.Ordinal));
|
.FirstOrDefault(s => string.Equals(s.CanonicalName, cmd.SourceCanonicalName, StringComparison.Ordinal));
|
||||||
if (resolvedSource == null)
|
if (resolvedSource == null)
|
||||||
throw new ManagementCommandException(
|
throw new ManagementCommandException(
|
||||||
$"Native alarm source '{cmd.SourceCanonicalName}' does not resolve for this instance and cannot be overridden.");
|
$"Native alarm source '{cmd.SourceCanonicalName}' does not resolve for this instance and cannot be overridden" +
|
||||||
|
CaseMismatchHint(cmd.SourceCanonicalName,
|
||||||
|
flattenResult.Value.Configuration.NativeAlarmSources.Select(x => x.CanonicalName)) + ".");
|
||||||
if (resolvedSource.IsLocked)
|
if (resolvedSource.IsLocked)
|
||||||
throw new ManagementCommandException(
|
throw new ManagementCommandException(
|
||||||
$"Native alarm source '{cmd.SourceCanonicalName}' is locked at the template level and cannot be overridden.");
|
$"Native alarm source '{cmd.SourceCanonicalName}' is locked at the template level and cannot be overridden.");
|
||||||
@@ -1349,7 +1363,8 @@ public class ManagementActor : ReceiveActor
|
|||||||
if (!resolvedByName.TryGetValue(entry.SourceCanonicalName, out var resolvedSource))
|
if (!resolvedByName.TryGetValue(entry.SourceCanonicalName, out var resolvedSource))
|
||||||
throw new ManagementCommandException(
|
throw new ManagementCommandException(
|
||||||
$"Native alarm source '{entry.SourceCanonicalName}' does not resolve for this instance " +
|
$"Native alarm source '{entry.SourceCanonicalName}' does not resolve for this instance " +
|
||||||
"and cannot be overridden. No overrides were applied.");
|
"and cannot be overridden" + CaseMismatchHint(entry.SourceCanonicalName, resolvedByName.Keys) +
|
||||||
|
". No overrides were applied.");
|
||||||
if (resolvedSource.IsLocked)
|
if (resolvedSource.IsLocked)
|
||||||
throw new ManagementCommandException(
|
throw new ManagementCommandException(
|
||||||
$"Native alarm source '{entry.SourceCanonicalName}' is locked at the template level and " +
|
$"Native alarm source '{entry.SourceCanonicalName}' is locked at the template level and " +
|
||||||
|
|||||||
@@ -2811,6 +2811,58 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
// command up front WITHOUT persisting any of the overrides.
|
// command up front WITHOUT persisting any of the overrides.
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
|
/// <summary>ScadaBridge#105: a bulk attribute override differing from the member only by case is rejected with a hint, nothing stored.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void SetInstanceOverrides_CaseVariantName_IsRejectedWithHint_PersistsNothing()
|
||||||
|
{
|
||||||
|
var instance = new Instance("Pump5") { Id = 5, SiteId = 1, TemplateId = 1 };
|
||||||
|
_templateRepo.GetInstanceByIdAsync(5, Arg.Any<CancellationToken>()).Returns(instance);
|
||||||
|
_templateRepo.GetAttributesByTemplateIdAsync(1, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<TemplateAttribute> { new("Setpoint") { Id = 1, TemplateId = 1 } });
|
||||||
|
_templateRepo.GetOverridesByInstanceIdAsync(5, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<Commons.Entities.Instances.InstanceAttributeOverride>());
|
||||||
|
|
||||||
|
var actor = CreateActor();
|
||||||
|
actor.Tell(Envelope(new SetInstanceOverridesCommand(5, new Dictionary<string, string?> { ["setpoint"] = "1" }), "Deployer"));
|
||||||
|
|
||||||
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||||
|
Assert.Contains("did you mean 'Setpoint'", response.Error);
|
||||||
|
_templateRepo.DidNotReceiveWithAnyArgs().AddInstanceAttributeOverrideAsync(default!, default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ScadaBridge#105: a bulk native-source override differing only by case is rejected, nothing stored.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void SetInstanceNativeAlarmSourceOverrides_CaseVariantName_IsRejectedWithHint()
|
||||||
|
{
|
||||||
|
StubFlattenedNativeSources("Pressure");
|
||||||
|
var actor = CreateActor();
|
||||||
|
actor.Tell(Envelope(
|
||||||
|
new SetInstanceNativeAlarmSourceOverridesCommand(1, new List<NativeAlarmSourceOverrideEntry>
|
||||||
|
{
|
||||||
|
new("pressure", "Opc2", null, null)
|
||||||
|
}),
|
||||||
|
"Deployer"));
|
||||||
|
|
||||||
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Contains("did you mean 'Pressure'", response.Error);
|
||||||
|
_templateRepo.DidNotReceiveWithAnyArgs().AddInstanceNativeAlarmSourceOverrideAsync(default!, default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ScadaBridge#105: a single native-source override differing only by case is rejected, nothing stored.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void SetInstanceNativeAlarmSourceOverride_CaseVariantName_IsRejectedWithHint()
|
||||||
|
{
|
||||||
|
StubFlattenedNativeSources("Pressure");
|
||||||
|
var actor = CreateActor();
|
||||||
|
actor.Tell(Envelope(
|
||||||
|
new SetInstanceNativeAlarmSourceOverrideCommand(1, "PRESSURE", "Opc2", null, null), "Deployer"));
|
||||||
|
|
||||||
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Contains("did you mean 'Pressure'", response.Error);
|
||||||
|
_templateRepo.DidNotReceiveWithAnyArgs().AddInstanceNativeAlarmSourceOverrideAsync(default!, default);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that set instance overrides, when with one invalid attribute, persists no overrides.</summary>
|
/// <summary>Verifies that set instance overrides, when with one invalid attribute, persists no overrides.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SetInstanceOverrides_WithOneInvalidAttribute_PersistsNoOverrides()
|
public void SetInstanceOverrides_WithOneInvalidAttribute_PersistsNoOverrides()
|
||||||
|
|||||||
Reference in New Issue
Block a user