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

@@ -247,6 +247,24 @@ namespace HC_APTBS.Services.Impl
xpump.Add(xtests);
}
// ── Serialise <BipStatus> (pre-injection pumps only) ──
if (pump.BipStatus != null && pump.BipStatus.Bits.Count > 0)
{
var xbip = new XElement("BipStatus");
for (int i = 0; i < pump.BipStatus.Bits.Count; i++)
{
var b = pump.BipStatus.Bits[i];
xbip.Add(new XElement("Bit",
new XAttribute("index", i.ToString(CultureInfo.InvariantCulture)),
new XAttribute("enabled", b.Enabled.ToString().ToLowerInvariant()),
new XAttribute("pattern", "0x" + b.HexPattern.ToString("X4", CultureInfo.InvariantCulture)),
new XAttribute("reaction", b.Reaction.ToString(CultureInfo.InvariantCulture)),
new XAttribute("specialFunction", b.SpecialFunction.ToString(CultureInfo.InvariantCulture)),
b.Description));
}
xpump.Add(xbip);
}
// ── Find existing pump by ID and replace, or append ──
XElement? existing = null;
foreach (var child in pumpsNode.Elements("Pump"))
@@ -395,9 +413,10 @@ namespace HC_APTBS.Services.Impl
{
bit.Values.Add(new StatusBitValue
{
State = int.Parse(xVal.Attribute("value")?.Value ?? "0"),
State = int.Parse(xVal.Attribute("value")?.Value ?? "0", CultureInfo.InvariantCulture),
Color = xVal.Attribute("color")?.Value ?? "26C200",
Description = xVal.Value.Trim()
Description = xVal.Value.Trim(),
Reaction = int.Parse(xVal.Attribute("reaction")?.Value ?? "0", CultureInfo.InvariantCulture)
});
}
@@ -456,6 +475,15 @@ namespace HC_APTBS.Services.Impl
TryString(r, "Language", v => _settings.Language = v);
TryString(r, "Relations", v => _settings.Relations = RpmVoltageRelation.Deserialise(v));
TryString(r, "Users", v => _settings.Users = v);
// ── Bounds enforcement (see docs/gap-config-validation.md) ───────
_settings.RefreshCanBusReadMs = FloorWithLog(_settings.RefreshCanBusReadMs, 1, nameof(_settings.RefreshCanBusReadMs));
_settings.RefreshPumpParamsMs = FloorWithLog(_settings.RefreshPumpParamsMs, 1, nameof(_settings.RefreshPumpParamsMs));
_settings.SecurityRpmLimit = ClampWithLog(_settings.SecurityRpmLimit, 100, 5000, nameof(_settings.SecurityRpmLimit));
_settings.MaxPressureBar = ClampWithLog(_settings.MaxPressureBar, 1, 100, nameof(_settings.MaxPressureBar));
_settings.PidP = FloorWithLog(_settings.PidP, 0.0, nameof(_settings.PidP));
_settings.PidI = FloorWithLog(_settings.PidI, 0.0, nameof(_settings.PidI));
_settings.PidD = FloorWithLog(_settings.PidD, 0.0, nameof(_settings.PidD));
}
catch (Exception ex)
{
@@ -500,7 +528,18 @@ namespace HC_APTBS.Services.Impl
continue;
}
var param = ParseParamElement(xe);
CanBusParameter? param;
try
{
param = ParseParamElement(xe);
}
catch (Exception ex)
{
_log.Warning(LogId, $"Skipped malformed param '{xe.Name.LocalName}': {ex.Message}");
continue;
}
if (param == null) continue;
_bench.ParametersByName[param.Name] = param;
if (!_bench.ParametersById.TryGetValue(param.MessageId, out var list))
@@ -581,17 +620,27 @@ namespace HC_APTBS.Services.Impl
/// <summary>
/// Parses a bench CAN parameter from an XML element.
/// Uses the clean factor/offset calibration model with explicit direction flags.
/// Returns null (and logs a warning) if byteh/bytel are outside 0-7.
/// </summary>
private static CanBusParameter ParseParamElement(XElement xe)
private CanBusParameter? ParseParamElement(XElement xe)
{
string name = xe.Name.LocalName;
ushort byteh = ushort.Parse(xe.Attribute("byteh")?.Value ?? "0");
ushort bytel = ushort.Parse(xe.Attribute("bytel")?.Value ?? "0");
if (byteh > 7 || bytel > 7)
{
_log.Warning(LogId, $"Rejected param '{name}': byteh={byteh} bytel={bytel} out of 0-7.");
return null;
}
string direction = xe.Attribute("direction")?.Value ?? "rx";
return new CanBusParameter
{
Name = xe.Name.LocalName,
Name = name,
MessageId = Convert.ToUInt32(xe.Attribute("id")?.Value ?? "0", 16),
ByteH = ushort.Parse(xe.Attribute("byteh")?.Value ?? "0"),
ByteL = ushort.Parse(xe.Attribute("bytel")?.Value ?? "0"),
ByteH = byteh,
ByteL = bytel,
Alpha = CanBusParameter.ParseDecimal(xe.Attribute("filter")?.Value, 1.0),
IsReceive = string.Equals(direction, "rx", StringComparison.OrdinalIgnoreCase),
Factor = CanBusParameter.ParseDecimal(xe.Attribute("factor")?.Value, 1.0),
@@ -604,14 +653,22 @@ namespace HC_APTBS.Services.Impl
private void ParseRelayElement(XElement xr)
{
string name = xr.Attribute("name")?.Value ?? "";
int bit = int.Parse(xr.Attribute("bit")?.Value ?? "0");
if (bit < 0 || bit > 63)
{
_log.Warning(LogId, $"Rejected relay '{name}': bit={bit} out of 0-63.");
return;
}
var relay = new Relay(
xr.Attribute("name")?.Value ?? "",
name,
Convert.ToUInt32(xr.Attribute("id")?.Value ?? "0", 16),
int.Parse(xr.Attribute("bit")?.Value ?? "0"));
bit);
_bench!.Relays[relay.Name] = relay;
}
private static PumpDefinition? ParsePumpElement(XElement xpump)
private PumpDefinition? ParsePumpElement(XElement xpump)
{
var pump = new PumpDefinition
{
@@ -646,7 +703,16 @@ namespace HC_APTBS.Services.Impl
{
foreach (var xe in xparams.Elements())
{
var param = CanBusParameter.FromXml(xe);
CanBusParameter param;
try
{
param = CanBusParameter.FromXml(xe);
}
catch (Exception ex)
{
_log.Warning(LogId, $"Skipped malformed pump param '{xe.Name.LocalName}' for pump '{pump.Id}': {ex.Message}");
continue;
}
pump.ParametersByName[param.Name] = param;
@@ -677,6 +743,44 @@ namespace HC_APTBS.Services.Impl
}
}
// ── Parse <BipStatus> (optional — pre-injection pumps only) ───────────
var xbip = xpump.Element("BipStatus");
if (xbip != null)
{
var bipDef = new PumpBipDefinition();
foreach (var xbit in xbip.Elements("Bit"))
{
try
{
var patternStr = xbit.Attribute("pattern")?.Value ?? "0";
if (patternStr.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
patternStr = patternStr.Substring(2);
var sfStr = xbit.Attribute("specialFunction")?.Value ?? "9";
int specialFn;
if (sfStr.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
specialFn = int.Parse(sfStr.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
else
specialFn = int.Parse(sfStr, CultureInfo.InvariantCulture);
bipDef.Bits.Add(new BipStatusDefinition
{
Enabled = !string.Equals(xbit.Attribute("enabled")?.Value, "false",
StringComparison.OrdinalIgnoreCase),
HexPattern = ushort.Parse(patternStr, NumberStyles.HexNumber, CultureInfo.InvariantCulture),
Reaction = int.Parse(xbit.Attribute("reaction")?.Value ?? "0", CultureInfo.InvariantCulture),
SpecialFunction = specialFn,
Description = xbit.Value.Trim()
});
}
catch (Exception ex)
{
_log.Warning(LogId, $"Skipped malformed <Bit> in BipStatus for pump '{pump.Id}': {ex.Message}");
}
}
pump.BipStatus = bipDef;
}
return pump;
}
@@ -729,12 +833,15 @@ namespace HC_APTBS.Services.Impl
// ── XML parse helpers ─────────────────────────────────────────────────────
private static void TryInt(XElement root, string name, Action<int> assign)
private void TryInt(XElement root, string name, Action<int> assign)
{
try { if (int.TryParse(root.Element(name)?.Value, out int v)) assign(v); }
catch { /* ignore malformed XML */ }
catch (Exception ex)
{
_log.Warning(LogId, $"Skipped malformed element '{name}': {ex.Message}");
}
}
private static void TryDouble(XElement root, string name, Action<double> assign)
private void TryDouble(XElement root, string name, Action<double> assign)
{
try
{
@@ -743,17 +850,56 @@ namespace HC_APTBS.Services.Impl
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out double v)) assign(v);
}
catch { }
catch (Exception ex)
{
_log.Warning(LogId, $"Skipped malformed element '{name}': {ex.Message}");
}
}
private static void TryBool(XElement root, string name, Action<bool> assign)
private void TryBool(XElement root, string name, Action<bool> assign)
{
try { if (bool.TryParse(root.Element(name)?.Value, out bool v)) assign(v); }
catch { }
catch (Exception ex)
{
_log.Warning(LogId, $"Skipped malformed element '{name}': {ex.Message}");
}
}
private static void TryString(XElement root, string name, Action<string> assign)
private void TryString(XElement root, string name, Action<string> assign)
{
try { var v = root.Element(name)?.Value; if (v != null) assign(v); }
catch { }
catch (Exception ex)
{
_log.Warning(LogId, $"Skipped malformed element '{name}': {ex.Message}");
}
}
/// <summary>
/// Clamps <paramref name="value"/> into [<paramref name="min"/>, <paramref name="max"/>].
/// Logs a warning if clamping occurred.
/// </summary>
private T ClampWithLog<T>(T value, T min, T max, string field) where T : IComparable<T>
{
if (value.CompareTo(min) < 0)
{
_log.Warning(LogId, $"{field}={value} below min {min}, clamped.");
return min;
}
if (value.CompareTo(max) > 0)
{
_log.Warning(LogId, $"{field}={value} above max {max}, clamped.");
return max;
}
return value;
}
/// <summary>Floors <paramref name="value"/> to <paramref name="min"/>, logging if floored.</summary>
private T FloorWithLog<T>(T value, T min, string field) where T : IComparable<T>
{
if (value.CompareTo(min) < 0)
{
_log.Warning(LogId, $"{field}={value} below min {min}, floored.");
return min;
}
return value;
}
// ── Users (PBKDF2-HMAC-SHA256 hashed credentials) ─────────────────────────
@@ -875,6 +1021,106 @@ namespace HC_APTBS.Services.Impl
Settings.Users = string.Join(",", entries);
SaveSettings();
}
/// <inheritdoc/>
public bool AddUser(string username, string password)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password))
{
_log.Warning(LogId, "AddUser rejected: empty username or password.");
return false;
}
if (username.IndexOfAny(new[] { ':', ',' }) >= 0)
{
_log.Warning(LogId, $"AddUser rejected: username '{username}' contains reserved separator character.");
return false;
}
var entries = ParseUserEntries();
if (entries.Any(e => e.user == username))
{
_log.Warning(LogId, $"AddUser rejected: user '{username}' already exists.");
return false;
}
var (salt, hash) = HashPassword(password);
entries.Add((username, salt, hash));
Settings.Users = FormatUserEntries(entries);
SaveSettings();
_log.Info(LogId, $"Added user '{username}'.");
return true;
}
/// <inheritdoc/>
public bool RemoveUser(string username)
{
if (string.IsNullOrWhiteSpace(username))
return false;
var entries = ParseUserEntries();
if (entries.Count <= 1)
{
_log.Warning(LogId, $"RemoveUser rejected: cannot remove '{username}' — at least one user must remain.");
return false;
}
int idx = entries.FindIndex(e => e.user == username);
if (idx < 0)
{
_log.Warning(LogId, $"RemoveUser rejected: user '{username}' does not exist.");
return false;
}
entries.RemoveAt(idx);
Settings.Users = FormatUserEntries(entries);
SaveSettings();
_log.Info(LogId, $"Removed user '{username}'.");
return true;
}
/// <inheritdoc/>
public bool ChangeUserPassword(string username, string newPassword)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(newPassword))
{
_log.Warning(LogId, "ChangeUserPassword rejected: empty username or password.");
return false;
}
var entries = ParseUserEntries();
int idx = entries.FindIndex(e => e.user == username);
if (idx < 0)
{
_log.Warning(LogId, $"ChangeUserPassword rejected: user '{username}' does not exist.");
return false;
}
var (salt, hash) = HashPassword(newPassword);
entries[idx] = (username, salt, hash);
Settings.Users = FormatUserEntries(entries);
SaveSettings();
_log.Info(LogId, $"Changed password for user '{username}'.");
return true;
}
/// <summary>Parses <see cref="AppSettings.Users"/> into a list of (user, salt, hash) tuples, skipping malformed entries.</summary>
private List<(string user, string salt, string hash)> ParseUserEntries()
{
var list = new List<(string, string, string)>();
if (string.IsNullOrEmpty(Settings.Users)) return list;
foreach (string entry in Settings.Users.Split(','))
{
string[] parts = entry.Split(':');
if (parts.Length == 3 && parts[0].Length > 0)
list.Add((parts[0], parts[1], parts[2]));
}
return list;
}
/// <summary>Serialises a list of (user, salt, hash) tuples back to the comma-separated storage format.</summary>
private static string FormatUserEntries(List<(string user, string salt, string hash)> entries)
=> string.Join(",", entries.Select(e => $"{e.user}:{e.salt}:{e.hash}"));
}
// ── XPath extension shim ──────────────────────────────────────────────────────