Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1475,6 +1475,19 @@ follow semantic versioning; release dates are ISO 8601.

### Tests

- **The DOCX export is held to its corpus, line by line.** `DocxFidelityCorpusTest` exports
every template preset — 62 documents across CVs, cover letters, invoices, proposals, a
receipt and a rota — to DOCX, has LibreOffice set each one, and finds each of the page's
lines in it by its letters — lines of the same letters on a page, a rota's shifts, each
paired with the editor's nearest. Against the committed baseline a document fails when a line
is no longer found (set at other words or on another page), when a line drifts more than half
a point further from the page, or when it takes a page more. Counts per document would not do: a
sidebar CV that LibreOffice sets about 3pt off throughout lost an indented list without
its counts moving, and the line check names the two lines lost and the six set 12pt
higher. It runs on request, `-Dgraphcompose.docxFidelity=libreoffice`; a change that moves
documents nearer the page rewrites the baseline, whose diff shows which. `DocxFidelityGateTest`
holds the gate itself to lines set lower, a line lost and a page more.

- **One DOCX export's state stays in that export — now pinned.** The backend keeps what an
export is in the middle of in its own fields, and starts each export from nothing.
`DocxExportIsolationTest` holds that to the byte on deterministic output: a backend used
Expand Down
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,16 @@ Choose the smallest tests that match the change:
[CvV2VisualParityTest.java (CV)](qa/src/test/java/com/demcha/compose/document/templates/cv/presets/CvV2VisualParityTest.java)
[CoverLetterV2VisualParityTest.java (cover letter)](qa/src/test/java/com/demcha/compose/document/templates/coverletter/presets/CoverLetterV2VisualParityTest.java)
and the [per-preset smoke tests](qa/src/test/java/com/demcha/compose/document/templates/cv/presets)
- For DOCX export (`render-docx`) changes:
[DocxFidelityCorpusTest.java](qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxFidelityCorpusTest.java).
It exports every template preset to DOCX, has LibreOffice set each one, and holds every
line of every document to the drift its baseline records: a line set further from the page,
a line set at other words, or a page more fails it. It needs LibreOffice and runs only when
asked for — install first, as for any standalone `qa` run:
`./mvnw -B -ntp test -f qa/pom.xml -Dtest=DocxFidelityCorpusTest -Dgraphcompose.docxFidelity=libreoffice`.
A change that moves documents nearer the page rewrites the baseline with
`-Dgraphcompose.docxFidelity.update=true`, and commits it with the change: its diff shows
which documents moved. A new preset joins the corpus in its family's `*DocxCorpus` class.

If a change affects public docs, examples, or screenshots, update those assets in the same PR so the repository stays internally consistent.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.demcha.compose.document.templates.coverletter.presets;

import com.demcha.compose.document.templates.api.DocumentTemplate;
import com.demcha.compose.document.templates.coverletter.data.CoverLetterDocument;
import com.demcha.compose.document.templates.fidelity.DocxCorpusDocument;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;

