Skip to content
Merged
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
11 changes: 11 additions & 0 deletions MainWindow.GooseSubscriber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ public GooseStreamRow? SelectedGooseStream
if (!Set(ref _selectedGooseStream, value)) return;
Raise(nameof(GooseSelectedStreamText));
Raise(nameof(GooseNoLeafValuesVisibility));
Raise(nameof(GooseDataSetInspectorVisibility));
}
}

Expand All @@ -68,6 +69,8 @@ private set
Raise(nameof(CanStopGooseSubscriber));
Raise(nameof(CanRefreshGooseConfiguration));
Raise(nameof(GooseCaptureStateText));
Raise(nameof(GooseStartVisibility));
Raise(nameof(GooseStopVisibility));
}
}

Expand Down Expand Up @@ -101,6 +104,10 @@ private set
: $"{SelectedGooseStream.IdentityText} • {SelectedGooseStream.DataSetReference} • {SelectedGooseStream.Leaves.Count:N0} ordered leaf value(s)";
public Visibility GooseNoStreamsVisibility => GooseStreams.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
public Visibility GooseNoLeafValuesVisibility => SelectedGooseStream?.Leaves.Count > 0 ? Visibility.Collapsed : Visibility.Visible;
public Visibility GooseDataSetInspectorVisibility => SelectedGooseStream is null ? Visibility.Collapsed : Visibility.Visible;
public Visibility GooseStartVisibility => IsGooseCapturing ? Visibility.Collapsed : Visibility.Visible;
public Visibility GooseStopVisibility => IsGooseCapturing ? Visibility.Visible : Visibility.Collapsed;
public Visibility GooseClearVisibility => GooseStreams.Count > 0 ? Visibility.Visible : Visibility.Collapsed;

private void InitializeGooseSubscriber()
{
Expand Down Expand Up @@ -204,6 +211,8 @@ private void ResetGooseView(bool resetCounters)
}
Raise(nameof(GooseNoStreamsVisibility));
Raise(nameof(GooseNoLeafValuesVisibility));
Raise(nameof(GooseDataSetInspectorVisibility));
Raise(nameof(GooseClearVisibility));
Raise(nameof(GooseCounterText));
Raise(nameof(GooseSelectedStreamText));
}
Expand Down Expand Up @@ -393,6 +402,8 @@ private void FlushGooseSubscriberUi()
Raise(nameof(GooseCounterText));
Raise(nameof(GooseNoStreamsVisibility));
Raise(nameof(GooseNoLeafValuesVisibility));
Raise(nameof(GooseDataSetInspectorVisibility));
Raise(nameof(GooseClearVisibility));
Raise(nameof(GooseSelectedStreamText));
}

Expand Down
197 changes: 118 additions & 79 deletions MainWindow.xaml

Large diffs are not rendered by default.

52 changes: 52 additions & 0 deletions Models/Iec61850ValueStatePresentation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,55 @@ private static bool IsBooleanType(string? dataType)
type.Contains("boolean", StringComparison.Ordinal);
}
}

/// <summary>
/// Presentation-only attention tone for IEC 61850 quality text. Process state remains
/// independent: this classifier only decides how strongly the Quality column should
/// call for operator attention.
/// </summary>
public static class Iec61850QualityPresentation
{
public const string Good = "Good";
public const string Attention = "Attention";
public const string Bad = "Bad";
public const string Unknown = "Unknown";

public static string Classify(string? quality)
{
var text = (quality ?? string.Empty).Trim();
if (text.Length == 0 || text == "-" || text.Equals("Unknown", StringComparison.OrdinalIgnoreCase))
return Unknown;

var normalized = text.ToLowerInvariant();
if (ContainsAny(normalized, "invalid", "bad", "failure", "failed"))
return Bad;

if (ContainsAny(
normalized,
"questionable",
"olddata",
"old data",
"substituted",
"test",
"operatorblocked",
"operator blocked",
"overflow",
"outofrange",
"out of range",
"inaccurate",
"oscillatory"))
{
return Attention;
}

if (normalized.Contains("good", StringComparison.Ordinal))
return Good;

// A non-empty quality string that is not explicitly proven Good still deserves
// a contained amber cue rather than being silently presented as healthy.
return Attention;
}

private static bool ContainsAny(string source, params string[] needles)
=> needles.Any(needle => source.Contains(needle, StringComparison.Ordinal));
}
1 change: 1 addition & 0 deletions Models/MonitorModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,7 @@ public string EdgeType
public string EventValue => string.IsNullOrWhiteSpace(NewValue) ? "-" : NewValue;
public string DisplayValue => EventValue;
public string ValueTone => Iec61850ValueStatePresentation.Classify(EventValue, IecDataType);
public string QualityTone => Iec61850QualityPresentation.Classify(Quality);

