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>
363 lines
15 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|