feat: page-based navigation shell + Tests page wizard

Replace the monolithic MainWindow with a SelectedPage-driven shell
(Dashboard / Pump / Bench / Tests / Results / Settings). The Tests
page gets the Plan -> Preconditions -> Running -> Done wizard from
ui-structure.md \u00a74, backed by a 7-item precondition gate and
shared sub-views (PhaseCardView / TestSectionView / GraphicIndicatorView)
extracted from the now-deleted monolithic TestPanelView.

New VMs / views:
- Tests wizard: TestPreconditions, PhaseCard, GraphicIndicator,
  TestSection, TestPlan, TestRunning, TestDone
- Dashboard panels: DashboardConnection, DashboardReadings,
  DashboardAlarms, InterlockBanner, ResultHistory
- Pump / bench panels: PumpIdentificationPanel, PumpLiveData,
  UnlockPanel, BenchDriveControl, BenchReadings, RelayBank,
  TemperatureControl, DtcList, AuthGate
- Dialogs: generic ConfirmDialog, UserManageDialog, UserPromptDialog

Supporting changes:
- IsOilPumpOn exposed on MainViewModel for precondition evaluation
- RequiresAuth added to TestDefinition (XML round-trip)
- BipStatusDefinition + CompletedTestRun models
- ~35 new Test.* localization keys (en + es)
- Settings moved from modal dialog to full page
- Pause / Retry / Skip stubs in TestRunningView; full spec in
  docs/gap-test-running-controls.md for follow-up implementation
- docs/ui-structure.md captures the wizard design

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 13:11:34 +02:00
parent 37d099cdbd
commit 0280a2fad1
110 changed files with 8008 additions and 1115 deletions

View File

@@ -0,0 +1,51 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace HC_APTBS.ViewModels.Dialogs
{
/// <summary>
/// Generic Yes/No (or Confirm/Cancel) modal dialog view-model.
///
/// <para>Reusable across abort-test, skip-phase, delete-pump, and any future
/// binary decision prompt. Caller sets <see cref="Title"/>, <see cref="Message"/>,
/// <see cref="ConfirmText"/>, <see cref="CancelText"/> before showing the dialog,
/// then inspects <see cref="Accepted"/> after the dialog closes.</para>
/// </summary>
public sealed partial class ConfirmDialogViewModel : ObservableObject
{
/// <summary>Window title.</summary>
[ObservableProperty] private string _title = string.Empty;
/// <summary>Body message — may be multi-line.</summary>
[ObservableProperty] private string _message = string.Empty;
/// <summary>Text shown on the positive-action button (defaults to a localised "OK").</summary>
[ObservableProperty] private string _confirmText = "OK";
/// <summary>Text shown on the cancel button (defaults to a localised "Cancel").</summary>
[ObservableProperty] private string _cancelText = "Cancel";
/// <summary>True when the operator clicked <see cref="ConfirmText"/>; false on cancel/close.</summary>
public bool Accepted { get; private set; }
/// <summary>Raised when the dialog should close itself.</summary>
public event Action? RequestClose;
/// <summary>Accepts the prompt — sets <see cref="Accepted"/> to true and closes.</summary>
[RelayCommand]
private void Confirm()
{
Accepted = true;
RequestClose?.Invoke();
}
/// <summary>Cancels the prompt — leaves <see cref="Accepted"/> false and closes.</summary>
[RelayCommand]
private void Cancel()
{
Accepted = false;
RequestClose?.Invoke();
}
}
}

View File

