Files
HC_APTBS/ViewModels/Pages/TestsPageViewModel.cs
LucianoDev 827b811b39 feat: developer tools page, auto-test orchestrator, BIP display, tests redesign
Bundles several feature streams that have been iterating on the working tree:

- Developer Tools page (Debug-only via DEVELOPER_TOOLS symbol): hosts the
  identification card, manual KWP write + transaction log, ROM/EEPROM dump
  card with progress banner and completion message, persisted custom-commands
  library, persisted EEPROM passwords library. New service primitives:
  IKwpService.SendRawCustomAsync / ReadEepromAsync / ReadRomEepromAsync.
  Persistence mirrors the Clients XML pattern in two new files
  (custom_commands.xml, eeprom_passwords.xml).
- Auto-test orchestrator (IAutoTestOrchestrator + AutoTestState): linear
  K-Line read -> unlock -> bench-on -> test sequence with snackbar UI and
  progress dialog VM, gated on dashboard alarms.
- BIP-STATUS display: BipDisplayViewModel + BipDisplayView, RAM read at
  0x0106 via IKwpService.ReadBipStatusAsync; status definitions in
  BipStatusDefinition.
- Tests page redesign: TestSectionCard + PhaseTileView replacing the old
  TestPlanView/TestRunningView/TestDoneView/TestPreconditionsView/
  TestSectionView controls and their VMs.
- Pump command sliders: Fluent thick-track style with overhang thumb,
  click-anywhere-and-drag, mouse-wheel adjustment.
- Window startup: app.manifest declares PerMonitorV2 DPI awareness,
  MainWindow installs a WM_GETMINMAXINFO hook in OnSourceInitialized and
  maximizes there (after the hook is in place) so the app fits the work
  area exactly on any display configuration.
- Misc: PercentToPixelsConverter, seed_aliases.py one-shot pump-alias
  importer, tools/Import-BipStatus.ps1, kline_eeprom_spec.md and
  dump-functions reference docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 13:59:50 +02:00

363 lines
15 KiB
C#

