From 1ab2a6fdf5d680ff70979675ee680714b1dee8ce Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 22 Aug 2026 21:43:27 +0100 Subject: [PATCH 1/2] feat(templates): stop claiming runtime modules as CV meanings A ModuleSection is a shape. SectionRouter.find and SectionAllocation.claim no longer take one: slots still match typed sections by heading, and modules stay in document order or the leftover tail and draw through CvConstructor. --- CHANGELOG.md | 36 +++---- .../cv/components/RoleRoutingTest.java | 67 ++++++------- .../cv/components/SectionAllocationTest.java | 31 +++---- .../cv/presets/SlotHeadingFidelityTest.java | 25 ++--- .../cv/components/SectionAllocation.java | 48 ++-------- .../cv/components/SectionRouter.java | 93 +++++++------------ .../cv/components/SectionRouterTest.java | 36 +++---- 7 files changed, 126 insertions(+), 210 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 435a40a2e..121319a62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -270,27 +270,21 @@ follow semantic versioning; release dates are ISO 8601. every continuation page of a dense CV, takes the rule back off to attribute the number to it, and pins page 1's own 78pt opening either way. -- **Presets route by what a section means, not by the language it is written in.** A - preset with a designed layout places sections into fixed slots, and it chose what went - where by matching the heading against a list of English words each preset kept - privately — then guarded the slot on the section's Java type as well. A CV headed - `Ausbildung`, `Опыт работы`, or anything else in the author's own language matched - nothing: the section was dropped and the slot that wanted it rendered empty. Nothing - failed; the CV came out looking finished, one job short. - - `SectionRouter` asks the module's `SectionRole` first and falls back to the headings - for the sections that carry no role — every hand-written one, and any module left as - `OTHER` — so a document of hand-written sections routes exactly as it did. A heading - may not overrule a role: a module declared `EXPERIENCE` and headed "Projects" goes where its - author put it, and the projects slot does not also claim it, which would have rendered - it twice. The router also hands each slot the section in the shape that slot draws, so - a module reaching a slot written against `EntriesSection` is no longer discarded by - the guard — the preset draws it exactly as it draws everything else, with the entry - style, rules and spacing that make it that preset. `SectionAllocation.claim` gained - the same role-first overload for the preset that allocates rather than looks up. - - Nine presets and every slot they compose changed; a CV written in Russian and German - now renders on all sixteen, which `RoleRoutingTest` holds by rendering one. +- **Runtime modules are not slotted by CV meaning.** `SectionRouter.find` and + `SectionAllocation.claim` never take a `ModuleSection`. A template does not + know Experience from Projects; a module is a shape, and it stays in document + order (or the leftover tail) and draws through `CvConstructor`. Typed + sections still match by heading, so a hand-written `EntriesSection` titled + "Experience" lands in the same slot as before. `RoleRoutingTest` holds the + modular templates: a CV headed `Опыт работы` still reaches the page, because + it is a module, not because a role named the slot. + +- **Typed sections still match slots by heading.** A hand-written + `EntriesSection` titled "Experience" lands where it always did. Runtime + modules do not: see the constructor-contract entry above. `SectionRouter` + still lowers a leftover module to the record a slot's renderer already + takes (`asEntries` / `asRows` / `asParagraph` / `asSkills` / + `naturalShape`), so a two-column leftover draws in the preset's own style. - **The three column-flow presets keep the modular promise too.** `SidebarPortrait`, `MonogramSidebar`, and `MintEditorial` declare `ModularCvTemplate`, so diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/RoleRoutingTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/RoleRoutingTest.java index 4b47faca6..f944e6251 100644 --- a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/RoleRoutingTest.java +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/RoleRoutingTest.java @@ -20,51 +20,42 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * A CV whose headings are in the author's own language reaches the page on - * every preset. + * A runtime module reaches the page on every modular template, whatever + * language its heading is written in — because it is a shape, not a CV + * meaning. * - *

Presets with a designed layout place sections into fixed slots, and they - * chose what goes where by matching the heading against a list of English - * words each kept privately. A CV headed {@code Berufserfahrung} or - * {@code Опыт работы} matched nothing: the section was dropped and the slot - * that wanted it rendered empty. Nothing failed — the CV came out looking - * finished, one job short.

