diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java index 2a19aa455..9167a1a35 100644 --- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java +++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java @@ -81,6 +81,7 @@ import com.demcha.examples.templates.cv.v2.CvTimelineMinimalExample; import com.demcha.examples.templates.invoice.InvoiceCinematicFileExample; import com.demcha.examples.templates.invoice.InvoiceFileExample; +import com.demcha.examples.templates.invoice.v2.ModernInvoiceV2Example; import com.demcha.examples.templates.proposal.CinematicProposalFileExample; import com.demcha.examples.templates.proposal.ProposalCinematicFileExample; import com.demcha.examples.templates.proposal.ProposalFileExample; @@ -138,6 +139,7 @@ public static void main(String[] args) throws Exception { // Invoices System.out.println("Generated: " + InvoiceFileExample.generate()); System.out.println("Generated: " + InvoiceCinematicFileExample.generate()); + System.out.println("Generated: " + ModernInvoiceV2Example.generate()); // Proposals System.out.println("Generated: " + ProposalFileExample.generate()); diff --git a/examples/src/main/java/com/demcha/examples/templates/invoice/v2/ModernInvoiceV2Example.java b/examples/src/main/java/com/demcha/examples/templates/invoice/v2/ModernInvoiceV2Example.java new file mode 100644 index 000000000..b27f82d2f --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/templates/invoice/v2/ModernInvoiceV2Example.java @@ -0,0 +1,59 @@ +package com.demcha.examples.templates.invoice.v2; + +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.data.invoice.InvoiceDocumentSpec; +import com.demcha.compose.document.templates.invoice.v2.presets.ModernInvoice; +import com.demcha.examples.support.ExampleDataFactory; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.nio.file.Path; + +/** + * Renders the layered {@code invoice.v2} Modern Invoice preset against + * the shared {@code InvoiceDocumentSpec} sample data using the default + * {@code BrandTheme.invoiceModern()} theme. + * + *

Output: + * {@code examples/target/generated-pdfs/templates/invoice/invoice-modern-v2.pdf}.

+ * + *

This is the "hello world" for the v2 invoice pipeline: fetch sample + * data, ask the preset for a template, render it — the same shape as the + * v2 CV examples, now on the invoice family.

+ */ +public final class ModernInvoiceV2Example { + + private ModernInvoiceV2Example() { + } + + /** + * @return absolute path of the rendered PDF + * @throws Exception if rendering fails + */ + public static Path generate() throws Exception { + Path outputFile = ExampleOutputPaths.prepare( + "templates/invoice", "invoice-modern-v2.pdf"); + InvoiceDocumentSpec spec = ExampleDataFactory.sampleInvoice(); + DocumentTemplate template = ModernInvoice.create(); + + float m = (float) ModernInvoice.RECOMMENDED_MARGIN; + try (DocumentSession document = GraphCompose.document(outputFile) + .pageSize(DocumentPageSize.A4) + .margin(m, m, m, m) + .create()) { + template.compose(document, spec); + document.buildPdf(); + } + return outputFile; + } + + /** + * @param args ignored + * @throws Exception if rendering fails + */ + public static void main(String[] args) throws Exception { + System.out.println("Generated: " + generate()); + } +} diff --git a/src/main/java/com/demcha/compose/document/templates/core/theme/BrandTheme.java b/src/main/java/com/demcha/compose/document/templates/core/theme/BrandTheme.java index 92702e3ec..0f20fd036 100644 --- a/src/main/java/com/demcha/compose/document/templates/core/theme/BrandTheme.java +++ b/src/main/java/com/demcha/compose/document/templates/core/theme/BrandTheme.java @@ -318,6 +318,23 @@ public static BrandTheme mintEditorial() { Spacing.mintEditorial(), Decoration.classic()); } + + /** + * The "Modern Invoice" look — Helvetica, slate ink, a pale-blue + * rounded hero panel with a blue accent strip, and light table + * rules. The first layered invoice flavour: the invoice + * presets read it exactly the way the CV presets read their own + * flavours, so the two families share one theme model. + * + * @return a {@code BrandTheme} for the "Modern Invoice" look + */ + public static BrandTheme invoiceModern() { + return new BrandTheme( + Palette.invoiceModern(), + Typography.invoiceModern(), + Spacing.invoiceModern(), + Decoration.classic()); + } // -- pre-built text-style helpers ------------------------------------ // Renderers ask the theme for an already-composed DocumentTextStyle // instead of re-assembling font + size + decoration + colour every diff --git a/src/main/java/com/demcha/compose/document/templates/core/theme/Palette.java b/src/main/java/com/demcha/compose/document/templates/core/theme/Palette.java index 8cf66c0cd..fd0ad864f 100644 --- a/src/main/java/com/demcha/compose/document/templates/core/theme/Palette.java +++ b/src/main/java/com/demcha/compose/document/templates/core/theme/Palette.java @@ -298,4 +298,23 @@ public static Palette mintEditorial() { DocumentColor.rgb(70, 70, 70), // rule (skill-bar track) DocumentColor.rgb(139, 207, 190)); // banner — reused as mint accent } + + /** + * Modern Invoice palette: slate body ink, grey metadata, light + * table-border rules, and a pale-blue fill carried in the + * {@code banner} slot for the invoice hero panel and table zebra + * rows. The stronger accent blue used for the hero strip / status + * is preset-local in {@code ModernInvoice} because no other v2 + * preset shares it today. + * + * @return a {@code Palette} for the Modern Invoice flavour + */ + public static Palette invoiceModern() { + return new Palette( + DocumentColor.rgb(33, 37, 41), // ink — slate body text + DocumentColor.rgb(108, 117, 125), // muted — metadata + DocumentColor.rgb(206, 212, 218), // rule — table borders / separators + DocumentColor.rgb(232, 240, 254), // banner — pale-blue hero panel / zebra fill + DocumentColor.WHITE); // mainFill — table surface + } } diff --git a/src/main/java/com/demcha/compose/document/templates/core/theme/Spacing.java b/src/main/java/com/demcha/compose/document/templates/core/theme/Spacing.java index 6c7c1ac9b..6ce6bc70b 100644 --- a/src/main/java/com/demcha/compose/document/templates/core/theme/Spacing.java +++ b/src/main/java/com/demcha/compose/document/templates/core/theme/Spacing.java @@ -501,4 +501,31 @@ public static Spacing mintEditorial() { 0.45, // entryDateWeight 12.0); // entrySeparation (roomy editorial gap) } + + /** + * Spacing for the Modern Invoice preset: a 14pt page-flow rhythm + * matching the cinematic invoice, a rounded hero panel (10pt radius, + * 14pt inner padding), and a 4pt accent strip down the hero's left + * edge. Entry-row tokens are unused by the invoice layout but kept + * at neutral defaults for the shared record shape. + * + * @return a {@code Spacing} for the Modern Invoice preset + */ + public static Spacing invoiceModern() { + return new Spacing( + 14, // pageFlowSpacing (matches the cinematic invoice) + 4, // sectionBodySpacing + DocumentInsets.zero(), // sectionBodyPadding + DocumentInsets.zero(), // headlinePadding + DocumentInsets.zero(), // contactPadding + 10.0, // bannerCornerRadius (hero panel radius) + 14.0, // bannerInnerPadding (hero panel padding) + DocumentInsets.zero(), // bannerMargin + 4.0, // accentRuleWidth (hero accent strip width) + 2.0, // paragraphMarginTop + 8.0, // entryHeaderRowSpacing + 1.0, // entryTitleWeight + 0.45, // entryDateWeight + 3.0); // entrySeparation + } } diff --git a/src/main/java/com/demcha/compose/document/templates/core/theme/Typography.java b/src/main/java/com/demcha/compose/document/templates/core/theme/Typography.java index d42779f01..b7d03ee29 100644 --- a/src/main/java/com/demcha/compose/document/templates/core/theme/Typography.java +++ b/src/main/java/com/demcha/compose/document/templates/core/theme/Typography.java @@ -362,4 +362,24 @@ public static Typography mintEditorial() { 8.0, // body (profile / experience) 1.3); // line spacing } + + /** + * Helvetica scale for the Modern Invoice preset — a clear invoice + * title, compact bold labels (FROM / BILL TO / table header), and a + * readable body for party blocks and line-item cells. + * + * @return a {@code Typography} scale for the Modern Invoice preset + */ + public static Typography invoiceModern() { + return new Typography( + FontName.HELVETICA_BOLD, FontName.HELVETICA, + 22.0, // headline (invoice title) + 9.0, // contact (unused by invoice; kept for the record shape) + 9.5, // banner (FROM / BILL TO / column labels) + 10.0, // entry title + 9.5, // entry date + 9.0, // entry subtitle (footer note) + 9.5, // body (party blocks, table cells) + 1.3); // line spacing + } } diff --git a/src/main/java/com/demcha/compose/document/templates/invoice/v2/presets/ModernInvoice.java b/src/main/java/com/demcha/compose/document/templates/invoice/v2/presets/ModernInvoice.java new file mode 100644 index 000000000..022a2550c --- /dev/null +++ b/src/main/java/com/demcha/compose/document/templates/invoice/v2/presets/ModernInvoice.java @@ -0,0 +1,304 @@ +package com.demcha.compose.document.templates.invoice.v2.presets; + +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.TableBuilder; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentCornerRadius; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.style.DocumentTextDecoration; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.table.DocumentTableColumn; +import com.demcha.compose.document.table.DocumentTableStyle; +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.core.theme.BrandTheme; +import com.demcha.compose.document.templates.data.invoice.InvoiceData; +import com.demcha.compose.document.templates.data.invoice.InvoiceDocumentSpec; +import com.demcha.compose.document.templates.data.invoice.InvoiceLineItem; +import com.demcha.compose.document.templates.data.invoice.InvoiceParty; +import com.demcha.compose.document.templates.data.invoice.InvoiceSummaryRow; + +import java.util.List; +import java.util.Objects; + +/** + * Modern Invoice — the first layered invoice preset. + * + *

