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
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
* {@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.</p>
*
* @param title bold heading on the left (job title, degree)
* @param subtitle italic subtitle on the line below (employer,
Expand All @@ -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<String> lines) {
this.body = lines == null ? "" : String.join("\n", lines);
return this;
}

/**
* Sets where this happened.
*
* @param value the location; null becomes blank
* @return this builder for chaining
*/
public Builder place(String value) {
this.place = value == null ? "" : value;
return this;
}

/**
* Sets the mark this entry asks for, in the vocabulary of the preset
* that will draw it.
*
* @param value the icon token; null becomes blank
* @return this builder for chaining
*/
public Builder icon(String value) {
this.icon = value == null ? "" : value;
return this;
}

/**
* Builds the immutable {@link CvEntry}.
*
* @return the assembled entry
*/
public CvEntry build() {
return new CvEntry(title, subtitle, date, body, place, icon);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,9 @@
* own title, since it is the one block with no heading; a skill's level,
* since there are no meters here; and a {@code SkillGroup}'s category. A
* berth is filled by the first section that names it, so a second section
* naming the same berth is not drawn either. An education entry uses all
* four of its fields — the degree, the institution, the body as the campus
* line, and the years.</p>
* naming the same berth is not drawn either. An education entry is drawn as
* four lines — the degree, the institution, the {@code place}, and the years
* and its body has nowhere to go on this sheet.</p>
*
* <h2>The portrait</h2>
*
Expand Down
Loading
Loading