diff --git a/Assets/RelayFascia.svg b/Assets/RelayFascia.svg index a5302ad8b..9a69c173b 100644 --- a/Assets/RelayFascia.svg +++ b/Assets/RelayFascia.svg @@ -1,47 +1,156 @@ - - - - - Layer 1 - - - - - - - - - - - - - - - - - - + + + ARSAS Premium IED Icon + Premium vector icon of a protection relay front fascia for ARSAS IED cards. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ARSAS + IED PREMIUM + P618 + PROTECTION + + + + + + + + BAY 1 + 33kV + + + + + + + + + + + TRIP + + ALM + + PKP + + + + + + + + + + + + OK + - - - - - - - - - - - - - - - - - - - - - - diff --git a/Models/IoTesting/IoSignalDisplayName.cs b/Models/IoTesting/IoSignalDisplayName.cs index 78303a978..e0130642f 100644 --- a/Models/IoTesting/IoSignalDisplayName.cs +++ b/Models/IoTesting/IoSignalDisplayName.cs @@ -16,15 +16,132 @@ public static string Format(string? signalName, string? iecReference) { var name = string.IsNullOrWhiteSpace(signalName) ? "Signal" : signalName.Trim(); var reference = iecReference?.Trim() ?? string.Empty; - var match = PhaseToken().Match(reference); - if (!match.Success) - return name; - var phase = match.Groups["phase"].Value.ToUpperInvariant(); - var suffix = $"Phs{phase}"; - if (name.EndsWith($" {suffix}", StringComparison.OrdinalIgnoreCase)) - return name; + var phaseMatch = PhaseToken().Match(reference); + if (phaseMatch.Success) + { + var phase = phaseMatch.Groups["phase"].Value.ToUpperInvariant(); + var suffix = $"Phs{phase}"; + if (name.EndsWith($" {suffix}", StringComparison.OrdinalIgnoreCase)) + return name; - return $"{name} {suffix}"; + return $"{name} {suffix}"; + } + + if (TryExtractLogicalNodeContext(reference, out var logicalNodeClass, out var dataObject) + && RequiresLogicalNodeOwner(dataObject) + && name.Equals(dataObject, StringComparison.OrdinalIgnoreCase)) + { + return $"{logicalNodeClass} {name}"; + } + + return name; + } + + private static bool TryExtractLogicalNodeContext( + string reference, + out string logicalNodeClass, + out string dataObject) + { + logicalNodeClass = string.Empty; + dataObject = string.Empty; + + if (string.IsNullOrWhiteSpace(reference)) + return false; + + var slashIndex = reference.LastIndexOf('/'); + var tail = slashIndex >= 0 && slashIndex + 1 < reference.Length + ? reference[(slashIndex + 1)..] + : reference; + + var dotIndex = tail.IndexOf('.'); + var dollarIndex = tail.IndexOf('$'); + var separatorIndex = dotIndex switch + { + >= 0 when dollarIndex >= 0 => Math.Min(dotIndex, dollarIndex), + >= 0 => dotIndex, + _ => dollarIndex + }; + + if (separatorIndex <= 0 || separatorIndex + 1 >= tail.Length) + return false; + + var logicalNodeToken = tail[..separatorIndex].Trim(); + if (!TryExtractLogicalNodeClass(logicalNodeToken, out logicalNodeClass)) + return false; + + var separator = tail[separatorIndex]; + var remainder = tail[(separatorIndex + 1)..]; + if (separator == '$') + { + var tokens = remainder.Split('$', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (tokens.Length == 0) + return false; + + var dataObjectIndex = IsFunctionalConstraint(tokens[0]) ? 1 : 0; + if (dataObjectIndex >= tokens.Length) + return false; + + dataObject = NormalizeDataObjectToken(tokens[dataObjectIndex]); + } + else + { + var tokenEnd = remainder.IndexOfAny(new[] { '.', '$', '[', '(' }); + var token = tokenEnd >= 0 ? remainder[..tokenEnd] : remainder; + dataObject = NormalizeDataObjectToken(token); + } + + return !string.IsNullOrWhiteSpace(dataObject); } + + private static bool TryExtractLogicalNodeClass(string logicalNodeToken, out string logicalNodeClass) + { + logicalNodeClass = string.Empty; + if (string.IsNullOrWhiteSpace(logicalNodeToken)) + return false; + + var token = logicalNodeToken.Trim(); + if (token.Equals("LLN0", StringComparison.OrdinalIgnoreCase)) + { + logicalNodeClass = "LLN0"; + return true; + } + + var end = token.Length; + while (end > 0 && char.IsDigit(token[end - 1])) + end--; + + if (end == 0) + return false; + + var withoutInstance = token[..end]; + if (withoutInstance.Length >= 4) + { + var candidate = withoutInstance[^4..]; + if (candidate.All(char.IsLetter)) + { + logicalNodeClass = candidate.ToUpperInvariant(); + return true; + } + } + + logicalNodeClass = withoutInstance.ToUpperInvariant(); + return true; + } + + private static string NormalizeDataObjectToken(string token) + { + var value = token.Trim(); + var end = value.IndexOfAny(new[] { '.', '$', '[', '(' }); + return (end >= 0 ? value[..end] : value).Trim(); + } + + private static bool RequiresLogicalNodeOwner(string dataObject) + => dataObject.ToUpperInvariant() is "POS" or "MOD" or "BEH" or "HEALTH" or "LOC" or "OPCNT"; + + private static bool IsFunctionalConstraint(string token) + => token.ToUpperInvariant() is + "ST" or "MX" or "CO" or "SP" or "SV" or "CF" or "DC" or "SG" or "SE" or + "SR" or "OR" or "BL" or "EX" or "RP" or "BR" or "LG" or "GO" or "GS" or + "MS" or "US"; } diff --git a/Resources/ArvrelMiniIedFascia.xaml b/Resources/ArvrelMiniIedFascia.xaml index bbe693f8b..e9d3e2aa9 100644 --- a/Resources/ArvrelMiniIedFascia.xaml +++ b/Resources/ArvrelMiniIedFascia.xaml @@ -3,84 +3,255 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + - + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + + + + + + diff --git a/Services/IoTesting/FatDataSetSignalProjectionService.cs b/Services/IoTesting/FatDataSetSignalProjectionService.cs index 1b78c2072..fef7620ae 100644 --- a/Services/IoTesting/FatDataSetSignalProjectionService.cs +++ b/Services/IoTesting/FatDataSetSignalProjectionService.cs @@ -79,6 +79,8 @@ private static FatVerificationSignal CreateRow( staticReference); var dataType = FirstNonEmpty(descriptor.MmsType, descriptor.SclBType, "Unknown"); var signalKind = Classify(descriptor.FunctionalConstraint, dataType); + var rawSignalName = FirstNonEmpty(descriptor.DataObject, descriptor.DataAttributePath, staticReference); + var displayReference = FirstNonEmpty(runtimeReference, staticReference); return new FatVerificationSignal { @@ -94,7 +96,7 @@ private static FatVerificationSignal CreateRow( DataSetMemberIndex = membership.MemberIndex, StaticMemberReference = staticReference, RuntimeReference = runtimeReference, - SignalName = FirstNonEmpty(descriptor.DataObject, descriptor.DataAttributePath, staticReference), + SignalName = IoFatSignalDisplayNameFormatter.Format(rawSignalName, displayReference), FunctionalConstraint = descriptor.FunctionalConstraint, DataType = dataType, SignalKind = signalKind, diff --git a/tests/ARSAS.Tests/ArvrelMiniIedFasciaTests.cs b/tests/ARSAS.Tests/ArvrelMiniIedFasciaTests.cs index 775a690d2..6230cbc92 100644 --- a/tests/ARSAS.Tests/ArvrelMiniIedFasciaTests.cs +++ b/tests/ARSAS.Tests/ArvrelMiniIedFasciaTests.cs @@ -27,13 +27,12 @@ public void CompactArvrelFascia_IsVectorOnlyAndKeepsRecognizableRelayHardware() .Cast() .ToHashSet(StringComparer.Ordinal); - Assert.Contains("RelayFasciaArtwork", namedParts); Assert.Contains("RelayStateRail", namedParts); // The operator-supplied SVG is transcribed to native WPF vector primitives so no // raster/image dependency is introduced at card scale. Assert.Empty(template.Descendants(presentation + "Image")); - Assert.Empty(template.Descendants(presentation + "TextBlock")); + Assert.NotEmpty(template.Descendants(presentation + "TextBlock")); Assert.NotEmpty(template.Descendants(presentation + "Rectangle")); Assert.NotEmpty(template.Descendants(presentation + "Ellipse")); Assert.NotEmpty(template.Descendants(presentation + "Line")); @@ -41,10 +40,11 @@ public void CompactArvrelFascia_IsVectorOnlyAndKeepsRecognizableRelayHardware() var templateText = template.ToString(); Assert.Contains("{TemplateBinding Foreground}", templateText, StringComparison.Ordinal); - Assert.Contains("#C0C0C0", templateText, StringComparison.OrdinalIgnoreCase); - Assert.Contains("#F2F2F2", templateText, StringComparison.OrdinalIgnoreCase); - Assert.Contains("#FF0000", templateText, StringComparison.OrdinalIgnoreCase); - Assert.Contains("#00FF7F", templateText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ArsasIedConnectionLed", document.ToString(), StringComparison.Ordinal); + Assert.Contains("{Binding IsConnected}", document.ToString(), StringComparison.Ordinal); + Assert.Contains("{Binding IsLiveConnected}", document.ToString(), StringComparison.Ordinal); + Assert.Contains("#FF5538", document.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("#2DE57A", document.ToString(), StringComparison.OrdinalIgnoreCase); } [Fact] diff --git a/tests/ARSAS.Tests/IoSignalDisplayNameTests.cs b/tests/ARSAS.Tests/IoSignalDisplayNameTests.cs new file mode 100644 index 000000000..041f2308a --- /dev/null +++ b/tests/ARSAS.Tests/IoSignalDisplayNameTests.cs @@ -0,0 +1,68 @@ +using ArIED61850Tester.Models.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoSignalDisplayNameTests +{ + [Theory] + [InlineData("IED1MEAS/MMXU1.A.phsA.cVal.mag.f", "A PhsA")] + [InlineData("IED1MEAS/MMXU1.A.phsB.cVal.mag.f", "A PhsB")] + [InlineData("IED1MEAS/MMXU1.A.phsC.cVal.mag.f", "A PhsC")] + public void Format_AppendsPhaseContextWithoutAddingMeasurementOwner(string reference, string expected) + { + Assert.Equal(expected, IoSignalDisplayName.Format("A", reference)); + } + + [Theory] + [InlineData("IED1CTRL/XCBR1.Pos.stVal", "XCBR Pos")] + [InlineData("IED1CTRL/CSWI1.Pos.stVal", "CSWI Pos")] + [InlineData("IED1CTRL/XCBR1$ST$Pos$stVal", "XCBR Pos")] + public void Format_QualifiesAmbiguousDataObjectWithLogicalNodeOwner(string reference, string expected) + { + Assert.Equal(expected, IoSignalDisplayName.Format("Pos", reference)); + } + + [Theory] + [InlineData("Mod", "IED1CTRL/XCBR1.Mod.stVal", "XCBR Mod")] + [InlineData("Beh", "IED1CTRL/CSWI1.Beh.stVal", "CSWI Beh")] + [InlineData("Health", "IED1CTRL/LPHD1.Health.stVal", "LPHD Health")] + [InlineData("Loc", "IED1CTRL/CSWI1.Loc.stVal", "CSWI Loc")] + [InlineData("OpCnt", "IED1CTRL/XCBR1.OpCnt.stVal", "XCBR OpCnt")] + public void Format_UsesSameOwnerRuleForOtherGenericIecDataObjects( + string signalName, + string reference, + string expected) + { + Assert.Equal(expected, IoSignalDisplayName.Format(signalName, reference)); + } + + [Fact] + public void Format_DoesNotDuplicateExistingOwnerContext() + { + Assert.Equal( + "XCBR Pos", + IoSignalDisplayName.Format("XCBR Pos", "IED1CTRL/XCBR1.Pos.stVal")); + } + + [Fact] + public void Format_LeavesSpecificDataObjectNameConcise() + { + Assert.Equal( + "Dig01", + IoSignalDisplayName.Format("Dig01", "IED1ADD/GGIO1.Dig01.stVal")); + } + + [Fact] + public void Format_LeavesCustomLabelUntouched() + { + Assert.Equal( + "Breaker position", + IoSignalDisplayName.Format("Breaker position", "IED1CTRL/XCBR1.Pos.stVal")); + } + + [Fact] + public void Format_FallsBackGracefullyWithoutReference() + { + Assert.Equal("Pos", IoSignalDisplayName.Format("Pos", null)); + } +} diff --git a/tests/ARSAS.Tests/P1RelayBenchHotfixRegressionTests.cs b/tests/ARSAS.Tests/P1RelayBenchHotfixRegressionTests.cs index 7d10c9926..f858b8570 100644 --- a/tests/ARSAS.Tests/P1RelayBenchHotfixRegressionTests.cs +++ b/tests/ARSAS.Tests/P1RelayBenchHotfixRegressionTests.cs @@ -76,13 +76,13 @@ public void RelayFascia_UsesUploadedSvgArtworkAsVectorSource() var fascia = Read("Resources/ArvrelMiniIedFascia.xaml"); var svg = Read("Assets/RelayFascia.svg"); - Assert.Contains("direct WPF vector transcription", fascia, StringComparison.OrdinalIgnoreCase); - Assert.Contains("x:Name=\"RelayFasciaArtwork\"", fascia, StringComparison.Ordinal); + Assert.Contains("WPF vector runtime counterpart", fascia, StringComparison.OrdinalIgnoreCase); + Assert.Contains("x:Key=\"ArsasIedConnectionLed\"", fascia, StringComparison.Ordinal); Assert.Contains("x:Name=\"RelayStateRail\"", fascia, StringComparison.Ordinal); - Assert.Contains("#C0C0C0", fascia, StringComparison.OrdinalIgnoreCase); - Assert.Contains("#FF0000", fascia, StringComparison.OrdinalIgnoreCase); - Assert.Contains("id=\"svg_1\"", svg, StringComparison.Ordinal); - Assert.Contains("id=\"svg_44\"", svg, StringComparison.Ordinal); + Assert.Contains("#FF5538", fascia, StringComparison.OrdinalIgnoreCase); + Assert.Contains("#2DE57A", fascia, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ARSAS Premium IED Icon", svg, StringComparison.Ordinal); + Assert.Contains("id=\"ledGreen\"", svg, StringComparison.Ordinal); } private static string Read(string relativePath) diff --git a/tests/ARSAS.Tests/P2BlueSteelGreigeThemeTests.cs b/tests/ARSAS.Tests/P2BlueSteelGreigeThemeTests.cs index 82a3c85d3..930897d0e 100644 --- a/tests/ARSAS.Tests/P2BlueSteelGreigeThemeTests.cs +++ b/tests/ARSAS.Tests/P2BlueSteelGreigeThemeTests.cs @@ -72,15 +72,14 @@ public void RelayFascia_UsesOperatorSuppliedSvgVectorAndKeepsSemanticStateRail() var source = File.ReadAllText(FindRepoFile("Resources/ArvrelMiniIedFascia.xaml")); var svg = File.ReadAllText(FindRepoFile("Assets/RelayFascia.svg")); - Assert.Contains("RelayFasciaArtwork", source, StringComparison.Ordinal); - Assert.Contains("#C0C0C0", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("#F2F2F2", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("#FF0000", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ArsasIedConnectionLed", source, StringComparison.Ordinal); + Assert.Contains("#FF5538", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("#2DE57A", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("{TemplateBinding Foreground}", source, StringComparison.Ordinal); Assert.Contains("RelayStateRail", source, StringComparison.Ordinal); - Assert.Contains("width=\"424.99999999999994\"", svg, StringComparison.Ordinal); - Assert.Contains("id=\"svg_44\"", svg, StringComparison.Ordinal); - Assert.Contains("#00ff7f", svg, StringComparison.OrdinalIgnoreCase); + Assert.Contains("width=\"256\"", svg, StringComparison.Ordinal); + Assert.Contains("id=\"ledRed\"", svg, StringComparison.Ordinal); + Assert.Contains("id=\"ledGreen\"", svg, StringComparison.Ordinal); } private static string FindRepoFile(string relativePath)