@@ -1,290 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Infrastructure.Kwp;
using HC_APTBS.Models;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels.Dialogs
{
/// <summary>
/// ViewModel for the application settings dialog.
/// Loads a local copy of every <see cref="AppSettings"/> property so that
/// Cancel discards all changes.
/// </summary>
public sealed partial class SettingsViewModel : ObservableObject
{
private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
// ── Dialog result ─────────────────────────────────────────────────────
/// <summary>True when the user clicked Accept.</summary>
public bool Accepted { get; private set; }
/// <summary>Raised to close the owning dialog window.</summary>
public event Action? RequestClose;
// ── Collections ───────────────────────────────────────────────────────
/// <summary>Available language codes for the language dropdown.</summary>
public ObservableCollection<string> AvailableLanguages { get; } = new() { "ESP", "ENG" };
/// <summary>RPM-voltage lookup table, editable via DataGrid.</summary>
public ObservableCollection<RpmVoltageRelation> Relations { get; } = new();
/// <summary>Available FTDI device serial numbers for K-Line port selection.</summary>
public ObservableCollection<string> AvailablePorts { get; } = new();
// ── General ───────────────────────────────────────────────────────────
[ObservableProperty] private string _selectedLanguage = "ESP";
[ObservableProperty] private int _daysKeepLogs = 7;
// ── Safety ────────────────────────────────────────────────────────────
[ObservableProperty] private int _tempMax = 45;
[ObservableProperty] private int _tempMin = 35;
[ObservableProperty] private int _securityRpmLimit = 2500;
[ObservableProperty] private int _maxPressureBar = 26;
[ObservableProperty] private double _toleranceUpExtension = 0.08;
[ObservableProperty] private double _tolerancePfpExtension = 0.1;
[ObservableProperty] private bool _defaultIgnoreTin = true;
// ── PID ───────────────────────────────────────────────────────────────
[ObservableProperty] private double _pidP = 0.1;
[ObservableProperty] private double _pidI = 0.1;
[ObservableProperty] private double _pidD = 0.04;
[ObservableProperty] private int _pidLoopMs = 250;
// ── Motor ─────────────────────────────────────────────────────────────
[ObservableProperty] private int _encoderResolution = 4096;
[ObservableProperty] private double _voltageForMaxRpm = 10;
[ObservableProperty] private int _maxRpm = 2500;
[ObservableProperty] private bool _rightRelayValue = true;
// ── Company ───────────────────────────────────────────────────────────
[ObservableProperty] private string _companyName = string.Empty;
[ObservableProperty] private string _companyInfo = string.Empty;
[ObservableProperty] private string _reportLogoPath = string.Empty;
// ── K-Line ────────────────────────────────────────────────────────────
[ObservableProperty] private string _selectedKLinePort = string.Empty;
// ── Advanced (refresh intervals) ──────────────────────────────────────
[ObservableProperty] private int _refreshBenchInterfaceMs = 20;
[ObservableProperty] private int _refreshWhileReadingMs = 1500;
[ObservableProperty] private int _refreshCanBusReadMs = 2;
[ObservableProperty] private int _refreshPumpRequestMs = 250;
[ObservableProperty] private int _refreshPumpParamsMs = 4;
[ObservableProperty] private int _blinkIntervalMs = 1000;
[ObservableProperty] private int _flasherIntervalMs = 800;
// ── Constructor ───────────────────────────────────────────────────────
/// <param name="configService">Configuration service for loading/saving settings.</param>
/// <param name="localizationService">Localization service for language switching.</param>
public SettingsViewModel(IConfigurationService configService, ILocalizationService localizationService)
{
_config = configService;
_loc = localizationService;
var s = configService.Settings;
// General
_selectedLanguage = s.Language;
_daysKeepLogs = s.DaysKeepLogs;
// Safety
_tempMax = s.TempMax;
_tempMin = s.TempMin;
_securityRpmLimit = s.SecurityRpmLimit;
_maxPressureBar = s.MaxPressureBar;
_toleranceUpExtension = s.ToleranceUpExtension;
_tolerancePfpExtension = s.TolerancePfpExtension;
_defaultIgnoreTin = s.DefaultIgnoreTin;
// PID
_pidP = s.PidP;
_pidI = s.PidI;
_pidD = s.PidD;
_pidLoopMs = s.PidLoopMs;
// Motor
_encoderResolution = s.EncoderResolution;
_voltageForMaxRpm = s.VoltageForMaxRpm;
_maxRpm = s.MaxRpm;
_rightRelayValue = s.RightRelayValue;
// Company
_companyName = s.CompanyName;
_companyInfo = s.CompanyInfo;
_reportLogoPath = s.ReportLogoPath;
// K-Line
_selectedKLinePort = s.KLinePort;
// Advanced
_refreshBenchInterfaceMs = s.RefreshBenchInterfaceMs;
_refreshWhileReadingMs = s.RefreshWhileReadingMs;
_refreshCanBusReadMs = s.RefreshCanBusReadMs;
_refreshPumpRequestMs = s.RefreshPumpRequestMs;
_refreshPumpParamsMs = s.RefreshPumpParamsMs;
_blinkIntervalMs = s.BlinkIntervalMs;
_flasherIntervalMs = s.FlasherIntervalMs;
// Deep-copy the RPM-voltage relation table
foreach (var r in s.Relations)
Relations.Add(new RpmVoltageRelation(r.Voltage, r.Rpm));
// Enumerate connected FTDI devices
EnumerateFtdiDevices();
}
// ── Commands ──────────────────────────────────────────────────────────
/// <summary>Copies all local values back to AppSettings, saves, and closes.</summary>
[RelayCommand]
private void Accept()
{
var s = _config.Settings;
// General
s.DaysKeepLogs = DaysKeepLogs;
// Safety
s.TempMax = TempMax;
s.TempMin = TempMin;
s.SecurityRpmLimit = SecurityRpmLimit;
s.MaxPressureBar = MaxPressureBar;
s.ToleranceUpExtension = ToleranceUpExtension;
s.TolerancePfpExtension = TolerancePfpExtension;
s.DefaultIgnoreTin = DefaultIgnoreTin;
// PID
s.PidP = PidP;
s.PidI = PidI;
s.PidD = PidD;
s.PidLoopMs = PidLoopMs;
// Motor
s.EncoderResolution = EncoderResolution;
s.VoltageForMaxRpm = VoltageForMaxRpm;
s.MaxRpm = MaxRpm;
s.RightRelayValue = RightRelayValue;
s.Relations = Relations.Select(r => new RpmVoltageRelation(r.Voltage, r.Rpm)).ToList();
// Company
s.CompanyName = CompanyName;
s.CompanyInfo = CompanyInfo;
s.ReportLogoPath = ReportLogoPath;
// K-Line
s.KLinePort = SelectedKLinePort;
// Advanced
s.RefreshBenchInterfaceMs = RefreshBenchInterfaceMs;
s.RefreshWhileReadingMs = RefreshWhileReadingMs;
s.RefreshCanBusReadMs = RefreshCanBusReadMs;
s.RefreshPumpRequestMs = RefreshPumpRequestMs;
s.RefreshPumpParamsMs = RefreshPumpParamsMs;
s.BlinkIntervalMs = BlinkIntervalMs;
s.FlasherIntervalMs = FlasherIntervalMs;
// Language — switch if changed (also persists via LocalizationService)
if (SelectedLanguage != _loc.CurrentLanguage)
_loc.SetLanguage(SelectedLanguage);
_config.SaveSettings();
Accepted = true;
RequestClose?.Invoke();
}
/// <summary>Discards changes and closes.</summary>
[RelayCommand]
private void Cancel()
{
Accepted = false;
RequestClose?.Invoke();
}
/// <summary>Opens a file dialog to select a company logo image.</summary>
[RelayCommand]
private void BrowseLogo()
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Title = _loc.GetString("Dialog.Settings.BrowseLogoTitle"),
Filter = "Image files|*.png;*.jpg;*.jpeg;*.bmp|All files|*.*"
};
if (!string.IsNullOrEmpty(ReportLogoPath))
{
try { dlg.InitialDirectory = System.IO.Path.GetDirectoryName(ReportLogoPath); }
catch { /* ignore invalid path */ }
}
if (dlg.ShowDialog() == true)
ReportLogoPath = dlg.FileName;
}
/// <summary>Re-enumerates connected FTDI devices into <see cref="AvailablePorts"/>.</summary>
[RelayCommand]
private void RefreshPorts()
{
EnumerateFtdiDevices();
}
/// <summary>Appends a new empty row to the RPM-voltage relation table.</summary>
[RelayCommand]
private void AddRelation()
{
Relations.Add(new RpmVoltageRelation(0.0, 0));
}
/// <summary>Removes the selected row from the RPM-voltage relation table.</summary>
[RelayCommand]
private void RemoveRelation(RpmVoltageRelation? relation)
{
if (relation != null)
Relations.Remove(relation);
}
// ── Helpers ───────────────────────────────────────────────────────────
/// <summary>
/// Populates <see cref="AvailablePorts"/> with serial numbers of connected
/// FTDI devices. Fails silently if the FTDI driver DLL is not present.
/// </summary>
private void EnumerateFtdiDevices()
{
AvailablePorts.Clear();
try
{
uint count = FtdiInterface.GetDevicesCount();
if (count == 0) return;
var list = new FT_DEVICE_INFO_NODE[count];
FtdiInterface.GetDeviceList(list);
foreach (var device in list)
{
if (!string.IsNullOrEmpty(device.SerialNumber))
AvailablePorts.Add(device.SerialNumber);
}
}
catch
{
// FTDI DLL not loaded or no devices — leave list empty.
}
}
}
}

