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

24 KiB
Raw Blame History

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):

/// <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):

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 projectdotnet 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

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):

// --- 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:

/// <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):

/// <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

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

<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):

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: Builddotnet 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

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:

var (success, message) = await _alarmsVm.ConfirmAlarmAsync(_alarm, comment);

(Keep the same OnConfirmClicked/OnCancelClicked shape, resultText.Text = "Confirming...";.)

Step 3: Builddotnet build src/Client/ZB.MOM.WW.OtOpcUa.Client.UI → 0 errors.

Step 4: Commit

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();):

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:

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):

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: Builddotnet 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

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

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).