diff --git a/docs/templates/v2-layered/contributor-guide.md b/docs/templates/v2-layered/contributor-guide.md index 9942a89b..235c0052 100644 --- a/docs/templates/v2-layered/contributor-guide.md +++ b/docs/templates/v2-layered/contributor-guide.md @@ -274,9 +274,65 @@ Minimum test coverage matching CV v2: | `SmokeTest` | `id()`, `displayName()`, default-factory render, custom-theme render | | `SectionDispatcherTest` *(optional)* | Each sealed subtype routes correctly | | `WidgetSmokeTest` | Each public widget variant renders without throwing | +| `V2VisualParityTest` | **Per-pixel diff** against a checked-in baseline PNG for each preset | -CI must be green before merge. Visual regression (PNG diff against -a reference render) is encouraged but optional today. +CI must be green before merge. + +### Visual regression — pixel-diff parity gate + +Each preset's visual signature is **frozen** in a checked-in +baseline PNG. A parameterised test renders the preset on A4 against +a canonical sample document, rasterises each page via PDFBox, and +asserts the per-pixel diff stays within a budget. Catches silent +visual breakage from theme / widget / renderer refactors. + +**Workflow:** + +```bash +# 1. After a deliberate visual change — refresh baselines: +./mvnw test -Dtest='V2VisualParityTest' -Dgraphcompose.visual.approve=true + +# 2. Commit the updated PNGs in the same change: +git add src/test/resources/visual-baselines/-v2-layered/*.png +git commit -m "test: refresh visual baselines after " + +# 3. Normal run (defends against unintended drift): +./mvnw test -Dtest='V2VisualParityTest' +``` + +**Where baselines live:** +`src/test/resources/visual-baselines/-v2-layered/-page-N.png` + +One PNG per page per preset. Pages overflow naturally — a 2-page +preset gets `-page-0.png` and `-page-1.png`. + +**Budget calibration** — mirror the CV v2 settings until you have +evidence your family needs different limits: + +```java +private static final long PIXEL_DIFF_BUDGET = 50_000L; // max mismatched pixels per page +private static final int PER_PIXEL_TOLERANCE = 8; // per-channel tolerance +``` + +These are calibrated for cross-platform PDFBox font + colour +rendering drift between Windows-recorded baselines and Linux CI. +**Helvetica-based presets** (e.g. ModernProfessional) hit ~40k +mismatched pixels on the Linux CI; **PT-Serif-based presets** +(BoxedSections, MinimalUnderlined) stay under 10k. The 50k budget +covers both with margin. + +If CI flakes on a specific preset above 50k, widen the budget for +that preset specifically (e.g. via a per-test `Map` +of overrides) rather than relaxing the global setting. + +**Failure mode:** when the diff exceeds budget, the harness writes +`-page-N.actual.png` and `-page-N.diff.png` next to the +baseline so a reviewer can see exactly what changed before deciding +to re-bless or fix. + +**Reference**: see +`src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java` +— a 200-line drop-in template you can copy for a new family. --- diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java new file mode 100644 index 00000000..c2c469d8 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java @@ -0,0 +1,210 @@ +package com.demcha.compose.document.templates.cv.v2.presets; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.cv.v2.data.CvDocument; +import com.demcha.compose.document.templates.cv.v2.data.CvIdentity; +import com.demcha.compose.document.templates.cv.v2.data.EntriesSection; +import com.demcha.compose.document.templates.cv.v2.data.ParagraphSection; +import com.demcha.compose.document.templates.cv.v2.data.RowStyle; +import com.demcha.compose.document.templates.cv.v2.data.RowsSection; +import com.demcha.testing.visual.PdfVisualRegression; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.nio.file.Path; +import java.util.function.Supplier; +import java.util.stream.Stream; + +/** + * Pixel-diff visual parity gate for the v2 layered CV presets. + * + *

Each preset renders the same canonical {@link CvDocument} on + * full A4 with the preset's {@code RECOMMENDED_MARGIN}; the resulting + * PDF is rasterised page-by-page and compared per-pixel against a + * checked-in baseline PNG. Failures write the actual render + + * diff image next to the baseline.

+ * + *

