Files
HC_APTBS/Services/Impl/PdfService.cs
LucianoDev 0280a2fad1 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>
2026-04-18 13:11:34 +02:00

589 lines
28 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using HC_APTBS.Models;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace HC_APTBS.Services.Impl
{
/// <summary>
/// Generates professional PDF test reports using QuestPDF.
///
/// <para>
/// Report layout:
/// <list type="bullet">
/// <item>Header (every page): company logo, company name, date, operator, client, report title.</item>
/// <item>Pump identification table: ID, serial, model, rotation, lock angle.</item>
/// <item>K-Line ECU data block: model reference, DFI, SW versions, fault codes.</item>
/// <item>Overall verdict section: large PASS/FAIL badge with summary statistics.</item>
/// <item>Per-test results: table with pass/fail rows, followed by measurement charts
/// showing sample values against target ± tolerance bands.</item>
/// <item>Footer (every page): software attribution and page numbers.</item>
/// </list>
/// </para>
/// </summary>
public sealed class PdfService : IPdfService
{
private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
private readonly byte[]? _defaultLogo;
/// <param name="configService">Provides company name, logo path, and report settings.</param>
/// <param name="localizationService">Provides localised strings for report text.</param>
public PdfService(IConfigurationService configService, ILocalizationService localizationService)
{
_config = configService;
_loc = localizationService;
// QuestPDF community licence — required for open-source use.
QuestPDF.Settings.License = LicenseType.Community;
// Load the embedded default logo as a fallback.
using var stream = typeof(PdfService).Assembly
.GetManifestResourceStream("HC_APTBS.Resources.Images.default_logo.png");
if (stream != null)
{
using var ms = new MemoryStream();
stream.CopyTo(ms);
_defaultLogo = ms.ToArray();
}
}
// ── IPdfService ───────────────────────────────────────────────────────────
/// <inheritdoc/>
public string GenerateReport(
PumpDefinition pump,
string operatorName,
string clientName,
string outputFolder,
string? clientInfo = null,
string? observations = null)
{
Directory.CreateDirectory(outputFolder);
string fileName = SanitiseFileName(
$"{pump.Id}_{pump.SerialNumber}_{clientName}_{DateTime.Now:yyyy-MM-dd_HH-mm}.pdf");
string filePath = Path.Combine(outputFolder, fileName);
var reportDate = DateTime.Now;
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(25, Unit.Millimetre);
page.DefaultTextStyle(x => x.FontSize(ReportTheme.BodySize).FontFamily(Fonts.Arial));
page.Header().Element(c => ComposeHeader(c, operatorName, clientName, clientInfo, reportDate));
page.Content().Element(c => ComposeContent(c, pump, observations));
page.Footer().Element(ComposeFooter);
});
});
document.GeneratePdf(filePath);
return filePath;
}
// ── Header ────────────────────────────────────────────────────────────────
/// <summary>Renders the page header: logo, company info, date/operator/client, title.</summary>
private void ComposeHeader(
IContainer container, string operatorName, string clientName, string? clientInfo, DateTime reportDate)
{
container.Column(outer =>
{
outer.Item().Row(row =>
{
// Company logo — prefer configured path, fall back to embedded default.
if (File.Exists(_config.Settings.ReportLogoPath))
{
row.ConstantItem(65).Height(45)
.Image(_config.Settings.ReportLogoPath)
.FitArea();
}
else if (_defaultLogo != null)
{
row.ConstantItem(65).Height(45)
.Image(_defaultLogo)
.FitArea();
}
// Company name and info.
row.RelativeItem().PaddingLeft(10).Column(col =>
{
col.Item().Text(_config.Settings.CompanyName)
.Bold().FontSize(ReportTheme.TitleSize);
col.Item().Text(_config.Settings.CompanyInfo)
.FontSize(ReportTheme.CaptionSize)
.FontColor(ReportTheme.HeaderGrey);
});
// Date / operator / client block.
row.ConstantItem(140).AlignRight().Column(col =>
{
col.Item().Text(string.Format(_loc.GetString("Pdf.Date"), reportDate))
.FontSize(ReportTheme.CaptionSize + 1);
col.Item().Text(string.Format(_loc.GetString("Pdf.Operator"), operatorName))
.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);
});
});
// Divider line.
outer.Item().PaddingTop(4).LineHorizontal(1)
.LineColor(ReportTheme.DividerLine);
// Report title.
outer.Item().PaddingTop(4).PaddingBottom(2)
.AlignCenter()
.Text(_loc.GetString("Pdf.ReportTitle"))
.Bold().FontSize(ReportTheme.SectionHeaderSize)
.FontColor(ReportTheme.HeaderNavy);
});
}
// ── Footer ────────────────────────────────────────────────────────────────
/// <summary>Renders the page footer: divider, attribution, and page numbers.</summary>
private void ComposeFooter(IContainer container)
{
container.Column(col =>
{
col.Item().LineHorizontal(0.5f).LineColor(ReportTheme.DividerLine);
col.Item().PaddingTop(3).Row(row =>
{
row.RelativeItem().Text(_loc.GetString("Pdf.GeneratedBy"))
.FontSize(ReportTheme.FooterSize)
.FontColor(ReportTheme.HeaderGrey);
row.ConstantItem(100).AlignRight().Text(t =>
{
t.DefaultTextStyle(x => x.FontSize(ReportTheme.FooterSize));
t.Span(_loc.GetString("Pdf.Page"));
t.CurrentPageNumber();
t.Span(_loc.GetString("Pdf.Of"));
t.TotalPages();
});
});
});
}
// ── Content ───────────────────────────────────────────────────────────────
/// <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 =>
{
// ── Pump identification ──────────────────────────────────────────
col.Item().PaddingBottom(ReportTheme.SectionGap)
.Element(c => ComposePumpInfoTable(c, pump));
// ── K-Line ECU data ──────────────────────────────────────────────
if (pump.KlineInfo.Count > 0)
col.Item().PaddingBottom(ReportTheme.SectionGap)
.Element(c => ComposeKlineTable(c, pump));
// ── Overall verdict ──────────────────────────────────────────────
col.Item().PaddingBottom(ReportTheme.SectionGap)
.Element(c => ComposeVerdictSection(c, pump));
// ── Test results — one section per test ──────────────────────────
foreach (var test in pump.Tests)
{
if (!test.HasResults()) continue;
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!));
});
}
// ── Pump info table ───────────────────────────────────────────────────────
/// <summary>Renders the pump identification table with alternating row stripes.</summary>
private void ComposePumpInfoTable(IContainer container, PumpDefinition pump)
{
container.Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(1);
cols.RelativeColumn(2);
cols.RelativeColumn(1);
cols.RelativeColumn(2);
});
table.Header(header =>
{
header.Cell().ColumnSpan(4)
.Background(ReportTheme.HeaderNavy)
.Padding(5)
.Text(_loc.GetString("Pdf.PumpIdentification"))
.FontColor(Colors.White).Bold()
.FontSize(ReportTheme.SectionHeaderSize);
});
int rowIndex = 0;
void AddRow(string label1, string value1, string label2, string value2)
{
string bg = (rowIndex++ % 2 == 1) ? ReportTheme.TableAltRow : "#FFFFFF";
table.Cell().Background(bg).Padding(ReportTheme.CellPad)
.Text(label1).Bold().FontSize(ReportTheme.BodySize);
table.Cell().Background(bg).Padding(ReportTheme.CellPad)
.Text(value1).FontSize(ReportTheme.BodySize);
table.Cell().Background(bg).Padding(ReportTheme.CellPad)
.Text(label2).Bold().FontSize(ReportTheme.BodySize);
table.Cell().Background(bg).Padding(ReportTheme.CellPad)
.Text(value2).FontSize(ReportTheme.BodySize);
}
AddRow(_loc.GetString("Pdf.PumpId"), pump.Id, _loc.GetString("Pdf.Model"), pump.Model);
AddRow(_loc.GetString("Pdf.SerialNo"), pump.SerialNumber, _loc.GetString("Pdf.Injector"), pump.Injector);
AddRow(_loc.GetString("Pdf.Tube"), pump.Tube, _loc.GetString("Pdf.Valve"), pump.Valve);
AddRow(_loc.GetString("Pdf.Tension"), pump.Tension, _loc.GetString("Pdf.Rotation"), pump.Rotation);
AddRow(_loc.GetString("Pdf.LockAngle"), $"{pump.LockAngle:F2}\u00B0",
_loc.GetString("Pdf.Measured"), $"{pump.LockAngleResult:F2}\u00B0");
AddRow(_loc.GetString("Pdf.Chaveta"), pump.Chaveta, _loc.GetString("Pdf.PreInj"), pump.HasPreInjection ? _loc.GetString("Common.Yes") : _loc.GetString("Common.No"));
});
}
// ── K-Line table ──────────────────────────────────────────────────────────
/// <summary>Renders the K-Line ECU data table with alternating row stripes.</summary>
private void ComposeKlineTable(IContainer container, PumpDefinition pump)
{
container.Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(1);
cols.RelativeColumn(2);
cols.RelativeColumn(1);
cols.RelativeColumn(2);
});
table.Header(header =>
{
header.Cell().ColumnSpan(4)
.Background(ReportTheme.HeaderNavy)
.Padding(5)
.Text(_loc.GetString("Pdf.EcuData"))
.FontColor(Colors.White).Bold()
.FontSize(ReportTheme.SectionHeaderSize);
});
int rowIndex = 0;
void AddKv(string key)
{
if (!pump.KlineInfo.TryGetValue(key, out var val)) return;
string bg = (rowIndex++ % 2 == 1) ? ReportTheme.TableAltRow : "#FFFFFF";
table.Cell().Background(bg).Padding(ReportTheme.CellPad)
.Text(key + ":").Bold().FontSize(ReportTheme.BodySize);
table.Cell().ColumnSpan(3).Background(bg).Padding(ReportTheme.CellPad)
.Text(val).FontSize(ReportTheme.BodySize);
}
AddKv(KlineKeys.ModelReference);
AddKv(KlineKeys.DataRecord);
AddKv(KlineKeys.SwVersion1);
AddKv(KlineKeys.SwVersion2);
AddKv(KlineKeys.PumpControl);
AddKv(KlineKeys.Dfi);
AddKv(KlineKeys.SerialNumber);
AddKv(KlineKeys.Errors);
});
}
// ── Verdict section ───────────────────────────────────────────────────────
/// <summary>Renders the overall test result badge with summary statistics.</summary>
private void ComposeVerdictSection(IContainer container, PumpDefinition pump)
{
// Compute summary statistics.
var testsWithResults = pump.Tests.Where(t => t.HasResults()).ToList();
int totalTests = pump.Tests.Count;
int testedCount = testsWithResults.Count;
int totalPhases = 0;
int passedPhases = 0;
foreach (var test in testsWithResults)
{
foreach (var phase in test.Phases)
{
if (!phase.Enabled || phase.Receives == null) continue;
foreach (var tp in phase.Receives)
{
if (tp.Result == null) continue;
totalPhases++;
if (tp.Result.Passed) passedPhases++;
}
}
}
bool allPassed = totalPhases > 0 && passedPhases == totalPhases;
container.Border(1).BorderColor(ReportTheme.DividerLine)
.Background(ReportTheme.TableAltRow)
.Padding(8)
.Row(row =>
{
// PASS/FAIL verdict badge (SVG).
row.ConstantItem(100).Height(50)
.Svg(ReportChartRenderer.RenderVerdictBadge(100, 50, allPassed))
.FitArea();
// Summary statistics.
row.RelativeItem().PaddingLeft(12).Column(col =>
{
col.Item().Text(_loc.GetString("Pdf.OverallResult"))
.Bold().FontSize(ReportTheme.SectionHeaderSize)
.FontColor(ReportTheme.HeaderNavy);
col.Item().PaddingTop(4).Text(
string.Format(_loc.GetString("Pdf.TestsExecuted"), testedCount, totalTests))
.FontSize(ReportTheme.BodySize);
col.Item().Text(
string.Format(_loc.GetString("Pdf.ParamsEvaluated"), passedPhases, totalPhases))
.FontSize(ReportTheme.BodySize);
// Per-test mini indicators.
col.Item().PaddingTop(4).Row(indicators =>
{
foreach (var test in pump.Tests)
{
bool hasResults = test.HasResults();
bool testPassed = hasResults && test.Phases
.Where(p => p.Enabled && p.Receives != null)
.SelectMany(p => p.Receives)
.Where(tp => tp.Result != null)
.All(tp => tp.Result!.Passed);
string indicator = !hasResults ? "--"
: testPassed ? "\u2713" : "\u2717";
string color = !hasResults ? ReportTheme.HeaderGrey
: testPassed ? ReportTheme.PassGreen : ReportTheme.FailRed;
indicators.AutoItem().PaddingRight(8).Text(t =>
{
t.Span($"{test.Name}: ").FontSize(ReportTheme.BodySize);
t.Span(indicator).Bold().FontSize(ReportTheme.BodySize)
.FontColor(color);
});
}
});
});
});
}
// ── 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>
private void ComposeTestSection(IContainer container, TestDefinition test)
{
container.Column(col =>
{
// Section header bar.
col.Item()
.Background(ReportTheme.HeaderNavy)
.Padding(5)
.Text(string.Format(_loc.GetString("Pdf.TestHeader"), test.Name))
.FontColor(Colors.White).Bold()
.FontSize(ReportTheme.SectionHeaderSize);
// Results table.
col.Item().Element(c => ComposeResultsTable(c, test));
// Charts — one per parameter that has sample data.
col.Item().PaddingTop(ReportTheme.SubsectionGap)
.Element(c => ComposeTestCharts(c, test));
});
}
/// <summary>Renders the pass/fail results table for one test.</summary>
private void ComposeResultsTable(IContainer container, TestDefinition test)
{
container.Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(2); // Phase
cols.RelativeColumn(1); // Parameter
cols.RelativeColumn(1); // Target
cols.RelativeColumn(1); // Tolerance
cols.RelativeColumn(1); // Average
cols.RelativeColumn(1); // Result
});
table.Header(header =>
{
foreach (var h in new[] { _loc.GetString("Pdf.Phase"), _loc.GetString("Pdf.Parameter"), _loc.GetString("Pdf.Target"), _loc.GetString("Pdf.ToleranceHeader"), _loc.GetString("Pdf.Average"), _loc.GetString("Pdf.Result") })
header.Cell()
.Background(ReportTheme.AccentBlue)
.Padding(ReportTheme.CellPad)
.Text(h).FontColor(Colors.White).Bold()
.FontSize(ReportTheme.CaptionSize + 1);
});
int rowIndex = 0;
foreach (var phase in test.Phases)
{
if (!phase.Enabled || phase.Receives == null) continue;
foreach (var tp in phase.Receives)
{
if (tp.Result == null) continue;
bool passed = tp.Result.Passed;
string resultText = passed ? _loc.GetString("Common.Pass") : _loc.GetString("Common.Fail");
// Alternating base row colour, tinted by pass/fail.
string bgColor = passed
? (rowIndex % 2 == 0 ? ReportTheme.PassGreenLight : "#D7ECD9")
: (rowIndex % 2 == 0 ? ReportTheme.FailRedLight : "#FFDBDB");
table.Cell().Background(bgColor).Padding(ReportTheme.CellPad)
.Text(phase.Name).FontSize(ReportTheme.CaptionSize + 1);
table.Cell().Background(bgColor).Padding(ReportTheme.CellPad)
.Text(tp.Name).FontSize(ReportTheme.CaptionSize + 1);
table.Cell().Background(bgColor).Padding(ReportTheme.CellPad)
.Text(tp.Value.ToString("F2")).FontSize(ReportTheme.CaptionSize + 1);
table.Cell().Background(bgColor).Padding(ReportTheme.CellPad)
.Text(tp.Tolerance.ToString("F2")).FontSize(ReportTheme.CaptionSize + 1);
table.Cell().Background(bgColor).Padding(ReportTheme.CellPad)
.Text(tp.Result.Average.ToString("F2")).FontSize(ReportTheme.CaptionSize + 1);
table.Cell().Background(bgColor).Padding(ReportTheme.CellPad)
.Text(resultText).Bold()
.FontColor(passed ? ReportTheme.PassGreen : ReportTheme.FailRed)
.FontSize(ReportTheme.CaptionSize + 1);
rowIndex++;
}
// Show any alarm bits that fired during this phase.
if (phase.ErrorBits?.Count > 0)
{
table.Cell().ColumnSpan(6)
.Background(ReportTheme.WarningBg)
.Padding(ReportTheme.CellPad)
.Text(string.Format(_loc.GetString("Pdf.ErrorBits"), string.Join(", ", phase.ErrorBits)))
.FontSize(ReportTheme.CaptionSize + 1)
.FontColor(ReportTheme.WarningText);
}
}
});
}
/// <summary>Renders measurement charts for each parameter that has sample data.</summary>
private void ComposeTestCharts(IContainer container, TestDefinition test)
{
container.Column(col =>
{
bool anyChart = false;
foreach (var phase in test.Phases)
{
if (!phase.Enabled || phase.Receives == null) continue;
foreach (var tp in phase.Receives)
{
if (tp.Result == null || tp.Result.Samples.Count < 2) continue;
anyChart = true;
string label = $"{phase.Name} \u2014 {tp.Name}";
// The chart itself (SVG rendered inline).
string chartSvg = ReportChartRenderer.RenderMeasurementChart(
480, ReportTheme.ChartHeight,
tp.Result.Samples,
tp.Value,
tp.Tolerance,
tp.Result.Average,
tp.Result.Passed,
label);
col.Item().PaddingBottom(2)
.Svg(chartSvg)
.FitWidth();
// Chart caption.
string passFailText = tp.Result.Passed
? _loc.GetString("Common.Pass")
: _loc.GetString("Common.Fail");
col.Item().PaddingBottom(ReportTheme.SubsectionGap)
.Text(string.Format(_loc.GetString("Pdf.ChartSamples"),
tp.Result.Samples.Count, tp.Value, tp.Tolerance,
tp.Result.Average, passFailText))
.FontSize(ReportTheme.CaptionSize)
.FontColor(ReportTheme.HeaderGrey);
}
}
if (!anyChart)
{
col.Item().PaddingTop(2).PaddingBottom(4)
.Text(_loc.GetString("Pdf.NoSampleData"))
.FontSize(ReportTheme.CaptionSize).Italic()
.FontColor(ReportTheme.HeaderGrey);
}
});
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// <summary>Sanitises a file name by replacing invalid characters with underscores.</summary>
private static string SanitiseFileName(string name)
{
foreach (char c in Path.GetInvalidFileNameChars())
name = name.Replace(c, '_');
return name;
}
}
}