Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f436ccc
fix(operator UX): render SNTP IPv4, responsive GOOSE timeline, safe a…
masarray Oct 24, 2026
fdc396f
fix(operator UX): render SNTP IPv4, responsive GOOSE timeline, safe a…
masarray Oct 24, 2026
1725072
fix(operator UX): render SNTP IPv4, responsive GOOSE timeline, safe a…
masarray Oct 24, 2026
d42620a
fix(operator UX): render SNTP IPv4, responsive GOOSE timeline, safe a…
masarray Oct 24, 2026
4fe547f
fix(operator UX): render SNTP IPv4, responsive GOOSE timeline, safe a…
masarray Oct 24, 2026
70d4ffa
fix(operator UX): render SNTP IPv4, responsive GOOSE timeline, safe a…
masarray Oct 24, 2026
cbaf829
fix(browser): preserve per-IED tree and per-page section expansion ac…
masarray Oct 24, 2026
8691b09
fix(GOOSE layout): default collapsed shared Command Dock on passive c…
masarray Oct 24, 2026
ecd3d73
test(RC0 UX): GOOSE bounded timeline/filter and adaptive height, SNTP…
masarray Oct 24, 2026
c0f425e
fix(GOOSE): actionable filtered-empty timeline to reveal all verified…
masarray Oct 24, 2026
1140c35
fix(GOOSE): actionable filtered-empty timeline to reveal all verified…
masarray Oct 24, 2026
4e03e87
fix(GOOSE): actionable filtered-empty timeline to reveal all verified…
masarray Oct 24, 2026
cdfa9b2
fix(GOOSE): actionable filtered-empty timeline to reveal all verified…
masarray Oct 24, 2026
209e31c
perf(browser): capture expansion preferences only on device/model rep…
masarray Oct 24, 2026
39ad335
test(browser): update legacy expansion assertions for user-requested …
masarray Oct 24, 2026
f2a9b40
test(browser): update legacy expansion assertions for user-requested …
masarray Oct 24, 2026
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
19 changes: 18 additions & 1 deletion MainWindow.GooseIedScope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace ArIED61850Tester;
public partial class MainWindow
{
// 0 = selected IED; 1 = all IEDs. Never filters the capture/decoder.
private int _gooseIedScopeIndex;
private int _gooseIedScopeIndex = 1; // Unresolved publishers must be visible at first capture.
private ListCollectionView? _gooseEventScopeView;
private ListCollectionView? _gooseStreamScopeView;

Expand All @@ -27,6 +27,20 @@ public int GooseIedScopeIndex
}
}

// Keep the 500-event canonical timeline unchanged; this is only the
// presentation scope. A selected IED cannot claim unresolved Ethernet
// publisher identity solely from its MMS endpoint.
public string GooseEmptyEventTitle => GooseEvents.Count == 0
? "Waiting for the first meaningful GOOSE event"
: "No events match the selected IED";
public System.Windows.Visibility GooseShowAllPublishersVisibility =>
GooseIedScopeIndex == 0 && GooseEvents.Count > 0 &&
!GooseVisibleEvents.Cast<GooseEventRow>().Any()
? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed;
public string GooseEmptyEventDetail => GooseEvents.Count == 0
? "Frames are captured independently. New publishers and actual changes will appear here."
: $"Captured {GooseEvents.Count:N0} timeline event(s), but none has a verified publisher identity matching the selected IED. Choose All IED GOOSE signals to view them.";

