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:
@@ -7,7 +7,7 @@
|
|||||||
| Review date | 2026-05-22 |
|
| Review date | 2026-05-22 |
|
||||||
| Commit reviewed | `76d35d1` |
|
| Commit reviewed | `76d35d1` |
|
||||||
| Status | Reviewed |
|
| Status | Reviewed |
|
||||||
| Open findings | 6 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Checklist coverage
|
## Checklist coverage
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ directly so the compiler can prove non-nullness.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | OtOpcUa conventions |
|
| Category | OtOpcUa conventions |
|
||||||
| Location | `ZB.MOM.WW.OtOpcUa.Client.UI.csproj:20-21`, `Program.cs:14-20` |
|
| Location | `ZB.MOM.WW.OtOpcUa.Client.UI.csproj:20-21`, `Program.cs:14-20` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** The csproj references `Serilog` and `Serilog.Sinks.Console`, and
|
**Description:** The csproj references `Serilog` and `Serilog.Sinks.Console`, and
|
||||||
`docs/Client.UI.md` lists Serilog as the logging technology, but no source file in
|
`docs/Client.UI.md` lists Serilog as the logging technology, but no source file in
|
||||||
@@ -104,7 +104,7 @@ rolling daily file sink the project standard calls for) and route Avalonia loggi
|
|||||||
through it, or drop the unused `Serilog` package references and correct
|
through it, or drop the unused `Serilog` package references and correct
|
||||||
`docs/Client.UI.md`.
|
`docs/Client.UI.md`.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-23 — Honoured the CLAUDE.md mandate by wiring up Serilog with a console sink + a rolling daily file sink (`{LocalAppData}/OtOpcUaClient/logs/client-ui-*.log`, retained 14 days). Added `Serilog.Sinks.File` to the csproj and a `ConfigureLogging()` initializer in `Program.Main` that creates `Log.Logger` before `BuildAvaloniaApp()` and calls `Log.CloseAndFlush()` on exit. Each VM that previously had silent swallow blocks now owns a static `Log.ForContext<>()` logger so failures (subscribe, alarm subscribe, redundancy probe, recursive browse) are written to the rolling file. Avalonia's own logging is still routed through `LogToTrace` — replacing that would require a custom `ILogSink` adapter outside the scope of this finding.
|
||||||
|
|
||||||
### Client.UI-004
|
### Client.UI-004
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ through it, or drop the unused `Serilog` package references and correct
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | OtOpcUa conventions |
|
| Category | OtOpcUa conventions |
|
||||||
| Location | `Views/MainWindow.axaml.cs:125-138` |
|
| Location | `Views/MainWindow.axaml.cs:125-138` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `OnBrowseCertPathClicked` uses `OpenFolderDialog`, which is
|
**Description:** `OnBrowseCertPathClicked` uses `OpenFolderDialog`, which is
|
||||||
obsolete in Avalonia 11.x (the version pinned in the csproj). The supported
|
obsolete in Avalonia 11.x (the version pinned in the csproj). The supported
|
||||||
@@ -125,7 +125,7 @@ Avalonia major version.
|
|||||||
**Recommendation:** Migrate the folder chooser to
|
**Recommendation:** Migrate the folder chooser to
|
||||||
`TopLevel.GetTopLevel(this).StorageProvider.OpenFolderPickerAsync(...)`.
|
`TopLevel.GetTopLevel(this).StorageProvider.OpenFolderPickerAsync(...)`.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-23 — Replaced `OpenFolderDialog` with `TopLevel.GetTopLevel(this).StorageProvider.OpenFolderPickerAsync(...)`, using `TryGetFolderFromPathAsync(vm.CertificateStorePath)` as the suggested start location and `TryGetLocalPath()` to extract the chosen path. The CS0618 obsoletion warning no longer appears in the build output.
|
||||||
|
|
||||||
### Client.UI-005
|
### Client.UI-005
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ method, not only from `DisconnectAsync`.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Error handling & resilience |
|
| Category | Error handling & resilience |
|
||||||
| Location | `ViewModels/MainWindowViewModel.cs:244-252`, `ViewModels/AlarmsViewModel.cs:88-112`, `ViewModels/SubscriptionsViewModel.cs:79-94` |
|
| Location | `ViewModels/MainWindowViewModel.cs:244-252`, `ViewModels/AlarmsViewModel.cs:88-112`, `ViewModels/SubscriptionsViewModel.cs:79-94` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** Many catch blocks swallow exceptions silently with an empty body
|
**Description:** Many catch blocks swallow exceptions silently with an empty body
|
||||||
and only a comment (`// Redundancy info not available`, `// Subscribe failed`,
|
and only a comment (`// Redundancy info not available`, `// Subscribe failed`,
|
||||||
@@ -180,7 +180,7 @@ permission denial effectively impossible from the UI.
|
|||||||
message or write the exception to a log. Distinguish "feature not supported"
|
message or write the exception to a log. Distinguish "feature not supported"
|
||||||
(condition refresh) from "operation failed" so genuine errors are not hidden.
|
(condition refresh) from "operation failed" so genuine errors are not hidden.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-23 — Added an observable `StatusMessage` property on `SubscriptionsViewModel` and `AlarmsViewModel`; each former silent catch now logs through Serilog (via Client.UI-003's logger) and writes a user-visible message. `MainWindowViewModel.InitializeService` subscribes to both child VMs' `StatusMessage` changes and bubbles them up into the shell's `StatusMessage` (which is already bound to the status bar). Soft conditions are distinguished from hard failures: `RequestConditionRefreshAsync` failures log at Information level and surface as "Condition refresh not supported by server" rather than a generic error, matching the recommendation. Redundancy probe failure still leaves `RedundancyInfo` null but now logs at Information level instead of dropping the exception. Regression tests `AddSubscription_OnFailure_SurfacesStatusMessage`, `AddSubscriptionForNodeAsync_OnFailure_SurfacesStatusMessage`, `Subscribe_OnFailure_SurfacesStatusMessage`, and `ConnectCommand_RedundancyFailure_DoesNotBreakConnection` cover the four affected swallow sites.
|
||||||
|
|
||||||
### Client.UI-007
|
### Client.UI-007
|
||||||
|
|
||||||
@@ -239,7 +239,7 @@ any background reconnect timers are leaked until process exit. The
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Design-document adherence |
|
| Category | Design-document adherence |
|
||||||
| Location | `ViewModels/HistoryViewModel.cs:44-54` |
|
| Location | `ViewModels/HistoryViewModel.cs:44-54` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `HistoryViewModel.AggregateTypes` exposes eight entries: `null`
|
**Description:** `HistoryViewModel.AggregateTypes` exposes eight entries: `null`
|
||||||
(Raw) plus Average, Minimum, Maximum, Count, Start, End, and `StandardDeviation`.
|
(Raw) plus Average, Minimum, Maximum, Count, Start, End, and `StandardDeviation`.
|
||||||
@@ -250,7 +250,7 @@ stale relative to the code.
|
|||||||
**Recommendation:** Update the "Aggregate" row in `docs/Client.UI.md` to include
|
**Recommendation:** Update the "Aggregate" row in `docs/Client.UI.md` to include
|
||||||
Standard Deviation.
|
Standard Deviation.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-23 — Added "Standard Deviation" to the Aggregate row of the Query Options table in `docs/Client.UI.md` so it matches the eighth entry already exposed by `HistoryViewModel.AggregateTypes`.
|
||||||
|
|
||||||
### Client.UI-010
|
### Client.UI-010
|
||||||
|
|
||||||
@@ -259,7 +259,7 @@ Standard Deviation.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Code organization & conventions |
|
| Category | Code organization & conventions |
|
||||||
| Location | `Controls/DateTimeRangePicker.axaml.cs:33-37`, `Controls/DateTimeRangePicker.axaml.cs:70-80` |
|
| Location | `Controls/DateTimeRangePicker.axaml.cs:33-37`, `Controls/DateTimeRangePicker.axaml.cs:70-80` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `DateTimeRangePicker` declares `MinDateTimeProperty` /
|
**Description:** `DateTimeRangePicker` declares `MinDateTimeProperty` /
|
||||||
`MaxDateTimeProperty` styled properties with public CLR accessors, but neither is
|
`MaxDateTimeProperty` styled properties with public CLR accessors, but neither is
|
||||||
@@ -272,7 +272,7 @@ constraint the control does not enforce.
|
|||||||
path (turn out-of-range input red, as invalid input already is) or remove the two
|
path (turn out-of-range input red, as invalid input already is) or remove the two
|
||||||
unused styled properties.
|
unused styled properties.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-23 — Removed `MinDateTimeProperty` / `MaxDateTimeProperty` and their CLR accessors from `DateTimeRangePicker.axaml.cs`. No XAML or external caller binds the properties (grep across the repo confirmed only the control file referenced them), so removing the dead API surface is the correct fix; adding min/max clamping would have been speculative behaviour without a calling site.
|
||||||
|
|
||||||
### Client.UI-011
|
### Client.UI-011
|
||||||
|
|
||||||
@@ -281,7 +281,7 @@ unused styled properties.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Documentation & comments |
|
| Category | Documentation & comments |
|
||||||
| Location | `Views/MainWindow.axaml:81`, `Services/JsonSettingsService.cs:11-15` |
|
| Location | `Views/MainWindow.axaml:81`, `Services/JsonSettingsService.cs:11-15` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** The certificate-store-path `TextBox` watermark reads
|
**Description:** The certificate-store-path `TextBox` watermark reads
|
||||||
`(default: AppData/LmxOpcUaClient/pki)`, referencing the legacy pre-task-#208
|
`(default: AppData/LmxOpcUaClient/pki)`, referencing the legacy pre-task-#208
|
||||||
@@ -293,4 +293,4 @@ that no longer matches where settings and the PKI store actually live.
|
|||||||
**Recommendation:** Update the watermark to reference `OtOpcUaClient/pki`, or bind
|
**Recommendation:** Update the watermark to reference `OtOpcUaClient/pki`, or bind
|
||||||
it to `ClientStoragePaths.GetPkiPath()` so it cannot drift again.
|
it to `ClientStoragePaths.GetPkiPath()` so it cannot drift again.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-23 — Updated the watermark text in `Views/MainWindow.axaml` from `(default: AppData/LmxOpcUaClient/pki)` to `(default: AppData/OtOpcUaClient/pki)` so it matches the canonical folder name resolved by `ClientStoragePaths` (the binding-to-helper alternative was considered but a static string keeps the watermark cheap; the path is also already documented in `docs/Client.UI.md`).
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ All times are in UTC. Invalid input turns red on blur.
|
|||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| Aggregate | Raw (default), Average, Minimum, Maximum, Count, Start, End |
|
| Aggregate | Raw (default), Average, Minimum, Maximum, Count, Start, End, Standard Deviation |
|
||||||
| Interval (ms) | Processing interval for aggregate queries (shown only for aggregates) |
|
| Interval (ms) | Processing interval for aggregate queries (shown only for aggregates) |
|
||||||
| Max Values | Maximum number of raw values to return (default 1000) |
|
| Max Values | Maximum number of raw values to return (default 1000) |
|
||||||
|
|
||||||
|
|||||||
@@ -30,12 +30,6 @@ public partial class DateTimeRangePicker : UserControl
|
|||||||
public static readonly StyledProperty<string> EndTextProperty =
|
public static readonly StyledProperty<string> EndTextProperty =
|
||||||
AvaloniaProperty.Register<DateTimeRangePicker, string>(nameof(EndText), defaultValue: "");
|
AvaloniaProperty.Register<DateTimeRangePicker, string>(nameof(EndText), defaultValue: "");
|
||||||
|
|
||||||
public static readonly StyledProperty<DateTimeOffset?> MinDateTimeProperty =
|
|
||||||
AvaloniaProperty.Register<DateTimeRangePicker, DateTimeOffset?>(nameof(MinDateTime));
|
|
||||||
|
|
||||||
public static readonly StyledProperty<DateTimeOffset?> MaxDateTimeProperty =
|
|
||||||
AvaloniaProperty.Register<DateTimeRangePicker, DateTimeOffset?>(nameof(MaxDateTime));
|
|
||||||
|
|
||||||
private bool _isUpdating;
|
private bool _isUpdating;
|
||||||
|
|
||||||
public DateTimeRangePicker()
|
public DateTimeRangePicker()
|
||||||
@@ -67,18 +61,6 @@ public partial class DateTimeRangePicker : UserControl
|
|||||||
set => SetValue(EndTextProperty, value);
|
set => SetValue(EndTextProperty, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public DateTimeOffset? MinDateTime
|
|
||||||
{
|
|
||||||
get => GetValue(MinDateTimeProperty);
|
|
||||||
set => SetValue(MinDateTimeProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public DateTimeOffset? MaxDateTime
|
|
||||||
{
|
|
||||||
get => GetValue(MaxDateTimeProperty);
|
|
||||||
set => SetValue(MaxDateTimeProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnLoaded(RoutedEventArgs e)
|
protected override void OnLoaded(RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
base.OnLoaded(e);
|
base.OnLoaded(e);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
using Serilog;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Client.UI;
|
namespace ZB.MOM.WW.OtOpcUa.Client.UI;
|
||||||
|
|
||||||
@@ -7,8 +9,16 @@ public class Program
|
|||||||
[STAThread]
|
[STAThread]
|
||||||
public static void Main(string[] args)
|
public static void Main(string[] args)
|
||||||
{
|
{
|
||||||
BuildAvaloniaApp()
|
ConfigureLogging();
|
||||||
.StartWithClassicDesktopLifetime(args);
|
try
|
||||||
|
{
|
||||||
|
BuildAvaloniaApp()
|
||||||
|
.StartWithClassicDesktopLifetime(args);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Log.CloseAndFlush();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static AppBuilder BuildAvaloniaApp()
|
public static AppBuilder BuildAvaloniaApp()
|
||||||
@@ -18,4 +28,35 @@ public class Program
|
|||||||
.WithInterFont()
|
.WithInterFont()
|
||||||
.LogToTrace();
|
.LogToTrace();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the Serilog root logger with a console sink + a rolling daily file sink
|
||||||
|
/// under <c>{LocalAppData}/OtOpcUaClient/logs/</c>. CLAUDE.md mandates Serilog with a
|
||||||
|
/// rolling daily file sink as the project standard; this is also the only way the swallow
|
||||||
|
/// blocks in the alarms / subscriptions / redundancy view-models surface a diagnosable
|
||||||
|
/// trace when an operator hits a problem in the field.
|
||||||
|
/// </summary>
|
||||||
|
private static void ConfigureLogging()
|
||||||
|
{
|
||||||
|
var logsDir = Path.Combine(ClientStoragePaths.GetRoot(), "logs");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(logsDir);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort; file sink will gracefully fall back if the dir can't be created.
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Logger = new LoggerConfiguration()
|
||||||
|
.MinimumLevel.Information()
|
||||||
|
.Enrich.FromLogContext()
|
||||||
|
.WriteTo.Console()
|
||||||
|
.WriteTo.File(
|
||||||
|
path: Path.Combine(logsDir, "client-ui-.log"),
|
||||||
|
rollingInterval: RollingInterval.Day,
|
||||||
|
retainedFileCountLimit: 14,
|
||||||
|
shared: true)
|
||||||
|
.CreateLogger();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
|
using Serilog;
|
||||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
||||||
using ZB.MOM.WW.OtOpcUa.Client.UI.Services;
|
using ZB.MOM.WW.OtOpcUa.Client.UI.Services;
|
||||||
@@ -13,9 +14,18 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.ViewModels;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class AlarmsViewModel : ObservableObject
|
public partial class AlarmsViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
|
private static readonly ILogger Logger = Log.ForContext<AlarmsViewModel>();
|
||||||
|
|
||||||
private readonly IUiDispatcher _dispatcher;
|
private readonly IUiDispatcher _dispatcher;
|
||||||
private readonly IOpcUaClientService _service;
|
private readonly IOpcUaClientService _service;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Last user-visible status message — set when an alarm subscribe / unsubscribe / refresh
|
||||||
|
/// operation fails so the shell can surface the diagnostic instead of silently dropping it.
|
||||||
|
/// Genuine failures are distinguished from "feature not supported" (condition refresh).
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty] private string? _statusMessage;
|
||||||
|
|
||||||
[ObservableProperty] private int _interval = 1000;
|
[ObservableProperty] private int _interval = 1000;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -95,19 +105,25 @@ public partial class AlarmsViewModel : ObservableObject
|
|||||||
|
|
||||||
await _service.SubscribeAlarmsAsync(sourceNodeId, Interval);
|
await _service.SubscribeAlarmsAsync(sourceNodeId, Interval);
|
||||||
IsSubscribed = true;
|
IsSubscribed = true;
|
||||||
|
StatusMessage = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _service.RequestConditionRefreshAsync();
|
await _service.RequestConditionRefreshAsync();
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception refreshEx)
|
||||||
{
|
{
|
||||||
// Refresh not supported
|
// Condition refresh is optional on the server side — log at info level and surface
|
||||||
|
// a soft notice rather than a hard failure so the operator can tell apart "server
|
||||||
|
// does not advertise refresh" from a genuine subscribe failure.
|
||||||
|
Logger.Information(refreshEx, "RequestConditionRefresh not supported by server");
|
||||||
|
StatusMessage = "Condition refresh not supported by server (subscribed).";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Subscribe failed
|
Logger.Warning(ex, "SubscribeAlarms failed for {Source}", MonitoredNodeIdText ?? "(all)");
|
||||||
|
StatusMessage = $"Subscribe to alarms failed: {ex.Message}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,10 +139,12 @@ public partial class AlarmsViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
await _service.UnsubscribeAlarmsAsync();
|
await _service.UnsubscribeAlarmsAsync();
|
||||||
IsSubscribed = false;
|
IsSubscribed = false;
|
||||||
|
StatusMessage = null;
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Unsubscribe failed
|
Logger.Warning(ex, "UnsubscribeAlarms failed");
|
||||||
|
StatusMessage = $"Unsubscribe alarms failed: {ex.Message}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,10 +154,14 @@ public partial class AlarmsViewModel : ObservableObject
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _service.RequestConditionRefreshAsync();
|
await _service.RequestConditionRefreshAsync();
|
||||||
|
StatusMessage = null;
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Refresh failed
|
// Same as the subscribe-time fallback: refresh is server-side optional. Information-
|
||||||
|
// level log + soft status so the operator sees why an explicit refresh did nothing.
|
||||||
|
Logger.Information(ex, "RequestConditionRefresh not supported by server");
|
||||||
|
StatusMessage = "Condition refresh not supported by server.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,19 +211,22 @@ public partial class AlarmsViewModel : ObservableObject
|
|||||||
|
|
||||||
await _service.SubscribeAlarmsAsync(nodeId, Interval);
|
await _service.SubscribeAlarmsAsync(nodeId, Interval);
|
||||||
IsSubscribed = true;
|
IsSubscribed = true;
|
||||||
|
StatusMessage = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _service.RequestConditionRefreshAsync();
|
await _service.RequestConditionRefreshAsync();
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception refreshEx)
|
||||||
{
|
{
|
||||||
// Refresh not supported
|
Logger.Information(refreshEx, "RequestConditionRefresh not supported by server (restore path)");
|
||||||
|
StatusMessage = "Condition refresh not supported by server (restored subscription).";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Subscribe failed
|
Logger.Warning(ex, "RestoreAlarmSubscription failed for {Source}", sourceNodeId);
|
||||||
|
StatusMessage = $"Restore alarm subscription failed: {ex.Message}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Serilog;
|
||||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
||||||
using ZB.MOM.WW.OtOpcUa.Client.UI.Services;
|
using ZB.MOM.WW.OtOpcUa.Client.UI.Services;
|
||||||
@@ -12,6 +13,8 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.ViewModels;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class MainWindowViewModel : ObservableObject, IDisposable
|
public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||||
{
|
{
|
||||||
|
private static readonly ILogger Logger = Log.ForContext<MainWindowViewModel>();
|
||||||
|
|
||||||
private readonly IUiDispatcher _dispatcher;
|
private readonly IUiDispatcher _dispatcher;
|
||||||
private readonly IOpcUaClientServiceFactory _factory;
|
private readonly IOpcUaClientServiceFactory _factory;
|
||||||
private readonly ISettingsService _settingsService;
|
private readonly ISettingsService _settingsService;
|
||||||
@@ -137,6 +140,15 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
|||||||
{
|
{
|
||||||
if (args.PropertyName == nameof(AlarmsViewModel.ActiveAlarmCount))
|
if (args.PropertyName == nameof(AlarmsViewModel.ActiveAlarmCount))
|
||||||
_dispatcher.Post(() => ActiveAlarmCount = Alarms.ActiveAlarmCount);
|
_dispatcher.Post(() => ActiveAlarmCount = Alarms.ActiveAlarmCount);
|
||||||
|
else if (args.PropertyName == nameof(AlarmsViewModel.StatusMessage)
|
||||||
|
&& !string.IsNullOrEmpty(Alarms.StatusMessage))
|
||||||
|
_dispatcher.Post(() => StatusMessage = Alarms.StatusMessage!);
|
||||||
|
};
|
||||||
|
Subscriptions.PropertyChanged += (_, args) =>
|
||||||
|
{
|
||||||
|
if (args.PropertyName == nameof(SubscriptionsViewModel.StatusMessage)
|
||||||
|
&& !string.IsNullOrEmpty(Subscriptions.StatusMessage))
|
||||||
|
_dispatcher.Post(() => StatusMessage = Subscriptions.StatusMessage!);
|
||||||
};
|
};
|
||||||
History = new HistoryViewModel(_service, _dispatcher);
|
History = new HistoryViewModel(_service, _dispatcher);
|
||||||
|
|
||||||
@@ -244,15 +256,17 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
|||||||
SessionLabel = $"{info.ServerName} | Session: {info.SessionName} ({info.SessionId})";
|
SessionLabel = $"{info.ServerName} | Session: {info.SessionName} ({info.SessionId})";
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load redundancy info
|
// Load redundancy info — the server may not implement the redundancy facet, in which
|
||||||
|
// case we leave RedundancyInfo null but log so a field diagnosis can tell the difference
|
||||||
|
// between "facet not advertised" and "facet errored". The connection itself stays up.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var redundancy = await _service!.GetRedundancyInfoAsync();
|
var redundancy = await _service!.GetRedundancyInfoAsync();
|
||||||
_dispatcher.Post(() => RedundancyInfo = redundancy);
|
_dispatcher.Post(() => RedundancyInfo = redundancy);
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception redundancyEx)
|
||||||
{
|
{
|
||||||
// Redundancy info not available
|
Logger.Information(redundancyEx, "GetRedundancyInfo unavailable on this server");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load root nodes
|
// Load root nodes
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
|
using Serilog;
|
||||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
||||||
using ZB.MOM.WW.OtOpcUa.Client.UI.Services;
|
using ZB.MOM.WW.OtOpcUa.Client.UI.Services;
|
||||||
@@ -13,9 +14,17 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.ViewModels;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class SubscriptionsViewModel : ObservableObject
|
public partial class SubscriptionsViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
|
private static readonly ILogger Logger = Log.ForContext<SubscriptionsViewModel>();
|
||||||
|
|
||||||
private readonly IUiDispatcher _dispatcher;
|
private readonly IUiDispatcher _dispatcher;
|
||||||
private readonly IOpcUaClientService _service;
|
private readonly IOpcUaClientService _service;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Last user-visible status message — set when a subscribe/unsubscribe operation fails so the
|
||||||
|
/// shell can surface the diagnostic instead of silently dropping the error. Cleared on success.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty] private string? _statusMessage;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyCanExecuteChangedFor(nameof(AddSubscriptionCommand))]
|
[NotifyCanExecuteChangedFor(nameof(AddSubscriptionCommand))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(RemoveSubscriptionCommand))]
|
[NotifyCanExecuteChangedFor(nameof(RemoveSubscriptionCommand))]
|
||||||
@@ -85,11 +94,13 @@ public partial class SubscriptionsViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
ActiveSubscriptions.Add(new SubscriptionItemViewModel(nodeIdStr, interval));
|
ActiveSubscriptions.Add(new SubscriptionItemViewModel(nodeIdStr, interval));
|
||||||
SubscriptionCount = ActiveSubscriptions.Count;
|
SubscriptionCount = ActiveSubscriptions.Count;
|
||||||
|
StatusMessage = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Subscription failed; no item added
|
Logger.Warning(ex, "AddSubscription failed for {NodeId}", nodeIdStr);
|
||||||
|
_dispatcher.Post(() => StatusMessage = $"Subscribe failed for {nodeIdStr}: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,9 +127,11 @@ public partial class SubscriptionsViewModel : ObservableObject
|
|||||||
|
|
||||||
_dispatcher.Post(() => ActiveSubscriptions.Remove(item));
|
_dispatcher.Post(() => ActiveSubscriptions.Remove(item));
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Unsubscribe failed for this item; continue with others
|
Logger.Warning(ex, "Unsubscribe failed for {NodeId}", item.NodeId);
|
||||||
|
_dispatcher.Post(() => StatusMessage = $"Unsubscribe failed for {item.NodeId}: {ex.Message}");
|
||||||
|
// Continue with the other items in the batch.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,11 +159,13 @@ public partial class SubscriptionsViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
ActiveSubscriptions.Add(new SubscriptionItemViewModel(nodeIdStr, intervalMs));
|
ActiveSubscriptions.Add(new SubscriptionItemViewModel(nodeIdStr, intervalMs));
|
||||||
SubscriptionCount = ActiveSubscriptions.Count;
|
SubscriptionCount = ActiveSubscriptions.Count;
|
||||||
|
StatusMessage = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Subscription failed
|
Logger.Warning(ex, "AddSubscriptionForNode failed for {NodeId}", nodeIdStr);
|
||||||
|
_dispatcher.Post(() => StatusMessage = $"Subscribe failed for {nodeIdStr}: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,9 +201,10 @@ public partial class SubscriptionsViewModel : ObservableObject
|
|||||||
foreach (var child in children)
|
foreach (var child in children)
|
||||||
await AddSubscriptionRecursiveAsync(child.NodeId, child.NodeClass, intervalMs, maxDepth, currentDepth + 1);
|
await AddSubscriptionRecursiveAsync(child.NodeId, child.NodeClass, intervalMs, maxDepth, currentDepth + 1);
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Browse failed for this node; skip it
|
Logger.Warning(ex, "Recursive browse failed for {NodeId}; skipping subtree", nodeIdStr);
|
||||||
|
_dispatcher.Post(() => StatusMessage = $"Browse failed for {nodeIdStr}: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
<TextBox Text="{Binding CertificateStorePath}"
|
<TextBox Text="{Binding CertificateStorePath}"
|
||||||
Width="370"
|
Width="370"
|
||||||
IsReadOnly="True"
|
IsReadOnly="True"
|
||||||
Watermark="(default: AppData/LmxOpcUaClient/pki)" />
|
Watermark="(default: AppData/OtOpcUaClient/pki)" />
|
||||||
<Button Name="BrowseCertPathButton"
|
<Button Name="BrowseCertPathButton"
|
||||||
Content="..."
|
Content="..."
|
||||||
Width="30"
|
Width="30"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.ComponentModel;
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
using SkiaSharp;
|
using SkiaSharp;
|
||||||
using Svg.Skia;
|
using Svg.Skia;
|
||||||
using ZB.MOM.WW.OtOpcUa.Client.UI.ViewModels;
|
using ZB.MOM.WW.OtOpcUa.Client.UI.ViewModels;
|
||||||
@@ -126,15 +127,34 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
if (DataContext is not MainWindowViewModel vm) return;
|
if (DataContext is not MainWindowViewModel vm) return;
|
||||||
|
|
||||||
var dialog = new OpenFolderDialog
|
var topLevel = TopLevel.GetTopLevel(this);
|
||||||
|
if (topLevel == null) return;
|
||||||
|
|
||||||
|
IStorageFolder? startLocation = null;
|
||||||
|
if (!string.IsNullOrEmpty(vm.CertificateStorePath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
startLocation = await topLevel.StorageProvider.TryGetFolderFromPathAsync(vm.CertificateStorePath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort: if the existing path can't be resolved (missing/permission), open the dialog without it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||||
{
|
{
|
||||||
Title = "Select Certificate Store Folder",
|
Title = "Select Certificate Store Folder",
|
||||||
Directory = vm.CertificateStorePath
|
AllowMultiple = false,
|
||||||
};
|
SuggestedStartLocation = startLocation
|
||||||
|
});
|
||||||
|
|
||||||
var result = await dialog.ShowAsync(this);
|
if (folders.Count == 0) return;
|
||||||
if (!string.IsNullOrEmpty(result))
|
|
||||||
vm.CertificateStorePath = result;
|
var picked = folders[0].TryGetLocalPath();
|
||||||
|
if (!string.IsNullOrEmpty(picked))
|
||||||
|
vm.CertificateStorePath = picked;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnClosing(WindowClosingEventArgs e)
|
protected override void OnClosing(WindowClosingEventArgs e)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0"/>
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0"/>
|
||||||
<PackageReference Include="Serilog" Version="4.2.0"/>
|
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -138,4 +138,21 @@ public class AlarmsViewModelTests
|
|||||||
{
|
{
|
||||||
_vm.Interval.ShouldBe(1000);
|
_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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -175,12 +175,23 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
|||||||
return Task.FromResult(BrowseResults);
|
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 />
|
/// <inheritdoc />
|
||||||
public Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default)
|
public Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
SubscribeCallCount++;
|
SubscribeCallCount++;
|
||||||
LastSubscribeNodeId = nodeId;
|
LastSubscribeNodeId = nodeId;
|
||||||
LastSubscribeIntervalMs = intervalMs;
|
LastSubscribeIntervalMs = intervalMs;
|
||||||
|
if (SubscribeException != null) throw SubscribeException;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +207,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
|||||||
public Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default)
|
public Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
SubscribeAlarmsCallCount++;
|
SubscribeAlarmsCallCount++;
|
||||||
|
if (SubscribeAlarmsException != null) throw SubscribeAlarmsException;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -515,6 +515,25 @@ public class MainWindowViewModelTests
|
|||||||
_settingsService.LastSaved!.SubscribedNodes.ShouldContain("ns=2;s=TestSub");
|
_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>
|
/// <summary>
|
||||||
/// Verifies that saved subscriptions are restored after reconnecting the shell.
|
/// Verifies that saved subscriptions are restored after reconnecting the shell.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -276,6 +276,41 @@ public class SubscriptionsViewModelTests
|
|||||||
_service.SubscribeCallCount.ShouldBe(2);
|
_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]
|
[Fact]
|
||||||
public async Task AddSubscriptionRecursiveAsync_RecursesNestedObjects()
|
public async Task AddSubscriptionRecursiveAsync_RecursesNestedObjects()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user