Files
lmxopcua/docs/plans/2026-06-16-stillpending-phase-7-client-alarm-buttons.md
T

495 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 7 — Client.UI Alarm Ack/Shelve/Confirm Buttons — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task. Read this plan + `docs/plans/2026-06-16-stillpending-phase-7-client-alarm-buttons-design.md` first. Branch `feat/stillpending-phase-7-client-alarm-buttons` (off master `ad3ec9d9`) already exists with the design committed (`573728b5`).
**Goal:** Give the Avalonia desktop client per-alarm **Acknowledge / Shelve / Confirm** via the existing right-click context menu, backed by the already-implemented `IOpcUaClientService` methods.
**Architecture:** Avalonia + CommunityToolkit.Mvvm. Two new `AlarmsViewModel` async methods (`ShelveAlarmAsync`, `ConfirmAlarmAsync`) mirroring the existing `AcknowledgeAlarmAsync`; two new `AlarmEventViewModel` enable predicates; two new dialog windows (`ShelveAlarmWindow`, `ConfirmAlarmWindow`); and the runtime context-menu builder in `AlarmsView.axaml.cs` extended to 3 items with per-item enablement. The VM methods + predicates are unit-tested against the UI `FakeOpcUaClientService`; the dialogs + menu are proven by build + a launch/connect smoke.
**Tech Stack:** C#/.NET 10, Avalonia, CommunityToolkit.Mvvm, OPCFoundation `Opc.Ua` (`StatusCode`), xUnit + Shouldly.
**Global rules (every task):** TDD red→green for the VM methods + predicates. Stage by path — never `git add .`; never stage `sql_login.txt`, `src/Server/.../Host/pki/`, `pending.md`, `current.md`, `docker-dev/docker-compose.yml`, `stillpending.md`. No `--no-verify`, no force-push. **NO EF migration, NO Commons/`IOpcUaClientService`/CLI contract change** (the service methods already exist). If `git commit` hits `.git/index.lock`, wait 2s + retry up to 5×.
**Dependency graph:** Task 1 → Task 2 → {Task 3 ∥ Task 4} → Task 5 → Task 6 → Task 7 → Task 8.
---
### Task 0: Feature branch *(done)*
Branch `feat/stillpending-phase-7-client-alarm-buttons` off `ad3ec9d9`; design committed `573728b5`. No action.
---
### Task 1: Extend the UI `FakeOpcUaClientService` with Shelve/Confirm call-tracking
**Classification:** small · **Est:** ~3 min · **Parallelizable with:** none
**Files:**
- Modify: `tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs`
**Context:** The fake already implements `ConfirmAlarmAsync`/`ShelveAlarmAsync` returning `ConfirmResult`/`ShelveResult`, but does NOT track call args or support a thrown exception (unlike `AcknowledgeAlarmAsync` which has `AcknowledgeCallCount` + `AcknowledgeException`). Add the parallel tracking so the Task-2 VM tests can assert the service was called with the right args.
**Step 1: Add tracking fields + exception props** (near the existing `ConfirmResult` / `ShelveResult`):
```csharp
/// <summary>Gets or sets the exception thrown to simulate alarm confirmation failures in the UI.</summary>
public Exception? ConfirmException { get; set; }
/// <summary>Gets the number of times ConfirmAlarmAsync has been called.</summary>
public int ConfirmCallCount { get; private set; }
/// <summary>Gets the (conditionNodeId, eventId, comment) of the last ConfirmAlarmAsync call.</summary>
public (string ConditionNodeId, byte[] EventId, string Comment)? LastConfirmCall { get; private set; }
/// <summary>Gets or sets the exception thrown to simulate alarm shelve failures in the UI.</summary>
public Exception? ShelveException { get; set; }
/// <summary>Gets the number of times ShelveAlarmAsync has been called.</summary>
public int ShelveCallCount { get; private set; }
/// <summary>Gets the (conditionNodeId, kind, shelvingTimeSeconds) of the last ShelveAlarmAsync call.</summary>
public (string ConditionNodeId, ShelveKind Kind, double ShelvingTimeSeconds)? LastShelveCall { get; private set; }
```
**Step 2: Update the two methods** to record + honor the exception (mirror `AcknowledgeAlarmAsync`):
```csharp
public Task<StatusCode> ConfirmAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
CancellationToken ct = default)
{
ConfirmCallCount++;
LastConfirmCall = (conditionNodeId, eventId, comment);
if (ConfirmException != null) throw ConfirmException;
return Task.FromResult(ConfirmResult);
}
public Task<StatusCode> ShelveAlarmAsync(string conditionNodeId, ShelveKind kind, double shelvingTimeSeconds = 0,
CancellationToken ct = default)
{
ShelveCallCount++;
LastShelveCall = (conditionNodeId, kind, shelvingTimeSeconds);
if (ShelveException != null) throw ShelveException;
return Task.FromResult(ShelveResult);
}
```
(`ShelveKind` is already imported via `using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;` at the top.)
**Step 3: Build the test project**`dotnet build tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests` → 0 errors (no test consumes the new members yet; this just confirms it compiles).
**Step 4: Commit**
```bash
git add tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs
git commit -m "test(client-ui): track Shelve/Confirm calls + exceptions in FakeOpcUaClientService"
```
---
### Task 2: `AlarmsViewModel.ShelveAlarmAsync` + `ConfirmAlarmAsync` + `CanShelve`/`CanConfirm` (TDD)
**Classification:** standard · **Est:** ~5 min · **Parallelizable with:** none · **blockedBy:** Task 1
**Files:**
- Modify: `src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/AlarmsViewModel.cs` (add 2 methods after `AcknowledgeAlarmAsync` ~:193)
- Modify: `src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/AlarmEventViewModel.cs` (add 2 predicates after `CanAcknowledge` :98)
- Test: `tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/AlarmsViewModelTests.cs`
**Step 1: Write the failing tests.** Read the existing `AlarmsViewModelTests.cs` first to copy its setup (how the `AlarmsViewModel` is constructed with the `FakeOpcUaClientService`, how `IsConnected` is set, how an `AlarmEventViewModel` is built — mirror the existing Acknowledge tests exactly). Add these cases (names indicative; match the file's naming style):
```csharp
// --- Shelve ---
// connected + OneShot: calls service with kind=OneShot, returns success
// _service.IsConnected = true; _vm... ; var alarm = new AlarmEventViewModel(... conditionNodeId: "ns=2;s=A", eventId: new byte[]{1});
// var (ok, _) = await _vm.ShelveAlarmAsync(alarm, ShelveKind.OneShot, 0);
// ok.ShouldBeTrue(); _service.ShelveCallCount.ShouldBe(1);
// _service.LastShelveCall!.Value.Kind.ShouldBe(ShelveKind.OneShot);
// _service.LastShelveCall!.Value.ConditionNodeId.ShouldBe("ns=2;s=A");
// connected + Timed + duration 300: passes ShelvingTimeSeconds=300
// connected + Unshelve: kind=Unshelve, success
// Timed + duration <= 0: returns (false, ...), ShelveCallCount==0 (no service call)
// not connected: returns (false, ...), ShelveCallCount==0
// missing ConditionNodeId (null): returns (false, ...), ShelveCallCount==0
// service returns Bad (_service.ShelveResult = StatusCodes.BadNodeIdUnknown): returns (false, message contains "Shelve failed")
// --- Confirm ---
// connected: calls service with nodeId+eventId+comment, returns success; ConfirmCallCount==1; LastConfirmCall comment matches
// not connected: (false, ...), ConfirmCallCount==0
// missing EventId (null): (false, ...), ConfirmCallCount==0
// service Bad (_service.ConfirmResult = StatusCodes.BadNodeIdUnknown): (false, message contains "Confirm failed")
// --- Predicates (pure, construct AlarmEventViewModel directly) ---
// CanShelve: true when ConditionNodeId != null; false when null
// CanConfirm: true when AckedState && EventId != null && ConditionNodeId != null; false when not acked / null event / null node
```
**Step 2: Run → FAIL** (`ShelveAlarmAsync`/`ConfirmAlarmAsync`/`CanShelve`/`CanConfirm` not defined):
`dotnet test tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests --filter "FullyQualifiedName~AlarmsViewModel"` → compile error / fail.
**Step 3: Implement.** In `AlarmsViewModel.cs` (ensure `using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;` is present for `ShelveKind` — add it if missing), after `AcknowledgeAlarmAsync`:
```csharp
/// <summary>Shelves / unshelves an alarm and returns (success, message).</summary>
public async Task<(bool Success, string Message)> ShelveAlarmAsync(
AlarmEventViewModel alarm, ShelveKind kind, double durationSeconds)
{
if (!IsConnected || alarm.ConditionNodeId == null)
return (false, "Alarm cannot be shelved (not connected or missing ConditionId).");
if (kind == ShelveKind.Timed && durationSeconds <= 0)
return (false, "Timed shelve requires a positive duration (seconds).");
try
{
var result = await _service.ShelveAlarmAsync(alarm.ConditionNodeId, kind, durationSeconds);
if (Opc.Ua.StatusCode.IsGood(result))
return (true, $"Alarm {kind.ToString().ToLowerInvariant()} succeeded.");
return (false, $"Shelve failed: {Helpers.StatusCodeFormatter.Format(result)}");
}
catch (Exception ex)
{
return (false, $"Error: {ex.Message}");
}
}
/// <summary>Confirms an alarm and returns (success, message).</summary>
public async Task<(bool Success, string Message)> ConfirmAlarmAsync(AlarmEventViewModel alarm, string comment)
{
if (!IsConnected || alarm.EventId == null || alarm.ConditionNodeId == null)
return (false, "Alarm cannot be confirmed (missing EventId or ConditionId).");
try
{
var result = await _service.ConfirmAlarmAsync(alarm.ConditionNodeId, alarm.EventId, comment);
if (Opc.Ua.StatusCode.IsGood(result))
return (true, "Alarm confirmed successfully.");
return (false, $"Confirm failed: {Helpers.StatusCodeFormatter.Format(result)}");
}
catch (Exception ex)
{
return (false, $"Error: {ex.Message}");
}
}
```
In `AlarmEventViewModel.cs`, after `CanAcknowledge` (:98):
```csharp
/// <summary>Whether this alarm can be shelved/unshelved (has a ConditionNodeId).</summary>
public bool CanShelve => ConditionNodeId != null;
/// <summary>Whether this alarm can be confirmed (already acked, has EventId + ConditionNodeId).</summary>
public bool CanConfirm => AckedState && EventId != null && ConditionNodeId != null;
```
**Step 4: Run → PASS.** `dotnet test tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests --filter "FullyQualifiedName~AlarmsViewModel"` → green. Then `dotnet build src/Client/ZB.MOM.WW.OtOpcUa.Client.UI` → 0 errors.
**Step 5: Commit**
```bash
git add src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/AlarmsViewModel.cs \
src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/AlarmEventViewModel.cs \
tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/AlarmsViewModelTests.cs
git commit -m "feat(client-ui): AlarmsViewModel Shelve/Confirm methods + CanShelve/CanConfirm"
```
---
### Task 3: `ShelveAlarmWindow` dialog (view + code-behind)
**Classification:** small · **Est:** ~5 min · **Parallelizable with:** Task 4 · **blockedBy:** Task 2
**Files:**
- Create: `src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Views/ShelveAlarmWindow.axaml`
- Create: `src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Views/ShelveAlarmWindow.axaml.cs`
**Context:** Model on `AckAlarmWindow.axaml{,.cs}` (read both first). Difference: instead of a comment box, a **ShelveKind selector** (ComboBox) + a **Duration (seconds)** input enabled only for Timed. No comment (the service `ShelveAlarmAsync` takes none).
**Step 1: `ShelveAlarmWindow.axaml`**
```xml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ZB.MOM.WW.OtOpcUa.Client.UI.Views.ShelveAlarmWindow"
Title="Shelve Alarm" Width="420" SizeToContent="Height" MinHeight="280"
WindowStartupLocation="CenterOwner" CanResize="False">
<StackPanel Margin="16" Spacing="12">
<TextBlock Text="Shelve Alarm" FontWeight="Bold" FontSize="16" />
<StackPanel Spacing="4">
<TextBlock Text="Source:" FontSize="12" Foreground="Gray" />
<TextBlock Name="SourceText" FontWeight="SemiBold" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Condition:" FontSize="12" Foreground="Gray" />
<TextBlock Name="ConditionText" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Shelve type:" FontSize="12" Foreground="Gray" />
<ComboBox Name="KindCombo" SelectedIndex="0" HorizontalAlignment="Stretch">
<ComboBoxItem>OneShot</ComboBoxItem>
<ComboBoxItem>Timed</ComboBoxItem>
<ComboBoxItem>Unshelve</ComboBoxItem>
</ComboBox>
</StackPanel>
<StackPanel Name="DurationPanel" Spacing="4" IsEnabled="False">
<TextBlock Text="Duration (seconds):" FontSize="12" Foreground="Gray" />
<NumericUpDown Name="DurationInput" Minimum="1" Value="60" Increment="10" FormatString="0" />
</StackPanel>
<TextBlock Name="ResultText" Foreground="Gray" />
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button Name="ShelveButton" Content="Shelve" />
<Button Name="CancelButton" Content="Cancel" />
</StackPanel>
</StackPanel>
</Window>
```
**Step 2: `ShelveAlarmWindow.axaml.cs`** (mirror `AckAlarmWindow.axaml.cs`):
```csharp
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
using ZB.MOM.WW.OtOpcUa.Client.UI.ViewModels;
namespace ZB.MOM.WW.OtOpcUa.Client.UI.Views;
public partial class ShelveAlarmWindow : Window
{
private readonly AlarmsViewModel _alarmsVm;
private readonly AlarmEventViewModel _alarm;
/// <summary>Designer ctor.</summary>
public ShelveAlarmWindow()
{
InitializeComponent();
_alarmsVm = null!;
_alarm = null!;
}
/// <summary>Creates the shelve dialog for an alarm.</summary>
public ShelveAlarmWindow(AlarmsViewModel alarmsVm, AlarmEventViewModel alarm)
{
InitializeComponent();
_alarmsVm = alarmsVm;
_alarm = alarm;
var sourceText = this.FindControl<TextBlock>("SourceText");
if (sourceText != null) sourceText.Text = alarm.SourceName;
var conditionText = this.FindControl<TextBlock>("ConditionText");
if (conditionText != null) conditionText.Text = $"{alarm.ConditionName} (Severity: {alarm.Severity})";
var kindCombo = this.FindControl<ComboBox>("KindCombo");
if (kindCombo != null) kindCombo.SelectionChanged += OnKindChanged;
var shelveButton = this.FindControl<Button>("ShelveButton");
if (shelveButton != null) shelveButton.Click += OnShelveClicked;
var cancelButton = this.FindControl<Button>("CancelButton");
if (cancelButton != null) cancelButton.Click += OnCancelClicked;
}
private void OnKindChanged(object? sender, SelectionChangedEventArgs e)
{
// Duration only applies to Timed (index 1).
var durationPanel = this.FindControl<StackPanel>("DurationPanel");
var kindCombo = this.FindControl<ComboBox>("KindCombo");
if (durationPanel != null && kindCombo != null)
durationPanel.IsEnabled = kindCombo.SelectedIndex == 1;
}
private async void OnShelveClicked(object? sender, RoutedEventArgs e)
{
var kindCombo = this.FindControl<ComboBox>("KindCombo");
var durationInput = this.FindControl<NumericUpDown>("DurationInput");
var resultText = this.FindControl<TextBlock>("ResultText");
if (kindCombo == null || durationInput == null || resultText == null) return;
var kind = kindCombo.SelectedIndex switch
{
1 => ShelveKind.Timed,
2 => ShelveKind.Unshelve,
_ => ShelveKind.OneShot
};
var duration = (double)(durationInput.Value ?? 0m);
resultText.Foreground = Brushes.Gray;
resultText.Text = "Working...";
var (success, message) = await _alarmsVm.ShelveAlarmAsync(_alarm, kind, duration);
if (success)
{
Close();
}
else
{
resultText.Foreground = Brushes.Red;
resultText.Text = message;
}
}
private void OnCancelClicked(object? sender, RoutedEventArgs e) => Close();
}
```
**Step 3: Build**`dotnet build src/Client/ZB.MOM.WW.OtOpcUa.Client.UI` → 0 errors (confirms the XAML compiles + the `NumericUpDown.Value` decimal→double cast is right).
**Step 4: Commit**
```bash
git add src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Views/ShelveAlarmWindow.axaml \
src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Views/ShelveAlarmWindow.axaml.cs
git commit -m "feat(client-ui): ShelveAlarmWindow dialog (kind + duration)"
```
---
### Task 4: `ConfirmAlarmWindow` dialog (view + code-behind)
**Classification:** small · **Est:** ~3 min · **Parallelizable with:** Task 3 · **blockedBy:** Task 2
**Files:**
- Create: `src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Views/ConfirmAlarmWindow.axaml`
- Create: `src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Views/ConfirmAlarmWindow.axaml.cs`
**Context:** **Clone `AckAlarmWindow`** (comment box + result banner) → call `ConfirmAlarmAsync`. Only the title, the button text/handler, and the watermark differ.
**Step 1: `ConfirmAlarmWindow.axaml`** — identical to `AckAlarmWindow.axaml` except `x:Class=...ConfirmAlarmWindow`, `Title="Confirm Alarm"`, the header `TextBlock Text="Confirm Alarm"`, the comment `Watermark="Enter confirmation comment"`, and the button `Name="ConfirmButton" Content="Confirm"`.
**Step 2: `ConfirmAlarmWindow.axaml.cs`** — identical to `AckAlarmWindow.axaml.cs` except the class name, the button lookup `"ConfirmButton"`, and the call:
```csharp
var (success, message) = await _alarmsVm.ConfirmAlarmAsync(_alarm, comment);
```
(Keep the same `OnConfirmClicked`/`OnCancelClicked` shape, `resultText.Text = "Confirming...";`.)
**Step 3: Build**`dotnet build src/Client/ZB.MOM.WW.OtOpcUa.Client.UI` → 0 errors.
**Step 4: Commit**
```bash
git add src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Views/ConfirmAlarmWindow.axaml \
src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Views/ConfirmAlarmWindow.axaml.cs
git commit -m "feat(client-ui): ConfirmAlarmWindow dialog (comment)"
```
---
### Task 5: Extend the `AlarmsView` context menu (3 items + per-item enablement)
**Classification:** small · **Est:** ~5 min · **Parallelizable with:** none · **blockedBy:** Task 3, Task 4
**Files:**
- Modify: `src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Views/AlarmsView.axaml.cs`
**Context:** Today `OnLoaded` builds a 1-item menu ("Acknowledge...") and `OnContextMenuOpening` cancels the WHOLE menu when `!CanAcknowledge`. Rework to 3 items, enable each per its own predicate, and cancel only when none apply. Add `using System.Linq;`.
**Step 1: Replace `OnLoaded`'s menu build** (after `var contextMenu = new ContextMenu();`):
```csharp
var ackItem = new MenuItem { Header = "Acknowledge..." };
ackItem.Click += OnAcknowledgeClicked;
contextMenu.Items.Add(ackItem);
var shelveItem = new MenuItem { Header = "Shelve..." };
shelveItem.Click += OnShelveClicked;
contextMenu.Items.Add(shelveItem);
var confirmItem = new MenuItem { Header = "Confirm..." };
confirmItem.Click += OnConfirmClicked;
contextMenu.Items.Add(confirmItem);
contextMenu.Opening += OnContextMenuOpening;
grid.ContextMenu = contextMenu;
```
**Step 2: Replace `OnContextMenuOpening`** with per-item enablement:
```csharp
private void OnContextMenuOpening(object? sender, CancelEventArgs e)
{
var grid = this.FindControl<DataGrid>("AlarmsGrid");
if (grid?.SelectedItem is not AlarmEventViewModel alarm)
{
e.Cancel = true;
return;
}
if (grid.ContextMenu is { } menu)
{
foreach (var item in menu.Items.OfType<MenuItem>())
{
item.IsEnabled = item.Header switch
{
"Acknowledge..." => alarm.CanAcknowledge,
"Shelve..." => alarm.CanShelve,
"Confirm..." => alarm.CanConfirm,
_ => true
};
}
}
// Cancel the whole menu only when nothing is actionable for this row.
if (!alarm.CanAcknowledge && !alarm.CanShelve && !alarm.CanConfirm)
e.Cancel = true;
}
```
**Step 3: Add the two click handlers** (mirror `OnAcknowledgeClicked`):
```csharp
private void OnShelveClicked(object? sender, RoutedEventArgs e)
{
if (DataContext is not AlarmsViewModel vm) return;
var grid = this.FindControl<DataGrid>("AlarmsGrid");
if (grid?.SelectedItem is not AlarmEventViewModel alarm || !alarm.CanShelve) return;
var parentWindow = this.FindAncestorOfType<Window>();
if (parentWindow == null) return;
new ShelveAlarmWindow(vm, alarm).ShowDialog(parentWindow);
}
private void OnConfirmClicked(object? sender, RoutedEventArgs e)
{
if (DataContext is not AlarmsViewModel vm) return;
var grid = this.FindControl<DataGrid>("AlarmsGrid");
if (grid?.SelectedItem is not AlarmEventViewModel alarm || !alarm.CanConfirm) return;
var parentWindow = this.FindAncestorOfType<Window>();
if (parentWindow == null) return;
new ConfirmAlarmWindow(vm, alarm).ShowDialog(parentWindow);
}
```
**Step 4: Build**`dotnet build src/Client/ZB.MOM.WW.OtOpcUa.Client.UI` → 0 errors. Then full client tests: `dotnet test tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests` → green.
**Step 5: Commit**
```bash
git add src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/Views/AlarmsView.axaml.cs
git commit -m "feat(client-ui): Shelve/Confirm context-menu items with per-item enablement"
```
---
### Task 6: Docs + bookkeeping
**Classification:** small · **Est:** ~3 min · **Parallelizable with:** none · **blockedBy:** Task 5
**Files:**
- Modify: the client UI doc (look for `docs/Client.UI.md`; if none, add a short "Alarm actions" note to the most relevant client doc — e.g. `docs/Client.CLI.md`'s sibling or `docs/ScriptedAlarms.md`'s client section). Document that the desktop client's Alarms tab now supports right-click **Acknowledge / Shelve (OneShot/Timed/Unshelve) / Confirm**, gated on alarm state, backed by the same `IOpcUaClientService` methods as the CLI.
**Step 1:** Find the doc (`ls docs/ | grep -i client`); add the note. Keep it short + accurate (Shelve has no comment; Confirm needs a prior Ack).
**Step 2: Commit**
```bash
git add docs/<the-doc>.md
git commit -m "docs(phase7): client desktop alarm Ack/Shelve/Confirm actions"
```
---
### Task 7: Full build + test + final integration review
**Classification:** standard · **Est:** ~4 min · **Parallelizable with:** none · **blockedBy:** Task 16
**Steps:**
- `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 errors.
- `dotnet test tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests` → green (all new VM/predicate tests + no regressions).
- Final integration review of the cumulative branch diff (`git diff ad3ec9d9..HEAD`): (a) the 2 VM methods mirror `AcknowledgeAlarmAsync` (guards, `StatusCode.IsGood`, formatter, try/catch); (b) `CanShelve`/`CanConfirm` match the design; (c) the context-menu cancels only when nothing is actionable + each item's `IsEnabled` is correct; (d) the dialogs wire to the right VM method + close on success / show Bad on failure; (e) NO `IOpcUaClientService`/CLI/Commons/EF change leaked. Fix findings, commit.
---
### Task 8: Live launch/connect smoke (agent-driven, honest)
**Classification:** small · **Est:** ~5 min · **Parallelizable with:** none · **blockedBy:** Task 7
**Context:** The Avalonia desktop GUI is **not agent-drivable** (no display; the Chrome tools are web-only). So this is a build + launch/connect smoke, NOT a click-through.
**Steps:**
- Confirm the rig server is up: `curl -s -o /dev/null -w "%{http_code}" http://localhost:9200/` (AdminUI) and that central-1 is serving OPC UA on `:4840` (it carries 1 scripted alarm). If down: `docker compose -f docker-dev/docker-compose.yml up -d central-1` (use `dangerouslyDisableSandbox: true` for any rig network call).
- Build + launch the client headlessly to confirm it starts without crashing: `dotnet build src/Client/ZB.MOM.WW.OtOpcUa.Client.UI` (0 errors). A full GUI run (`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.UI`) needs a display; if it can't render headlessly, record that the **click-through is operator-gated** and rely on the unit-proven VM→service round-trip + the clean build.
- Record results honestly in the tasks.json (verified: build + tests + VM round-trip unit-proven; operator-gated: the actual button click-through, since Avalonia native isn't agent-drivable).
---
## Done =
Build clean + `Client.UI.Tests` green + final integration review SHIP + launch/connect smoke recorded. Then `finishing-a-development-branch` → merge to master + push (standing directive). Update the `project_stillpending_backlog` memory (Phase 7 shipped; §4 Client.UI gaps closed).