public string IecTelegram => Iec61850MonitorPoint.StripIedNamePrefix(IecReference, DeviceName);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ public void Explorer_OffersExplicitAlarmCheckbox_AndAnnunciatorWorkspaceUsesAckC
Assert.Contains("CanUseAsAnnunciator", source, StringComparison.Ordinal);
Assert.Contains("Click=\"AnnunciatorSelection_Click\"", source, StringComparison.Ordinal);
Assert.Contains("Header=\"Alarm Annunciator\"", section, StringComparison.Ordinal);
Assert.Contains("FLASH = UNACK", section, StringComparison.Ordinal);
Assert.Contains("Unacknowledged flashes • acknowledged steady • returned awaits ACK", section, StringComparison.Ordinal);
Assert.DoesNotContain("FLASH = UNACK", section, StringComparison.Ordinal);
Assert.Contains("Text=\"{Binding CurrentValue}\" FontSize=\"18\"", section, StringComparison.Ordinal);
Assert.Contains("Click=\"AcknowledgeAlarm_Click\"", section, StringComparison.Ordinal);
Assert.Contains("Click=\"AcknowledgeAllAlarms_Click\"", section, StringComparison.Ordinal);
Expand Down
2 changes: 1 addition & 1 deletion tests/ARSAS.Tests/ProcessValueStatePresentationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public void MainWindow_UsesPremiumBlueSlateStateBadgesAcrossProcessValueSurfaces
Assert.Contains("#245F9E", source, StringComparison.OrdinalIgnoreCase);
Assert.Contains("#F3F6F9", source, StringComparison.OrdinalIgnoreCase);
Assert.Contains("#617286", source, StringComparison.OrdinalIgnoreCase);
Assert.Contains("state color is not alarm severity", source, StringComparison.OrdinalIgnoreCase);
Assert.Contains("process state and signal quality are shown separately", source, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Closed/ON/true is red", source, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Open/OFF/false is green", source, StringComparison.OrdinalIgnoreCase);
}
Expand Down
116 changes: 116 additions & 0 deletions tests/ARSAS.Tests/WorkspaceNoiseP1RegressionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using ArIED61850Tester.Models;

namespace ARSAS.Tests;