View File

@@ -0,0 +1,179 @@
using System;
using System.Collections.ObjectModel;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Services;
using HC_APTBS.Views.Dialogs;
namespace HC_APTBS.ViewModels.Dialogs
{
/// <summary>
/// ViewModel for the user management dialog.
/// Invokes incremental methods on <see cref="IConfigurationService"/>
/// (<c>AddUser</c>, <c>RemoveUser</c>, <c>ChangeUserPassword</c>) so that
/// hashes of untouched accounts are preserved. Each action persists immediately;
/// the dialog has no Accept/Cancel split — Close simply dismisses the window.
/// </summary>
public sealed partial class UserManageViewModel : ObservableObject
{
private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
/// <summary>Creates the ViewModel and populates <see cref="Users"/> from the service.</summary>
public UserManageViewModel(IConfigurationService config, ILocalizationService loc)
{
_config = config;
_loc = loc;
ReloadUsers();
}
// ── Bindable state ────────────────────────────────────────────────────
/// <summary>Usernames currently stored in the configuration.</summary>
public ObservableCollection<string> Users { get; } = new();
/// <summary>Currently selected username in the DataGrid, or null when nothing is selected.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(RemoveCommand))]
[NotifyCanExecuteChangedFor(nameof(ChangePasswordCommand))]
private string? _selectedUser;
/// <summary>Raised to close the owning dialog window.</summary>
public event Action? RequestClose;
// ── Commands ──────────────────────────────────────────────────────────
/// <summary>Prompts for a username and password, then adds the user via the service.</summary>
[RelayCommand]
private void Add()
{
var prompt = new UserPromptDialog(
_loc.GetString("Dialog.UserManage.Prompt.AddTitle"),
usernameVisible: true)
{
Owner = Application.Current?.Windows.Count > 0
? GetActiveWindow()
: null
};
if (prompt.ShowDialog() != true)
return;
string username = prompt.EnteredUsername?.Trim() ?? string.Empty;
string password = prompt.EnteredPassword ?? string.Empty;
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
{
ShowError("Dialog.UserManage.Error.Empty", "Dialog.UserManage.Error.EmptyTitle");
return;
}
if (username.IndexOfAny(new[] { ':', ',' }) >= 0)
{
ShowError("Dialog.UserManage.Error.InvalidChars", "Dialog.UserManage.Error.InvalidCharsTitle");
return;
}
if (!_config.AddUser(username, password))
{
ShowError("Dialog.UserManage.Error.Duplicate", "Dialog.UserManage.Error.DuplicateTitle");
return;
}
ReloadUsers();
SelectedUser = username;
}
/// <summary>Removes the selected user after confirmation, refusing when only one user remains.</summary>
[RelayCommand(CanExecute = nameof(HasSelection))]
private void Remove()
{
string user = SelectedUser!;
var confirm = MessageBox.Show(
string.Format(_loc.GetString("Dialog.UserManage.Confirm.Remove"), user),
_loc.GetString("Dialog.UserManage.Confirm.RemoveTitle"),
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (confirm != MessageBoxResult.Yes)
return;
if (!_config.RemoveUser(user))
{
ShowError("Dialog.UserManage.Error.LastUser", "Dialog.UserManage.Error.LastUserTitle");
return;
}
ReloadUsers();
}
/// <summary>Prompts for a new password for the selected user and applies it.</summary>
[RelayCommand(CanExecute = nameof(HasSelection))]
private void ChangePassword()
{
string user = SelectedUser!;
var prompt = new UserPromptDialog(
string.Format(_loc.GetString("Dialog.UserManage.Prompt.ChangeTitle"), user),
usernameVisible: false,
prefillUsername: user)
{
Owner = GetActiveWindow()
};
if (prompt.ShowDialog() != true)
return;
string newPassword = prompt.EnteredPassword ?? string.Empty;
if (string.IsNullOrEmpty(newPassword))
{
ShowError("Dialog.UserManage.Error.Empty", "Dialog.UserManage.Error.EmptyTitle");
return;
}
if (!_config.ChangeUserPassword(user, newPassword))
{
ShowError("Dialog.UserManage.Error.Empty", "Dialog.UserManage.Error.EmptyTitle");
return;
}
}
/// <summary>Closes the dialog.</summary>
[RelayCommand]
private void Close() => RequestClose?.Invoke();
// ── Helpers ───────────────────────────────────────────────────────────
private bool HasSelection() => !string.IsNullOrEmpty(SelectedUser);
/// <summary>Reloads the user list from the service, preserving selection when possible.</summary>
private void ReloadUsers()
{
string? previous = SelectedUser;
Users.Clear();
foreach (var name in _config.GetUsers())
Users.Add(name);
SelectedUser = (previous != null && Users.Contains(previous)) ? previous : null;
}
private void ShowError(string messageKey, string titleKey)
{
MessageBox.Show(
_loc.GetString(messageKey),
_loc.GetString(titleKey),
MessageBoxButton.OK,
MessageBoxImage.Stop);
}
/// <summary>Returns the topmost active window for use as a dialog owner, or null if none.</summary>
private static Window? GetActiveWindow()
{
foreach (Window w in Application.Current.Windows)
{
if (w.IsActive) return w;
}
return Application.Current.MainWindow;
}
}
}