Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 78 additions & 8 deletions Models/IoTesting/IoSignalDisplayName.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,92 @@ namespace ArIED61850Tester.Models.IoTesting;
/// </summary>
public static partial class IoSignalDisplayName
{
private static readonly HashSet<string> OwnerQualifiedLogicalNodeClasses = new(StringComparer.OrdinalIgnoreCase)
{
"XCBR",
"XSWI",
"CSWI",
"CILO"
};

[GeneratedRegex(@"(?:^|[.$/])phs(?<phase>AB|BC|CA|A|B|C)(?:$|[.$/])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex PhaseToken();

[GeneratedRegex(@"^(?<class>[A-Z]{4})(?<instance>\d*)$", RegexOptions.CultureInvariant)]
private static partial Regex LogicalNodeClassToken();

public static string Format(string? signalName, string? iecReference)
=> Format(signalName, iecReference, logicalNode: null, dataObject: null, dataAttribute: null);

/// <summary>
/// Builds one compact engineering display name from the canonical signal identity.
/// FAT may provide parsed LN/DO/DA metadata while Engineering may provide only a complete
/// IEC reference; both routes intentionally converge on this same presentation rule.
/// </summary>
public static string Format(
string? signalName,
string? iecReference,
string? logicalNode,
string? dataObject,
string? dataAttribute)
{
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;
// Phase is DA/sub-DO context. FAT imports can retain it either in the canonical
// source reference or in DataAttribute even when a report/event lookup reference
// was normalized to a shorter form.
var phaseSource = string.IsNullOrWhiteSpace(dataAttribute)
? reference
: reference + "." + dataAttribute.Trim();
var phaseMatch = PhaseToken().Match(phaseSource);
if (phaseMatch.Success)
{
var phase = phaseMatch.Groups["phase"].Value.ToUpperInvariant();
var suffix = $"Phs{phase}";
if (!name.EndsWith($" {suffix}", StringComparison.OrdinalIgnoreCase))
name = $"{name} {suffix}";
}

// Position/control objects are not human-unique without their owning LN class.
// Keep measurement names compact (A PhsA, PhV PhsB, Hz), but qualify switching
// and interlocking objects such as XCBR.Pos and CSWI.Pos.
var ownerClass = ResolveLogicalNodeClass(logicalNode, reference);
if (ownerClass.Length > 0 &&
OwnerQualifiedLogicalNodeClasses.Contains(ownerClass) &&
!name.StartsWith(ownerClass, StringComparison.OrdinalIgnoreCase))
{
name = $"{ownerClass} {name}";
}

return name;
}

private static string ResolveLogicalNodeClass(string? logicalNode, string reference)
{
var explicitClass = ExtractLogicalNodeClass(logicalNode);
if (explicitClass.Length > 0)
return explicitClass;

if (reference.Length == 0)
return string.Empty;

foreach (var token in reference.Split(['/', '$', '.'], StringSplitOptions.RemoveEmptyEntries))
{
var candidate = ExtractLogicalNodeClass(token);
if (candidate.Length > 0)
return candidate;
}

return string.Empty;
}

private static string ExtractLogicalNodeClass(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;

return $"{name} {suffix}";
var match = LogicalNodeClassToken().Match(value.Trim().ToUpperInvariant());
return match.Success ? match.Groups["class"].Value : string.Empty;
}
}
21 changes: 20 additions & 1 deletion Services/IoTesting/IoFatSignalDisplayNameFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,28 @@ public static class IoFatSignalDisplayNameFormatter
public static string Format(IoTestPointPlan point)
{
ArgumentNullException.ThrowIfNull(point);
return IoSignalDisplayName.Format(point.SignalName, point.ReportIecReference);

// Presentation needs the richest original IEC context, not necessarily the reference
// selected for event-log/report lookup. SourceIecReference and DA metadata preserve
// phsA/phsB/phsC and LN ownership even when a lookup alias was normalized shorter.
var semanticReference = FirstNonBlank(
point.SourceIecReference,
point.ReportDisplayReference,
point.ObjectReference,
point.EventLogSearchReference,
point.ReportIecReference);

return IoSignalDisplayName.Format(
point.SignalName,
semanticReference,
point.LogicalNode,
point.DataObject,
point.DataAttribute);
}

public static string Format(string? signalName, string? iecReference)
=> IoSignalDisplayName.Format(signalName, iecReference);

private static string FirstNonBlank(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;
}
2 changes: 1 addition & 1 deletion Services/IoTesting/IoFatV2ReportLayoutEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ private static void DrawPointRow(
var cells = new[]
{
rowNumber.ToString(),
Short(IoSignalDisplayName.Format(point.SignalName, point.ReportIecReference), 26),
Short(IoFatSignalDisplayNameFormatter.Format(point), 26),
string.Empty,
point.SignalKind.ToString(),
ValueCell(point, FatValueSlot.Value1),
Expand Down
145 changes: 145 additions & 0 deletions tests/ARSAS.Tests/ContextAwareSignalPresentationRegressionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
using ArIED61850Tester.Models.IoTesting;
using ArIED61850Tester.Services.IoTesting;

namespace ARSAS.Tests;

public sealed class ContextAwareSignalPresentationRegressionTests
{
[Theory]
[InlineData("A", "IEDLD/MMXU1.A.phsA.cVal.mag.f", "A PhsA")]
[InlineData("A", "IEDLD/MMXU1.A.phsB.cVal.mag.f", "A PhsB")]
[InlineData("A", "IEDLD/MMXU1.A.phsC.cVal.mag.f", "A PhsC")]
[InlineData("PhV", "IEDLD/MMXU1.PhV.phsA.cVal.mag.f", "PhV PhsA")]
[InlineData("Pos", "IEDLD/XCBR1.Pos.stVal", "XCBR Pos")]
[InlineData("Pos", "IEDLD/CSWI1.Pos.stVal", "CSWI Pos")]
public void SharedEngineeringPresentation_IsPhaseAndOwnerAware(
string signalName,
string reference,
string expected)
{
Assert.Equal(expected, IoSignalDisplayName.Format(signalName, reference));
}

[Fact]
public void FatPresentation_RecoversPhaseFromDaContext_WhenLookupReferenceIsShorter()
{
var point = Point(
signalName: "A",
objectReference: "IEDLD/MMXU1.A.cVal.mag.f",
logicalNode: "MMXU1",
dataObject: "A",
dataAttribute: "phsB.cVal.mag.f",
sourceReference: "IEDLD/MMXU1.A");

Assert.Equal("A PhsB", IoFatSignalDisplayNameFormatter.Format(point));
}

[Theory]
[InlineData("XCBR1", "XCBR Pos")]
[InlineData("CSWI1", "CSWI Pos")]
public void FatPresentation_QualifiesGenericPositionWithOwningLogicalNode(
string logicalNode,
string expected)
{
var point = Point(
signalName: "Pos",
objectReference: $"IEDLD/{logicalNode}.Pos.stVal",
logicalNode: logicalNode,
dataObject: "Pos",
dataAttribute: "stVal",
sourceReference: $"IEDLD/{logicalNode}.Pos.stVal");

Assert.Equal(expected, IoFatSignalDisplayNameFormatter.Format(point));
}

[Theory]
[InlineData("A PhsA", "IEDLD/MMXU1.A.phsA.cVal.mag.f", "A PhsA")]
[InlineData("XCBR Pos", "IEDLD/XCBR1.Pos.stVal", "XCBR Pos")]
public void SemanticPresentation_DoesNotDuplicateExistingContext(
string signalName,
string reference,
string expected)
{
Assert.Equal(expected, IoSignalDisplayName.Format(signalName, reference));
}

[Fact]
public void FatReport_UsesTheSamePointAwareSemanticFormatterAsFatWorkspace()
{
var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatV2ReportLayoutEngine.cs"));

Assert.Contains("IoFatSignalDisplayNameFormatter.Format(point)", source, StringComparison.Ordinal);
Assert.DoesNotContain(
"IoSignalDisplayName.Format(point.SignalName, point.ReportIecReference)",
source,
StringComparison.Ordinal);
}

[Fact]
public void EngineeringAndFat_ConvergeOnTheSameSemanticAuthority()
{
var engineering = File.ReadAllText(FindRepoFile("MainWindow.FieldPresentationFix.cs"));
var fat = File.ReadAllText(FindRepoFile("Services/IoTesting/IoFatSignalDisplayNameFormatter.cs"));

Assert.Contains("IoSignalDisplayName.Format(preferred, reference)", engineering, StringComparison.Ordinal);
Assert.Contains("IoSignalDisplayName.Format(", fat, StringComparison.Ordinal);
Assert.Contains("point.SourceIecReference", fat, StringComparison.Ordinal);
Assert.Contains("point.LogicalNode", fat, StringComparison.Ordinal);
Assert.Contains("point.DataObject", fat, StringComparison.Ordinal);
Assert.Contains("point.DataAttribute", fat, StringComparison.Ordinal);
}

[Fact]
public void FatLiveValueBinding_RemainsCanonicalReferenceBased_NotDisplayNameBased()
{
var source = File.ReadAllText(FindRepoFile("Services/IoTesting/IoTestLiveBindingService.cs"));

Assert.Contains(
"expectedReferences.Contains(NormalizeReference(item.IecReference))",
source,
StringComparison.Ordinal);
Assert.Contains("binding.LivePoint.Value", source, StringComparison.Ordinal);
Assert.Contains("ExactSignalIdentityMatches", source, StringComparison.Ordinal);
Assert.DoesNotContain("SignalName.Equals(", source, StringComparison.Ordinal);
}

private static IoTestPointPlan Point(
string signalName,
string objectReference,
string logicalNode,
string dataObject,
string dataAttribute,
string sourceReference)
=> new()
{
TestPointId = Guid.NewGuid().ToString("N"),
IedName = "IED",
IpAddress = "192.0.2.1",
SignalName = signalName,
ObjectReference = objectReference,
FunctionalConstraint = "ST",
ExpectedOnText = "ON",
ExpectedOffText = "OFF",
LogicalDevice = "IEDLD",
LogicalNode = logicalNode,
DataObject = dataObject,
DataAttribute = dataAttribute,
SourceIecReference = sourceReference,
ReportDisplayReference = sourceReference,
EventLogSearchReference = objectReference
};

private static string FindRepoFile(string relativePath)
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory != null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
return candidate;
directory = directory.Parent;
}

throw new FileNotFoundException(relativePath);
}
}
Loading