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);
}
}