diff --git a/CHANGELOG.md b/CHANGELOG.md index 33772dcd5..7102a2d6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **`CvEntry` carries a location and a mark, and gains a builder.** The record held a + title, a subtitle, a date and a body, which is enough for a dated block and not + enough for the designs that set the city beside the employer in its own colour, or + that open a project with an icon. Folding a location into the subtitle would have + merged two things a design styles apart; deriving an icon from the text would have + been guesswork. `CvEntry` now carries `place` and `icon` — both plain strings, blank + when absent, matching how `subtitle` and `date` already behave — and + `CvEntry.builder(title)` reaches them without counting six positions. The icon + vocabulary is preset-scoped: a token means something only to the preset that packages + it, and the presets that draw no marks ignore it. The four-argument constructor is + kept explicitly, so existing calls compile and link unchanged. + - **`CvIdentity` carries an optional portrait.** A CV design with a photograph in it had nowhere to put one: the identity record held the name, the title, the contact triple and the links, and a preset that wanted a face had to ship a silhouette of its @@ -60,7 +72,8 @@ follow semantic versioning; release dates are ISO 8601. `RowsSection` because this design writes the proficiency out — "Native", "Advanced" — which a levelled skill could not carry back. The photograph comes from the new `CvIdentity.portrait()`; an identity without one draws the ring around an empty navy - disc. The phone, the email and each link are reachable from the PDF, with the + disc; its education entries read the `place` field for the campus line. The phone, + the email and each link are reachable from the PDF, with the `tel:` and `mailto:` targets built from the values — the published sheet drew its channels as plain text, and this is the one place the port deliberately improves on it, at no cost to the render: annotations move no pixel and no layout node, which diff --git a/examples/src/main/java/com/demcha/examples/support/NavySidebarSampleData.java b/examples/src/main/java/com/demcha/examples/support/NavySidebarSampleData.java index 712d5dcdf..6068025ca 100644 --- a/examples/src/main/java/com/demcha/examples/support/NavySidebarSampleData.java +++ b/examples/src/main/java/com/demcha/examples/support/NavySidebarSampleData.java @@ -4,6 +4,7 @@ import com.demcha.compose.document.templates.core.identity.Contact; import com.demcha.compose.document.templates.core.identity.Link; import com.demcha.compose.document.templates.cv.data.CvDocument; +import com.demcha.compose.document.templates.cv.data.CvEntry; import com.demcha.compose.document.templates.cv.data.CvIdentity; import com.demcha.compose.document.templates.cv.data.EntriesSection; import com.demcha.compose.document.templates.cv.data.ParagraphSection; @@ -60,10 +61,16 @@ public static CvDocument sample() { + " whether it worked. Happiest with a small team and a" + " short feedback loop.")) .section(Slot.SIDEBAR, EntriesSection.builder("Education") - .entry("MSc Marketing Analytics", "University of Bristol", - "2015 - 2016", "Bristol, UK") - .entry("BA Business Management", "University of Leeds", - "2012 - 2015", "Leeds, UK") + .entry(CvEntry.builder("MSc Marketing Analytics") + .subtitle("University of Bristol") + .date("2015 - 2016") + .place("Bristol, UK") + .build()) + .entry(CvEntry.builder("BA Business Management") + .subtitle("University of Leeds") + .date("2012 - 2015") + .place("Leeds, UK") + .build()) .build()) .section(Slot.SIDEBAR, SkillsSection.of("Skills", SkillGroup.of("Core", "Positioning", diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/data/CvEntryPlaceAndIconTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/data/CvEntryPlaceAndIconTest.java new file mode 100644 index 000000000..8b3b933bc --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/data/CvEntryPlaceAndIconTest.java @@ -0,0 +1,93 @@ +package com.demcha.compose.document.templates.cv.data; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Pins the compatibility promise made when {@link CvEntry} grew a location + * and a mark: the four-argument constructor that predates them is still + * there and still means what it meant, so a caller written against it keeps + * compiling and linking. + */ +class CvEntryPlaceAndIconTest { + + @Test + void theConstructorThatPredatesThemLeavesBothBlank() { + CvEntry entry = new CvEntry("Engineer", "Acme", "2021", "Did the work."); + assertThat(entry.place()).isEmpty(); + assertThat(entry.icon()).isEmpty(); + assertThat(entry.subtitle()).isEqualTo("Acme"); + } + + @Test + void nullsNormalizeToBlank() { + CvEntry entry = new CvEntry("Engineer", "Acme", "2021", "Body", null, null); + assertThat(entry.place()).isEmpty(); + assertThat(entry.icon()).isEmpty(); + } + + @Test + void theOriginalFieldsStillRejectNull() { + assertThatThrownBy(() -> new CvEntry("Engineer", null, "2021", "Body", "Berlin", "cart")) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("subtitle"); + } + + @Test + void aBlankTitleIsStillRejected() { + assertThatThrownBy(() -> new CvEntry(" ", "Acme", "2021", "Body", "", "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("title"); + } + + @Test + void theBuilderCarriesEveryFieldThrough() { + CvEntry entry = CvEntry.builder("Ledgerkit") + .subtitle("Java, PostgreSQL") + .date("2024") + .body("An open-source ledger.") + .place("Remote") + .icon("cart") + .build(); + assertThat(entry).isEqualTo(new CvEntry("Ledgerkit", "Java, PostgreSQL", "2024", + "An open-source ledger.", "Remote", "cart")); + } + + @Test + void theBuilderLeavesWhatItIsNotToldBlank() { + CvEntry entry = CvEntry.builder("Ledgerkit").build(); + assertThat(entry.subtitle()).isEmpty(); + assertThat(entry.date()).isEmpty(); + assertThat(entry.body()).isEmpty(); + assertThat(entry.place()).isEmpty(); + assertThat(entry.icon()).isEmpty(); + } + + @Test + void theBuilderJoinsABodyGivenAsLines() { + // The presets that draw a bulleted entry read one bullet per line, so + // the list form saves every caller the same String.join. + CvEntry entry = CvEntry.builder("Engineer") + .body(List.of("Shipped a thing.", "Shipped another.")) + .build(); + assertThat(entry.body()).isEqualTo("Shipped a thing.\nShipped another."); + } + + @Test + void theBuilderTreatsNullsAsBlank() { + CvEntry entry = CvEntry.builder("Engineer") + .subtitle(null) + .date(null) + .body((String) null) + .place(null) + .icon(null) + .build(); + assertThat(entry.subtitle()).isEmpty(); + assertThat(entry.body()).isEmpty(); + assertThat(entry.place()).isEmpty(); + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarFixtures.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarFixtures.java index 923ebec22..c5464103a 100644 --- a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarFixtures.java +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarFixtures.java @@ -4,6 +4,7 @@ import com.demcha.compose.document.templates.core.identity.Contact; import com.demcha.compose.document.templates.core.identity.Link; import com.demcha.compose.document.templates.cv.data.CvDocument; +import com.demcha.compose.document.templates.cv.data.CvEntry; import com.demcha.compose.document.templates.cv.data.CvIdentity; import com.demcha.compose.document.templates.cv.data.EntriesSection; import com.demcha.compose.document.templates.cv.data.ParagraphSection; @@ -67,10 +68,16 @@ public static CvDocument canonicalCv() { + " measurable results. Skilled in digital marketing, market" + " research, and cross-functional collaboration.")) .section(Slot.SIDEBAR, EntriesSection.builder("Education") - .entry("Master of Science in Marketing", "New York University", - "2020 - 2022", "New York, NY") - .entry("Bachelor of Business Administration", "University of California", - "2016 - 2020", "Los Angeles, CA") + .entry(CvEntry.builder("Master of Science in Marketing") + .subtitle("New York University") + .date("2020 - 2022") + .place("New York, NY") + .build()) + .entry(CvEntry.builder("Bachelor of Business Administration") + .subtitle("University of California") + .date("2016 - 2020") + .place("Los Angeles, CA") + .build()) .build()) .section(Slot.SIDEBAR, SkillsSection.of("Skills", SkillGroup.of("Core", "Digital Marketing", diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarSmokeTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarSmokeTest.java index 96b6c30ce..d0d245e81 100644 --- a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarSmokeTest.java +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarSmokeTest.java @@ -7,6 +7,7 @@ import com.demcha.compose.document.templates.core.identity.Contact; import com.demcha.compose.document.templates.core.identity.Link; import com.demcha.compose.document.templates.cv.data.CvDocument; +import com.demcha.compose.document.templates.cv.data.CvEntry; import com.demcha.compose.document.templates.cv.data.CvIdentity; import com.demcha.compose.document.templates.cv.data.CvSkill; import com.demcha.compose.document.templates.cv.data.EntriesSection; @@ -141,7 +142,11 @@ void degreesAndTitlesAreDrawnInCapitals() throws Exception { String text = textOf(render(CvDocument.builder() .identity(identity()) .section(EntriesSection.builder("Education") - .entry("Master of Science", "Some University", "2019", "Berlin") + .entry(CvEntry.builder("Master of Science") + .subtitle("Some University") + .date("2019") + .place("Berlin") + .build()) .build()) .build())); assertThat(text).contains("MASTER OF SCIENCE").doesNotContain("Master of Science"); diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvEntry.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvEntry.java index 7e915cc72..025805f15 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvEntry.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvEntry.java @@ -1,15 +1,19 @@ package com.demcha.compose.document.templates.cv.data; +import java.util.List; import java.util.Objects; /** * Timeline-style entry used inside an {@link EntriesSection}. Covers - * both Education and Professional Experience — they share the same - * four fields so authors don't have to learn two record types. + * Education, Professional Experience, Projects and anything else a preset + * lays out as a dated block — they share the same fields so authors don't + * have to learn a record type per section. * *
Blank fields are honoured: a blank {@code date} omits the date * column, a blank {@code subtitle} drops the italic line, a blank - * {@code body} drops the description paragraph.
+ * {@code body} drops the description paragraph, a blank {@code place} + * drops the location, and a blank {@code icon} leaves the entry unmarked. + * A preset draws only what it has somewhere to put. * * @param title bold heading on the left (job title, degree) * @param subtitle italic subtitle on the line below (employer, @@ -19,19 +23,154 @@ * blank removes the date column * @param body full-width prose paragraph beneath the subtitle; * may contain inline markdown + * @param place where this happened — a city, a campus, "Remote". The + * designs that show it set it beside the employer or the + * years rather than inside them, which is why it is its own + * field; blank when absent + * @param icon token naming the mark a preset draws for this entry. The + * vocabulary is preset-scoped — each preset packages its own + * set — so a token means something only to the preset that + * declares it, and the presets that draw no marks ignore it; + * blank when absent */ -public record CvEntry(String title, String subtitle, String date, String body) { +public record CvEntry(String title, String subtitle, String date, String body, + String place, String icon) { /** - * Validates that every field is non-null and that {@code title} is non-blank. + * Validates that the four original fields are non-null and that + * {@code title} is non-blank, treating a null {@code place} or + * {@code icon} as absent. */ public CvEntry { Objects.requireNonNull(title, "title"); Objects.requireNonNull(subtitle, "subtitle"); Objects.requireNonNull(date, "date"); Objects.requireNonNull(body, "body"); + place = place == null ? "" : place; + icon = icon == null ? "" : icon; if (title.isBlank()) { throw new IllegalArgumentException("title must not be blank"); } } + + /** + * Backward-compatible constructor for callers that predate the location + * and the mark. The entry simply carries neither. + * + * @param title bold heading on the left + * @param subtitle subtitle on the line below; blank collapses it + * @param date date column next to the title; blank removes it + * @param body prose paragraph beneath the subtitle + */ + public CvEntry(String title, String subtitle, String date, String body) { + this(title, subtitle, date, body, "", ""); + } + + /** + * Creates a fluent builder, which is the readable way to reach the + * optional fields without counting positions. + * + * @param title the entry's heading (required, non-blank) + * @return new fluent builder + * @since 2.2.3 + */ + public static Builder builder(String title) { + return new Builder(title); + } + + /** + * Mutable builder for {@link CvEntry}. Every field but the title starts + * blank, so an entry names only what it has. + * + * @since 2.2.3 + */ + public static final class Builder { + private final String title; + private String subtitle = ""; + private String date = ""; + private String body = ""; + private String place = ""; + private String icon = ""; + + private Builder(String title) { + this.title = title; + } + + /** + * Sets the subtitle — the employer, the institution, the stack. + * + * @param value the subtitle; null becomes blank + * @return this builder for chaining + */ + public Builder subtitle(String value) { + this.subtitle = value == null ? "" : value; + return this; + } + + /** + * Sets the date column. + * + * @param value the date text; null becomes blank + * @return this builder for chaining + */ + public Builder date(String value) { + this.date = value == null ? "" : value; + return this; + } + + /** + * Sets the prose body. + * + * @param value the body; null becomes blank + * @return this builder for chaining + */ + public Builder body(String value) { + this.body = value == null ? "" : value; + return this; + } + + /** + * Sets the prose body from one line per element — the shape the + * presets that draw a bulleted entry read. + * + * @param lines the lines, in order; null becomes blank + * @return this builder for chaining + */ + public Builder body(List