Skip to content

Latest commit

 

History

History
438 lines (303 loc) · 15 KB

File metadata and controls

438 lines (303 loc) · 15 KB

Layout Snapshot Testing

Layout snapshot tests are the primary geometry-regression layer in GraphCompose.

They sit between unit-level layout math tests and final PDF render tests:

  1. unit tests validate isolated geometry rules
  2. layout snapshot tests validate the resolved document tree after layout and pagination
  3. PDF render tests remain the outer smoke and human inspection layer

This ordering makes regressions cheap to diagnose:

  • if coordinates drift, the JSON snapshot fails immediately
  • if layering or pagination changes unexpectedly, the diff shows it directly
  • if the snapshot still matches but the final PDF looks wrong, the issue is likely in rendering rather than layout

Purpose

Visual PDF tests are still useful, but they are expensive to inspect and harder to diff precisely.

Layout snapshots solve a different problem: they let the library compare resolved geometry directly, before rendered pixels become the source of truth.

Use them when you want to know that:

  • a node moved to a different coordinate
  • a page break started or ended on a different page
  • sibling ordering changed
  • Layer(depth) resolution changed
  • a template still resolves to the same layout after internal engine changes

Pipeline position

DocumentSession.layoutSnapshot() captures the document after layout and pagination, but before PDF rendering.

That means the coordinates in the snapshot are:

  • after layout resolution
  • after page-breaking decisions have been applied
  • before any PDFBox drawing happens

In other words, the snapshot represents the layout engine's resolved truth, not the renderer's output.

Debug-only API contract

layoutSnapshot() is a debug and test API.

It does not render the PDF by itself.

If you later call:

  • buildPdf()
  • toPdfBytes()
  • render(...)

on the same DocumentSession, GraphCompose reuses the already resolved layout so the debug snapshot and final PDF stay in sync.

This matters for two reasons:

  1. the runtime PDF path stays clean and predictable for normal library users
  2. snapshot-first regression tests can still render the exact same resolved layout for inspection

If application code never calls layoutSnapshot(), this feature does not change the normal output pipeline.

Public API

Capture a raw layout snapshot

try (DocumentSession document = GraphCompose.document()
        .pageSize(DocumentPageSize.A4)
        .margin(24, 24, 24, 24)
        .create()) {

    document.pageFlow(page -> page
            .module("Snapshot Example", module -> module.paragraph("Hello GraphCompose")));

    LayoutSnapshot snapshot = document.layoutSnapshot();
}

Assert a committed JSON baseline

Use the test harness for normal snapshot regression coverage:

import com.demcha.compose.testing.layout.LayoutSnapshotAssertions;

@Test
void shouldMatchInvoiceLayoutSnapshotAndRenderPdf() throws Exception {
    Path outputFile = VisualTestOutputs.preparePdf("invoice_render_file", "clean", "templates", "invoice");

    try (DocumentSession document = GraphCompose.document(outputFile)
            .pageSize(DocumentPageSize.A4)
            .margin(22, 22, 22, 22)
            .create()) {

        template.compose(document, spec);
        LayoutSnapshotAssertions.assertMatches(document, "canonical-templates/invoice/invoice_standard_layout");
        document.buildPdf();
    }
}

This gives one test two kinds of feedback:

  • machine-precise layout regression coverage
  • a PDF artifact for visual inspection

Quick recipe for adding a snapshot test

If you are adding a new feature, template, or pagination case, the fastest way to add snapshot coverage is:

  1. create a JUnit test that instantiates a canonical DocumentSession
  2. compose the document into that session
  3. call LayoutSnapshotAssertions.assertMatches(...)
  4. optionally call buildPdf() if you also want a PDF artifact for visual inspection

Minimal pattern:

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentPageSize;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.testing.layout.LayoutSnapshotAssertions;
import org.junit.jupiter.api.Test;

class MyFeatureLayoutSnapshotTest {

    @Test
    void shouldKeepMyFeatureLayoutStable() throws Exception {
        try (DocumentSession document = GraphCompose.document()
                .pageSize(DocumentPageSize.A4)
                .margin(22, 22, 22, 22)
                .create()) {

            feature.compose(document, fixtureData());

            LayoutSnapshotAssertions.assertMatches(
                    document,
                    "features/my_feature_layout");
        }
    }
}

First run for a brand-new snapshot:

./mvnw "-Dgraphcompose.updateSnapshots=true" "-Dtest=MyFeatureLayoutSnapshotTest" test

That creates the committed baseline under:

  • core/src/test/resources/layout-snapshots/features/my_feature_layout.json

