diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index bac5f1c2..a212173a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -1140,6 +1140,18 @@ public class ManagementActor : ReceiveActor : throw new ManagementCommandException(result.Error); } + /// + /// 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. + /// + internal static string CaseMismatchHint(string name, IEnumerable 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 HandleSetInstanceOverrides(IServiceProvider sp, SetInstanceOverridesCommand cmd, AuthenticatedUser user) { await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId); @@ -1163,8 +1175,8 @@ public class ManagementActor : ReceiveActor { if (!attrsByName.TryGetValue(attrName, out var templateAttr)) throw new ManagementCommandException( - $"Attribute '{attrName}' does not exist in template {instance.TemplateId}. " + - "No overrides were applied."); + $"Attribute '{attrName}' does not exist in template {instance.TemplateId}" + + CaseMismatchHint(attrName, attrsByName.Keys) + ". No overrides were applied."); if (templateAttr.IsLocked) throw new ManagementCommandException( $"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)); if (resolvedSource == null) 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) throw new ManagementCommandException( $"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)) throw new ManagementCommandException( $"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) throw new ManagementCommandException( $"Native alarm source '{entry.SourceCanonicalName}' is locked at the template level and " + diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index 17b20579..51409e70 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -2811,6 +2811,58 @@ public class ManagementActorTests : TestKit, IDisposable // command up front WITHOUT persisting any of the overrides. // ======================================================================== + /// ScadaBridge#105: a bulk attribute override differing from the member only by case is rejected with a hint, nothing stored. + [Fact] + public void SetInstanceOverrides_CaseVariantName_IsRejectedWithHint_PersistsNothing() + { + var instance = new Instance("Pump5") { Id = 5, SiteId = 1, TemplateId = 1 }; + _templateRepo.GetInstanceByIdAsync(5, Arg.Any()).Returns(instance); + _templateRepo.GetAttributesByTemplateIdAsync(1, Arg.Any()) + .Returns(new List { new("Setpoint") { Id = 1, TemplateId = 1 } }); + _templateRepo.GetOverridesByInstanceIdAsync(5, Arg.Any()) + .Returns(new List()); + + var actor = CreateActor(); + actor.Tell(Envelope(new SetInstanceOverridesCommand(5, new Dictionary { ["setpoint"] = "1" }), "Deployer")); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("COMMAND_FAILED", response.ErrorCode); + Assert.Contains("did you mean 'Setpoint'", response.Error); + _templateRepo.DidNotReceiveWithAnyArgs().AddInstanceAttributeOverrideAsync(default!, default); + } + + /// ScadaBridge#105: a bulk native-source override differing only by case is rejected, nothing stored. + [Fact] + public void SetInstanceNativeAlarmSourceOverrides_CaseVariantName_IsRejectedWithHint() + { + StubFlattenedNativeSources("Pressure"); + var actor = CreateActor(); + actor.Tell(Envelope( + new SetInstanceNativeAlarmSourceOverridesCommand(1, new List + { + new("pressure", "Opc2", null, null) + }), + "Deployer")); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Contains("did you mean 'Pressure'", response.Error); + _templateRepo.DidNotReceiveWithAnyArgs().AddInstanceNativeAlarmSourceOverrideAsync(default!, default); + } + + /// ScadaBridge#105: a single native-source override differing only by case is rejected, nothing stored. + [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(TimeSpan.FromSeconds(5)); + Assert.Contains("did you mean 'Pressure'", response.Error); + _templateRepo.DidNotReceiveWithAnyArgs().AddInstanceNativeAlarmSourceOverrideAsync(default!, default); + } + /// Verifies that set instance overrides, when with one invalid attribute, persists no overrides. [Fact] public void SetInstanceOverrides_WithOneInvalidAttribute_PersistsNoOverrides()