From 9eeb6575d0f423bff050dc740bbc1c6a55de1300 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 31 Aug 2026 17:32:32 +0100 Subject: [PATCH] feat(templates): give CvSkill the level as the document words it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rated skill could say how much — a number in [0, 1] a preset draws as dots or a meter — or it could say it in words by not being a skill at all and living in a RowsSection instead. It could not say both, which is why CharcoalGold sets its languages as rows: that design writes "Native" and "B2 – Upper Intermediate" out, and a number could not carry them back. A design that shows a rating and names it in the same line had nowhere to go. CvSkill now carries `note`, a plain string blank when absent, reachable through CvSkill.of(name, level, note). The two channels stay separate because a number cannot carry a wording and a wording cannot be measured into a meter; a preset draws whichever it has room for, or both. The two-argument constructor and both existing factories are kept explicitly, so existing calls compile and link unchanged and every skill built through them still carries no note. --- CHANGELOG.md | 13 ++++ .../templates/cv/data/CvSkillNoteTest.java | 63 +++++++++++++++++++ .../document/templates/cv/data/CvSkill.java | 38 ++++++++++- 3 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 qa/src/test/java/com/demcha/compose/document/templates/cv/data/CvSkillNoteTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f2e77d060..1e16148fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **`CvSkill` carries the level as the document words it.** A rated skill could say how + much — a number in `[0, 1]` a preset draws as dots or a meter — or it could say it in + words by not being a skill at all and living in a `RowsSection` instead. It could not + say both, which is why `CharcoalGold` sets its languages as rows: that design writes + "Native" and "B2 – Upper Intermediate" out, and a number could not carry them back. A + design that shows a rating *and* names it had nowhere to go. `CvSkill` now carries + `note` — a plain string, blank when absent, like the fields beside it — reachable + through `CvSkill.of(name, level, note)`. The two channels stay separate because a + number cannot carry a wording and a wording cannot be measured into a meter; a preset + draws whichever it has room for, or both. The two-argument constructor and both + existing factories are kept explicitly, so existing calls compile and link unchanged + and every skill built through them still carries no note. + - **The structured invoice model carries what a second sheet needs.** It landed with one consumer, `ConsultingInvoice`, and a model shaped around one document is a model nobody has tested. Fitting a second published invoice to it found six things it could diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/data/CvSkillNoteTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/data/CvSkillNoteTest.java new file mode 100644 index 000000000..a76b79a1c --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/data/CvSkillNoteTest.java @@ -0,0 +1,63 @@ +package com.demcha.compose.document.templates.cv.data; + +import org.junit.jupiter.api.Test; + +import java.util.OptionalDouble; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Pins the written-out level on {@link CvSkill}: what it normalises, and that + * every way of building a skill that predates it still compiles and still + * produces a skill without one. + */ +class CvSkillNoteTest { + + @Test + void aSkillCarriesTheLevelAsTheDocumentWordsIt() { + CvSkill skill = new CvSkill("Spanish", OptionalDouble.of(0.6), + "Professional Working"); + assertThat(skill.note()).isEqualTo("Professional Working"); + assertThat(skill.level()).hasValue(0.6); + } + + @Test + void theFactoryTakesBothChannels() { + CvSkill skill = CvSkill.of("French", 0.4, "Basic"); + assertThat(skill.note()).isEqualTo("Basic"); + assertThat(skill.level()).hasValue(0.4); + } + + @Test + void anAbsentNoteIsBlankRatherThanNull() { + assertThat(new CvSkill("Java", OptionalDouble.empty(), null).note()).isEmpty(); + assertThat(new CvSkill("Java", OptionalDouble.empty(), " ").note()).isEmpty(); + } + + @Test + void aNoteIsTrimmed() { + assertThat(new CvSkill("Java", OptionalDouble.empty(), " Native ").note()) + .isEqualTo("Native"); + } + + @Test + void theTwoArgumentConstructorStillBuildsASkillWithoutANote() { + CvSkill skill = new CvSkill("Java", OptionalDouble.of(0.9)); + assertThat(skill.note()).isEmpty(); + assertThat(skill.level()).hasValue(0.9); + } + + @Test + void theNameOnlyAndLevelFactoriesStillBuildSkillsWithoutANote() { + assertThat(CvSkill.of("Java").note()).isEmpty(); + assertThat(CvSkill.of("Java").level()).isEmpty(); + assertThat(CvSkill.of("Java", 0.5).note()).isEmpty(); + assertThat(CvSkill.of("Java", 0.5).level()).hasValue(0.5); + } + + @Test + void aSkillGroupBuiltFromNamesCarriesNoNotes() { + SkillGroup group = SkillGroup.of("Languages", "Java", "Kotlin"); + assertThat(group.entries()).allSatisfy(skill -> assertThat(skill.note()).isEmpty()); + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvSkill.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvSkill.java index 6ab5e3b28..1f1590061 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvSkill.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvSkill.java @@ -15,11 +15,18 @@ * empty level, so name-only renderers are completely unaffected by the * level channel.

* + *

The note is the level written out — "Native", "B2 – Upper + * Intermediate", "3 years" — for the designs that show a rating and name it + * in the same line. It is a separate channel from {@link #level} because a + * number cannot carry a wording and a wording cannot be measured into a + * meter; a preset draws whichever of the two it has room for, or both.

+ * * @param name non-blank skill label * @param level optional proficiency in {@code [0, 1]}; empty when * unspecified + * @param note the level as the document words it; blank when unspecified */ -public record CvSkill(String name, OptionalDouble level) { +public record CvSkill(String name, OptionalDouble level, String note) { /** * Validates that both fields are non-null, trims {@code name} and @@ -29,6 +36,7 @@ public record CvSkill(String name, OptionalDouble level) { public CvSkill { Objects.requireNonNull(name, "name"); Objects.requireNonNull(level, "level"); + note = note == null ? "" : note.trim(); name = name.trim(); if (name.isBlank()) { throw new IllegalArgumentException("name must not be blank"); @@ -42,6 +50,17 @@ public record CvSkill(String name, OptionalDouble level) { } } + /** + * Backward-compatible constructor for callers that predate the written-out + * level. + * + * @param name non-blank skill label + * @param level optional proficiency in {@code [0, 1]} + */ + public CvSkill(String name, OptionalDouble level) { + this(name, level, ""); + } + /** * Skill with no proficiency level. * @@ -49,7 +68,7 @@ public record CvSkill(String name, OptionalDouble level) { * @return a {@code CvSkill} carrying an empty level */ public static CvSkill of(String name) { - return new CvSkill(name, OptionalDouble.empty()); + return new CvSkill(name, OptionalDouble.empty(), ""); } /** @@ -61,6 +80,19 @@ public static CvSkill of(String name) { */ public static CvSkill of(String name, double level) { return new CvSkill(name, - OptionalDouble.of(Math.max(0.0, Math.min(1.0, level)))); + OptionalDouble.of(Math.max(0.0, Math.min(1.0, level))), ""); + } + + /** + * Skill with a proficiency level and the wording that names it. + * + * @param name non-blank skill label + * @param level proficiency, clamped into {@code [0, 1]} + * @param note the level as the document words it + * @return a {@code CvSkill} carrying both channels + */ + public static CvSkill of(String name, double level, String note) { + return new CvSkill(name, + OptionalDouble.of(Math.max(0.0, Math.min(1.0, level))), note); } }