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

@@ -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(

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 ──────────────────────────────────────────────────────

View File

@@ -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>