public string GooseSelectedScopeHint => GooseIedScopeIndex == 1
? "Display messages from every captured GOOSE publisher, including unresolved identities."
: SelectedDevice is null
Expand Down Expand Up @@ -58,6 +72,9 @@ private void RefreshGooseScopeViews()
_gooseEventScopeView?.Refresh();
_gooseStreamScopeView?.Refresh();
Raise(nameof(GooseSelectedScopeHint));
Raise(nameof(GooseEmptyEventTitle));
Raise(nameof(GooseEmptyEventDetail));
Raise(nameof(GooseShowAllPublishersVisibility));
_settingGooseLiveSelection = true;
try
{
Expand Down
3 changes: 3 additions & 0 deletions MainWindow.GooseTimeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,9 @@ private void RaiseGoosePresentationState()
{
Raise(nameof(GoosePublisherCountText));
Raise(nameof(GooseEventCountText));
Raise(nameof(GooseEmptyEventTitle));
Raise(nameof(GooseEmptyEventDetail));
Raise(nameof(GooseShowAllPublishersVisibility));
Raise(nameof(GooseSelectedLeafCountText));
Raise(nameof(GooseNoEventsVisibility));
}
Expand Down
63 changes: 57 additions & 6 deletions MainWindow.IedBrowser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,43 @@ public partial class MainWindow
private readonly IedBrowserTreeProjection _iedBrowserTree = new();
private readonly IedBrowserDetailProjection _iedBrowserDetails = new();
private readonly IedBrowserNavigationTrail _iedBrowserNavigation = new();
// Presentation preferences only. Never cache IEC values or MMS evidence.
// Exact DeviceId + stable page key, bounded to avoid accumulating transient IEDs.
private readonly Dictionary<string, (string? SelectedKey, HashSet<string> ExpandedKeys)>
_iedBrowserTreePreferences = new(StringComparer.Ordinal);
private readonly Dictionary<string, HashSet<string>> _iedBrowserSectionPreferences =
new(StringComparer.Ordinal);
private string? _iedBrowserRenderedSectionsKey;

private void RememberIedBrowserTreePreferences()
{
if (_iedBrowserCachedDevice is null || _iedBrowserTree.Rows.Count == 0)
return;
var id = _iedBrowserCachedDevice.DeviceId;
if (_iedBrowserTreePreferences.Count >= 64 &&
!_iedBrowserTreePreferences.ContainsKey(id))
_iedBrowserTreePreferences.Remove(_iedBrowserTreePreferences.Keys.First());
_iedBrowserTreePreferences[id] = (
(IedBrowserTreeGrid.SelectedItem as IedBrowserTreeRow)?.Key,
_iedBrowserTree.ExpandedKeys());
}

private static string? BrowserSectionKey(Iec61850MonitorDevice? device, IedBrowserTreeRow? page)
=> device is null || page is null ? null : device.DeviceId + "|" + page.Key;

private void RememberIedBrowserSectionPreferences()
{
if (_iedBrowserRenderedSectionsKey is null ||
!_iedBrowserDetails.Rows.Any(row => row.Kind == "Section" && row.HasChildren))
return;
if (_iedBrowserSectionPreferences.Count >= 256 &&
!_iedBrowserSectionPreferences.ContainsKey(_iedBrowserRenderedSectionsKey))
_iedBrowserSectionPreferences.Remove(_iedBrowserSectionPreferences.Keys.First());
_iedBrowserSectionPreferences[_iedBrowserRenderedSectionsKey] =
_iedBrowserDetails.Rows.Where(row => row.Kind == "Section" &&
row.HasChildren && row.IsExpanded)
.Select(row => row.Name).ToHashSet(StringComparer.Ordinal);
}
private bool _iedBrowserSyncingSelection;
private string? _iedBrowserSelectedDoReference;
private int _iedBrowserNavigationRenderTicket;
Expand Down Expand Up @@ -2061,6 +2098,12 @@ private void RefreshIedBrowser()
catch (IOException) { /* An unavailable source file cannot poison in-memory browsing. */ }
catch (UnauthorizedAccessException) { /* Still show the cached/discovered model. */ }

// Checkpoint only on an actual IED/model replacement. On the ordinary
// reporting refresh path there is no need to scan the visible tree
// every 200 ms just to remember unchanged expansion preferences.
if (!ReferenceEquals(device, _iedBrowserCachedDevice) ||
!ReferenceEquals(model, _iedBrowserCachedModel))
RememberIedBrowserTreePreferences();
// Heavy schema indexing must never block the WPF selection dispatcher.
if (!TryActivateIedBrowserSchema(device, model))
return;
Expand All @@ -2078,12 +2121,13 @@ private void RefreshIedBrowser()
var sameDevice = ReferenceEquals(device, _iedBrowserCachedDevice) &&
device.LiveValues.Association == _iedBrowserCachedAssociation &&
ReferenceEquals(model, _iedBrowserCachedModel);
_iedBrowserTreePreferences.TryGetValue(device.DeviceId, out var recalled);
var priorKey = sameDevice
? (IedBrowserTreeGrid.SelectedItem as IedBrowserTreeRow)?.Key
: null;
: recalled.SelectedKey;
var expandedKeys = sameDevice
? _iedBrowserTree.ExpandedKeys()
: new HashSet<string>(StringComparer.Ordinal);
: recalled.ExpandedKeys ?? new HashSet<string>(StringComparer.Ordinal);

// Source inspection parses SCL XML; never perform it inside a click handler.
if (!TryResolveIedBrowserSclInventory(
Expand Down Expand Up @@ -2111,9 +2155,8 @@ private void RefreshIedBrowser()
_iedBrowserRemoteFiles, EffectiveSclIedName(device),
device.SclWorkspace?.DesignModel);
_iedBrowserTree.Reset(roots, expandedKeys);
// Fresh IED/association/model epoch: all six Objects categories
// remain collapsed. Selecting Reports fills the right pane
// without materializing left LD/LN/RCB descendants.
// Restore only previously operator-expanded stable keys; no new
// branches are expanded automatically and no MMS reads are issued.

IedBrowserTreeGrid.ItemsSource = _iedBrowserTree.Rows;
var selected = priorKey == null ? null : _iedBrowserTree.FindVisible(priorKey);
Expand Down Expand Up @@ -2326,7 +2369,14 @@ private void ReplaceIedBrowserDetails(IReadOnlyList<IedBrowserDetailRow> rows)
// Reset. A bound reset during routed selection can corrupt ItemInfo.
IedBrowserDetailList.SelectedItem = null;
IedBrowserDetailList.ItemsSource = null;
_iedBrowserDetails.Reset(rows);
RememberIedBrowserSectionPreferences();
var nextKey = rows.Count == 0 ? null
: BrowserSectionKey(SelectedDevice, _iedBrowserRenderedPage);
var restored = nextKey is not null &&
_iedBrowserSectionPreferences.TryGetValue(nextKey, out var expanded)
? expanded : null;
_iedBrowserDetails.Reset(rows, restored);
_iedBrowserRenderedSectionsKey = nextKey;
RebuildIedBrowserViewportIndex();
IedBrowserDetailList.ItemsSource = _iedBrowserDetails.Rows;
}
Expand All @@ -2342,6 +2392,7 @@ private void ToggleIedBrowserDetails(IedBrowserDetailRow row)
IedBrowserDetailList.SelectedItem = null;
IedBrowserDetailList.ItemsSource = null;
_iedBrowserDetails.Toggle(row);
RememberIedBrowserSectionPreferences();
RebuildIedBrowserViewportIndex();
IedBrowserDetailList.ItemsSource = _iedBrowserDetails.Rows;
_iedBrowserLastLiveVersion = -1;
Expand Down
11 changes: 10 additions & 1 deletion MainWindow.PersistentWorkbench.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ private sealed class PersistentWorkbenchState
// SV inspection is independent of MMS command admission. Default
// collapsed, but retain the operator's explicit override per workspace.
public bool SmvDockExpanded { get; set; } = false;
// Passive GOOSE observation gets the available timeline height by
// default. The operator can still expand MMS controls and the choice
// persists independently of the rest of the engineering workspaces.
public bool GooseDockExpanded { get; set; } = false;
}