using System;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Services;
using HC_APTBS.ViewModels.Dialogs;
using HC_APTBS.Views.Dialogs;
namespace HC_APTBS.ViewModels.Pages
{
/// <summary>
/// Single-page Tests orchestrator.
///
/// <para>Replaces the former Plan → Preconditions → Running → Done wizard with
/// one status-bar-driven page. The body is always the test-section cards; the
/// status bar narrates the current state (idle / blocked / running / complete)
/// and the action bar exposes the contextual primary action (Start, Abort, or
/// Report + Clear). A PASSED / FAILED snackbar overlay auto-dismisses a few
/// seconds after the test finishes; the Report / Clear-data buttons stay
/// available on the action bar until the operator clears the results.</para>
/// </summary>
public sealed partial class TestsPageViewModel : ObservableObject
{
private const int SnackbarDurationMs = 5000;
private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
private DispatcherTimer? _snackbarTimer;
/// <summary>Root ViewModel — owns services, live readings, and global commands.</summary>
public MainViewModel Root { get; }
/// <summary>Test panel: sections, phase cards, live indicators.</summary>
public TestPanelViewModel TestPanel => Root.TestPanel;
/// <summary>Measurement results table (per-phase pass/fail).</summary>
public ResultDisplayViewModel ResultDisplay => Root.ResultDisplay;
/// <summary>Auth gate scoped to the Tests page (used when an enabled test has <c>RequiresAuth</c>).</summary>
public AuthGateViewModel TestAuth { get; }
/// <summary>Creates the Tests page orchestrator.</summary>
public TestsPageViewModel(MainViewModel root, IConfigurationService config, ILocalizationService loc)
{
Root = root;
_config = config;
_loc = loc;
TestAuth = new AuthGateViewModel(config, loc);
Root.PropertyChanged += OnRootPropertyChanged;
Root.DashboardAlarms.PropertyChanged += OnAlarmsPropertyChanged;
Root.TestPanel.PropertyChanged += OnTestPanelPropertyChanged;
Root.TestPanel.Tests.CollectionChanged += OnTestSectionsChanged;
TestAuth.PropertyChanged += OnAuthPropertyChanged;
_loc.LanguageChanged += OnLanguageChanged;
RebindPhaseEnabledWatchers();
RefreshAuthRequired();
Reevaluate();
}
// ── Runtime state ─────────────────────────────────────────────────────────
/// <summary>Mirrors <see cref="MainViewModel.IsTestRunning"/>.</summary>
public bool IsTestRunning => Root.IsTestRunning;
/// <summary>Latched after a test completes; gates Report / Clear-data buttons.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ClearTestDataCommand))]
[NotifyPropertyChangedFor(nameof(ShowDoneSnackbar))]
private bool _hasCompletedResults;
/// <summary>True if the most recent completed run passed.</summary>
[ObservableProperty] private bool _lastRunPassed;
/// <summary>Auto-dismiss flag for the Done snackbar overlay.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowDoneSnackbar))]
private bool _isSnackbarVisible;
/// <summary>True while the PASSED / FAILED snackbar overlay is visible.</summary>
public bool ShowDoneSnackbar => HasCompletedResults && IsSnackbarVisible;
// ── Preconditions ─────────────────────────────────────────────────────────
/// <summary>True when at least one enabled test requires operator authentication.</summary>
[ObservableProperty] private bool _isAuthRequired;
/// <summary>True when every required precondition passes.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(StartTestCommand))]
private bool _allPreconditionsPassed;
/// <summary>Localised blocker message shown on the status bar when Start is not available.</summary>
[ObservableProperty] private string _blockingReason = string.Empty;
/// <summary>Localised headline shown on the status bar (state summary).</summary>
[ObservableProperty] private string _statusHeadline = string.Empty;
// ── Commands ──────────────────────────────────────────────────────────────
/// <summary>
/// Starts the test sequence. Mirrors the former Preconditions step: if the oil
/// pump is off, turning it on triggers the leak-check confirmation dialog; a
/// cancelled confirmation aborts the start.
/// </summary>
[RelayCommand(CanExecute = nameof(CanStart))]
private async Task StartTestAsync()
{
if (!Root.BenchControl.IsOilPumpOn)
{
// Setter shows OilPumpConfirmDialog; reverts on cancel.
// BenchControlViewModel.OnIsOilPumpOnChanged re-asserts the
// backing field after SetRelay so the guard below reflects
// the user's choice even if RefreshFromTick fired during
// the dialog's nested dispatcher pump.
Root.BenchControl.IsOilPumpOn = true;
if (!Root.BenchControl.IsOilPumpOn)
return;
}
// CanStart already gated on every precondition (pump/CAN/alarms/auth/
// phases), so bypass Root.StartTestCommand.CanExecute — it can report
// false transiently right after the oil-pump relay toggles — and drive
// the async command directly. Awaited so any exception surfaces on
// the command's ExecutionTask instead of being swallowed.
await Root.StartTestCommand.ExecuteAsync(null);
}
private bool CanStart() => AllPreconditionsPassed && !IsTestRunning;
/// <summary>Confirms then delegates to <see cref="MainViewModel.StopTestCommand"/>.</summary>
[RelayCommand(CanExecute = nameof(CanAbort))]
private void Abort()
{
var vm = new ConfirmDialogViewModel
{
Title = _loc.GetString("Test.Abort.Title"),
Message = _loc.GetString("Test.Abort.Message"),
ConfirmText = _loc.GetString("Test.Abort.Confirm"),
CancelText = _loc.GetString("Test.Abort.Cancel"),
};
var dlg = new ConfirmDialog(vm) { Owner = Application.Current.MainWindow };
dlg.ShowDialog();
if (!vm.Accepted) return;
if (Root.StopTestCommand.CanExecute(null))
Root.StopTestCommand.Execute(null);
}
private bool CanAbort() => IsTestRunning;
/// <summary>Resets phase results and hides post-test affordances.</summary>
[RelayCommand(CanExecute = nameof(CanClearTestData))]
private void ClearTestData()
{
Root.TestPanel.ResetResults();
Root.ResultDisplay.Clear();
HasCompletedResults = false;
IsSnackbarVisible = false;
StopSnackbarTimer();
Reevaluate();
}
private bool CanClearTestData() => HasCompletedResults && !IsTestRunning;
/// <summary>Dismisses the pass/fail snackbar overlay (Report / Clear remain on the action bar).</summary>
[RelayCommand]
private void DismissSnackbar()
{
IsSnackbarVisible = false;
StopSnackbarTimer();
}
// ── Evaluation ────────────────────────────────────────────────────────────
private void Reevaluate()
{
bool hasPump = Root.CurrentPump != null;
bool hasCan = Root.IsCanConnected;
bool noAlarms = !Root.DashboardAlarms.HasCritical;
bool authOk = !IsAuthRequired || TestAuth.IsAuthenticated;
bool anyPhase = Root.TestPanel.Tests.Any(s => s.Phases.Any(p => p.IsEnabled));
string blocker =
!hasPump ? _loc.GetString("Test.Precheck.Remediation.SelectPump") :
!hasCan ? _loc.GetString("Test.Precheck.Remediation.CheckCan") :
!noAlarms ? _loc.GetString("Test.Precheck.Remediation.ClearAlarms") :
!authOk ? _loc.GetString("Test.Precheck.Remediation.Authenticate") :
!anyPhase ? _loc.GetString("Test.Status.NoPhases") :
string.Empty;
BlockingReason = blocker;
AllPreconditionsPassed = blocker.Length == 0;
RefreshStatusHeadline();
}
private void RefreshStatusHeadline()
{
if (IsTestRunning)
StatusHeadline = _loc.GetString("Test.Status.Running");
else if (HasCompletedResults)
StatusHeadline = _loc.GetString(LastRunPassed ? "Test.Done.Passed" : "Test.Done.Failed");
else if (AllPreconditionsPassed)
StatusHeadline = _loc.GetString("Test.Status.Ready");
else
StatusHeadline = _loc.GetString("Test.Status.NotReady");
}
private void RefreshAuthRequired()
{
IsAuthRequired = Root.TestPanel.Tests
.Any(s => (s.Source?.RequiresAuth ?? false) && s.Phases.Any(p => p.IsEnabled));
}
// ── Event handlers ────────────────────────────────────────────────────────
private void OnRootPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case nameof(MainViewModel.CurrentPump):
case nameof(MainViewModel.IsCanConnected):
Reevaluate();
break;
case nameof(MainViewModel.IsTestRunning):
OnPropertyChanged(nameof(IsTestRunning));
StartTestCommand.NotifyCanExecuteChanged();
AbortCommand.NotifyCanExecuteChanged();
ClearTestDataCommand.NotifyCanExecuteChanged();
OnTestRunningChanged();
break;
case nameof(MainViewModel.LastTestSuccess):
LastRunPassed = Root.LastTestSuccess;
RefreshStatusHeadline();
break;
}
}
private void OnTestRunningChanged()
{
if (Root.IsTestRunning)
{
// Starting a fresh run — hide any stale completion state.
HasCompletedResults = false;
IsSnackbarVisible = false;
StopSnackbarTimer();
}
else
{
// Just finished.
LastRunPassed = Root.LastTestSuccess;
HasCompletedResults = true;
IsSnackbarVisible = true;
StartSnackbarTimer();
}
RefreshStatusHeadline();
}
private void OnAlarmsPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(DashboardAlarmsViewModel.HasCritical))
Reevaluate();
}
private void OnAuthPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(AuthGateViewModel.IsAuthenticated))
Reevaluate();
}
private void OnTestPanelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(TestPanelViewModel.RemainingSeconds))
{
RefreshAuthRequired();
Reevaluate();
}
}
private void OnTestSectionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
RebindPhaseEnabledWatchers();
RefreshAuthRequired();
Reevaluate();
}
/// <summary>
/// TestPanel does not raise a dedicated event for individual phase toggles, so
/// we subscribe directly to every <see cref="PhaseCardViewModel"/>. Called once
/// at construction and again whenever the section collection is replaced.
/// </summary>
private void RebindPhaseEnabledWatchers()
{
foreach (var section in Root.TestPanel.Tests)
{
foreach (var phase in section.Phases)
{
phase.PropertyChanged -= OnPhaseCardPropertyChanged;
phase.PropertyChanged += OnPhaseCardPropertyChanged;
}
}
}
private void OnPhaseCardPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(PhaseCardViewModel.IsEnabled))
{
RefreshAuthRequired();
Reevaluate();
}
}
private void OnLanguageChanged() => Reevaluate();
/// <summary>
/// Public hook for the view to request a precondition recompute after the
/// user toggles phases on/off (TestPanel does not raise a dedicated event
/// for enabled-phase changes).
/// </summary>
public void OnEnabledPhasesChanged()
{
RefreshAuthRequired();
Reevaluate();
}
// ── Snackbar timer ────────────────────────────────────────────────────────
private void StartSnackbarTimer()
{
StopSnackbarTimer();
_snackbarTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(SnackbarDurationMs)
};
_snackbarTimer.Tick += (_, _) =>
{
IsSnackbarVisible = false;
StopSnackbarTimer();
};
_snackbarTimer.Start();
}
private void StopSnackbarTimer()
{
if (_snackbarTimer == null) return;
_snackbarTimer.Stop();
_snackbarTimer = null;
}
}
}