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>
This commit is contained in:
2026-05-07 13:59:50 +02:00
parent da0581967b
commit 827b811b39
102 changed files with 7522 additions and 1798 deletions

View File

@@ -0,0 +1,58 @@
# Gotcha: Oil-pump confirmation dialog vs. `RefreshFromTick` race
## Symptom
On the Tests page, pressing **Start Test** shows the oil-pump leak-check dialog. After the operator clicks **Accept**, the tests **do not start** — the operator has to press **Start Test** a second time. The second press works.
## Why it happens
`BenchControlViewModel.OnIsOilPumpOnChanged` uses `dlg.ShowDialog()`, which runs a **nested dispatcher message pump** on the UI thread. While that pump is draining, the `MainViewModel` refresh timer keeps ticking and calls `BenchControlViewModel.RefreshFromTick()`, which reads the relay state from config and writes it back into the `_isOilPumpOn` backing field:
```csharp
bool relayOn = _config.Bench.Relays.TryGetValue(RelayNames.OilPump, out var oilRelay) && oilRelay.State;
if (_isOilPumpOn != relayOn)
{
_isOilPumpOn = relayOn;
OnPropertyChanged(nameof(IsOilPumpOn));
}
```
The ordering on the first press is:
1. `IsOilPumpOn = true` — setter writes backing field to `true`, then calls `OnIsOilPumpOnChanged`.
2. Inside the partial: `dlg.ShowDialog()` blocks. **`SetRelay` has not been called yet, so `relay.State` is still `false`.**
3. A refresh tick fires during the nested pump. `RefreshFromTick` sees `_isOilPumpOn (true) != relayOn (false)` and **clobbers `_isOilPumpOn` back to `false`**.
4. Operator clicks Accept. `ShowDialog` returns. `_bench.SetRelay(OilPump, true)` finally runs and commits `relay.State = true`.
5. `OnIsOilPumpOnChanged` returns — but `_isOilPumpOn` is still `false` from step 3.
6. The caller (`TestsPageViewModel.StartTestAsync`) checks `if (!Root.BenchControl.IsOilPumpOn) return;` — guard trips, **test never starts**.
On the second press, `relay.State` is already `true`, so `RefreshFromTick` is a no-op during the second dialog and the flow completes.
## The fix
After `SetRelay` commits the real state at the bottom of `OnIsOilPumpOnChanged`, re-assert the backing field:
```csharp
_bench.SetRelay(RelayNames.OilPump, value);
if (_isOilPumpOn != value)
{
_isOilPumpOn = value;
OnPropertyChanged(nameof(IsOilPumpOn));
}
```
Writing through the backing field (not the setter) avoids re-triggering the confirmation dialog.
## General lesson — nested message pumps
**Any `ShowDialog()` call is a nested dispatcher pump.** While it blocks, timers, CAN callbacks marshalled to the UI thread, and property-change handlers keep running. Mutable state that other handlers may "correct" based on transient external readings can be rewritten under you before your synchronous code resumes. When mixing a modal dialog with a periodic state-sync task:
- Either **suspend the sync task** while the dialog is open, or
- **Re-assert local state after the dialog returns** once the ground truth (relay, register, etc.) has actually been committed.
Symptoms of this class of bug:
- An operation "works the second time but not the first"
- A property setter appears to silently revert
- A guard on a property right after a dialog accept evaluates the opposite of what the user chose
## Files involved
- [ViewModels/BenchControlViewModel.cs](../ViewModels/BenchControlViewModel.cs) — `OnIsOilPumpOnChanged`, `RefreshFromTick`
- [ViewModels/Pages/TestsPageViewModel.cs](../ViewModels/Pages/TestsPageViewModel.cs) — `StartTestAsync` guard that exposed the race
- [Services/Impl/BenchService.cs](../Services/Impl/BenchService.cs) — `SetRelay` (synchronous update of `relay.State` + async CAN transmit)