|
| 1 | +package com.demcha.compose.document.templates.cv.v2.presets; |
| 2 | + |
| 3 | +import com.demcha.compose.GraphCompose; |
| 4 | +import com.demcha.compose.document.api.DocumentPageSize; |
| 5 | +import com.demcha.compose.document.api.DocumentSession; |
| 6 | +import com.demcha.compose.document.templates.api.DocumentTemplate; |
| 7 | +import com.demcha.compose.document.templates.cv.v2.data.CvDocument; |
| 8 | +import com.demcha.compose.document.templates.cv.v2.data.CvIdentity; |
| 9 | +import com.demcha.compose.document.templates.cv.v2.data.EntriesSection; |
| 10 | +import com.demcha.compose.document.templates.cv.v2.data.ParagraphSection; |
| 11 | +import com.demcha.compose.document.templates.cv.v2.data.RowStyle; |
| 12 | +import com.demcha.compose.document.templates.cv.v2.data.RowsSection; |
| 13 | +import com.demcha.testing.visual.PdfVisualRegression; |
| 14 | +import org.junit.jupiter.params.ParameterizedTest; |
| 15 | +import org.junit.jupiter.params.provider.Arguments; |
| 16 | +import org.junit.jupiter.params.provider.MethodSource; |
| 17 | + |
| 18 | +import java.nio.file.Path; |
| 19 | +import java.util.function.Supplier; |
| 20 | +import java.util.stream.Stream; |
| 21 | + |
| 22 | +/** |
| 23 | + * Pixel-diff visual parity gate for the v2 layered CV presets. |
| 24 | + * |
| 25 | + * <p>Each preset renders the same canonical {@link CvDocument} on |
| 26 | + * full A4 with the preset's {@code RECOMMENDED_MARGIN}; the resulting |
| 27 | + * PDF is rasterised page-by-page and compared per-pixel against a |
| 28 | + * checked-in baseline PNG. Failures write the actual render + |
| 29 | + * diff image next to the baseline.</p> |
| 30 | + * |
| 31 | + * <p><strong>Re-blessing baselines</strong> — after a deliberate |
| 32 | + * visual change, re-run with |
| 33 | + * {@code -Dgraphcompose.visual.approve=true} (or environment variable |
| 34 | + * {@code GRAPHCOMPOSE_VISUAL_APPROVE=true}) to overwrite the |
| 35 | + * baselines with the current rendering. Commit the updated PNGs as |
| 36 | + * part of the same change.</p> |
| 37 | + * |
| 38 | + * <p>Baselines live under |
| 39 | + * {@code src/test/resources/visual-baselines/cv-v2-layered/}. Budget |
| 40 | + * mirrors the v1 {@code PresetVisualParityTest} (20 000 mismatched |
| 41 | + * pixels at per-channel tolerance 8) — calibrated for cross-platform |
| 42 | + * PDFBox font + colour rendering drift between Windows-recorded |
| 43 | + * baselines and Linux CI.</p> |
| 44 | + */ |
| 45 | +class CvV2VisualParityTest { |
| 46 | + |
| 47 | + private static final Path BASELINE_ROOT = Path.of( |
| 48 | + "src", "test", "resources", "visual-baselines", "cv-v2-layered"); |
| 49 | + |
| 50 | + // Calibrated against the worst observed cross-platform drift: |
| 51 | + // ModernProfessional renders ~40k mismatched pixels on Linux CI |
| 52 | + // vs Windows-recorded baseline because Helvetica is the only |
| 53 | + // PDFBox built-in font where text glyph outlines + base colours |
| 54 | + // differ noticeably between platforms (the PT-Serif presets hit |
| 55 | + // ~5-10k). Budget sized to cover the MP case with margin — |
| 56 | + // tighter per-preset overrides can be introduced later if drift |
| 57 | + // patterns diverge. |
| 58 | + private static final long PIXEL_DIFF_BUDGET = 50_000L; |
| 59 | + private static final int PER_PIXEL_TOLERANCE = 8; |
| 60 | + |
| 61 | + @ParameterizedTest(name = "{0}") |
| 62 | + @MethodSource("presets") |
| 63 | + void rendersWithinPixelDiffBudget(String slug, |
| 64 | + double margin, |
| 65 | + Supplier<DocumentTemplate<CvDocument>> factory) |
| 66 | + throws Exception { |
| 67 | + DocumentTemplate<CvDocument> template = factory.get(); |
| 68 | + float m = (float) margin; |
| 69 | + byte[] pdfBytes; |
| 70 | + try (DocumentSession document = GraphCompose.document() |
| 71 | + .pageSize(DocumentPageSize.A4) |
| 72 | + .margin(m, m, m, m) |
| 73 | + .create()) { |
| 74 | + template.compose(document, canonicalDocument()); |
| 75 | + pdfBytes = document.toPdfBytes(); |
| 76 | + } |
| 77 | + |
| 78 | + PdfVisualRegression.standard() |
| 79 | + .baselineRoot(BASELINE_ROOT) |
| 80 | + .perPixelTolerance(PER_PIXEL_TOLERANCE) |
| 81 | + .mismatchedPixelBudget(PIXEL_DIFF_BUDGET) |
| 82 | + .assertMatchesBaseline(slug, pdfBytes); |
| 83 | + } |
| 84 | + |
| 85 | + private static Stream<Arguments> presets() { |
| 86 | + return Stream.of( |
| 87 | + Arguments.of("boxed_sections", |
| 88 | + BoxedSections.RECOMMENDED_MARGIN, |
| 89 | + (Supplier<DocumentTemplate<CvDocument>>) BoxedSections::create), |
| 90 | + Arguments.of("minimal_underlined", |
| 91 | + MinimalUnderlined.RECOMMENDED_MARGIN, |
| 92 | + (Supplier<DocumentTemplate<CvDocument>>) MinimalUnderlined::create), |
| 93 | + Arguments.of("modern_professional", |
| 94 | + ModernProfessional.RECOMMENDED_MARGIN, |
| 95 | + (Supplier<DocumentTemplate<CvDocument>>) ModernProfessional::create)); |
| 96 | + } |
| 97 | + |
| 98 | + /** |
| 99 | + * Canonical sample document — Jordan Rivera with every v2 section |
| 100 | + * subtype exercised so the gate covers paragraph, all three |
| 101 | + * row-styles, and timeline entries. |
| 102 | + * |
| 103 | + * <p>Kept inline (not pulled from the examples module) so the |
| 104 | + * test depends only on main + main-test code.</p> |
| 105 | + */ |
| 106 | + private static CvDocument canonicalDocument() { |
| 107 | + return CvDocument.builder() |
| 108 | + .identity(CvIdentity.builder() |
| 109 | + .name("Jordan", "Rivera") |
| 110 | + .contact("+44 20 5555 1000", |
| 111 | + "jordan.rivera@example.com", |
| 112 | + "London, UK") |
| 113 | + .link("LinkedIn", "https://linkedin.com/in/jordan-rivera-demo") |
| 114 | + .link("GitHub", "https://github.com/jrivera-demo") |
| 115 | + .build()) |
| 116 | + .section(new ParagraphSection("Professional Summary", |
| 117 | + "Platform engineer with **10+ years** building resilient " |
| 118 | + + "document-generation pipelines, layout engines, and " |
| 119 | + + "developer-facing template systems. Specialised in " |
| 120 | + + "high-throughput PDF rendering, semantic authoring " |
| 121 | + + "DSLs, and turning brittle production-ops scripts " |
| 122 | + + "into typed, snapshot-tested libraries that scale.")) |
| 123 | + .section(RowsSection.builder("Technical Skills", RowStyle.BULLETED) |
| 124 | + .row("Languages", "Java 21, Kotlin, Groovy, Python, SQL") |
| 125 | + .row("Document & Print", "PDFBox, Apache POI (DOCX/XLSX), iText, " |
| 126 | + + "PostScript, ICC colour profiles, font metrics") |
| 127 | + .row("Layout engines", "Custom DSL design, semantic layout trees, " |
| 128 | + + "pagination, snapshot testing, visual regression") |
| 129 | + .row("Build & infrastructure", "Maven, Gradle, GitHub Actions, " |
| 130 | + + "JitPack, Docker, JMH benchmarking") |
| 131 | + .row("Testing", "JUnit 5, AssertJ, PDFBox-based PNG diff, " |
| 132 | + + "layout-graph snapshots, mutation testing (Pitest)") |
| 133 | + .row("Distribution", "Maven Central, Sonatype OSSRH, GPG signing, " |
| 134 | + + "JitPack, semantic versioning discipline") |
| 135 | + .build()) |
| 136 | + .section(EntriesSection.builder("Education & Certifications") |
| 137 | + .entry("MSc Computer Science", |
| 138 | + "University of Manchester", |
| 139 | + "2021", |
| 140 | + "Distinction. Thesis: *Composable layout primitives " |
| 141 | + + "for deterministic document rendering*.") |
| 142 | + .entry("BSc Software Engineering", |
| 143 | + "Imperial College London", |
| 144 | + "2019", |
| 145 | + "First-class honours. Specialisation in compilers and " |
| 146 | + + "static analysis.") |
| 147 | + .entry("Oracle Java Certification", |
| 148 | + "Professional track", |
| 149 | + "2023", |
| 150 | + "Java 17 platform deep-dive: records, sealed types, " |
| 151 | + + "pattern matching, virtual threads.") |
| 152 | + .build()) |
| 153 | + .section(RowsSection.builder("Projects", RowStyle.BULLETED_STACKED) |
| 154 | + .row("GraphCompose (Java 21, PDFBox, Maven, JMH)", |
| 155 | + "Declarative Java PDF layout engine. Semantic DSL, " |
| 156 | + + "slot-based templates, snapshot testing. Powers " |
| 157 | + + "production CV / invoice / proposal pipelines for " |
| 158 | + + "hiring tools and billing systems. *(Open source)*") |
| 159 | + .row("Template Studio (Kotlin, Compose Desktop, PDFBox PNG diff)", |
| 160 | + "Internal tool for evaluating CV, proposal, and " |
| 161 | + + "invoice output across 14 design presets. PNG " |
| 162 | + + "diffing, side-by-side layout, baseline freezing.") |
| 163 | + .row("LayoutLint (Java 21, JavaParser, Spoon)", |
| 164 | + "Static analyser that flags fragile authoring patterns " |
| 165 | + + "(deeply nested rows, untyped offsets, implicit " |
| 166 | + + "page breaks) before they ship to production.") |
| 167 | + .row("ChromeForge (Java, GraphCompose, Pandoc bridge)", |
| 168 | + "Editorial-magazine document toolkit built on " |
| 169 | + + "GraphCompose: cinematic covers, pull quotes, " |
| 170 | + + "multi-column flow, sidebar callouts.") |
| 171 | + .build()) |
| 172 | + .section(EntriesSection.builder("Professional Experience") |
| 173 | + .entry("Senior Platform Engineer", |
| 174 | + "Northwind Systems", |
| 175 | + "2024-Present", |
| 176 | + "Led the reusable document-generation platform serving " |
| 177 | + + "billing, hiring, and reporting flows across " |
| 178 | + + "**8 product teams**. Reduced template maintenance " |
| 179 | + + "time by **70%** by retiring per-team PDF scripts " |
| 180 | + + "in favour of one canonical engine.") |
| 181 | + .entry("Software Engineer", |
| 182 | + "BrightLeaf Labs", |
| 183 | + "2021-2024", |
| 184 | + "Built backend services and production document rendering " |
| 185 | + + "pipelines processing **2M+ documents per month**. " |
| 186 | + + "Drove the migration from iText to a custom layout " |
| 187 | + + "engine, eliminating licensing risk and cutting " |
| 188 | + + "p99 render latency from 1.4s to 380ms.") |
| 189 | + .entry("Backend Engineer", |
| 190 | + "Helix Print Co", |
| 191 | + "2019-2021", |
| 192 | + "Maintained a high-volume invoice-printing service " |
| 193 | + + "(15M PDFs/year) and authored the compliance test " |
| 194 | + + "harness that gated every template change.") |
| 195 | + .build()) |
| 196 | + .section(RowsSection.builder("Additional Information", RowStyle.PLAIN) |
| 197 | + .row("Languages", |
| 198 | + "English (Fluent), German (Intermediate), Spanish (Basic)") |
| 199 | + .row("Work Eligibility", |
| 200 | + "Eligible to work in the UK and the EU") |
| 201 | + .row("Open Source", |
| 202 | + "Maintainer of GraphCompose. Regular contributor to " |
| 203 | + + "PDFBox issue triage.") |
| 204 | + .row("Speaking", |
| 205 | + "JVM Summit 2024, Devoxx UK 2025 — both on declarative " |
| 206 | + + "document layout.") |
| 207 | + .build()) |
| 208 | + .build(); |
| 209 | + } |
| 210 | +} |
0 commit comments