Re-blessing baselines — after a deliberate + * visual change, re-run with + * {@code -Dgraphcompose.visual.approve=true} (or environment variable + * {@code GRAPHCOMPOSE_VISUAL_APPROVE=true}) to overwrite the + * baselines with the current rendering. Commit the updated PNGs as + * part of the same change.

+ * + *

Baselines live under + * {@code src/test/resources/visual-baselines/cv-v2-layered/}. Budget + * mirrors the v1 {@code PresetVisualParityTest} (20 000 mismatched + * pixels at per-channel tolerance 8) — calibrated for cross-platform + * PDFBox font + colour rendering drift between Windows-recorded + * baselines and Linux CI.

+ */ +class CvV2VisualParityTest { + + private static final Path BASELINE_ROOT = Path.of( + "src", "test", "resources", "visual-baselines", "cv-v2-layered"); + + // Calibrated against the worst observed cross-platform drift: + // ModernProfessional renders ~40k mismatched pixels on Linux CI + // vs Windows-recorded baseline because Helvetica is the only + // PDFBox built-in font where text glyph outlines + base colours + // differ noticeably between platforms (the PT-Serif presets hit + // ~5-10k). Budget sized to cover the MP case with margin — + // tighter per-preset overrides can be introduced later if drift + // patterns diverge. + private static final long PIXEL_DIFF_BUDGET = 50_000L; + private static final int PER_PIXEL_TOLERANCE = 8; + + @ParameterizedTest(name = "{0}") + @MethodSource("presets") + void rendersWithinPixelDiffBudget(String slug, + double margin, + Supplier> factory) + throws Exception { + DocumentTemplate template = factory.get(); + float m = (float) margin; + byte[] pdfBytes; + try (DocumentSession document = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(m, m, m, m) + .create()) { + template.compose(document, canonicalDocument()); + pdfBytes = document.toPdfBytes(); + } + + PdfVisualRegression.standard() + .baselineRoot(BASELINE_ROOT) + .perPixelTolerance(PER_PIXEL_TOLERANCE) + .mismatchedPixelBudget(PIXEL_DIFF_BUDGET) + .assertMatchesBaseline(slug, pdfBytes); + } + + private static Stream presets() { + return Stream.of( + Arguments.of("boxed_sections", + BoxedSections.RECOMMENDED_MARGIN, + (Supplier>) BoxedSections::create), + Arguments.of("minimal_underlined", + MinimalUnderlined.RECOMMENDED_MARGIN, + (Supplier>) MinimalUnderlined::create), + Arguments.of("modern_professional", + ModernProfessional.RECOMMENDED_MARGIN, + (Supplier>) ModernProfessional::create)); + } + + /** + * Canonical sample document — Jordan Rivera with every v2 section + * subtype exercised so the gate covers paragraph, all three + * row-styles, and timeline entries. + * + *

Kept inline (not pulled from the examples module) so the + * test depends only on main + main-test code.

+ */ + private static CvDocument canonicalDocument() { + return CvDocument.builder() + .identity(CvIdentity.builder() + .name("Jordan", "Rivera") + .contact("+44 20 5555 1000", + "jordan.rivera@example.com", + "London, UK") + .link("LinkedIn", "https://linkedin.com/in/jordan-rivera-demo") + .link("GitHub", "https://github.com/jrivera-demo") + .build()) + .section(new ParagraphSection("Professional Summary", + "Platform engineer with **10+ years** building resilient " + + "document-generation pipelines, layout engines, and " + + "developer-facing template systems. Specialised in " + + "high-throughput PDF rendering, semantic authoring " + + "DSLs, and turning brittle production-ops scripts " + + "into typed, snapshot-tested libraries that scale.")) + .section(RowsSection.builder("Technical Skills", RowStyle.BULLETED) + .row("Languages", "Java 21, Kotlin, Groovy, Python, SQL") + .row("Document & Print", "PDFBox, Apache POI (DOCX/XLSX), iText, " + + "PostScript, ICC colour profiles, font metrics") + .row("Layout engines", "Custom DSL design, semantic layout trees, " + + "pagination, snapshot testing, visual regression") + .row("Build & infrastructure", "Maven, Gradle, GitHub Actions, " + + "JitPack, Docker, JMH benchmarking") + .row("Testing", "JUnit 5, AssertJ, PDFBox-based PNG diff, " + + "layout-graph snapshots, mutation testing (Pitest)") + .row("Distribution", "Maven Central, Sonatype OSSRH, GPG signing, " + + "JitPack, semantic versioning discipline") + .build()) + .section(EntriesSection.builder("Education & Certifications") + .entry("MSc Computer Science", + "University of Manchester", + "2021", + "Distinction. Thesis: *Composable layout primitives " + + "for deterministic document rendering*.") + .entry("BSc Software Engineering", + "Imperial College London", + "2019", + "First-class honours. Specialisation in compilers and " + + "static analysis.") + .entry("Oracle Java Certification", + "Professional track", + "2023", + "Java 17 platform deep-dive: records, sealed types, " + + "pattern matching, virtual threads.") + .build()) + .section(RowsSection.builder("Projects", RowStyle.BULLETED_STACKED) + .row("GraphCompose (Java 21, PDFBox, Maven, JMH)", + "Declarative Java PDF layout engine. Semantic DSL, " + + "slot-based templates, snapshot testing. Powers " + + "production CV / invoice / proposal pipelines for " + + "hiring tools and billing systems. *(Open source)*") + .row("Template Studio (Kotlin, Compose Desktop, PDFBox PNG diff)", + "Internal tool for evaluating CV, proposal, and " + + "invoice output across 14 design presets. PNG " + + "diffing, side-by-side layout, baseline freezing.") + .row("LayoutLint (Java 21, JavaParser, Spoon)", + "Static analyser that flags fragile authoring patterns " + + "(deeply nested rows, untyped offsets, implicit " + + "page breaks) before they ship to production.") + .row("ChromeForge (Java, GraphCompose, Pandoc bridge)", + "Editorial-magazine document toolkit built on " + + "GraphCompose: cinematic covers, pull quotes, " + + "multi-column flow, sidebar callouts.") + .build()) + .section(EntriesSection.builder("Professional Experience") + .entry("Senior Platform Engineer", + "Northwind Systems", + "2024-Present", + "Led the reusable document-generation platform serving " + + "billing, hiring, and reporting flows across " + + "**8 product teams**. Reduced template maintenance " + + "time by **70%** by retiring per-team PDF scripts " + + "in favour of one canonical engine.") + .entry("Software Engineer", + "BrightLeaf Labs", + "2021-2024", + "Built backend services and production document rendering " + + "pipelines processing **2M+ documents per month**. " + + "Drove the migration from iText to a custom layout " + + "engine, eliminating licensing risk and cutting " + + "p99 render latency from 1.4s to 380ms.") + .entry("Backend Engineer", + "Helix Print Co", + "2019-2021", + "Maintained a high-volume invoice-printing service " + + "(15M PDFs/year) and authored the compliance test " + + "harness that gated every template change.") + .build()) + .section(RowsSection.builder("Additional Information", RowStyle.PLAIN) + .row("Languages", + "English (Fluent), German (Intermediate), Spanish (Basic)") + .row("Work Eligibility", + "Eligible to work in the UK and the EU") + .row("Open Source", + "Maintainer of GraphCompose. Regular contributor to " + + "PDFBox issue triage.") + .row("Speaking", + "JVM Summit 2024, Devoxx UK 2025 — both on declarative " + + "document layout.") + .build()) + .build(); + } +} diff --git a/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-0.png b/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-0.png new file mode 100644 index 00000000..5fd9a913 Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-0.png differ diff --git a/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-1.png b/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-1.png new file mode 100644 index 00000000..e618177b Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-1.png differ diff --git a/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-0.png b/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-0.png new file mode 100644 index 00000000..212c150b Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-0.png differ diff --git a/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-1.png b/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-1.png new file mode 100644 index 00000000..9ed44abe Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-1.png differ diff --git a/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-0.png b/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-0.png new file mode 100644 index 00000000..c030b73f Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-0.png differ diff --git a/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-1.png b/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-1.png new file mode 100644 index 00000000..ea36dfc5 Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-1.png differ