/** The cover-letter presets of the DOCX fidelity corpus, each on the canonical letter. */
public final class CoverLetterDocxCorpus {

private CoverLetterDocxCorpus() {
}

public static List<DocxCorpusDocument> documents() {
List<DocxCorpusDocument> documents = new ArrayList<>();
CoverLetterPresetFixtures.presets().forEach(arguments -> {
Object[] preset = arguments.get();
@SuppressWarnings("unchecked")
Supplier<DocumentTemplate<CoverLetterDocument>> template =
(Supplier<DocumentTemplate<CoverLetterDocument>>) preset[2];
documents.add(new DocxCorpusDocument("letter", (String) preset[0], (Double) preset[1],
session -> template.get().compose(session, CoverLetterPresetFixtures.canonicalLetter())));
});
return documents;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.demcha.compose.document.templates.cv.presets;

import com.demcha.compose.document.templates.api.DocumentTemplate;
import com.demcha.compose.document.templates.cv.data.CvDocument;
import com.demcha.compose.document.templates.fidelity.DocxCorpusDocument;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;

/** The CV presets of the DOCX fidelity corpus, each on the fixture its own tests render. */
public final class CvDocxCorpus {

private CvDocxCorpus() {
}

public static List<DocxCorpusDocument> documents() {
List<DocxCorpusDocument> documents = new ArrayList<>();
CvPresetFixtures.presets().forEach(arguments -> {
Object[] preset = arguments.get();
@SuppressWarnings("unchecked")
Supplier<DocumentTemplate<CvDocument>> template = (Supplier<DocumentTemplate<CvDocument>>) preset[2];
documents.add(cv((String) preset[0], (Double) preset[1], template, CvPresetFixtures.canonicalDocument()));
});
documents.add(cv("charcoal_gold", 0, CharcoalGold::create, CharcoalGoldFixtures.canonicalCv()));
documents.add(cv("navy_sidebar", 0, NavySidebar::create, NavySidebarFixtures.canonicalCv()));
documents.add(cv("slate_orange", -1, SlateOrange::create, SlateOrangeFixtures.canonicalCv()));
documents.add(cv("midnight_navy", -1, MidnightNavy::create, MidnightNavyFixtures.canonicalCv()));
documents.add(cv("orange_ops", -1, OrangeOps::create, OrangeOpsFixtures.canonicalCv()));
documents.add(cv("professional_sidebar", -1, ProfessionalSidebar::create, ProfessionalSidebarFixtures.canonicalCv()));
documents.add(cv("serif_headline", -1, SerifHeadline::create, SerifHeadlineFixtures.canonicalCv()));
documents.add(cv("teal_pulse", -1, TealPulse::create, TealPulseFixtures.canonicalCv()));
documents.add(cv("terracotta_rail", -1, TerracottaRail::create, TerracottaRailFixtures.canonicalCv()));
documents.add(cv("violet_grid", -1, VioletGrid::create, VioletGridFixtures.canonicalCv()));
return documents;
}

private static DocxCorpusDocument cv(String name, double margin,
Supplier<DocumentTemplate<CvDocument>> template, CvDocument cv) {
return new DocxCorpusDocument("cv", name, margin, session -> {
OrangeOpsTestFont.register(session);
template.get().compose(session, cv);
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.demcha.compose.document.templates.fidelity;

import com.demcha.compose.document.api.DocumentSession;

import java.util.Objects;
import java.util.function.Consumer;

/**
* One document of the DOCX fidelity corpus: a template preset composed on its fixture.
*
* @param family the template family, such as {@code cv} or {@code invoice}
* @param name the preset, unique within its family
* @param margin the page margin on A4, in points, or a negative value for the session's own
* page and margins
* @param compose composes the document into a fresh session
*/
public record DocxCorpusDocument(String family, String name, double margin, Consumer<DocumentSession> compose) {

public DocxCorpusDocument {
Objects.requireNonNull(family, "family");
Objects.requireNonNull(name, "name");
Objects.requireNonNull(compose, "compose");
}

/** The file stem the document's PDF and DOCX are written under. */
public String stem() {
return family + "-" + name;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package com.demcha.compose.document.templates.fidelity;

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentPageSize;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.backend.semantic.docx.DocxSemanticBackend;
import com.demcha.compose.document.templates.coverletter.presets.CoverLetterDocxCorpus;
import com.demcha.compose.document.templates.cv.presets.CvDocxCorpus;
import com.demcha.compose.document.templates.invoice.presets.InvoiceDocxCorpus;
import com.demcha.compose.document.templates.proposal.presets.ProposalDocxCorpus;
import com.demcha.compose.document.templates.receipt.presets.ReceiptDocxCorpus;
import com.demcha.compose.document.templates.rota.presets.RotaDocxCorpus;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Every template preset's DOCX, set by an editor, stands no further from the engine's PDF than
* its baseline holds it.
*
* <p>The engine draws each corpus document to PDF and exports it to DOCX; LibreOffice converts
* the DOCX to PDF; each document is measured line by line against the page
* ({@link FidelityMeasurement}) and held to the baseline for this editor and operating system
* ({@link FidelityBaseline}). A change to the export that sets any document further from the
* page fails here, wherever the document is.</p>
*
* <p>It runs only when asked for, with {@code -Dgraphcompose.docxFidelity=libreoffice}: it
* needs LibreOffice and takes a few minutes. Asked for and without LibreOffice, it fails rather
* than passing unrun. {@code -Dgraphcompose.docxFidelity.update=true} writes what it measured as
* the baseline, for a change that moves documents nearer the page; the baseline's diff then
* shows the reviewer which documents moved. The documents, their PDFs and the report are left
* in {@code target/docx-fidelity}.</p>
*/
class DocxFidelityCorpusTest {

private static final String EDITOR = "libreoffice";

@Test
void everyDocumentStandsNoFurtherFromThePageThanItsBaseline() throws Exception {
Assumptions.assumeTrue(EDITOR.equals(System.getProperty("graphcompose.docxFidelity")),
"NOT_RUN: the DOCX fidelity corpus runs with -Dgraphcompose.docxFidelity=libreoffice");
LibreOfficeConverter converter = LibreOfficeConverter.find().orElseThrow(() -> new IllegalStateException(
"the DOCX fidelity corpus was asked for, and no LibreOffice was found: set -Dgraphcompose.soffice"));

Path work = Path.of("target", "docx-fidelity").toAbsolutePath();
Path engine = Files.createDirectories(work.resolve("engine"));
Path editor = work.resolve(EDITOR);
// LibreOffice can exit cleanly when a file fails to load: a PDF left from an earlier
// run would then be measured in its place.
deleteTree(editor);
List<DocxCorpusDocument> corpus = corpus();
List<Path> docx = new ArrayList<>();
for (DocxCorpusDocument document : corpus) {
docx.add(export(document, engine));
}
converter.convert(docx, editor, work);

List<FidelityMeasurement> measurements = new ArrayList<>();
for (DocxCorpusDocument document : corpus) {
Path converted = editor.resolve(document.stem() + ".pdf");
assertThat(converted).as("LibreOffice's PDF of %s", document.stem()).exists();
measurements.add(FidelityMeasurement.of(document.stem(),
engine.resolve(document.stem() + ".pdf"), converted));
}
String note = EDITOR + " " + converter.build() + " on " + LibreOfficeConverter.platform();
FidelityBaseline.write(work.resolve("measured-" + EDITOR + ".tsv"), note, measurements);

Path baseline = Path.of("src", "test", "resources", "docx-fidelity",
EDITOR + "-" + LibreOfficeConverter.platform() + ".tsv");
List<String> regressions = FidelityBaseline.read(baseline).regressions(measurements);
if (Boolean.getBoolean("graphcompose.docxFidelity.update")) {
// Written all the same: what it lets through is the baseline's diff, shown here first.
regressions.forEach(regression -> System.err.println("baseline rewritten over: " + regression));
FidelityBaseline.write(baseline, note, measurements);
return;
}
assertThat(regressions)
.as("documents set further from the page than %s holds them (measured: %s)",
baseline, work.resolve("measured-" + EDITOR + ".tsv"))
.isEmpty();
}

@Test
void everyCorpusDocumentHasAStemOfItsOwn() {
Set<String> stems = new HashSet<>();
for (DocxCorpusDocument document : corpus()) {
assertThat(stems.add(document.stem())).as("%s named once", document.stem()).isTrue();
}
assertThat(stems).hasSizeGreaterThanOrEqualTo(60);
}

private static void deleteTree(Path dir) throws java.io.IOException {
if (!Files.exists(dir)) {
return;
}
try (var paths = Files.walk(dir)) {
for (Path path : paths.sorted(java.util.Comparator.reverseOrder()).toList()) {
Files.delete(path);
}
}
}

/** Every family's presets. */
static List<DocxCorpusDocument> corpus() {
List<DocxCorpusDocument> corpus = new ArrayList<>();
corpus.addAll(CvDocxCorpus.documents());
corpus.addAll(CoverLetterDocxCorpus.documents());
corpus.addAll(InvoiceDocxCorpus.documents());
corpus.addAll(ProposalDocxCorpus.documents());
corpus.addAll(ReceiptDocxCorpus.documents());
corpus.addAll(RotaDocxCorpus.documents());
return corpus;
}

/** Writes a document's PDF and its DOCX; returns the DOCX. */
private static Path export(DocxCorpusDocument document, Path dir) throws Exception {
var builder = GraphCompose.document();
if (document.margin() >= 0) {
float margin = (float) document.margin();
builder.pageSize(DocumentPageSize.A4).margin(margin, margin, margin, margin);
}
try (DocumentSession session = builder.create()) {
document.compose().accept(session);
Files.write(dir.resolve(document.stem() + ".pdf"), session.toPdfBytes());
Path docx = dir.resolve(document.stem() + ".docx");
Files.write(docx, session.export(DocxSemanticBackend.builder().deterministic(true).build()));
return docx;
}
}
}
Loading
Loading