Normal verification run after that:

./mvnw "-Dtest=MyFeatureLayoutSnapshotTest" test

If the test fails, compare:

  • expected baseline in core/src/test/resources/layout-snapshots/...
  • generated actual file in target/visual-tests/layout-snapshots/.../*.actual.json

Use snapshot tests when the thing you care about is layout stability. If you need visual confirmation too, keep document.buildPdf() in the same test or pair the snapshot test with a render test.

Using snapshots in downstream projects

Library consumers can use the same public helpers that GraphCompose uses in its own tests:

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentPageSize;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.testing.layout.LayoutSnapshotAssertions;
import org.junit.jupiter.api.Test;

class InvoiceTemplateSnapshotTest {

    @Test
    void shouldKeepInvoiceLayoutStable() throws Exception {
        DocumentTemplate<InvoiceDocumentSpec> template = ModernInvoice.create();
        InvoiceDocumentSpec spec = invoiceFixture();

        try (DocumentSession document = GraphCompose.document()
                .pageSize(DocumentPageSize.A4)
                .margin(22, 22, 22, 22)
                .create()) {

            template.compose(document, spec);
            LayoutSnapshotAssertions.assertMatches(document, "canonical-templates/invoice/invoice_standard_layout");
        }
    }
}

Repository snapshot coverage should be authored against the canonical DocumentSession path because layoutSnapshot() now lives there directly.

If you want different baseline folders in your own project, use the public overloads with custom roots:

LayoutSnapshotAssertions.assertMatches(
        document,
        Path.of("src", "test", "resources", "layout-snapshots"),
        Path.of("target", "visual-tests", "layout-snapshots"),
        "consumer/invoice_layout");

Snapshot contents

DocumentSession.layoutSnapshot() extracts a deterministic JSON snapshot of the resolved entity tree.

The snapshot intentionally contains stable layout data only:

  • format version
  • canvas and page metadata
  • total page count
  • deterministic node paths
  • parent path and child index
  • depth and layer
  • computed coordinates
  • placement box coordinates, size, and page span
  • content size
  • margin and padding

Optional diagnostics: typography

Since 2.2.2 layoutSnapshot(...) can also report typography. It is opt-in, and it does not live on LayoutSnapshot:

LayoutDiagnosticSnapshot rich = document.layoutSnapshot(
        LayoutSnapshotOptions.builder().typography(true).build());

LayoutSnapshot layout = rich.layout();   // identical to document.layoutSnapshot()

LayoutSnapshot still has exactly the four components it had in 2.0, so its JSON, its toString() and its equals are unchanged however you serialize it — through LayoutSnapshotJson, through an ObjectMapper of your own, or by hand. Nothing added here can reach a baseline you already have on disk.

LayoutDiagnosticSnapshot wraps that snapshot and carries the sections you asked for. Its formatVersion versions the envelope, independently of the layout snapshot's 2.0: adding a section later moves one and not the other. LayoutSnapshotOptions is a builder so that next section costs a method rather than a new layoutSnapshot(...) overload. LayoutSnapshotOptions is a builder so a later section — links, paint, accessibility — is one more method rather than one more overload.

Each entry describes one resolved paragraph fragment:

  • the declared font, the resolved family, and the decoration that picks the face within it, plus a fontSubstituted flag
  • font size and line count
  • the bounds of the laid-out line boxes
  • one entry per line: index, bounds, and baseline, in absolute page coordinates

It hangs off fragments rather than nodes because that is what text is: a paragraph broken across a page boundary has one fragment per page, each with its own lines. Join it to nodes on path — one-to-many, since a node such as a chart owns many text fragments. Entries are ordered by path, then page, then emission ordinal.

All three font fields are needed to identify a face. HELVETICA_BOLD is an alias of its family and contributes nothing on its own — the bold comes from the decoration — so fontSubstituted is true for HELVETICA_BOLD with no decoration (it renders regular) and false for HELVETICA_BOLD with BOLD (it renders exactly what it named). resolvedFamily, decoration and fontSize describe the text the engine measured, after any autoSize shrink or span override.

What it does not cover

  • baselineExact is false for a paragraph using a non-default TextVerticalAlign: that mode shifts the glyphs by a correction derived from the backend font's cap height, and a renderer-neutral snapshot has no backend font to ask. The line box is not shifted with them, so treat the whole entry as positional there, not as a bound on painted output.
  • Bounds are laid-out line boxes, not tight glyph ink. A code chip's fill extends past them by its own padding, and an inline graphic on the baseline can rise above them.
  • Text drawn outside the paragraph pipeline — a table cell written as a plain string, a header, a watermark — produces no entry. An empty list means "no paragraph text", not "no text".
  • Coordinates are the laid-out ones, so a shape container carrying a transform, or one that clips its children, is not reflected.
  • A paragraph whose spans mix fonts is described by its first span.

It intentionally excludes unstable or noisy values such as:

  • UUIDs
  • raw text payload — including the text of each line, which is why a line is identified by its index
  • colors
  • PDF resource ids

Identity and determinism

Each node is identified by stable tree order plus semantic naming.

The extractor uses the following strategy:

  • prefer EntityName when it exists
  • otherwise fall back to <entityKind>[childIndex]
  • build the final identity from the full parent path

This keeps sibling collisions deterministic and makes diffs readable even when many nodes share the same render kind.

The extractor also normalizes numeric values before serialization. The current default is rounding doubles to 3 decimal places so snapshots stay stable across tiny floating-point differences while still catching real layout regressions.

Where files live

Committed baselines:

  • core/src/test/resources/layout-snapshots/...

Mismatch artifacts generated during normal test runs:

  • target/visual-tests/layout-snapshots/.../*.actual.json

Snapshot naming

Prefer semantic names that describe the document state:

  • canonical-templates/invoice/invoice_standard_layout
  • canonical-templates/proposal/proposal_long_layout
  • integration/table_pagination_test
  • templates/cv/font-themes/template_cv_1_poppins

The last path segment becomes the JSON file name. The preceding segments become folders.

Local workflow

Normal mode compares against committed baselines:

./mvnw test

To accept an intentional layout change locally:

./mvnw "-Dgraphcompose.updateSnapshots=true" test

Or update a focused test only:

./mvnw "-Dgraphcompose.updateSnapshots=true" "-Dtest=MyFeatureLayoutSnapshotTest" test

The same property works for downstream projects that use LayoutSnapshotAssertions from the published graph-compose-testing artifact.

In normal mode:

  • expected JSON stays committed in core/src/test/resources/layout-snapshots
  • mismatches write an .actual.json artifact under target/visual-tests/layout-snapshots
  • the assertion failure points to both expected and actual paths

In update mode:

  • the baseline JSON is overwritten intentionally
  • the .actual.json mismatch artifact is removed if it exists

CI expectations

CI should never enable graphcompose.updateSnapshots.

The expected behavior in CI is strict comparison only:

  • match the committed baseline
  • write .actual.json when there is a mismatch
  • fail fast so the diff can be reviewed locally

This keeps baseline updates explicit and prevents accidental golden-file drift in automated pipelines.

Recommended adoption pattern

When adding snapshot coverage to an existing visual test:

  1. if the test already creates a DocumentSession, add LayoutSnapshotAssertions.assertMatches(...) before buildPdf()
  2. keep the composition path explicit so the same canonical document can be snapshotted and rendered in one test
  3. keep the existing PDF render assertion and artifact generation
  4. generate the baseline once with -Dgraphcompose.updateSnapshots=true

The recommended developer flow is:

  1. unit tests for local layout math
  2. layout snapshot tests for full-document geometry regressions
  3. PDF render tests for final visual confidence

What to snapshot first

Prioritize documents that are most sensitive to layout regressions:

  • multi-page templates
  • tables with pagination
  • nested container compositions
  • documents where sibling order or Layer(depth) matters
  • theme or font variants that affect text measurement

Examples in this repository

  • FontShowcaseLayoutSnapshotTest
  • ChartLayoutSnapshotTest
  • ShapeContainerLayoutSnapshotTest
  • LayoutSnapshotPublicApiDogfoodTest

Interpreting a mismatch

If a snapshot fails:

  1. open the .actual.json file under target/visual-tests/layout-snapshots
  2. compare path, coordinates, page span, and layer/order changes
  3. decide whether the change is expected
  4. if expected, re-run with -Dgraphcompose.updateSnapshots=true
  5. if not expected, investigate the layout math before trusting the rendered PDF

Useful signals to check first:

  • startPage and endPage
  • computedX and computedY
  • placementX, placementY, width, and height
  • node path
  • layer

When not to use snapshots

Layout snapshots are not a replacement for every test.

Do not use them as the only safety net when:

  • you are testing renderer-specific drawing behavior
  • the failure you care about is pixel-level rather than geometry-level
  • a small unit test can prove the same rule more directly

They work best as the middle layer in the test pyramid, not as the only layer.