public sealed class WorkspaceNoiseP1RegressionTests
{
[Theory]
[InlineData("Good", Iec61850QualityPresentation.Good)]
[InlineData("Good [0x0000]", Iec61850QualityPresentation.Good)]
[InlineData("Questionable • oldData", Iec61850QualityPresentation.Attention)]
[InlineData("Good • substituted", Iec61850QualityPresentation.Attention)]
[InlineData("Invalid", Iec61850QualityPresentation.Bad)]
[InlineData("Failure", Iec61850QualityPresentation.Bad)]
[InlineData("Unknown", Iec61850QualityPresentation.Unknown)]
public void QualityPresentation_SeparatesHealthyAttentionAndBadWithoutChangingProcessState(string quality, string expected)
=> Assert.Equal(expected, Iec61850QualityPresentation.Classify(quality));

[Fact]
public void EventLog_ContainsQualityAttentionInsideDedicatedBadge()
{
var xaml = File.ReadAllText(FindRepoFile("MainWindow.xaml"));
var models = File.ReadAllText(FindRepoFile(Path.Combine("Models", "MonitorModels.cs")));
var section = Slice(xaml, "<!-- EVENT LOG -->", "<!-- EVENT-LATCHED ALARM ANNUNCIATOR -->");

Assert.Contains("x:Key=\"EventQualityBadgeTemplate\"", xaml, StringComparison.Ordinal);
Assert.Contains("CellTemplate=\"{StaticResource EventQualityBadgeTemplate}\"", section, StringComparison.Ordinal);
Assert.Contains("process state and signal quality are shown separately", section, StringComparison.OrdinalIgnoreCase);
Assert.Contains("QualityTone => Iec61850QualityPresentation.Classify(Quality)", models, StringComparison.Ordinal);
Assert.DoesNotContain("Header=\"Quality\" Binding=\"{Binding Quality}\"", section, StringComparison.Ordinal);
}

[Fact]
public void Diagnostics_UsesContainedLevelBadgeAndRail_NotWholeCellSeverityFlood()
{
var xaml = File.ReadAllText(FindRepoFile("MainWindow.xaml"));
var section = Slice(xaml, "<!-- DIAGNOSTICS -->", "</TabControl>");

Assert.Contains("x:Key=\"DiagnosticLevelBadgeTemplate\"", xaml, StringComparison.Ordinal);
Assert.Contains("CellTemplate=\"{StaticResource DiagnosticLevelBadgeTemplate}\"", section, StringComparison.Ordinal);
Assert.Contains("BorderThickness\" Value=\"3,0,0,0\"", section, StringComparison.Ordinal);
Assert.DoesNotContain("<DataGrid.CellStyle>", section, StringComparison.Ordinal);
Assert.DoesNotContain("<Setter Property=\"Background\" Value=\"#FFF1F2\"/>", section, StringComparison.Ordinal);
Assert.DoesNotContain("<Setter Property=\"Background\" Value=\"#FFFAEB\"/>", section, StringComparison.Ordinal);
}

[Fact]
public void Alarm_RemovesCrypticMicrocopyAndKeepsOperationalStateSemantics()
{
var xaml = File.ReadAllText(FindRepoFile("MainWindow.xaml"));
var section = Slice(xaml, "<!-- EVENT-LATCHED ALARM ANNUNCIATOR -->", "<!-- SCL / DISCOVERY-AWARE GOOSE SUBSCRIBER -->");

Assert.Contains("Unacknowledged flashes • acknowledged steady • returned awaits ACK", section, StringComparison.Ordinal);
Assert.Contains("Text=\"IEDs\"", section, StringComparison.Ordinal);
Assert.DoesNotContain("FLASH = UNACK", section, StringComparison.Ordinal);
Assert.DoesNotContain("Select fascia", section, StringComparison.Ordinal);
Assert.DoesNotContain("FontSize=\"8.2\"", section, StringComparison.Ordinal);
Assert.DoesNotContain("FontSize=\"8.8\"", section, StringComparison.Ordinal);
Assert.Contains("Value=\"ActiveUnacknowledged\"", section, StringComparison.Ordinal);
Assert.Contains("Value=\"ActiveAcknowledged\"", section, StringComparison.Ordinal);
Assert.Contains("Value=\"ReturnedUnacknowledged\"", section, StringComparison.Ordinal);
}

[Fact]
public void Goose_IdleStateUsesProgressiveDisclosureWithoutRemovingEngineeringControls()
{
var xaml = File.ReadAllText(FindRepoFile("MainWindow.xaml"));
var code = File.ReadAllText(FindRepoFile("MainWindow.GooseSubscriber.cs"));
var section = Slice(xaml, "<!-- SCL / DISCOVERY-AWARE GOOSE SUBSCRIBER -->", "<!-- DIAGNOSTICS -->");

Assert.Contains("x:Name=\"GooseCaptureOptionsExpander\"", section, StringComparison.Ordinal);
Assert.Contains("Header=\"Capture options\" IsExpanded=\"False\"", section, StringComparison.Ordinal);
Assert.Contains("GooseCaptureFilter", section, StringComparison.Ordinal);
Assert.Contains("RefreshGooseModels_Click", section, StringComparison.Ordinal);
Assert.Contains("Visibility=\"{Binding GooseDataSetInspectorVisibility}\"", section, StringComparison.Ordinal);
Assert.Contains("Visibility=\"{Binding GooseStartVisibility}\"", section, StringComparison.Ordinal);
Assert.Contains("Visibility=\"{Binding GooseStopVisibility}\"", section, StringComparison.Ordinal);
Assert.Contains("Visibility=\"{Binding GooseClearVisibility}\"", section, StringComparison.Ordinal);
Assert.Contains("GooseDataSetInspectorVisibility => SelectedGooseStream is null", code, StringComparison.Ordinal);
Assert.Contains("GooseClearVisibility => GooseStreams.Count > 0", code, StringComparison.Ordinal);
Assert.Contains("Raise(nameof(GooseDataSetInspectorVisibility))", code, StringComparison.Ordinal);
Assert.Contains("Raise(nameof(GooseClearVisibility))", code, StringComparison.Ordinal);
}

[Fact]
public void P1_KeepsReviewedAriecEnginePinUntouched()
{
var root = Path.GetDirectoryName(FindRepoFile("MainWindow.xaml"))!;
var engineLock = File.ReadAllText(Path.Combine(root, "engines", "ARIEC61850.lock.json"));

Assert.Contains("becda399b4a3ae34831215fc915798b4f846c1be", engineLock, StringComparison.OrdinalIgnoreCase);
Assert.Contains("\"sourcePullRequest\": 81", engineLock, StringComparison.Ordinal);
}

private static string Slice(string source, string start, string end)
{
var a = source.IndexOf(start, StringComparison.Ordinal);
Assert.True(a >= 0, $"Start marker not found: {start}");
var b = source.IndexOf(end, a + start.Length, StringComparison.Ordinal);
Assert.True(b > a, $"End marker not found: {end}");
return source[a..b];
}

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