fix(client-ui): resolve Low code-review findings (Client.UI-003,004,006,009,010,011)

- Client.UI-003: wire Serilog properly per CLAUDE.md — console sink +
  rolling daily file sink in Program.Main, Log.CloseAndFlush in finally,
  per-VM Log.ForContext<> loggers.
- Client.UI-004: migrate the cert-store folder picker from the obsolete
  OpenFolderDialog to StorageProvider.OpenFolderPickerAsync (with
  TryGetFolderFromPathAsync seed + TryGetLocalPath extraction).
- Client.UI-006: surface formerly silent catch blocks via an observable
  StatusMessage on the Subscriptions / Alarms VMs that bubbles up into
  the shell's status bar; soft fallbacks log at Information level so
  hard failures stay distinguishable.
- Client.UI-009: docs/Client.UI.md now lists Standard Deviation in the
  Aggregate row of the Query Options table.
- Client.UI-010: removed the unused MinDateTimeProperty /
  MaxDateTimeProperty styled properties from DateTimeRangePicker.
- Client.UI-011: updated the cert-store TextBox watermark from the
  legacy AppData/LmxOpcUaClient/pki to the canonical
  AppData/OtOpcUaClient/pki.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 11:25:20 -04:00
parent 59ecd18169
commit 1b10194634
14 changed files with 246 additions and 64 deletions

View File

@@ -138,4 +138,21 @@ public class AlarmsViewModelTests
{
_vm.Interval.ShouldBe(1000);
}
/// <summary>
/// Regression test for Client.UI-006 — when SubscribeAlarmsAsync throws, the failure must be
/// surfaced to the operator via the view model's StatusMessage rather than silently swallowed.
/// </summary>
[Fact]
public async Task Subscribe_OnFailure_SurfacesStatusMessage()
{
_vm.IsConnected = true;
_service.SubscribeAlarmsException = new Exception("Server returned BadSubscriptionIdInvalid");
await _vm.SubscribeCommand.ExecuteAsync(null);
_vm.IsSubscribed.ShouldBeFalse();
_vm.StatusMessage.ShouldNotBeNullOrWhiteSpace();
_vm.StatusMessage.ShouldContain("BadSubscriptionIdInvalid");
}
}

View File

@@ -175,12 +175,23 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(BrowseResults);
}
/// <summary>
/// Gets or sets the exception thrown to simulate subscribe failures in the UI.
/// </summary>
public Exception? SubscribeException { get; set; }
/// <summary>
/// Gets or sets the exception thrown to simulate alarm-subscribe failures in the UI.
/// </summary>
public Exception? SubscribeAlarmsException { get; set; }
/// <inheritdoc />
public Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default)
{
SubscribeCallCount++;
LastSubscribeNodeId = nodeId;
LastSubscribeIntervalMs = intervalMs;
if (SubscribeException != null) throw SubscribeException;
return Task.CompletedTask;
}
@@ -196,6 +207,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
public Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default)
{
SubscribeAlarmsCallCount++;
if (SubscribeAlarmsException != null) throw SubscribeAlarmsException;
return Task.CompletedTask;
}

View File

@@ -515,6 +515,25 @@ public class MainWindowViewModelTests
_settingsService.LastSaved!.SubscribedNodes.ShouldContain("ns=2;s=TestSub");
}
/// <summary>
/// Regression test for Client.UI-006 — when GetRedundancyInfoAsync throws (the server
/// does not implement the redundancy facet) the connection must still succeed and the
/// view model must leave RedundancyInfo null without crashing or hiding the diagnostic.
/// The Status text is expected to remain "Connected" (redundancy is optional).
/// </summary>
[Fact]
public async Task ConnectCommand_RedundancyFailure_DoesNotBreakConnection()
{
_service.RedundancyException = new Exception("BadServiceUnsupported");
await _vm.ConnectCommand.ExecuteAsync(null);
_vm.ConnectionState.ShouldBe(ConnectionState.Connected);
_vm.RedundancyInfo.ShouldBeNull();
// Connection succeeded; status reflects connection, not the optional redundancy probe failure
_vm.StatusMessage.ShouldContain("Connected");
}
/// <summary>
/// Verifies that saved subscriptions are restored after reconnecting the shell.
/// </summary>

View File

@@ -276,6 +276,41 @@ public class SubscriptionsViewModelTests
_service.SubscribeCallCount.ShouldBe(2);
}
/// <summary>
/// Regression test for Client.UI-006 — when SubscribeAsync throws, the failure must be surfaced
/// to the operator via the view model's StatusMessage rather than silently swallowed.
/// </summary>
[Fact]
public async Task AddSubscription_OnFailure_SurfacesStatusMessage()
{
_vm.IsConnected = true;
_vm.NewNodeIdText = "ns=2;s=SomeNode";
_service.SubscribeException = new Exception("Permission denied");
await _vm.AddSubscriptionCommand.ExecuteAsync(null);
_vm.ActiveSubscriptions.ShouldBeEmpty();
_vm.StatusMessage.ShouldNotBeNullOrWhiteSpace();
_vm.StatusMessage.ShouldContain("Permission denied");
}
/// <summary>
/// Regression test for Client.UI-006 — silent swallow when adding a subscription for a node
/// (the context-menu helper) must also surface a status to the operator.
/// </summary>
[Fact]
public async Task AddSubscriptionForNodeAsync_OnFailure_SurfacesStatusMessage()
{
_vm.IsConnected = true;
_service.SubscribeException = new Exception("Bad node id");
await _vm.AddSubscriptionForNodeAsync("ns=2;s=ContextMenuNode");
_vm.ActiveSubscriptions.ShouldBeEmpty();
_vm.StatusMessage.ShouldNotBeNullOrWhiteSpace();
_vm.StatusMessage.ShouldContain("Bad node id");
}
[Fact]
public async Task AddSubscriptionRecursiveAsync_RecursesNestedObjects()
{