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:
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user