[ModuleInitializer]
Expand Down Expand Up @@ -386,6 +390,8 @@ private void CaptureP0CommandDockState(bool expanded)

if (MainTabs.SelectedIndex == EngineeringNavigationRoute.SmvWorkspaceIndex)
state.SmvDockExpanded = expanded;
else if (MainTabs.SelectedIndex == GooseSubscriberTabIndex)
state.GooseDockExpanded = expanded;
else
state.DockExpanded = expanded;
}
Expand All @@ -396,7 +402,10 @@ private void ApplyP0CommandDockState(PersistentWorkbenchState state, int workspa
// MMS commands on passive SV inspection. Do not recreate the panel or
// invoke any control/refresh operation as a navigation side effect.
var desiredExpanded = workspaceIndex == EngineeringNavigationRoute.SmvWorkspaceIndex
? state.SmvDockExpanded : state.DockExpanded;
? state.SmvDockExpanded
: workspaceIndex == GooseSubscriberTabIndex
? state.GooseDockExpanded
: state.DockExpanded;
if (CommandPanelExpander.IsExpanded == desiredExpanded)
return;

Expand Down
5 changes: 4 additions & 1 deletion Models/IedBrowserDetailSectionLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ public static IReadOnlyList<IedBrowserDetailRow> Attach(
result.Add(section with
{
LoadChildren = () => children,
ExpandByDefault = true
// Status summaries are rarely needed during routine browsing.
// Respect any explicit user toggle when the page is revisited.
ExpandByDefault = section.Name is not
("Information received in last Report" or "Information received in last GOOSE")
});
index = end - 1;
}
Expand Down
6 changes: 4 additions & 2 deletions Models/IedBrowserPresentation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,8 @@
{
public BulkObservableCollection<IedBrowserDetailRow> Rows { get; } = new();

public void Reset(IReadOnlyList<IedBrowserDetailRow> rows)
public void Reset(IReadOnlyList<IedBrowserDetailRow> rows,
IReadOnlySet<string>? expandedSectionNames = null)
{
// Attach UI-only collapsibility to exact section boundaries. Do not
// mutate source projection rows or create a second evidence store.
Expand All @@ -357,7 +358,8 @@
foreach (var row in sections)
{
display.Add(row);
if (!row.ExpandByDefault || !row.HasChildren) continue;
if (!row.HasChildren || (expandedSectionNames is null
? !row.ExpandByDefault : !expandedSectionNames.Contains(row.Name))) continue;
row.IsExpanded = true;
display.AddRange(row.GetChildren());
}
Expand Down Expand Up @@ -402,7 +404,7 @@
string? iedName = null,
LiveIedModelDiscoveryDocument? sclModel = null)
{
const int childDepth = 1;

Check warning on line 407 in Models/IedBrowserPresentation.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

The variable 'childDepth' is assigned but its value is never used

Check warning on line 407 in Models/IedBrowserPresentation.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

The variable 'childDepth' is assigned but its value is never used

Check warning on line 407 in Models/IedBrowserPresentation.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

The variable 'childDepth' is assigned but its value is never used
// Keep domain/object keys untouched; only presentation labels are short.
var labelIed = !string.IsNullOrWhiteSpace(model?.IedName)
? model.IedName : iedName;
Expand Down
5 changes: 5 additions & 0 deletions Services/SntpNetworkRouteResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ public sealed record SntpNetworkBinding(
string InterfaceName,
string InterfaceId)
{
// The shared WPF ComboBox selection template may render the selected
// object via ToString rather than DisplayMemberPath. Never expose the
// internal record type where the operator expects a local IPv4 address.
public override string ToString() => LocalAddress.ToString();

public string Summary => DirectedBroadcast == null
? $"{InterfaceName} • {LocalAddress}"
: $"{InterfaceName} • {LocalAddress} → {DirectedBroadcast}";
Expand Down
11 changes: 8 additions & 3 deletions Views/GooseSubscriberLiteView.xaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<UserControl x:Class="ArIED61850Tester.Views.GooseSubscriberLiteView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
MinWidth="900" MinHeight="560">
MinWidth="900" MinHeight="0">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
Expand Down Expand Up @@ -410,11 +410,16 @@
BorderBrush="#DCE6F3" BorderThickness="1" CornerRadius="11"
Padding="15" HorizontalAlignment="Center" VerticalAlignment="Center" MaxWidth="320">
<StackPanel>
<TextBlock Text="Waiting for a meaningful event" FontSize="12.4"
<TextBlock Text="{Binding GooseEmptyEventTitle}" FontSize="12.4"
FontWeight="SemiBold" Foreground="{StaticResource Ink}" HorizontalAlignment="Center"/>
<TextBlock Text="New publishers, changed values and new warnings appear here."
<TextBlock Text="{Binding GooseEmptyEventDetail}"
FontSize="10" Foreground="{StaticResource Muted}" TextAlignment="Center"
TextWrapping="Wrap" Margin="0,3,0,0"/>
<Button Content="Show all publishers"
Click="ShowAllPublishers_Click"
Visibility="{Binding GooseShowAllPublishersVisibility}"
Style="{StaticResource SoftButton}" MinHeight="28"
HorizontalAlignment="Center" Margin="0,10,0,0"/>
</StackPanel>
</Border>
</Grid>
Expand Down
3 changes: 3 additions & 0 deletions Views/GooseSubscriberLiteView.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,7 @@ private void StopCapture_Click(object sender, RoutedEventArgs e)

private void ClearCapture_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ClearRequestedEvent, this));

private void ShowAllPublishers_Click(object sender, RoutedEventArgs e)
=> GooseEventScope.SelectedIndex = 1;
}
2 changes: 1 addition & 1 deletion tests/ARSAS.Tests/IedBrowserPresentationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ public void BrowserSelectionDefersAndDetachesBeforeDetailCollectionReset()
var method = source.IndexOf("private void ReplaceIedBrowserDetails(", StringComparison.Ordinal);
Assert.True(method >= 0);
var detach = source.IndexOf("IedBrowserDetailList.ItemsSource = null;", method, StringComparison.Ordinal);
var reset = source.IndexOf("_iedBrowserDetails.Reset(rows);", method, StringComparison.Ordinal);
var reset = source.IndexOf("_iedBrowserDetails.Reset(rows, restored);", method, StringComparison.Ordinal);
var attach = source.IndexOf("IedBrowserDetailList.ItemsSource = _iedBrowserDetails.Rows;", method, StringComparison.Ordinal);
Assert.True(detach > method && reset > detach && attach > reset);
}
Expand Down
20 changes: 13 additions & 7 deletions tests/ARSAS.Tests/M214BrowserCollapsibleReportSectionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,28 +22,31 @@ public void RcbFlagsCollapseWithControlBlockWhileLastReportRemainsIndependent()
};
var model = new IedBrowserDetailProjection();
model.Reset(source);
Assert.Equal(source.Length, model.Rows.Count);
// User-requested default: passive last-Report metadata is collapsed.
// Trigger/options stay with the independent control-block section.
Assert.Equal(source.Length - 2, model.Rows.Count);
var controls = Assert.Single(model.Rows, row =>
row.Kind == "Section" && row.Name == "Control Block attributes");
var received = Assert.Single(model.Rows, row =>
row.Kind == "Section" && row.Name == "Information received in last Report");
Assert.True(controls.IsExpanded);
Assert.True(received.IsExpanded);
Assert.False(received.IsExpanded);
Assert.Equal(4, controls.GetChildren().Count);
Assert.Equal(2, received.GetChildren().Count);
model.Toggle(controls);
Assert.False(controls.IsExpanded);
Assert.DoesNotContain(model.Rows, row => row.Name == "Enabled / RptEna");
Assert.Contains(model.Rows, row => row.Name == "Received frame count");
Assert.DoesNotContain(model.Rows, row => row.Name == "Received frame count");
Assert.Contains(model.Rows, row => row.Name == "Data");
model.Toggle(received);
Assert.DoesNotContain(model.Rows, row => row.Name == "Received frame count");
Assert.Contains(model.Rows, row => row.Name == "Received frame count");
Assert.DoesNotContain(model.Rows, row => row.Name == "Trigger options");
model.Toggle(controls);
model.Toggle(received);
Assert.Equal(source.Length, model.Rows.Count);
Assert.Equal("true", Assert.Single(model.Rows,
row => row.Name == "Enabled / RptEna").Value);
model.Toggle(received);
Assert.DoesNotContain(model.Rows, row => row.Name == "Received frame count");
}

[Fact]
Expand All @@ -57,9 +60,12 @@ public void RcbSectionCollapse_DoesNotDestroyLiveRowEvidence()
var model = new IedBrowserDetailProjection();
model.Reset(source);
var section = model.Rows[0];
Assert.False(section.IsExpanded);
model.Toggle(section);
Assert.Equal("1", model.Rows[1].Value);
// Retained projected child can be refreshed from canonical ledger
// even while collapsed; expansion never creates a parallel value store.
model.Toggle(section);
// The retained projected child is refreshed from the canonical
// ledger when the operator re-expands; no independent value cache.
section.GetChildren()[0].Value = "2";
model.Toggle(section);
Assert.Equal("2", model.Rows[1].Value);
Expand Down
Loading
Loading