- * - *

A module states its {@link SectionRole}, so the routing has an answer - * that does not depend on the language the CV is written in. Every heading - * here is deliberately in Russian and German: if any preset still routes by - * keyword, its slot stays empty and this goes red.

+ *

Slots that still ask for Experience or Skills only see the four + * hand-written section types. A {@link ModuleSection} is not a slot claim: + * it stays in document order (or the leftover tail) and draws through + * {@link com.demcha.compose.document.templates.cv.api.CvConstructor}. This + * suite holds that promise for headings no English keyword list contains.

*/ class RoleRoutingTest { - private static Stream>> everyPreset() { - return CvTemplates.all().stream().map(t -> Named.of(t.id(), t)); + private static Stream>> modularPresets() { + return CvTemplates.modular().stream().map(t -> Named.of(t.id(), t)); } @ParameterizedTest(name = "{0}") - @MethodSource("everyPreset") - void aCvWrittenInAnotherLanguageRendersOnEveryPreset(DocumentTemplate preset) { + @MethodSource("modularPresets") + void aCvWrittenInAnotherLanguageRendersOnEveryModularPreset( + DocumentTemplate preset) { String text = CvComposedText.squashedNodes(preset, foreignLanguageCv()); - assertRendered(text, "Ведущий инженер", preset, "experience"); - assertRendered(text, "Информатика", preset, "education"); + assertRendered(text, "Ведущий инженер", preset, "entries-dated"); + assertRendered(text, "Информатика", preset, "entries-dated"); } @ParameterizedTest(name = "{0}") - @MethodSource("everyPreset") - void aRoleRoutedModuleRendersWhateverItsKind(DocumentTemplate preset) { - // The slots were guarded on the section's Java type as well as its - // heading, so a module routed correctly was dropped anyway. Kinds here - // are deliberately the "wrong" shape for the slot each role names — - // experience as bullets, education as an inline list — because the - // author picks the kind and the preset does not get a veto. + @MethodSource("modularPresets") + void aModuleRendersWhateverItsKind(DocumentTemplate preset) { CvDocument doc = CvDocument.builder() .identity(identity()) - .section(ModuleSection.builder("Berufserfahrung", SectionRole.EXPERIENCE, + .section(ModuleSection.builder("Berufserfahrung", SectionRole.OTHER, CvKind.BULLETS) .item(CvItem.of("Senior Engineer").paragraphs("Acme GmbH, 2021-2025")) .build()) - .section(ModuleSection.builder("Kenntnisse", SectionRole.SKILLS, + .section(ModuleSection.builder("Kenntnisse", SectionRole.OTHER, CvKind.INLINE_LIST) .item(CvItem.of("Sprachen").paragraphs("Java 21", "Kotlin")) .build()) @@ -72,23 +63,21 @@ void aRoleRoutedModuleRendersWhateverItsKind(DocumentTemplate preset String text = CvComposedText.squashedNodes(preset, doc); - assertRendered(text, "Senior Engineer", preset, "EXPERIENCE"); - assertRendered(text, "Java 21", preset, "SKILLS"); + assertRendered(text, "Senior Engineer", preset, "bullets"); + assertRendered(text, "Java 21", preset, "inline-list"); } @Test - void theRoleWinsOverAHeadingThatMatchesADifferentSlot() { - // A module titled "Projects" but declared EXPERIENCE belongs where its - // author said, not where its heading reads. + void aModuleIsNotClaimedByRoleOrHeading() { List sections = List.of( ModuleSection.builder("Projects", SectionRole.EXPERIENCE, CvKind.ENTRIES_DATED) .item(CvItem.of("Senior Engineer").period("2021")).build()); assertThat(SectionRouter.find(sections, SectionRole.EXPERIENCE, List.of("experience"))) - .as("the role names the slot") - .isNotNull(); + .as("a module is not a CV meaning") + .isNull(); assertThat(SectionRouter.find(sections, SectionRole.PROJECTS, List.of("projects"))) - .as("...and the heading no longer claims a slot the role did not name") + .as("nor a heading the slot happens to recognise") .isNull(); } @@ -107,8 +96,8 @@ void aSectionWithoutARoleStillRoutesByItsHeading() { .as("a hand-written section still matches by heading") .isNotNull(); assertThat(SectionRouter.find(sections, SectionRole.OTHER, List.of("awards"))) - .as("SectionRole.OTHER claims no slot and falls through to the heading") - .isNotNull(); + .as("a runtime module is not claimed by heading either") + .isNull(); } /** @@ -120,7 +109,7 @@ void aSectionWithoutARoleStillRoutesByItsHeading() { private static void assertRendered(String text, String words, DocumentTemplate preset, String slot) { assertThat(text) - .as("%s must render the %s module routed by role", preset.id(), slot) + .as("%s must render the %s module", preset.id(), slot) .contains(CvComposedText.squash(words)); } diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/SectionAllocationTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/SectionAllocationTest.java index 516521788..8921bce65 100644 --- a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/SectionAllocationTest.java +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/SectionAllocationTest.java @@ -136,7 +136,7 @@ void aMissingFallbackLabelIsARejectedArgument() { } @Test - void aRoleClaimTakesTheModuleThatNamedTheRole() { + void aModuleIsNeverClaimedAndStaysInTheLeftovers() { ModuleSection experience = ModuleSection.builder("Опыт работы", SectionRole.EXPERIENCE, CvKind.ENTRIES_DATED) .item(CvItem.of("Ведущий инженер").period("2021")) @@ -144,11 +144,11 @@ void aRoleClaimTakesTheModuleThatNamedTheRole() { SectionAllocation allocation = SectionAllocation.of(List.of(SUMMARY, experience)); assertThat(allocation.claim(SectionRole.EXPERIENCE, List.of("experience"))) - .as("the heading matches no English keyword; the role is the answer") - .isSameAs(experience); + .as("a runtime module is a shape, not a slot") + .isNull(); assertThat(allocation.remaining()) - .as("a role-claimed section is claimed, so it is not also a leftover") - .doesNotContain(experience); + .as("so it is drawn with the leftovers, under the author's heading") + .contains(experience); } @Test @@ -161,33 +161,28 @@ void aRoleClaimFallsBackToTheHeadingForSectionsWithoutARole() { } @Test - void aDeclaredRoleIsNotClaimableByAnotherSlotsKeywords() { - // Otherwise the experience slot takes it by role and the projects slot - // takes it by heading, and the same module renders twice. - ModuleSection module = ModuleSection.builder("Projects", SectionRole.EXPERIENCE, + void aModuleHeadingDoesNotClaimATypedSlot() { + ModuleSection module = ModuleSection.builder("Projects", SectionRole.OTHER, CvKind.ENTRIES_DATED) .item(CvItem.of("Senior Engineer").period("2021")) .build(); SectionAllocation allocation = SectionAllocation.of(List.of(module)); assertThat(allocation.claim(SectionRole.PROJECTS, List.of("projects"))).isNull(); - assertThat(allocation.claim(SectionRole.EXPERIENCE, List.of("experience"))) - .isSameAs(module); + assertThat(allocation.remaining()).containsExactly(module); } @Test - void aRoleClaimsAtMostOneSectionSoASecondSlotSeesTheNextOne() { - ModuleSection first = ModuleSection.builder("Erfahrung", SectionRole.EXPERIENCE, + void twoModulesStayInTheLeftoversInDocumentOrder() { + ModuleSection first = ModuleSection.builder("Erfahrung", SectionRole.OTHER, CvKind.ENTRIES_DATED).item(CvItem.of("First").period("2021")).build(); ModuleSection second = ModuleSection.builder("Weitere Erfahrung", - SectionRole.EXPERIENCE, CvKind.ENTRIES_DATED) + SectionRole.OTHER, CvKind.ENTRIES_DATED) .item(CvItem.of("Second").period("2019")).build(); SectionAllocation allocation = SectionAllocation.of(List.of(first, second)); - assertThat(allocation.claim(SectionRole.EXPERIENCE, List.of("experience"))).isSameAs(first); - assertThat(allocation.claim(SectionRole.EXPERIENCE, List.of("experience"))) - .as("claiming hands each section out once") - .isSameAs(second); + assertThat(allocation.claim(SectionRole.EXPERIENCE, List.of("experience"))).isNull(); + assertThat(allocation.remaining()).containsExactly(first, second); } @Test diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/SlotHeadingFidelityTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/SlotHeadingFidelityTest.java index dbe72b0a7..bb57c7d53 100644 --- a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/SlotHeadingFidelityTest.java +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/SlotHeadingFidelityTest.java @@ -165,18 +165,12 @@ void theLanguageBlockKeepsItsLabelWhenItShowsPartOfASection() { @Test void aLanguagesModuleKeepsEveryRowEvenWhenNoneLooksLikeALanguage() { - // The block sniffs for language-shaped rows because it also accepts a - // wider section. A section routed here by its role is entirely - // languages whatever its rows look like, so nothing in it may be - // picked over. - // - // The first item carries a bracket and the other two do not, which is - // the shape that used to lose them: one sniff hit was enough to - // suppress the whole-section fallback, so English was drawn while - // Deutsch and Українська reached no page at all — and the section - // counted as claimed, so the leftover tail never saw them either. - // Written without that bracket, this fixture would pass against the - // old code too and guard nothing. + // A runtime module is not the language slot: it is leftover in the + // main column, under the heading the author wrote. The rows still + // have to all reach the page — the first item carries a bracket and + // the others do not, which is the shape that used to lose Deutsch + // and Українська when a claimed section was sniffed for + // language-shaped rows. CvDocument doc = cv(ModuleSection.builder("Языки", SectionRole.LANGUAGES, CvKind.INLINE_LIST) .item(CvItem.of("English").paragraphs("(C1 advanced)")) @@ -188,13 +182,10 @@ void aLanguagesModuleKeepsEveryRowEvenWhenNoneLooksLikeALanguage() { assertBoundaryIsOne(text); assertThat(text) - .as("every row is drawn in the language block itself — before the " - + "main column starts — under the author's own heading, each " - + "keeping the level written next to it") + .as("every row reaches the page under the author's heading") .containsSubsequence(squash("Языки"), squash("English"), squash("Deutsch"), squash("B2"), - squash("Українська"), squash("рідна"), - squash(COLUMN_BOUNDARY)); + squash("Українська"), squash("рідна")); } @Test diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java index a0a1a9d23..b2f802290 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java @@ -67,51 +67,20 @@ public static SectionAllocation of(List sections) { } /** - * Claims the section this slot means, preferring a module that named the - * role over one whose heading happens to match. + * Claims the typed section this slot's keywords match. * - *

Headings are the fallback because a section that carries no role — - * every hand-written one — has nothing else to be found by. A module that - * did name a role is never claimed by a different slot's - * keywords: it would then render in two places, which is a worse failure - * than the one role routing exists to fix.

+ *

A {@link ModuleSection} is never claimed. Runtime modules are + * shapes, not CV meanings, so they stay in {@link #remaining()} and + * draw through the constructor. The {@code role} argument is the + * slot's label for typed sections and does not inspect a module.

* - * @param role the role this slot holds; {@code null} or - * {@link SectionRole#OTHER} means "keywords only" + * @param role the role this slot holds; unused for modules * @param keys candidate heading fragments * @return the claimed section, or {@code null} when nothing matches * @since 2.3.0 */ public CvSection claim(SectionRole role, List keys) { - if (role != null && role != SectionRole.OTHER) { - for (CvSection section : sections) { - if (claimedSections.containsKey(section)) { - continue; - } - if (section instanceof ModuleSection module && module.role() == role - && SectionLookup.hasContent(section)) { - claimedSections.put(section, Boolean.TRUE); - return section; - } - } - } - for (CvSection section : sections) { - if (claimedSections.containsKey(section)) { - continue; - } - if (section instanceof ModuleSection module - && module.role() != SectionRole.OTHER) { - continue; - } - String title = SectionLookup.normalize(section.title()); - for (String key : keys == null ? List.of() : keys) { - if (title.contains(SectionLookup.normalize(key))) { - claimedSections.put(section, Boolean.TRUE); - return section; - } - } - } - return null; + return claim(keys); } /** @@ -137,6 +106,9 @@ public CvSection claim(List keys) { if (claimedSections.containsKey(section)) { continue; } + if (section instanceof ModuleSection) { + continue; + } String title = SectionLookup.normalize(section.title()); for (String key : keys) { if (title.contains(SectionLookup.normalize(key))) { diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java index 7ef0048da..1542f8ce6 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java @@ -20,38 +20,23 @@ import java.util.List; /** - * Finds the section a preset's slot should hold, by what it means rather - * than by what it is called — and hands it back in the shape that slot - * knows how to draw. + * Finds the typed section a preset's slot should hold, and lowers a + * leftover {@link ModuleSection} to the shape that slot (or the module's + * own kind) knows how to draw. * - *

Presets with a designed layout place their sections into fixed slots, - * and they have been choosing what goes where by matching the section's - * heading against a list of English words each preset keeps privately. That - * works for a CV written in English by someone who used the expected - * headings. A CV headed {@code Ausbildung} or {@code Навыки} matches - * nothing and loses the section; so does {@code "Volunteering"}, and so does - * a second section whose heading matched a word the first one took.

+ *

Slots that still ask for Experience or Skills only see the four + * compile-time records, matched by heading. A runtime module is a shape, + * not a CV meaning — the template does not know Experience from Projects + * — so {@link #find} never claims one. Modules stay in document order, or + * in {@link SectionAllocation#remaining()}, and draw through + * {@link com.demcha.compose.document.templates.cv.api.CvConstructor}.

* - *

A {@link ModuleSection} carries a {@link SectionRole} because the - * author already knew the answer, so the role is asked first and the - * keywords are the fallback for the sections that have no role to give.

- * - *

The second half is the shape. These slots are written against a - * particular section type — {@code if (!(section instanceof EntriesSection - * entries)) return;} — because each draws its content its own way, and a - * module reaching one would be dropped by that guard however well it was - * routed. Each finder below therefore lowers a matched module to the type - * its slot expects, so the preset draws it exactly as it draws everything - * else. What that costs is stated per method: a module's description lines - * are joined where the target type holds one string, and a bulleted - * description reads as prose.

- * - *

A section that matches no slot at all is a different question, and one - * this class cannot answer: it keeps no memory of what a previous slot took. - * {@link SectionAllocation} does, so a preset that wants to draw the leftovers - * claims through it and reads {@link SectionAllocation#remaining()} — with - * {@link #naturalShape(CvSection)} for the shape those sections have no slot - * to give them.

+ *

The lowering half is for a leftover that has no slot of its own: + * {@link #naturalShape(CvSection)} and {@link #asEntries}, {@link #asRows}, + * {@link #asParagraph}, {@link #asSkills} turn a module into the record + * a preset's existing renderer already takes. What that costs is stated + * per method: a module's description lines are joined where the target + * type holds one string, and a bulleted description reads as prose.

* * @since 2.3.0 */ @@ -265,18 +250,21 @@ public static CvSection naturalShape(CvSection section) { } /** - * The section this slot should hold, or {@code null} when the document - * has none: the first module whose role is the slot's, else the first - * section whose heading matches one of the keys. + * The typed section this slot should hold, or {@code null} when the + * document has none: the first hand-written section whose heading + * matches one of the keys. * - *

Role first, and only a role the author actually chose — - * {@link SectionRole#OTHER} is what a module carries when the catalogue - * has no name for it, so it never claims a slot and falls through to the - * headings like any other section.

+ *

A {@link ModuleSection} is never claimed here. A runtime module is + * a shape, not a CV meaning — the template does not know Experience + * from Projects — so slots that still ask for a {@link SectionRole} + * only see the four compile-time records. Modules stay in document + * order (or in {@link SectionAllocation#remaining()}) and draw through + * {@link com.demcha.compose.document.templates.cv.api.CvConstructor}.

* * @param sections the document's sections for this slot's column - * @param role the role this slot holds - * @param keys heading fragments to fall back on + * @param role unused for modules; kept so existing slot call sites + * compile while they still name a role for typed sections + * @param keys heading fragments to match against typed sections * @return the section, or {@code null} when nothing matches */ public static CvSection find(List sections, SectionRole role, @@ -284,31 +272,18 @@ public static CvSection find(List sections, SectionRole role, if (sections == null) { return null; } - if (role != null && role != SectionRole.OTHER) { - for (CvSection section : sections) { - if (section instanceof ModuleSection module && module.role() == role - && SectionLookup.hasContent(section)) { - return section; - } - } - } - // The heading is the fallback, and it may not overrule a role. A module - // declared EXPERIENCE and headed "Projects" belongs where its author put - // it; letting the projects slot claim it by heading would render it in - // both places, which is worse than the drop this routing exists to fix. - return SectionLookup.firstMatching(spokenFor(sections), keys); + return SectionLookup.firstMatching(typedOnly(sections), keys); } - /** The sections a keyword slot may still claim: everything but a module that named its own role. */ - private static List spokenFor(List sections) { - List open = new ArrayList<>(sections.size()); + /** Hand-written sections only — a runtime module is not a slot claim. */ + private static List typedOnly(List sections) { + List typed = new ArrayList<>(sections.size()); for (CvSection section : sections) { - if (section instanceof ModuleSection module && module.role() != SectionRole.OTHER) { - continue; + if (!(section instanceof ModuleSection)) { + typed.add(section); } - open.add(section); } - return open; + return typed; } /** The title, as markdown link syntax when the item carries a link. */ diff --git a/templates/src/test/java/com/demcha/compose/document/templates/cv/components/SectionRouterTest.java b/templates/src/test/java/com/demcha/compose/document/templates/cv/components/SectionRouterTest.java index 8ee264a57..1fe2be6e8 100644 --- a/templates/src/test/java/com/demcha/compose/document/templates/cv/components/SectionRouterTest.java +++ b/templates/src/test/java/com/demcha/compose/document/templates/cv/components/SectionRouterTest.java @@ -37,21 +37,23 @@ private static List only(CvSection section) { // -- role beats heading, heading still works ------------------------ @Test - void aModuleIsFoundByItsRoleWhateverItsHeadingSays() { + void aModuleIsNotASlotClaim() { CvSection module = ModuleSection.builder("Berufserfahrung", SectionRole.EXPERIENCE, CvKind.ENTRIES_DATED) .item(CvItem.of("Senior Engineer").period("2021")) .build(); assertThat(SectionRouter.find(only(module), SectionRole.EXPERIENCE, List.of("experience"))) - .isSameAs(module); + .as("a runtime module is a shape, not a CV meaning") + .isNull(); + assertThat(SectionRouter.find(only(module), SectionRole.PROJECTS, List.of("beruf"))) + .as("nor is its heading a slot claim") + .isNull(); } @Test - void aHeadingNeverOverrulesADeclaredRole() { - // Both slots would otherwise claim it — the experience slot by role and - // the projects slot by heading — and the module would render twice. - CvSection module = ModuleSection.builder("Projects", SectionRole.EXPERIENCE, + void aModuleHeadingDoesNotStealATypedSlot() { + CvSection module = ModuleSection.builder("Projects", SectionRole.OTHER, CvKind.ENTRIES_DATED) .item(CvItem.of("Senior Engineer").period("2021")) .build(); @@ -131,11 +133,11 @@ void onlyTheStackedRowStyleCarriesALinkedTitle() { @Test void aPlainListOfSkillsArrivesAsSkillsNotAsCategoriesHoldingThemselves() { - CvSection lowered = SectionRouter.skills(only(ModuleSection + CvSection lowered = SectionRouter.asSkills(ModuleSection .builder("Kenntnisse", SectionRole.SKILLS, CvKind.BULLETS) .item("Java 21") .item("Kotlin") - .build()), SectionRole.SKILLS, List.of("skills")); + .build()); assertThat(lowered).asInstanceOf(type(SkillsSection.class)) .extracting(SkillsSection::groups, org.assertj.core.api.InstanceOfAssertFactories.LIST) @@ -149,11 +151,11 @@ void aPlainListOfSkillsArrivesAsSkillsNotAsCategoriesHoldingThemselves() { @Test void anItemWithADescriptionBecomesItsOwnSkillCategory() { - CvSection lowered = SectionRouter.skills(only(ModuleSection + CvSection lowered = SectionRouter.asSkills(ModuleSection .builder("Technical Skills", SectionRole.SKILLS, CvKind.INLINE_LIST) .item(CvItem.of("Languages").paragraphs("Java 21", "Kotlin")) .item("Docker") - .build()), SectionRole.SKILLS, List.of("skills")); + .build()); SkillsSection skills = (SkillsSection) lowered; assertThat(skills.groups()).extracting(SkillGroup::category) @@ -164,10 +166,10 @@ void anItemWithADescriptionBecomesItsOwnSkillCategory() { @Test void proseJoinsEveryItemsDescriptionIntoOneBlock() { - CvSection lowered = SectionRouter.paragraph(only(ModuleSection + CvSection lowered = SectionRouter.asParagraph(ModuleSection .builder("Profile", SectionRole.SUMMARY, CvKind.PARAGRAPH) .item(CvItem.of("first").paragraphs("Backend engineer.", "Ten years of it.")) - .build()), SectionRole.SUMMARY, List.of("summary")); + .build()); assertThat(lowered).asInstanceOf(type(ParagraphSection.class)) .extracting(ParagraphSection::body) @@ -200,16 +202,14 @@ void nothingMatchingYieldsNullFromEveryFinder() { // -- helpers --------------------------------------------------------- private static List entriesOf(CvKind kind, CvItem item) { - CvSection lowered = SectionRouter.entries(only(ModuleSection - .of("Experience", SectionRole.EXPERIENCE, kind, item)), - SectionRole.EXPERIENCE, List.of("experience")); + CvSection lowered = SectionRouter.asEntries(ModuleSection + .of("Experience", SectionRole.EXPERIENCE, kind, item)); return ((EntriesSection) lowered).entries(); } private static CvSection rowsOf(RowStyle style, CvItem item) { - return SectionRouter.rows(only(ModuleSection - .of("Section", SectionRole.OTHER, CvKind.BULLETS, item)), - SectionRole.OTHER, List.of("section"), style); + return SectionRouter.asRows(ModuleSection + .of("Section", SectionRole.OTHER, CvKind.BULLETS, item), style); } private static String rowBody(CvSection section) { From 453c663b1fedc6d5952f18cb7bf45694ceda26d1 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 22 Aug 2026 21:44:18 +0100 Subject: [PATCH 2/2] docs(templates): runtime modules are shapes, not CV meanings --- docs/templates/v2-layered/using-templates.md | 23 ++++++-------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/docs/templates/v2-layered/using-templates.md b/docs/templates/v2-layered/using-templates.md index 412edc6ca..f7e9d0547 100644 --- a/docs/templates/v2-layered/using-templates.md +++ b/docs/templates/v2-layered/using-templates.md @@ -203,22 +203,13 @@ shaped exactly like Education without a new type. a payload standing in for parsed JSON, and its PDF is the shortest way to see what each kind does to the same item. -`SectionRole` says what a section *means*, separately from how it -draws — and it is the first thing a preset routes on. A preset with a designed -layout places sections into fixed slots, and it used to choose what -went where by matching the heading against a list of English words: -a CV headed `Ausbildung` or `Навыки` matched nothing, so the section -was dropped and the slot that wanted it rendered empty. Give the module -a role and it lands in the right slot whatever language the CV is -written in, and whatever kind you chose to draw it with — for the roles -that preset has a slot for. `SectionRole.OTHER` names no slot, so a -module carrying it routes by heading like any other section. - -A heading that matches a keyword still routes a section that has no -role — every hand-written section, and any module you left as -`SectionRole.OTHER`. What a heading may not do is overrule a role: a -module declared `EXPERIENCE` and headed "Projects" goes where you put -it, and the projects slot does not also claim it. +A runtime module is a shape, not a CV meaning. The template does not +know Experience from Projects. `SectionRole` is still on the builder +so existing call sites compile; slots do not read it. A module stays +in document order (or the leftover tail of a two-column preset) and +draws through `CvConstructor`. Hand-written sections still match slots +by heading, so an `EntriesSection` titled "Experience" lands where it +always did. Modules and the four fixed types mix freely in one document, and both render through the same components — a module drawn as `ENTRIES_DATED`