It composes an invoice on a + * {@link com.demcha.compose.document.templates.core.theme.BrandTheme} + * plus the canonical DSL, mirroring the {@code cv.v2} preset shape: a + * {@code create(BrandTheme)} factory returns a thin + * {@link DocumentTemplate} whose {@code compose} sequences a hero panel, + * the FROM / BILL TO parties, the line-items table, and a notes / + * payment-terms footer. The visual intent is ported from the cinematic + * {@code builtins.InvoiceTemplateV2}; this preset reads every colour, + * font, size, and spacing value from the theme instead of a + * {@code BusinessTheme}.

+ * + *

Why the parties render inline rather than through + * {@code core.identity.PartyIdentity}: an invoice carries two + * parties (sender + bill-to) shown as side-by-side blocks, and an + * {@link InvoiceParty}'s email / phone are optional, whereas the shared + * {@code Contact} / {@code Masthead} widgets model a single mandatory + * contact triple for a one-person CV masthead. The two-party inline + * layout is the invoice idiom, so the preset composes the party blocks + * directly.

+ */ +public final class ModernInvoice { + + /** + * Stable template identifier. + */ + public static final String ID = "invoice-modern"; + + /** + * Human-readable display name. + */ + public static final String DISPLAY_NAME = "Modern Invoice"; + + /** + * Recommended page margin (in points). + */ + public static final double RECOMMENDED_MARGIN = 28.0; + + private static final double TABLE_PADDING = 7.0; + + /** + * Strong accent blue for the hero accent strip and the status read + * out. Preset-local because no other v2 preset shares it today; if a + * second invoice preset reaches for it, promote it to a + * {@link com.demcha.compose.document.templates.core.theme.Palette} + * slot. + */ + private static final DocumentColor ACCENT = DocumentColor.rgb(41, 128, 185); + + private ModernInvoice() { + } + + /** + * Builds the preset with the Modern Invoice theme + * ({@link BrandTheme#invoiceModern()}). + * + * @return ready-to-use template + */ + public static DocumentTemplate create() { + return create(BrandTheme.invoiceModern()); + } + + /** + * Builds the preset with a caller-supplied theme, so callers can + * vary the invoice flavour (typography scale, palette) without + * forking this class. + * + * @param theme active theme + * @return ready-to-use template + */ + public static DocumentTemplate create(BrandTheme theme) { + Objects.requireNonNull(theme, "theme"); + return new Template(theme); + } + + private record Template(BrandTheme theme) implements DocumentTemplate { + + @Override + public String id() { + return ID; + } + + @Override + public String displayName() { + return DISPLAY_NAME; + } + + @Override + public void compose(DocumentSession document, InvoiceDocumentSpec spec) { + Objects.requireNonNull(document, "document"); + InvoiceData data = Objects.requireNonNull(spec, "spec").invoice(); + + DocumentColor panelFill = theme.palette().banner(); + DocumentColor rule = theme.palette().rule(); + DocumentColor surface = theme.palette().mainFill(); + + DocumentTextStyle titleStyle = theme.headlineStyle(); + DocumentTextStyle labelStyle = theme.bannerStyle(); + DocumentTextStyle bodyStyle = theme.bodyStyle(); + + DocumentTableStyle bordered = DocumentTableStyle.builder() + .stroke(DocumentStroke.of(rule, 0.6)) + .padding(DocumentInsets.of(TABLE_PADDING)) + .textStyle(bodyStyle) + .build(); + DocumentTableStyle headerStyle = DocumentTableStyle.builder() + .fillColor(theme.palette().ink()) + .stroke(DocumentStroke.of(rule, 0.6)) + .padding(DocumentInsets.of(TABLE_PADDING + 1)) + .textStyle(DocumentTextStyle.builder() + .fontName(theme.typography().bodyFont()) + .decoration(DocumentTextDecoration.BOLD) + .size(theme.typography().sizeBanner()) + .color(surface) + .build()) + .build(); + DocumentTableStyle totalStyle = DocumentTableStyle.builder() + .fillColor(panelFill) + .stroke(DocumentStroke.of(rule, 0.6)) + .padding(DocumentInsets.of(TABLE_PADDING + 1)) + .textStyle(theme.bodyBoldStyle()) + .build(); + + document.dsl().pageFlow() + .name("InvoiceV2ModernRoot") + .spacing(theme.spacing().pageFlowSpacing()) + .addSection("InvoiceHero", section -> section + .softPanel(panelFill, + DocumentCornerRadius.right(theme.spacing().bannerCornerRadius()), + theme.spacing().bannerInnerPadding()) + .accentLeft(ACCENT, theme.spacing().accentRuleWidth()) + .spacing(6) + .addParagraph(p -> p + .text(data.title().isBlank() ? "Invoice" : data.title()) + .textStyle(titleStyle) + .margin(DocumentInsets.zero())) + .addRich(rich -> rich + .plain("Invoice ").bold(data.invoiceNumber()) + .plain(" Issued ").bold(data.issueDate()) + .plain(" Due ").bold(data.dueDate()) + .plain(" Status ").accent(safeStatus(data.status()), ACCENT))) + .addRow("InvoiceParties", row -> row + .spacing(18) + .weights(1, 1) + .addSection("InvoiceFromParty", col -> col + .spacing(2) + .addParagraph(p -> p + .text("FROM") + .textStyle(labelStyle) + .margin(DocumentInsets.zero())) + .addParagraph(p -> p + .text(data.fromParty().name()) + .textStyle(labelStyle) + .margin(DocumentInsets.zero())) + .addParagraph(p -> p + .text(joinAddress(data.fromParty())) + .textStyle(bodyStyle) + .lineSpacing(1.3) + .margin(DocumentInsets.zero()))) + .addSection("InvoiceBillToParty", col -> col + .spacing(2) + .addParagraph(p -> p + .text("BILL TO") + .textStyle(labelStyle) + .margin(DocumentInsets.zero())) + .addParagraph(p -> p + .text(data.billToParty().name()) + .textStyle(labelStyle) + .margin(DocumentInsets.zero())) + .addParagraph(p -> p + .text(joinAddress(data.billToParty())) + .textStyle(bodyStyle) + .lineSpacing(1.3) + .margin(DocumentInsets.zero())))) + .addTable(table -> { + TableBuilder configured = table + .name("InvoiceLineItems") + .columns( + DocumentTableColumn.auto(), + DocumentTableColumn.fixed(54), + DocumentTableColumn.fixed(96), + DocumentTableColumn.fixed(96)) + .defaultCellStyle(bordered) + .headerRow("Description", "Qty", "Unit", "Amount") + .headerStyle(headerStyle) + .repeatHeader() + .zebra(panelFill, surface); + // Render only item.description() in the cell. The optional + // details string can be a long sentence; including it would + // force the auto-sized description column to measure against + // that width and overflow the inner page on A4 — the cinematic + // builtin guards the same way. Do not add item.details() here. + for (InvoiceLineItem item : data.lineItems()) { + configured.row(item.description(), item.quantity(), + item.unitPrice(), item.amount()); + } + // Convention (shared with the cinematic builtin): the LAST + // summary row is the grand total — it renders via totalRow + // so it picks up the bold total style; earlier rows render + // as plain body rows. + List summaries = data.summaryRows(); + for (int i = 0; i < summaries.size(); i++) { + InvoiceSummaryRow summary = summaries.get(i); + if (i == summaries.size() - 1) { + configured.totalRow(totalStyle, "", "", summary.label(), summary.value()); + } else { + configured.row("", "", summary.label(), summary.value()); + } + } + }) + .addRow("InvoiceFooterRow", row -> row + .spacing(18) + .weights(1, 1) + .addSection("InvoiceNotes", col -> col + .accentLeft(ACCENT, 3) + .padding(0, 0, 0, 8) + .spacing(3) + .addParagraph(p -> p + .text("Notes") + .textStyle(labelStyle) + .margin(DocumentInsets.zero())) + .addList(list -> list.items(data.notes()))) + .addSection("InvoicePaymentTerms", col -> col + .accentLeft(ACCENT, 3) + .padding(0, 0, 0, 8) + .spacing(3) + .addParagraph(p -> p + .text("Payment terms") + .textStyle(labelStyle) + .margin(DocumentInsets.zero())) + .addList(list -> list.items(data.paymentTerms())))) + .build(); + + if (!data.footerNote().isBlank()) { + document.dsl().pageFlow() + .name("InvoiceV2ModernFooter") + .addParagraph(p -> p + .text(data.footerNote()) + .textStyle(theme.entrySubtitleStyle()) + .margin(new DocumentInsets(14, 0, 0, 0))) + .build(); + } + } + } + + private static String safeStatus(String status) { + if (status == null || status.isBlank()) { + return "—"; + } + return status; + } + + private static String joinAddress(InvoiceParty party) { + StringBuilder builder = new StringBuilder(); + for (String line : party.addressLines()) { + if (line == null || line.isBlank()) { + continue; + } + append(builder, line); + } + if (!party.email().isBlank()) { + append(builder, party.email()); + } + if (!party.phone().isBlank()) { + append(builder, party.phone()); + } + if (!party.taxId().isBlank()) { + append(builder, "Tax ID " + party.taxId()); + } + return builder.toString(); + } + + private static void append(StringBuilder builder, String line) { + if (builder.length() > 0) { + builder.append('\n'); + } + builder.append(line); + } +} diff --git a/src/main/java/com/demcha/compose/document/templates/invoice/v2/presets/package-info.java b/src/main/java/com/demcha/compose/document/templates/invoice/v2/presets/package-info.java new file mode 100644 index 000000000..cc8982c45 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/templates/invoice/v2/presets/package-info.java @@ -0,0 +1,17 @@ +/** + * Layered invoice presets — thin orchestrators that compose an invoice + * on a {@link com.demcha.compose.document.templates.core.theme.BrandTheme} + * plus the canonical DSL. + * + *

This package mirrors {@code cv.v2.presets}: each preset is a + * {@code final} class with a {@code create(BrandTheme)} factory returning + * a {@link com.demcha.compose.document.templates.api.DocumentTemplate} + * parameterised on + * {@link com.demcha.compose.document.templates.data.invoice.InvoiceDocumentSpec}. + * The presets read every visual value from the theme and reuse the shared + * {@code templates.core.*} layer; the invoice family does not own a theme + * type of its own.

+ * + * @since 2.0.0 + */ +package com.demcha.compose.document.templates.invoice.v2.presets; diff --git a/src/test/java/com/demcha/compose/document/templates/invoice/v2/presets/ModernInvoiceSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/invoice/v2/presets/ModernInvoiceSmokeTest.java new file mode 100644 index 000000000..86050e706 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/templates/invoice/v2/presets/ModernInvoiceSmokeTest.java @@ -0,0 +1,103 @@ +package com.demcha.compose.document.templates.invoice.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.style.DocumentInsets; +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.core.theme.BrandTheme; +import com.demcha.compose.document.templates.data.invoice.InvoiceData; +import com.demcha.compose.document.templates.data.invoice.InvoiceDocumentSpec; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Smoke test for the layered {@code invoice.v2} pipeline through + * {@link ModernInvoice} — proves the preset renders an + * {@link InvoiceDocumentSpec} end-to-end on a {@link BrandTheme}, via + * both factory variants, with any theme, and on an empty invoice. + */ +class ModernInvoiceSmokeTest { + + private static InvoiceDocumentSpec sampleSpec() { + return InvoiceDocumentSpec.from(InvoiceData.builder() + .title("Invoice") + .invoiceNumber("GC-2026-001") + .issueDate("01 May 2026") + .dueDate("15 May 2026") + .status("Sent") + .fromParty(from -> from + .name("GraphCompose Studio") + .addressLines("18 Layout Street", "London, UK") + .email("billing@graphcompose.dev") + .phone("+44 20 5555 1000") + .taxId("GB-99887766")) + .billToParty(to -> to + .name("Northwind Systems") + .addressLines("Attn: Finance", "410 Market Avenue") + .email("ap@northwind.example") + .phone("+44 161 555 2200")) + .lineItem("Discovery workshop", "Stakeholder interviews", + "1", "GBP 1,450", "GBP 1,450") + .lineItem("Template architecture", "Reusable document flows", + "2", "GBP 980", "GBP 1,960") + .summaryRow("Subtotal", "GBP 3,410") + .summaryRow("VAT (20%)", "GBP 682") + .totalRow("Total", "GBP 4,092") + .note("Payment due within 14 days.") + .paymentTerm("Bank transfer, NET 14") + .footerNote("Thank you for your business.") + .build()); + } + + /** An invoice with no line items, summaries, notes, or footer — the empty paths. */ + private static InvoiceDocumentSpec minimalSpec() { + return InvoiceDocumentSpec.from(InvoiceData.builder() + .invoiceNumber("GC-2026-002") + .fromParty(from -> from.name("GraphCompose Studio")) + .billToParty(to -> to.name("Northwind Systems")) + .build()); + } + + private static void render(DocumentTemplate template, + InvoiceDocumentSpec spec) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(DocumentInsets.of(28)) + .create()) { + template.compose(session, spec); + assertThat(session.roots()).isNotEmpty(); + } + } + + @Test + void exposesStableIdentity() { + DocumentTemplate template = ModernInvoice.create(); + assertThat(template.id()).isEqualTo(ModernInvoice.ID); + assertThat(template.displayName()).isEqualTo(ModernInvoice.DISPLAY_NAME); + } + + @Test + void defaultFactoryRendersWithInvoiceTheme() throws Exception { + // create() wires BrandTheme.invoiceModern() — the variant the example uses. + render(ModernInvoice.create(), sampleSpec()); + } + + @Test + void rendersWithExplicitTheme() throws Exception { + render(ModernInvoice.create(BrandTheme.invoiceModern()), sampleSpec()); + } + + @Test + void readsAnyTheme() throws Exception { + // Proves the preset reads the theme rather than assuming invoiceModern() slots. + render(ModernInvoice.create(BrandTheme.boxedClassic()), sampleSpec()); + } + + @Test + void rendersEmptyInvoice() throws Exception { + // Exercises the empty-collection + header-only-table + skipped-footer paths. + render(ModernInvoice.create(), minimalSpec()); + } +}