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:
@@ -65,6 +65,14 @@ namespace HC_APTBS.Services.Impl
|
||||
// Alarm bitmask snapshot for edge detection during test phases
|
||||
private int _lastAlarmMask;
|
||||
|
||||
// Active pump's status-word definition, cached when a test starts.
|
||||
// Used by PollPumpStatusReactions to evaluate reaction codes per (bit,state).
|
||||
private PumpStatusDefinition? _activeStatusDef;
|
||||
|
||||
// Tracks which (bit, state) pairs have already fired their reaction during
|
||||
// the current phase so we don't re-fire every tick. Entry value true = fired.
|
||||
private readonly Dictionary<(int bit, int state), bool> _statusReactionEdge = new();
|
||||
|
||||
// QOver zero-flow safety debounce (elapsed ms from phase stopwatch)
|
||||
private long _qOverZeroSinceMs;
|
||||
private const int QOverDebounceSec = 3;
|
||||
@@ -103,6 +111,14 @@ namespace HC_APTBS.Services.Impl
|
||||
public event Action<string, double>? MeasurementSampled;
|
||||
/// <inheritdoc/>
|
||||
public event Action<string>? EmergencyStopTriggered;
|
||||
/// <inheritdoc/>
|
||||
public event Action<string, int, int>? PhaseTimerTick;
|
||||
/// <inheritdoc/>
|
||||
public event Action<int, int, string>? StatusReactionTriggered;
|
||||
|
||||
// Section labels for PhaseTimerTick — constants avoid per-tick string allocations.
|
||||
private const string SectionConditioning = "Conditioning";
|
||||
private const string SectionMeasuring = "Measuring";
|
||||
|
||||
// ── Constructor ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -195,7 +211,6 @@ namespace HC_APTBS.Services.Impl
|
||||
SendRpmVoltage(voltage);
|
||||
|
||||
_lastTargetRpm = safeRpm;
|
||||
RpmCommandSent?.Invoke();
|
||||
_log.Debug(LogId, $"SetRpm({safeRpm}) → voltage={voltage:F3}V");
|
||||
}
|
||||
|
||||
@@ -212,7 +227,6 @@ namespace HC_APTBS.Services.Impl
|
||||
if (safeRpm <= 0)
|
||||
{
|
||||
SendRpmVoltage(0);
|
||||
RpmCommandSent?.Invoke();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -220,7 +234,6 @@ namespace HC_APTBS.Services.Impl
|
||||
double initialVoltage = RpmVoltageRelation.VoltageForRpm(
|
||||
(int)safeRpm, _config.Settings.Relations);
|
||||
SendRpmVoltage(initialVoltage);
|
||||
RpmCommandSent?.Invoke();
|
||||
|
||||
// Step 2: Calculate approach delay based on RPM distance.
|
||||
double actualRpm = ReadBenchParameter(BenchParameterNames.BenchRpm);
|
||||
@@ -261,7 +274,6 @@ namespace HC_APTBS.Services.Impl
|
||||
_pidController = null;
|
||||
_lastTargetRpm = 0;
|
||||
SendRpmVoltage(0);
|
||||
RpmCommandSent?.Invoke();
|
||||
_log.Info(LogId, "StopRpmPid: PID stopped, 0V sent.");
|
||||
}
|
||||
|
||||
@@ -274,9 +286,15 @@ namespace HC_APTBS.Services.Impl
|
||||
if (volts < 0) volts = 0;
|
||||
_lastCommandVoltage = volts;
|
||||
|
||||
SetParameter(BenchParameterNames.Rpm, volts);
|
||||
// Scale voltage (0-10V) to 12-bit DAC value (0-4095).
|
||||
// The embedded motor controller has a 12-bit DAC: 0 = 0V, 4095 = 10V.
|
||||
double dacValue = (volts * 4095.0) / 10.0;
|
||||
|
||||
SetParameter(BenchParameterNames.Rpm, dacValue);
|
||||
SendParameters(_config.Bench.ParametersByName.TryGetValue(
|
||||
BenchParameterNames.Rpm, out var rpmParam) ? rpmParam.MessageId : 0x10);
|
||||
|
||||
RpmCommandSent?.Invoke();
|
||||
}
|
||||
|
||||
// ── IBenchService: temperature ────────────────────────────────────────────
|
||||
@@ -585,6 +603,15 @@ namespace HC_APTBS.Services.Impl
|
||||
{
|
||||
TestStarted?.Invoke();
|
||||
|
||||
// Cache the active pump's status-word definition so PollPumpStatusReactions
|
||||
// can evaluate reaction codes without repeating the config lookup each tick.
|
||||
_activeStatusDef = null;
|
||||
_statusReactionEdge.Clear();
|
||||
if (pump.ParametersByName.TryGetValue(PumpParameterNames.Status, out var statusParam))
|
||||
{
|
||||
_activeStatusDef = _config.LoadPumpStatus(statusParam.Type);
|
||||
}
|
||||
|
||||
// Small delay to allow oil circulation to stabilise before the first test.
|
||||
await Task.Delay(2000, _cts.Token);
|
||||
|
||||
@@ -618,6 +645,8 @@ namespace HC_APTBS.Services.Impl
|
||||
finally
|
||||
{
|
||||
_running = false;
|
||||
_activeStatusDef = null;
|
||||
_statusReactionEdge.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -738,13 +767,15 @@ namespace HC_APTBS.Services.Impl
|
||||
long conditioningRemainMs = (long)test.ConditioningTimeSec * 1000 - sw.ElapsedMilliseconds;
|
||||
_log.Debug(LogId, $"{phase.Name}: conditioning remaining={conditioningRemainMs}ms");
|
||||
|
||||
int conditioningTotalSec = (int)(conditioningRemainMs / 1000);
|
||||
for (int i = 0; i * 1000 < conditioningRemainMs; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
CheckQOverSafety(i * 1000L);
|
||||
PollAlarms(phase);
|
||||
int remaining = (int)(conditioningRemainMs / 1000) - i;
|
||||
int remaining = conditioningTotalSec - i;
|
||||
VerboseMessage?.Invoke($"{phase.Name} — Conditioning... {remaining}s");
|
||||
PhaseTimerTick?.Invoke(SectionConditioning, remaining, conditioningTotalSec);
|
||||
await Task.Delay(1000, ct);
|
||||
}
|
||||
|
||||
@@ -812,8 +843,13 @@ namespace HC_APTBS.Services.Impl
|
||||
|
||||
long measureMs = (long)test.MeasurementTimeSec * 1000;
|
||||
int sleepMs = (int)(1000.0 / Math.Max(test.MeasurementsPerSecond, 0.1));
|
||||
int measureTotalSec = (int)(measureMs / 1000);
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// Initial tick so the UI captures total + full remaining at t=0.
|
||||
PhaseTimerTick?.Invoke(SectionMeasuring, measureTotalSec, measureTotalSec);
|
||||
int lastRemaining = measureTotalSec;
|
||||
|
||||
while (sw.ElapsedMilliseconds <= measureMs)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
@@ -830,7 +866,16 @@ namespace HC_APTBS.Services.Impl
|
||||
MeasurementSampled?.Invoke(tp.Name, sample.Value);
|
||||
}
|
||||
|
||||
// Emit countdown only when the integer-second value changes.
|
||||
int remaining = (int)Math.Max(0, (measureMs - sw.ElapsedMilliseconds + 999) / 1000);
|
||||
if (remaining != lastRemaining)
|
||||
{
|
||||
PhaseTimerTick?.Invoke(SectionMeasuring, remaining, measureTotalSec);
|
||||
lastRemaining = remaining;
|
||||
}
|
||||
|
||||
PollAlarms(phase);
|
||||
PollPumpStatusReactions();
|
||||
await Task.Delay(sleepMs, ct);
|
||||
}
|
||||
|
||||
@@ -982,9 +1027,17 @@ namespace HC_APTBS.Services.Impl
|
||||
SetRpm(0);
|
||||
ZeroPumpParameters();
|
||||
_cts?.Cancel();
|
||||
_statusReactionEdge.Clear();
|
||||
EmergencyStopTriggered?.Invoke(reason);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RequestEmergencyStop(string reason)
|
||||
{
|
||||
_log.Warning(LogId, $"Operator-initiated emergency stop: {reason}");
|
||||
PerformEmergencyStop(reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the current alarm bitmask from the Alarms CAN parameter, detects
|
||||
/// bits that transitioned 0→1 since the last snapshot, and records them
|
||||
@@ -1008,6 +1061,78 @@ namespace HC_APTBS.Services.Impl
|
||||
_lastAlarmMask = currentMask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the current pump status word, evaluates each enabled status bit
|
||||
/// against its <see cref="PumpStatusDefinition"/>, and dispatches the
|
||||
/// configured reaction (abort / warning / log) on a 0→1 transition to a
|
||||
/// state whose <see cref="StatusBitValue.Reaction"/> is non-zero.
|
||||
///
|
||||
/// <para>
|
||||
/// v1 treats every status bit as single-bit (state 0 or 1); multi-bit grouped
|
||||
/// status fields are not yet supported — extend the <c>currentState</c>
|
||||
/// computation when needed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void PollPumpStatusReactions()
|
||||
{
|
||||
if (_activeStatusDef == null) return;
|
||||
|
||||
uint raw = (uint)ReadParameter(PumpParameterNames.Status);
|
||||
|
||||
foreach (var bit in _activeStatusDef.Bits)
|
||||
{
|
||||
if (!bit.Enabled) continue;
|
||||
|
||||
int currentState = (int)((raw >> bit.Bit) & 1u);
|
||||
|
||||
// Find the StatusBitValue matching the current state.
|
||||
StatusBitValue? match = null;
|
||||
foreach (var v in bit.Values)
|
||||
{
|
||||
if (v.State == currentState) { match = v; break; }
|
||||
}
|
||||
if (match == null || match.Reaction == 0) continue;
|
||||
|
||||
var key = (bit.Bit, currentState);
|
||||
if (_statusReactionEdge.TryGetValue(key, out var fired) && fired)
|
||||
continue;
|
||||
|
||||
// Clear any sibling (same bit, different state) entries so the next
|
||||
// transition re-arms.
|
||||
foreach (var v in bit.Values)
|
||||
{
|
||||
if (v.State != currentState)
|
||||
_statusReactionEdge[(bit.Bit, v.State)] = false;
|
||||
}
|
||||
|
||||
_statusReactionEdge[key] = true;
|
||||
HandleStatusReaction(bit.Bit, match.Reaction, match.Description);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches a status-bit reaction: 1 = abort (emergency stop),
|
||||
/// 2 = warning (log + event), 3 = log-only (log + event).
|
||||
/// </summary>
|
||||
private void HandleStatusReaction(int bit, int reaction, string description)
|
||||
{
|
||||
switch (reaction)
|
||||
{
|
||||
case 1:
|
||||
StatusReactionTriggered?.Invoke(bit, reaction, description);
|
||||
PerformEmergencyStop($"Pump status bit {bit} ({description})");
|
||||
break;
|
||||
case 2:
|
||||
_log.Warning(LogId, $"Status warning bit {bit}: {description}");
|
||||
StatusReactionTriggered?.Invoke(bit, reaction, description);
|
||||
break;
|
||||
case 3:
|
||||
_log.Warning(LogId, $"Status log bit {bit}: {description}");
|
||||
StatusReactionTriggered?.Invoke(bit, reaction, description);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task WaitForParameter(
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -59,7 +59,9 @@ namespace HC_APTBS.Services.Impl
|
||||
PumpDefinition pump,
|
||||
string operatorName,
|
||||
string clientName,
|
||||
string outputFolder)
|
||||
string outputFolder,
|
||||
string? clientInfo = null,
|
||||
string? observations = null)
|
||||
{
|
||||
Directory.CreateDirectory(outputFolder);
|
||||
|
||||
@@ -77,8 +79,8 @@ namespace HC_APTBS.Services.Impl
|
||||
page.Margin(25, Unit.Millimetre);
|
||||
page.DefaultTextStyle(x => x.FontSize(ReportTheme.BodySize).FontFamily(Fonts.Arial));
|
||||
|
||||
page.Header().Element(c => ComposeHeader(c, operatorName, clientName, reportDate));
|
||||
page.Content().Element(c => ComposeContent(c, pump));
|
||||
page.Header().Element(c => ComposeHeader(c, operatorName, clientName, clientInfo, reportDate));
|
||||
page.Content().Element(c => ComposeContent(c, pump, observations));
|
||||
page.Footer().Element(ComposeFooter);
|
||||
});
|
||||
});
|
||||
@@ -91,7 +93,7 @@ namespace HC_APTBS.Services.Impl
|
||||
|
||||
/// <summary>Renders the page header: logo, company info, date/operator/client, title.</summary>
|
||||
private void ComposeHeader(
|
||||
IContainer container, string operatorName, string clientName, DateTime reportDate)
|
||||
IContainer container, string operatorName, string clientName, string? clientInfo, DateTime reportDate)
|
||||
{
|
||||
container.Column(outer =>
|
||||
{
|
||||
@@ -130,6 +132,12 @@ namespace HC_APTBS.Services.Impl
|
||||
.FontSize(ReportTheme.CaptionSize + 1);
|
||||
col.Item().Text(string.Format(_loc.GetString("Pdf.Client"), clientName))
|
||||
.FontSize(ReportTheme.CaptionSize + 1).Bold();
|
||||
|
||||
// Optional multi-line client address/contact info.
|
||||
if (!string.IsNullOrWhiteSpace(clientInfo))
|
||||
col.Item().Text(clientInfo)
|
||||
.FontSize(ReportTheme.CaptionSize)
|
||||
.FontColor(ReportTheme.HeaderGrey);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,8 +182,8 @@ namespace HC_APTBS.Services.Impl
|
||||
|
||||
// ── Content ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Composes the full report body: pump info, ECU data, verdict, test sections.</summary>
|
||||
private void ComposeContent(IContainer container, PumpDefinition pump)
|
||||
/// <summary>Composes the full report body: pump info, ECU data, verdict, test sections, observations.</summary>
|
||||
private void ComposeContent(IContainer container, PumpDefinition pump, string? observations)
|
||||
{
|
||||
container.PaddingTop(6).Column(col =>
|
||||
{
|
||||
@@ -199,6 +207,11 @@ namespace HC_APTBS.Services.Impl
|
||||
col.Item().PaddingBottom(ReportTheme.SectionGap)
|
||||
.Element(c => ComposeTestSection(c, test));
|
||||
}
|
||||
|
||||
// ── Operator observations (free-text) ────────────────────────────
|
||||
if (!string.IsNullOrWhiteSpace(observations))
|
||||
col.Item().PaddingBottom(ReportTheme.SectionGap)
|
||||
.Element(c => ComposeObservationsSection(c, observations!));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -383,6 +396,31 @@ namespace HC_APTBS.Services.Impl
|
||||
});
|
||||
}
|
||||
|
||||
// ── Observations section ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Renders a free-text "Observations" block at the bottom of the report.</summary>
|
||||
private void ComposeObservationsSection(IContainer container, string observations)
|
||||
{
|
||||
container.Column(col =>
|
||||
{
|
||||
// Section header bar — matches the navy style used by test sections.
|
||||
col.Item()
|
||||
.Background(ReportTheme.HeaderNavy)
|
||||
.Padding(5)
|
||||
.Text(_loc.GetString("Pdf.Observations"))
|
||||
.FontColor(Colors.White)
|
||||
.Bold().FontSize(ReportTheme.SectionHeaderSize);
|
||||
|
||||
// Bordered text block — matches the verdict block visual treatment.
|
||||
col.Item()
|
||||
.Border(1).BorderColor(ReportTheme.DividerLine)
|
||||
.Background(ReportTheme.TableAltRow)
|
||||
.Padding(8)
|
||||
.Text(observations)
|
||||
.FontSize(ReportTheme.BodySize);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Test results section ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Renders a single test: results table followed by measurement charts.</summary>
|
||||
|
||||
Reference in New Issue
Block a user