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

@@ -10,10 +10,28 @@ using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Models;
using HC_APTBS.Services;
using HC_APTBS.ViewModels.Dialogs;
using HC_APTBS.ViewModels.Pages;
using HC_APTBS.Views.Dialogs;
namespace HC_APTBS.ViewModels
{
/// <summary>Identifies the top-level navigation page shown in the shell.</summary>
public enum AppPage
{
/// <summary>Bench controls, flowmeter charts, encoder angles.</summary>
Bench = 0,
/// <summary>Pump manual control, DFI, status displays.</summary>
Pump = 1,
/// <summary>Test suite, live progress, results.</summary>
Tests = 2,
/// <summary>At-a-glance operator landing page: readings, connections, alarms, quick actions.</summary>
Dashboard = 3,
/// <summary>Application configuration: safety limits, PID, motor, report, K-Line, language.</summary>
Settings = 4,
/// <summary>Session-only history of completed test runs with detail view and PDF export.</summary>
Results = 5
}
/// <summary>
/// Root ViewModel for the application's main window.
///
@@ -55,6 +73,25 @@ namespace HC_APTBS.ViewModels
/// <summary>ViewModel for the non-modal unlock progress window.</summary>
private UnlockProgressViewModel? _unlockVm;
/// <summary>
/// Publicly observable accessor for the currently running (or last completed)
/// immobilizer unlock VM. Used by the Pump page's inline unlock panel to
/// display the same state that the floating dialog shows. Null while no
/// unlock has been started for the current pump.
/// </summary>
public UnlockProgressViewModel? CurrentUnlockVm
{
get => _unlockVm;
private set
{
if (!ReferenceEquals(_unlockVm, value))
{
_unlockVm = value;
OnPropertyChanged();
}
}
}
/// <summary>The non-modal unlock progress window, if open.</summary>
private UnlockProgressDialog? _unlockDlg;
@@ -96,6 +133,40 @@ namespace HC_APTBS.ViewModels
/// <summary>ViewModel for the second pump status display (Empf3 word).</summary>
public StatusDisplayViewModel StatusDisplay2 { get; } = new();
/// <summary>ViewModel for the Dashboard's active-alarm list.</summary>
public DashboardAlarmsViewModel DashboardAlarms { get; }
/// <summary>Diagnostic Trouble Code list for the Pump page §3.b sub-section.</summary>
public DtcListViewModel DtcList { get; }
/// <summary>Auth gate for the Pump page §3.d Adaptation sub-section.</summary>
public AuthGateViewModel AdaptationAuth { get; }
// ── Page ViewModels (thin façades over the child VMs above) ───────────────
/// <summary>Dashboard navigation page VM.</summary>
public DashboardPageViewModel DashboardPage { get; private set; } = null!;
/// <summary>Bench navigation page VM.</summary>
public BenchPageViewModel BenchPage { get; private set; } = null!;
/// <summary>Pump navigation page VM.</summary>
public PumpPageViewModel PumpPage { get; private set; } = null!;
/// <summary>Tests navigation page VM.</summary>
public TestsPageViewModel TestsPage { get; private set; } = null!;
/// <summary>Settings navigation page VM.</summary>
public SettingsPageViewModel SettingsPage { get; private set; } = null!;
/// <summary>Results navigation page VM (session-only test-run history).</summary>
public ResultsPageViewModel ResultsPage { get; private set; } = null!;
// ── Navigation state ──────────────────────────────────────────────────────
/// <summary>Currently selected top-level navigation page.</summary>
[ObservableProperty] private AppPage _selectedPage = AppPage.Dashboard;
// ── Constructor ───────────────────────────────────────────────────────────
/// <summary>
@@ -130,6 +201,20 @@ namespace HC_APTBS.ViewModels
PumpControl = new PumpControlViewModel(benchService);
BenchControl = new BenchControlViewModel(benchService, configService);
AngleDisplay = new AngleDisplayViewModel(configService);
DashboardAlarms = new DashboardAlarmsViewModel(configService.Settings.Alarms);
DtcList = new DtcListViewModel(kwpService, localizationService, logger);
AdaptationAuth = new AuthGateViewModel(configService, localizationService);
// Page ViewModels are thin façades over the child VMs above; they hold a
// reference back to this coordinator so page XAML can bind MainViewModel-owned
// values via {Binding Root.X}.
DashboardPage = new DashboardPageViewModel(this);
BenchPage = new BenchPageViewModel(this, benchService, configService);
PumpPage = new PumpPageViewModel(this, DtcList, AdaptationAuth);
TestsPage = new TestsPageViewModel(this, configService, localizationService);
SettingsPage = new SettingsPageViewModel(configService, localizationService);
SettingsPage.SettingsSaved += OnSettingsSaved;
ResultsPage = new ResultsPageViewModel(this, pdfService, configService, localizationService, logger);
// React to pump changes from the identification child VM.
PumpIdentification.PumpChanged += OnPumpChanged;
@@ -163,7 +248,12 @@ namespace HC_APTBS.ViewModels
{
CurrentPhaseName = phase;
TestPanel.SetActivePhase(phase);
// Clear real-time plot traces at each new phase boundary.
FlowmeterChart.Delivery.Clear();
FlowmeterChart.Over.Clear();
});
_bench.PhaseTimerTick += (section, remaining, total) => App.Current.Dispatcher.Invoke(
() => TestPanel.ApplyPhaseTimerTick(section, remaining, total));
_bench.VerboseMessage += msg => App.Current.Dispatcher.Invoke(() =>
{
VerboseStatus = msg;
@@ -191,6 +281,10 @@ namespace HC_APTBS.ViewModels
{
VerboseStatus = string.Format(_loc.GetString("Error.EmergencyStop"), reason);
});
_bench.StatusReactionTriggered += (bit, reaction, desc) => App.Current.Dispatcher.Invoke(() =>
{
VerboseStatus = $"[STATUS] bit {bit} reaction={reaction}: {desc}";
});
// Angle display: lock angle and PSG zero from test phases
_bench.LockAngleFaseReady += () => App.Current.Dispatcher.Invoke(() =>
@@ -281,8 +375,8 @@ namespace HC_APTBS.ViewModels
if (pump.UnlockType == 0) return;
_unlockCts = new CancellationTokenSource();
_unlockVm = new UnlockProgressViewModel(_unlock, pump.UnlockType, _unlockCts, _loc);
_unlockDlg = new UnlockProgressDialog(_unlockVm)
CurrentUnlockVm = new UnlockProgressViewModel(_unlock, pump.UnlockType, _unlockCts, _loc);
_unlockDlg = new UnlockProgressDialog(_unlockVm!)
{ Owner = Application.Current.MainWindow };
// Start unlock in background — ViewModel tracks via event subscriptions.
@@ -312,7 +406,7 @@ namespace HC_APTBS.ViewModels
if (_unlockVm != null)
{
_unlockVm.Dispose();
_unlockVm = null;
CurrentUnlockVm = null;
}
if (_unlockDlg != null)
@@ -394,6 +488,13 @@ namespace HC_APTBS.ViewModels
/// <summary>PSG encoder position value.</summary>
[ObservableProperty] private double _psgEncoderValue;
/// <summary>
/// True when the Oil Pump relay is currently energised. Mirrored on each refresh
/// tick from <c>_config.Bench.Relays[RelayNames.OilPump]</c> so the Tests page
/// preconditions checklist can bind to it without walking the relay dictionary.
/// </summary>
[ObservableProperty] private bool _isOilPumpOn;
// ── Pump live readings (from pump CAN parameters) ──────────────────────────
/// <summary>Pump RPM reported by the ECU over CAN.</summary>
@@ -489,6 +590,17 @@ namespace HC_APTBS.ViewModels
private bool CanStopTest() => IsTestRunning;
/// <summary>
/// Operator-initiated emergency stop from the Dashboard.
/// Zeros the motor, zeros pump parameters, and cancels any running test.
/// </summary>
[RelayCommand]
private void EmergencyStop()
{
_bench.RequestEmergencyStop("Operator pressed E-Stop on Dashboard");
_testCts?.Cancel();
}
// ── Commands: relay toggles ───────────────────────────────────────────────
/// <summary>Toggles the electronic relay (pump solenoid power).</summary>
@@ -534,7 +646,12 @@ namespace HC_APTBS.ViewModels
{
string desktop = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
string path = _pdf.GenerateReport(
CurrentPump, reportVm.OperatorName, reportVm.SelectedClientName, desktop);
CurrentPump,
reportVm.OperatorName,
reportVm.SelectedClientName,
desktop,
clientInfo: reportVm.ClientInfo,
observations: reportVm.Observations);
_log.Info(LogId, $"Report saved: {path}");
IsTestSaved = true;
@@ -552,15 +669,6 @@ namespace HC_APTBS.ViewModels
private bool CanGenerateReport()
=> CurrentPump != null && !IsTestRunning && CurrentPump.Tests.Count > 0;
// ── Commands: language toggle ──────────────────────────────────────────────
/// <summary>Toggles the UI language between Spanish and English.</summary>
[RelayCommand]
private void ToggleLanguage()
{
_loc.SetLanguage(_loc.CurrentLanguage == "ESP" ? "ENG" : "ESP");
}
/// <summary>Refreshes all ViewModel-cached localised strings after a language change.</summary>
private void RefreshLocalisedStrings()
{
@@ -569,17 +677,13 @@ namespace HC_APTBS.ViewModels
: _loc.GetString("Status.Disconnected");
}
// ── Commands: settings ────────────────────────────────────────────────────
/// <summary>Opens the settings dialog for editing application configuration.</summary>
[RelayCommand]
private void OpenSettings()
/// <summary>
/// Reseeds settings-dependent runtime state after the operator saves on the Settings page.
/// Currently only the bench refresh-timer interval needs re-application.
/// </summary>
private void OnSettingsSaved()
{
var vm = new SettingsViewModel(_config, _loc);
var dlg = new SettingsDialog(vm) { Owner = Application.Current.MainWindow };
dlg.ShowDialog();
if (vm.Accepted && _refreshTimer != null)
if (_refreshTimer != null)
_refreshTimer.Interval = TimeSpan.FromMilliseconds(_config.Settings.RefreshBenchInterfaceMs);
}
@@ -658,6 +762,15 @@ namespace HC_APTBS.ViewModels
FlowmeterChart.AddSamples(QDelivery, QOver);
BenchControl.RefreshFromTick();
// Mirror the oil pump relay state for the Tests page preconditions checklist.
IsOilPumpOn = _config.Bench.Relays.TryGetValue(RelayNames.OilPump, out var oilRelay) && oilRelay.State;
// Feed page-scoped Bench VMs (pressure trace + interlock banner).
BenchPage.RefreshFromTick();
// Refresh Dashboard's active-alarm list from the bench alarm bitmask.
DashboardAlarms.Update((int)_bench.ReadBenchParameter(BenchParameterNames.Alarms));
if (CurrentPump != null)
{
PumpRpm = _bench.ReadPumpParameter(PumpParameterNames.Rpm);
@@ -708,6 +821,7 @@ namespace HC_APTBS.ViewModels
LastTestSuccess = !interrupted && success;
VerboseStatus = interrupted ? _loc.GetString("Test.Stopped") : (success ? _loc.GetString("Common.Pass") : _loc.GetString("Common.Fail"));
TestPanel.IsRunning = false;
TestPanel.ClearPhaseTimer();
_bench.StopPumpSender();
StartTestCommand.NotifyCanExecuteChanged();
StopTestCommand.NotifyCanExecuteChanged();
@@ -716,6 +830,13 @@ namespace HC_APTBS.ViewModels
// Populate results table from all completed tests.
if (!interrupted && CurrentPump != null)
ResultDisplay.LoadAllResults(CurrentPump.Tests);
// Capture a session-only history entry (Results page §5) — covers normal
// and interrupted completions. Snapshot is deep-cloned so later runs
// cannot mutate this entry's data.
if (CurrentPump != null)
ResultsPage.CaptureRun(CurrentPump, interrupted, success);
_log.Info(LogId,
$"Test finished — interrupted={interrupted}, success={success}");
});