Skip to content
Open
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
36 changes: 15 additions & 21 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 7 additions & 16 deletions docs/templates/v2-layered/using-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,75 +20,64 @@
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.
*
* <p>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.</p>
*
* <p>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.</p>
* <p>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.</p>
*/
class RoleRoutingTest {

private static Stream<Named<DocumentTemplate<CvDocument>>> everyPreset() {
return CvTemplates.all().stream().map(t -> Named.of(t.id(), t));
private static Stream<Named<DocumentTemplate<CvDocument>>> modularPresets() {
return CvTemplates.modular().stream().map(t -> Named.of(t.id(), t));
}

@ParameterizedTest(name = "{0}")
@MethodSource("everyPreset")
void aCvWrittenInAnotherLanguageRendersOnEveryPreset(DocumentTemplate<CvDocument> preset) {
@MethodSource("modularPresets")
void aCvWrittenInAnotherLanguageRendersOnEveryModularPreset(
DocumentTemplate<CvDocument> 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<CvDocument> 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<CvDocument> 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())
.build();

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<com.demcha.compose.document.templates.cv.data.CvSection> 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();
}

Expand All @@ -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();
}

/**
Expand All @@ -120,7 +109,7 @@ void aSectionWithoutARoleStillRoutesByItsHeading() {
private static void assertRendered(String text, String words,
DocumentTemplate<CvDocument> 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));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,19 +136,19 @@ void aMissingFallbackLabelIsARejectedArgument() {
}

@Test
void aRoleClaimTakesTheModuleThatNamedTheRole() {
void aModuleIsNeverClaimedAndStaysInTheLeftovers() {
ModuleSection experience = ModuleSection.builder("Опыт работы",
SectionRole.EXPERIENCE, CvKind.ENTRIES_DATED)
.item(CvItem.of("Ведущий инженер").period("2021"))
.build();
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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)"))
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,51 +67,20 @@ public static SectionAllocation of(List<CvSection> 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.
*
* <p>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
* <em>did</em> 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.</p>
* <p>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.</p>
*
* @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<String> 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.<String>of() : keys) {
if (title.contains(SectionLookup.normalize(key))) {
claimedSections.put(section, Boolean.TRUE);
return section;
}
}
}
return null;
return claim(keys);
}

/**
Expand All @@ -137,6 +106,9 @@ public CvSection claim(List<String> 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))) {
Expand Down
Loading
Loading