diff --git a/src/components/features/ReconstructedResume.remove-parsed-entry.test.tsx b/src/components/features/ReconstructedResume.remove-parsed-entry.test.tsx index 1df82c59..c53d219a 100644 --- a/src/components/features/ReconstructedResume.remove-parsed-entry.test.tsx +++ b/src/components/features/ReconstructedResume.remove-parsed-entry.test.tsx @@ -309,3 +309,188 @@ describe("the same section rendered for certifications (#884)", () => { expect(container.textContent).toContain("Add certification"); }); }); + +/** + * #899 — the certification-only bugs on top of the shared `AchievementsSection` + * component #884 introduced: the achievements-only `AchievementTypePicker` + * leaking onto credential rows, a dangling `·` in front of an empty year, and + * multiple credentials each taking a full vertical row instead of compressing + * onto one wrapped, middot-separated line. + * + * The compact line is where a row loses its own vertical space, so what it may + * and may not take with it is the rest of this block: the year form has to be + * the one the PDF draws (`compactCredentialHeader`), the `·` may mean only one + * thing at a time, and no row may be routed onto the line if the affordance it + * owns lives off it — which is what stranded an added credential with no way to + * ever get a bullet. + */ +describe("compact certifications layout (#899)", () => { + function renderCertsWith( + certs: readonly HeuristicAchievement[], + added: readonly AddedEntry[] = [], + ): void { + const all: HeuristicAchievement[] = [ + ...certs, + ...added.map((a) => ({ title: a.title })), + ]; + act(() => + root.render( + createElement(AchievementsSection, { + section: "certifications", + fallbackHeading: "Certifications", + entryNoun: "certification", + achievements: all, + groups: all.map((c, i) => ({ + experienceIndex: i, + experience: { title: c.title }, + bullets: [], + })), + addedAchievements: [...added], + originalCount: certs.length, + parsedIndices: survivingParsedIndices( + "certifications", + new Set(), + certs.length, + ), + onAddEntry: () => {}, + onEntryField: () => {}, + onAddBullet: () => {}, + onPruneEmpty: () => {}, + onRemoveEntry: () => {}, + onRemoveBullet: vi.fn(() => true), + onAchievementField: vi.fn(), + }), + ), + ); + } + + /** Every rendered `·` glyph — the achievement type↔title separator, the + * title↔year separator, and the between-item compact-row separator all + * draw through the same `aria-hidden` span shape. */ + function middotSpans(): HTMLElement[] { + return [ + ...container.querySelectorAll('span[aria-hidden="true"]'), + ].filter((el) => el.textContent === "·"); + } + + it("never renders the achievement type picker for a certification row", () => { + renderCertsWith([ + { title: "AWS Certified Solutions Architect", year: "2022" }, + ]); + expect(container.querySelector('[aria-haspopup="menu"]')).toBeNull(); + }); + + it("omits the dangling separator when a certification has no year", () => { + renderCertsWith([{ title: "Patent Bar Registration" }]); + // A single, dateless certification: no type-picker separator (gated off), + // no title↔year separator (no year to set off), no between-item separator + // (only one item) — zero middots anywhere in the row. + expect(middotSpans()).toHaveLength(0); + }); + + it("joins two or more certifications with exactly one between-item middot", () => { + renderCertsWith([ + { title: "AWS Certified Solutions Architect" }, + { title: "CKA" }, + ]); + // Neither carries a year, so the only middot either could draw is the + // compact row's between-item separator — one, joining the pair, none + // trailing the last item. + expect(middotSpans()).toHaveLength(1); + }); + + it("parenthesises the year, so one glyph never means two things", () => { + renderCertsWith([ + { title: "AWS Certified Solutions Architect", year: "2022" }, + { title: "CKA" }, + ]); + // The compact line reuses `·` as its item boundary, so a year set off by + // the same glyph would be unreadable ("Solutions Architect·2022·CKA"). The + // exporter already parenthesises for exactly that reason + // (`compactCredentialHeader`); the view has to draw the same line the PDF + // will. One middot survives — the boundary between the two credentials. + expect(container.textContent).toContain("(2022)"); + expect(middotSpans()).toHaveLength(1); + }); + + it("leaves a LONE credential's year on its ordinary separator", () => { + // Nothing to be ambiguous with: a single credential draws no boundary + // middot, and the exporter does not compact one either (its guard starts at + // two), so parenthesising here would invent a divergence from the PDF + // rather than remove one. The "+ year" affordance survives for the same + // reason — the line it would clutter does not exist. + renderCertsWith([{ title: "CKA", year: "2021" }]); + expect(container.textContent).not.toContain("(2021)"); + expect(middotSpans()).toHaveLength(1); + + renderCertsWith([{ title: "CKA" }]); + expect(container.querySelector('[aria-label^="Add Year"]')).not.toBeNull(); + }); + + it("re-emits a NON-middot source separator verbatim, as the PDF does", () => { + // The parenthesised form replaces only the ambiguous glyph. A résumé that + // wrote its own comma keeps it, matching `compactCredentialHeader`'s same + // carve-out, so #380's punctuation fidelity is not traded away wholesale. + renderCertsWith([ + { title: "CKA", year: "2021", year_separator: "," }, + { title: "Terraform Associate" }, + ]); + expect(container.textContent).not.toContain("(2021)"); + expect(container.textContent).toContain(","); + }); + + it("hides the '+ year' affordance on a dateless compact credential AT REST, not from the DOM", () => { + // One "+ year" permanently on the shared line is the clutter the compact + // form exists to remove and it competes with the boundary glyph — so it's + // `opacity-0` until the row is hovered/focused, not omitted outright. A + // parsed dateless credential still stays dateable from this line (#899 + // AC 5) — see AchievementYearSlot's docblock. + renderCertsWith([{ title: "CKA" }, { title: "Terraform Associate" }]); + const addYear = container.querySelector('[aria-label^="Add Year"]'); + expect(addYear).not.toBeNull(); + // opacity-at-rest lives on the parenthesised wrapper span, not the field + // itself — the parens hide alongside the field. + const wrapper = addYear?.parentElement; + expect(wrapper?.className).toContain("opacity-0"); + expect(wrapper?.className).toContain("group-hover:opacity-100"); + expect(wrapper?.className).toContain("group-focus-within:opacity-100"); + }); + + it("keeps the add-bullet affordance on an ADDED certification", () => { + // The bug: an added credential starts with no bullets, so routing rows by + // bullets alone put it on the compact line — which carries no + // `InlineBulletAdd` — and it could never get one. It takes the full-width + // branch instead, where the affordance lives. + renderCertsWith([{ title: "CKA" }, { title: "Terraform Associate" }]); + expect(container.textContent).not.toContain("Add bullet"); + renderCertsWith([{ title: "CKA" }], [ + { id: "added-1", section: "certifications", title: "Terraform Associate" }, + ]); + expect(container.textContent).toContain("Add bullet"); + // …and the parsed credential before it draws no trailing separator into + // the line break that full-width row forces. + expect(middotSpans()).toHaveLength(0); + }); + + it("stays individually editable and removable inside the compact row", () => { + renderCertsWith([ + { title: "AWS Certified Solutions Architect", year: "2022" }, + { title: "CKA" }, + ]); + // Title + year fields for both entries, and one remove control each. The + // dateless CKA row still renders its year field (hidden at rest, revealed + // on hover/focus — see AchievementYearSlot) rather than omitting it, so a + // parsed-but-undated credential stays dateable from the compact line. + expect( + container.querySelectorAll('[aria-label^="Edit Certification title"], [aria-label^="Add Certification title"]') + .length, + ).toBe(2); + expect( + container.querySelectorAll('[aria-label^="Edit Year"], [aria-label^="Add Year"]') + .length, + ).toBe(2); + expect( + container.querySelectorAll('[aria-label="Remove certification"]').length, + ).toBe(2); + }); +}); diff --git a/src/components/features/ReconstructedResume.tsx b/src/components/features/ReconstructedResume.tsx index 5b4a5cdd..2a80ad00 100644 --- a/src/components/features/ReconstructedResume.tsx +++ b/src/components/features/ReconstructedResume.tsx @@ -905,12 +905,32 @@ function ProjectsSection({ * vocabulary ("Patent", "Talk", "Award"), so typing it out invites the typo the * exporter would then bold. The picker still commits free text for the labels a * real résumé used that no preset covers — see `AchievementTypePicker`. + * + * `showTypePicker` scopes the picker to ACHIEVEMENTS only (#899): a + * certification's title is never "Type · label" shaped — extraction never sets + * `type` for one (`splitType: false`, `extractAchievements`) — so the closed + * "Patent" / "Talk" / "Award" vocabulary makes no sense on a credential row. + * The same flag also drops the title↔year separator when there is no year: a + * certification the résumé never dated should not draw a dangling `·` in front + * of an empty field. Achievements always show the separator (byte-identical to + * before #899) because their year IS the field the picker's paired glyph + * anchors to. + * + * `compactYear` is the ROW-level half of that (#899), and separate from + * `showTypePicker` on purpose: it is set only for a credential that actually + * SHARES the compact line with another — never for one drawn on a full-width + * row of its own, and never for a lone credential. The exporter draws the same + * parenthesised form but decides WHO shares the line by a different predicate + * (see `compactCredentialHeader` in `ats-resume-model.ts` for both and where + * they disagree), so this is not a mirror of it. See {@link AchievementYearSlot}. */ function AchievementHeader({ type, title, year, yearSeparator, + showTypePicker = true, + compactYear = false, onFieldChange, }: { type?: string; @@ -918,51 +938,152 @@ function AchievementHeader({ year?: string; /** The source's own title↔year punctuation (#380); middot when it had none. */ yearSeparator?: string; + /** Achievements-only affordance (#899) — see the docblock above. */ + showTypePicker?: boolean; + /** This row joins the shared compact line (#899) — see the docblock above. */ + compactYear?: boolean; onFieldChange: (field: keyof AchievementFieldOverrides, value: string) => void; }) { return (
- onFieldChange("type", v)} - /> - + {showTypePicker && ( + <> + onFieldChange("type", v)} + /> + + + )} onFieldChange("title", v)} /> - {/* The source's own separator, not a hardcoded middot: a résumé that wrote - "Globex Engineering Excellence, 2021" keeps its comma (#380). Tight - punctuation cancels the flex row's gap so it hugs the title, matching - how the exported PDF spaces it (`achievementYearJoiner`). */} + onFieldChange("year", v)} + /> +
+ ); +} + +/** + * The trailing year of a credential / achievement header, with the punctuation + * that sets it off from the title. + * + * Normally that punctuation is the SOURCE's own, not a hardcoded middot: a + * résumé that wrote "Globex Engineering Excellence, 2021" keeps its comma + * (#380). Tight punctuation cancels the flex row's gap so it hugs the title, + * matching how the exported PDF spaces it (`achievementYearJoiner`). The + * separator is drawn even with no year for an ACHIEVEMENT, whose year field is + * always offered; for a dateless credential it is omitted, since a `·` in front + * of nothing is just a dangling glyph (#899). + * + * `compact` — the row joins the shared middot-joined credential line — changes + * both halves, and both changes exist because that line reuses ONE glyph for two + * jobs (#899): + * + * - the year is PARENTHESISED rather than middot-separated, exactly as + * `compactCredentialHeader` does it in `ats-resume-model.ts` and under the + * same condition (a source separator that is neither absent nor the default + * middot is still re-emitted verbatim). Without this the line reads + * "Solutions Architect·2022·CKA", where nothing distinguishes the middot + * that ends a year from the one that ends a credential — and the view would + * be drawing a line the PDF does not. + * - a MISSING year draws its "+ year" add-affordance at zero cost AT REST: + * `opacity-0`, revealed only on `group-hover`/`group-focus-within` of the + * row (the row is already a hover target for the remove control, so this + * adds no new surface). Every dateless credential would otherwise + * permanently contribute a "+ year" to the shared line — both the clutter + * the compact form exists to remove and one more thing competing with the + * boundary glyph — but hiding it outright made a parsed, undated + * credential impossible to date at all before export (#899 AC 5). A + * user-ADDED credential is unaffected — it draws on a full-width row of + * its own (see `AchievementsSection`) and keeps the ordinary, always-shown + * slot. + */ +function AchievementYearSlot({ + year, + yearSeparator, + alwaysShowSeparator, + compact, + onCommit, +}: { + year?: string; + yearSeparator?: string; + alwaysShowSeparator: boolean; + compact: boolean; + onCommit: (value: string) => void; +}) { + const field = ( + + ); + const sourceSeparator = yearSeparator?.trim(); + if ( + compact && + (!sourceSeparator || + sourceSeparator === DEFAULT_ACHIEVEMENT_YEAR_SEPARATOR) + ) { + // A dateless credential's add-affordance costs nothing AT REST (opacity-0) + // and reveals on the row's hover/focus (`group` on the row wrapper below) — + // the row is already a hover target for the remove control, so this adds + // no new surface. A credential that already carries a year stays visible + // as normal running text. + const hiddenAtRest = !year; + return ( - onFieldChange("year", v)} - /> - + ); + } + return ( + <> + {(alwaysShowSeparator || year) && ( + + )} + {field} + ); } @@ -1044,13 +1165,46 @@ export function AchievementsSection({ /** Drop a blank added entry when focus leaves the section (#379). */ onPruneEmpty: () => void; }) { + // Compact certifications (#899): multiple credentials compress onto a + // wrapped, middot-separated line instead of each taking a full vertical row + // — achievements are UNCHANGED (`compact` is false there, taking the + // original one-row-per-entry branch below verbatim). Derived from `section` + // rather than a new prop: the two are already 1:1 (every certifications + // caller passes `section="certifications"`) and a caller can't set one + // without the other going stale. + const compact = section === "certifications"; + // Which rows actually join the shared compact line. Two exclusions, and both + // of them own an affordance the line has no room for: + // - a credential carrying a BULLET body (only a user-added one can — see + // `ats-resume-model.ts`'s certifications comment) needs the full-width + // block those bullets render in; + // - a user-ADDED credential needs `InlineBulletAdd`, which lives on that + // same full-width block. It starts life with no bullets, so routing it by + // bullets alone put it on the compact line and left it with no way to + // ever get one — the affordance was gone permanently, not merely hidden. + const joinsCompactLine = (idx: number) => + compact && idx < originalCount && (groups[idx]?.bullets.length ?? 0) === 0; + // Whether the line is genuinely SHARED. The parenthesised year exists to stop + // one `·` from meaning both "end of year" and "end of credential", and with a + // single credential on the line there is no boundary middot to be confused + // with — which is also why the exporter's own compaction starts at two + // (`ats-resume-model.ts`). Below that, the row keeps the ordinary separator + // and matches the PDF, which draws a lone credential as its own row. + const sharesCompactLine = + achievements.filter((_, idx) => joinsCompactLine(idx)).length >= 2; return (
{heading ?? fallbackHeading} -
+
{achievements.map((achievement, i) => { const group = groups[i]; const added = @@ -1060,58 +1214,97 @@ export function AchievementsSection({ // PARSED index, not the render position — see `parsedIndices`. const parsedIdx = parsedIndices[i] ?? i; const entryKey = added ? added.id : parsedEntryKey(section, parsedIdx); + const inline = joinsCompactLine(i); + const compactYear = inline && sharesCompactLine; + const header = added ? ( + // Same header as a parsed achievement — an added entry stores its + // label under `achievementType` on the flat AddedEntry, so only the + // commit target differs. + + onEntryField( + added.id, + field === "type" ? "achievementType" : field, + value, + ) + } + /> + ) : ( + + onAchievementField(parsedIdx, field, value) + } + /> + ); + const removeButton = ( + + removeEntryWithBullets(entryKey, group?.bullets ?? [], { + onRemoveEntry, + onRemoveBullet, + }) + } + /> + ); + const bullets = + group && group.bullets.length > 0 ? ( +
    + {group.bullets.map((b) => ( + + ))} +
+ ) : null; + + // A parsed, bullet-less credential (the common shape) joins the shared + // flex-wrap line, middot-separated from its neighbour. Everything + // `joinsCompactLine` excludes falls through to the same full-width + // block an achievement gets, `w-full` forcing it onto its own row + // inside the flex-wrap container. + // + // The between-item middot is drawn only when the NEXT row also joins + // the line — keyed on that rather than on "not the last entry", or a + // credential followed by a full-width added one would trail a + // separator into a line break. + if (inline) { + return ( +
+ {header} + {removeButton} + {joinsCompactLine(i + 1) && ( + + )} +
+ ); + } return ( // The ENTRY key, not the render position (#856) — see the same note // in `ExperienceSection`. -
+
- {added ? ( - // Same header as a parsed achievement — an added entry stores - // its label under `achievementType` on the flat AddedEntry, so - // only the commit target differs. - - onEntryField( - added.id, - field === "type" ? "achievementType" : field, - value, - ) - } - /> - ) : ( - - onAchievementField(parsedIdx, field, value) - } - /> - )} - - removeEntryWithBullets(entryKey, group?.bullets ?? [], { - onRemoveEntry, - onRemoveBullet, - }) - } - /> + {header} + {removeButton}
- {group && group.bullets.length > 0 && ( -
    - {group.bullets.map((b) => ( - - ))} -
- )} + {bullets} {added && ( onAddBullet(added.id, text)} /> )} diff --git a/src/lib/edit/apply-overrides.test.ts b/src/lib/edit/apply-overrides.test.ts index 044f4a2b..97e756d4 100644 --- a/src/lib/edit/apply-overrides.test.ts +++ b/src/lib/edit/apply-overrides.test.ts @@ -1497,12 +1497,15 @@ describe("applyOverrides — achievements", () => { // apart, so an `achievements:0` edit never lands on a certification. /** A parse carrying one achievement AND one certification, so an override - * keyed `0` is ambiguous unless the buckets are genuinely separate. */ + * keyed `0` is ambiguous unless the buckets are genuinely separate. The + * certification carries NO `type`: extraction stopped splitting one off a + * credential header in #899 (`splitType: false`), and a stored record that + * still has one is folded away — pinned in its own block below. */ function credentialParsed(): HeuristicParsedResume { return { ...baseParsed(), heuristic_achievements: [{ title: "Best Paper Award", year: "2021" }], - heuristic_certifications: [{ type: "AWS", title: "SAA", year: "2022" }], + heuristic_certifications: [{ title: "SAA", year: "2022" }], }; } @@ -1542,8 +1545,8 @@ describe("applyOverrides — certifications (#884)", () => { 0: { title: "Solutions Architect – Professional" }, }); expect(out.heuristic_certifications?.[0]).toMatchObject({ - type: "AWS", title: "Solutions Architect – Professional", + year: "2022", }); expect(out.heuristic_achievements?.[0].title).toBe("Best Paper Award"); }); @@ -1561,7 +1564,6 @@ describe("applyOverrides — certifications (#884)", () => { { id: "added:1", section: "certifications", - achievementType: "CNCF", title: "CKA", year: "2023", }, @@ -1575,6 +1577,51 @@ describe("applyOverrides — certifications (#884)", () => { ]); }); + it("folds an ADDED certification's legacy label into its title too", () => { + // The picker is achievements-only (#899), so an `achievementType` on an + // added CERTIFICATION can only come from an entry captured before that gate + // — the same legacy shape `foldCertificationTypes` retires on the parsed + // side. Pushed raw it survives only on the added lane, which is worse than + // either surface alone: the view has no slot to draw it in, the PDF still + // draws it bold, and one raw label fails the exporter's `every(c => + // !c.type)` compaction guard for the WHOLE section. + const { fields: out } = applyCerts(credentialParsed(), {}, [ + { + id: "added:1", + section: "certifications", + achievementType: "CNCF", + title: "CKA", + year: "2023", + }, + ]); + expect(out.heuristic_certifications?.[1]).toEqual({ + title: "CNCF · CKA", + year: "2023", + description: undefined, + }); + expect(out.heuristic_certifications?.every((c) => !c.type?.trim())).toBe( + true, + ); + }); + + it("keeps an ADDED achievement's label a real field", () => { + // The other half of the same fork: `type` is picker-edited on an + // achievement, so it must reach the bucket intact and NOT be folded. + const { fields: out } = applyCerts(credentialParsed(), {}, [ + { + id: "added:1", + section: "achievements", + achievementType: "Patent", + title: "Bulk editor", + year: "2023", + }, + ]); + expect(out.heuristic_achievements?.[1]).toMatchObject({ + type: "Patent", + title: "Bulk editor", + }); + }); + it("does not mutate the input parse", () => { const parsed = credentialParsed(); applyCerts(parsed, { 0: { title: "edited" } }); @@ -1582,6 +1629,101 @@ describe("applyOverrides — certifications (#884)", () => { }); }); +// ── The legacy certification `type`, retired (#899) ────────────────────────── +// +// Every résumé saved to the library BEFORE #899 carries a `type` the old split +// lopped off the front of a credential title. Nothing reads it on a credential +// any more — no picker, no `AchievementHeader` slot — so left alone the record +// would display as the tail ALONE while the PDF kept drawing the whole thing, +// and it would fail the exporter's compaction guard, keeping #899's compact +// line off every pre-existing résumé. Folding it back into the title at this +// one seam is what both surfaces then read. + +/** The shape a pre-#899 save left behind: "AWS · Certified Solutions Architect" + * stored as a type + the remainder. */ +function legacyCertParsed(): HeuristicParsedResume { + return { + ...baseParsed(), + heuristic_achievements: [{ type: "Patent", title: "Bulk editor", year: "2019" }], + heuristic_certifications: [ + { type: "AWS", title: "Certified Solutions Architect", year: "2022" }, + { title: "CKA", year: "2021" }, + ], + }; +} + +describe("applyOverrides — legacy certification type fold (#899)", () => { + it("folds the label back into the title and drops the field", () => { + const { fields: out } = applyCerts(legacyCertParsed(), {}); + expect(out.heuristic_certifications?.[0].title).toBe( + "AWS · Certified Solutions Architect", + ); + expect(out.heuristic_certifications?.[0].type).toBeUndefined(); + // The composition is the exporter's own (`credentialTitle`), so the PDF a + // legacy record draws is unchanged by the fold — only the view it was + // missing from changes. + expect(out.heuristic_certifications?.[0].year).toBe("2022"); + // A credential that never had one is untouched, field for field. + expect(out.heuristic_certifications?.[1]).toEqual({ + title: "CKA", + year: "2021", + }); + }); + + it("leaves the compaction guard satisfiable — no type survives the fold", () => { + // The guard is `certifications.every(c => !c.type?.trim() && …)` in + // `ats-resume-model.ts`. Before the fold a single legacy record failed it + // for the whole section. + const { fields: out } = applyCerts(legacyCertParsed(), {}); + expect(out.heuristic_certifications?.every((c) => !c.type?.trim())).toBe( + true, + ); + }); + + it("never touches an ACHIEVEMENT's type — that field is still real", () => { + const { fields: out } = applyCerts(legacyCertParsed(), {}); + expect(out.heuristic_achievements?.[0]).toMatchObject({ + type: "Patent", + title: "Bulk editor", + }); + }); + + it("lets a title edit replace the folded string, not be re-prefixed", () => { + // The fold runs BEFORE the overrides for exactly this: the user edits the + // title they can SEE ("AWS · Certified Solutions Architect"), so re-folding + // the label onto their edit afterwards would read "AWS · AWS · …" and grow + // by one label on every render. + const { fields: out } = applyCerts(legacyCertParsed(), { + 0: { title: "AWS · Certified Solutions Architect – Professional" }, + }); + expect(out.heuristic_certifications?.[0].title).toBe( + "AWS · Certified Solutions Architect – Professional", + ); + }); + + it("ignores a stale type override on a certification", () => { + // The picker is achievements-only now, so a certification type override can + // only be one captured before that gate — and re-applying it would put back + // the very label the fold just retired. + const { fields: out } = applyCerts(legacyCertParsed(), { + 0: { type: "Amazon Web Services" }, + }); + expect(out.heuristic_certifications?.[0].type).toBeUndefined(); + expect(out.heuristic_certifications?.[0].title).toBe( + "AWS · Certified Solutions Architect", + ); + }); + + it("does not mutate the stored parse it folded", () => { + const parsed = legacyCertParsed(); + applyCerts(parsed, {}); + expect(parsed.heuristic_certifications?.[0]).toMatchObject({ + type: "AWS", + title: "Certified Solutions Architect", + }); + }); +}); + // ── Summary override (#625) ─────────────────────────────────────────────────── /** applyOverrides with ONLY the summary override set — the 17th positional diff --git a/src/lib/edit/apply-overrides.ts b/src/lib/edit/apply-overrides.ts index 51629228..a456a73f 100644 --- a/src/lib/edit/apply-overrides.ts +++ b/src/lib/edit/apply-overrides.ts @@ -50,6 +50,7 @@ import { computeEditedSkills, isEmptySkillsOverride, } from "./skills-categories.ts"; +import { joinAchievementType } from "../score/entry-dates.ts"; import { classifyProfile, profilesFromUrls } from "../contact/profile-registry.ts"; import type { LegacyLinkKey, ProfileLink } from "../score/types.ts"; import type { @@ -411,21 +412,67 @@ function applyCredentialOverrides( for (const [idxStr, fields] of Object.entries(overrides)) { const item = items[Number(idxStr)]; if (!item) continue; - mergeAchievementFields(item, fields); + mergeAchievementFields(item, fields, bucket !== "heuristic_certifications"); } nextParsed[bucket] = items; } -/** Fold one achievement's field overrides into the (already cloned) entry. */ +/** Fold one achievement's field overrides into the (already cloned) entry. + * `allowType` is false for the certifications bucket (#899): the type picker + * is achievements-only, so a certification can only carry a type override + * captured before that gate existed — and re-applying one would put back the + * very label {@link foldCertificationTypes} just retired. */ function mergeAchievementFields( ach: HeuristicAchievement, fields: AchievementFieldOverrides, + allowType: boolean, ): void { - if (fields.type !== undefined) ach.type = fields.type || undefined; + if (allowType && fields.type !== undefined) ach.type = fields.type || undefined; if (fields.title !== undefined) ach.title = fields.title; if (fields.year !== undefined) ach.year = fields.year || undefined; } +/** + * Retire a certification's `type` label by folding it back into the title + * (#899). + * + * Extraction stopped splitting one off a credential header (`splitType: false`) + * and the edit surface stopped rendering the picker — but every résumé saved to + * the library BEFORE that carries a label the old split lopped off the front of + * its title (`{type: "AWS", title: "Certified Solutions Architect"}`). With + * nothing reading `type` on a credential any more, such a record would display + * as the tail alone, silently missing the half the résumé led with, while the + * PDF still drew the whole thing — the two surfaces disagreeing about the same + * stored record. It also failed the exporter's compaction guard, so #899's + * compact line never applied to a single pre-existing résumé. + * + * Folding here, at the ONE seam both the edit surface and the export projection + * read (`applyOverrides`'s display result), is what keeps them agreeing without + * a second copy of the rule at each read site. The composition is + * {@link joinAchievementType} — the exact string `credentialTitle` already + * composed for the PDF, so a legacy record's EXPORT is byte-identical across + * the fold and only the view it was missing from changes. + * + * Runs BEFORE {@link applyCredentialOverrides}, so a title the user edits after + * the fold replaces the folded string wholesale instead of being prefixed with + * the label a second time on the next render. + * + * ACHIEVEMENTS ARE UNTOUCHED — their `type` is a real, picker-edited field. + */ +function foldCertificationTypes(nextParsed: HeuristicParsedResume): void { + const items = nextParsed.heuristic_certifications; + if (!items?.some((c) => c.type?.trim())) return; + nextParsed.heuristic_certifications = items.map((c) => { + if (!c.type?.trim()) return c; + const folded: HeuristicAchievement = { + ...c, + title: joinAchievementType(c.type, c.title), + }; + delete folded.type; + return folded; + }); +} + // ── Prose descriptions (#489) ─────────────────────────────────────────────── /** @@ -920,14 +967,27 @@ function pushAddedEntry( // real fields (#456) — `achievementType` is the bold label, `title` the rest // — so an added achievement is indistinguishable from a parsed-then-edited // one (#455) without any recomposition. A certification carries the same - // shape (#884) and differs only in which bucket it lands in. - const bucket = - entry.section === "certifications" - ? nextParsed.heuristic_certifications! - : nextParsed.heuristic_achievements!; + // shape (#884) and differs in which bucket it lands in and in carrying no + // label of its own (#899, below). + const certification = entry.section === "certifications"; + const bucket = certification + ? nextParsed.heuristic_certifications! + : nextParsed.heuristic_achievements!; bucket.push({ - type: entry.achievementType || undefined, - title: entry.title, + // A credential's label is folded into its title, exactly as + // `foldCertificationTypes` does on the PARSED side (#899) — the picker is + // gated off for certifications, so a label here can only come from an + // entry added before that gate existed. Left raw, one such entry drew a + // bold run in the PDF and failed the exporter's `every(c => !c.type)` + // compaction guard for the WHOLE section. The folded title is the same + // string `credentialTitle` composed for the PDF anyway, so the export text + // is unchanged and only the emphasis and the guard are. What this does NOT + // reach is the VIEW: `AchievementHeader` renders an added entry from its + // own `AddedEntry` fields, where the label stays unrendered (no picker) — + // closing that needs the stored entry migrated, not a fold here. + ...(certification + ? { title: joinAchievementType(entry.achievementType, entry.title) } + : { type: entry.achievementType || undefined, title: entry.title }), year: entry.year, description, }); @@ -1335,6 +1395,10 @@ export function applyOverrides( ); applyEducationFieldOverrides(nextParsed.education, education); + // Retire a legacy certification `type` before ANY credential override lands, + // so an edit to the folded title replaces it rather than being re-prefixed + // (#899) — see the function's docblock for why this is the seam. + foldCertificationTypes(nextParsed); // Before the added-entry append below, so the override keys stay aligned with // the PARSED credential indices they were captured against. applyCredentialOverrides(nextParsed, "heuristic_achievements", achievements); diff --git a/src/lib/heuristics/corpus-roundtrip.test.ts b/src/lib/heuristics/corpus-roundtrip.test.ts index 1d5ee392..b2b6cb8b 100644 --- a/src/lib/heuristics/corpus-roundtrip.test.ts +++ b/src/lib/heuristics/corpus-roundtrip.test.ts @@ -53,7 +53,8 @@ import { fileURLToPath } from "node:url"; import { describe, it, expect } from "vitest"; import { runCascade } from "./cascade.ts"; import type { CascadeResult } from "./types.ts"; -import { runRoundtripHop } from "./roundtrip-hop.ts"; +import { runRoundtripHop, scoreForCascade } from "./roundtrip-hop.ts"; +import { buildAtsResumeModel } from "../pdf/ats-resume-model.ts"; import type { RoundtripCategory } from "./localize/roundtrip.ts"; import { invariantFailures, harnessDiff } from "./localize/roundtrip.ts"; import { @@ -215,9 +216,19 @@ describe("skills categories round-trip (#473)", () => { * a re-parse of that PDF put them in the achievements bucket — the parse was * lossy in one direction and self-consistently wrong in the other. * - * PII-free: the fixture is a synthetic persona; only counts are asserted. + * Since #899 the hop carries a SECOND lossy step, and this fixture (three + * credentials, so the compaction fires) is where both are proved together: the + * exporter no longer draws one row per credential but compresses all three onto + * a single middot-joined line, which the re-parser has to split back apart. The + * count assertion alone would not have caught the first attempt at that — it + * came back with TWO certifications, one of them two credentials glued into a + * single title — so the entries themselves are compared, not just how many there + * are. + * + * PII-free: the fixture is a synthetic persona; the parse is compared against + * ITSELF (before vs after), so no résumé value is written into this file. */ -describe("certifications round-trip (#884)", () => { +describe("certifications round-trip (#884, #899)", () => { const CERTIFICATIONS_FIXTURE = join( FIXTURE_ROOT, "google-docs", @@ -228,15 +239,24 @@ describe("certifications round-trip (#884)", () => { const before = await runCascade( new Uint8Array(readFileSync(CERTIFICATIONS_FIXTURE)), ); - const certCount = before.canonical.fields.heuristic_certifications?.length; - expect(certCount).toBeGreaterThan(0); + const certs = before.canonical.fields.heuristic_certifications; + // Multi-credential by construction — the shape #899 compacts. A fixture + // that ever dropped to one would make every assertion below vacuous. + expect(certs?.length).toBeGreaterThan(1); expect(before.canonical.fields.heuristic_achievements).toBeUndefined(); + // The export really does compact: without this the hop below could pass on + // the OLD one-row-per-credential layout and prove nothing about #899. + const model = buildAtsResumeModel(before, scoreForCascade(before)); + const section = model.sections.find((s) => s.kind === "certifications"); + expect(section?.entries).toHaveLength(certs!.length); + expect(section?.compactLine?.split(" · ")).toHaveLength(certs!.length); + const { after, renderError } = await runRoundtripHop(before); expect(renderError).toBeUndefined(); - expect(after?.canonical.fields.heuristic_certifications?.length).toBe( - certCount, - ); + // Every credential comes back whole — same title, same year, same year + // punctuation — not merely the same number of them. + expect(after?.canonical.fields.heuristic_certifications).toEqual(certs); expect(after?.canonical.fields.heuristic_achievements).toBeUndefined(); }); }); diff --git a/src/lib/heuristics/extract/achievements.test.ts b/src/lib/heuristics/extract/achievements.test.ts index 760489c7..3e286b71 100644 --- a/src/lib/heuristics/extract/achievements.test.ts +++ b/src/lib/heuristics/extract/achievements.test.ts @@ -164,6 +164,329 @@ describe("extractAchievements — year_separator (#380)", () => { }); }); +describe("extractAchievements — splitType: false (#899)", () => { + it("never splits a leading 'Type · title' segment for a certification", () => { + // Un-opted-in (achievements default): a header carrying the canonical + // "Type · title" shape splits — the exact shape `splitAchievementType` + // recognizes, and the one a credential line can genuinely carry when a + // résumé sets its issuer or category off with a middot ("AWS · Certified + // Solutions Architect"). + const split = extractAchievements( + mkAchievements(["AWS · Certified Solutions Architect"]), + ); + expect(split.value[0].type).toBe("AWS"); + expect(split.value[0].title).toBe("Certified Solutions Architect"); + + // `splitType: false` preserves the whole credential name and never sets + // `type` — nonsensical for a credential, which is not "Type · label" shaped + // even when it happens to contain a middot (#899). This exercises the + // option ALONE; the real certifications call site pairs it with + // `splitCompactList`, under which that same middot reads as a credential + // boundary instead — see the `splitCompactList` block below for why that + // reading has to win. + const unsplit = extractAchievements( + mkAchievements(["AWS · Certified Solutions Architect"]), + { splitType: false }, + ); + expect(unsplit.value[0].type).toBeUndefined(); + expect(unsplit.value[0].title).toBe("AWS · Certified Solutions Architect"); + }); + + it("preserves a plain credential title (no split candidate either way)", () => { + const { value } = extractAchievements( + mkAchievements(["Patent Bar Registration"]), + { splitType: false }, + ); + expect(value[0].type).toBeUndefined(); + expect(value[0].title).toBe("Patent Bar Registration"); + }); +}); + +// ── The compact certifications line, read back apart (#899) ────────────────── +// +// The exporter compresses two or more credentials onto ONE middot-joined line +// and the renderer wraps it ATOMICALLY, so every extracted line of the block +// begins at a credential boundary. `splitCompactList` is the inverse of that, +// and these cover the shapes the hop can produce plus the source shapes the +// split must not damage. The end-to-end proof over a real PDF lives in +// `corpus-roundtrip.test.ts`; this pins the line-level contract. +describe("extractAchievements — splitCompactList (#899)", () => { + /** The certifications call site: both options, exactly as `openresume.ts` + * passes them. */ + const certifications = (rows: string[]) => + extractAchievements(mkAchievements(rows), { + splitType: false, + splitCompactList: true, + }).value; + + it("splits one middot-joined line into one entry per credential", () => { + const value = certifications([ + "AWS Certified Cloud Practitioner (2025) · AWS Certified Solutions Architect (2026) · CKA", + ]); + expect(value.map((v) => [v.title, v.year])).toEqual([ + ["AWS Certified Cloud Practitioner", "2025"], + ["AWS Certified Solutions Architect", "2026"], + ["CKA", undefined], + ]); + }); + + it("splits a WRAPPED compact list, whose lines are credential-aligned", () => { + // What `wrapSegmentsToLines` actually emits: whole segments per line, the + // separator re-drawn only BETWEEN the segments that share a line. The tail + // line carries no separator of its own and must still open its own entry. + const value = certifications([ + "AWS Certified Cloud Practitioner (2025) · AWS Certified Solutions Architect (2026)", + "Certified Kubernetes Administrator (CKA) (2021)", + ]); + expect(value.map((v) => v.title)).toEqual([ + "AWS Certified Cloud Practitioner", + "AWS Certified Solutions Architect", + "Certified Kubernetes Administrator (CKA)", + ]); + expect(value.map((v) => v.year)).toEqual(["2025", "2026", "2021"]); + }); + + it("opens an entry for a lowercase-led credential on a wrapped tail line", () => { + // The wrapped-tail FOLD (`isAwardContinuation`) is switched off for the + // whole section once any line carries the separator: a credential that + // happens to start lowercase would otherwise be swallowed by the line + // above, silently losing it. Nothing can be a wrapped tail here — the + // renderer never breaks inside a credential. + const value = certifications([ + "AWS Certified Cloud Practitioner (2025) · AWS Certified Solutions Architect (2026)", + "iOS App Development Certification (2020)", + ]); + expect(value).toHaveLength(3); + expect(value[2].title).toBe("iOS App Development Certification"); + }); + + it("re-joins a date-only fragment to the credential it dates", () => { + // A source that wrote "CKA · 2021" means the middot as its YEAR separator, + // not as a list boundary. Splitting there would strand "2021" as a + // title-less entry (dropped) and rob "CKA" of its year, so the fragment is + // re-joined verbatim — leaving this line parsing exactly as it did before + // the split existed, separator included. + const value = certifications(["CKA · 2021 · AWS Certified Developer"]); + expect(value).toEqual([ + { title: "CKA", year: "2021", year_separator: "·" }, + { title: "AWS Certified Developer" }, + ]); + }); + + it("re-joins a MONTH-year fragment, not only a bare 4-digit year", () => { + // The first cut gated the re-join on `isLoneDateRange({allowSingle:true})`, + // which by its own docblock admits ONLY a bare `(19|20)\d{2}`. "May 2021" + // fell through it, opened an empty-titled block of its own and was dropped + // by `finalizeEntries` — the credential silently lost its date on an + // ordinary source shape. The gate is now "the segment reduces to nothing + // but a date", which every parseable date form satisfies. + expect(certifications(["CKA · May 2021"])).toEqual([ + { title: "CKA", year: "2021", year_separator: "·" }, + ]); + expect(certifications(["CKA · May 2021 · AWS Certified Developer"])).toEqual( + [ + { title: "CKA", year: "2021", year_separator: "·" }, + { title: "AWS Certified Developer" }, + ], + ); + }); + + it("keeps an apostrophe-year with its credential instead of minting one", () => { + // No date regex in the pipeline reads a bare "'21" as a date, so it cannot + // become a `year` — but it must not become a CREDENTIAL either. Re-joining + // keeps it verbatim on the title it dates; splitting it off listed a + // certification named "'21". + const value = certifications(["CKA · '21 · AWS Certified Developer"]); + expect(value.map((v) => v.title)).toEqual([ + "CKA · '21", + "AWS Certified Developer", + ]); + }); + + it("dates the credential AFTER a leading date fragment", () => { + // A source that writes the year on the left has nothing behind the date to + // re-join it to. Holding it for the credential that follows is what keeps + // the year; the first cut opened a title-less block and dropped it. + expect(certifications(["2021 · CKA · AWS Certified Developer"])).toEqual([ + { title: "CKA", year: "2021", year_separator: "·" }, + { title: "AWS Certified Developer" }, + ]); + }); + + it("reads 'Name · Issuer · Year' as ONE dated credential, not two", () => { + // The fabrication case. Split segment-by-segment, the trailing year binds + // to the ISSUER and the section lists a certification called "Amazon Web + // Services, 2024" that the résumé never claimed. A trailing date after two + // or more date-less segments is the everyday "Credential · Issuer · Date" + // row — a shape our own exporter never emits, since it parenthesises every + // year — so the whole line stays one entry. + expect( + certifications([ + "AWS Certified Solutions Architect · Amazon Web Services · 2024", + ]), + ).toEqual([ + { + title: "AWS Certified Solutions Architect · Amazon Web Services", + year: "2024", + year_separator: "·", + }, + ]); + }); + + it("still splits a list whose credentials each carry their own year", () => { + // The guard that keeps the row-reading above from swallowing a genuine + // list: an EARLIER segment carrying a date of its own means the middots are + // list boundaries, so the trailing year dates only the credential before it. + expect( + certifications(["CKA · 2021 · AWS Certified Developer · 2022"]), + ).toEqual([ + { title: "CKA", year: "2021", year_separator: "·" }, + { title: "AWS Certified Developer", year: "2022", year_separator: "·" }, + ]); + // Same guard, the exporter's own parenthesised shape. + expect( + certifications([ + "AWS Certified Cloud Practitioner (2025) · CKA · 2021", + ]).map((v) => [v.title, v.year]), + ).toEqual([ + ["AWS Certified Cloud Practitioner", "2025"], + ["CKA", "2021"], + ]); + }); + + it("reads SEVERAL dated credential rows off ONE line", () => { + // A two-column certifications block reaches line assembly as a single + // `PdfLine`, so the "Credential · Issuer · Year" row above arrives twice + // over. Judged whole-line, the first triple's year counts as "an earlier + // segment carries a date", the collapse is refused for the entire line, and + // every trailing year binds to the ISSUER beside it — fabricating "Google, + // 2023" and "CNCF, 2021". Each date-terminated RUN is judged on its own. + expect( + certifications([ + "Google Cloud Architect · Google · Mar 2023 · CKA · CNCF · Jun 2021", + ]), + ).toEqual([ + { + title: "Google Cloud Architect · Google", + year: "2023", + year_separator: "·", + }, + { title: "CKA · CNCF", year: "2021", year_separator: "·" }, + ]); + }); + + it("never dates the ISSUER of a trailing UNDATED row", () => { + // Same line shape, but the second triple's year is missing. The dated run + // still collapses; the undated tail has no year to bind, so it splits per + // credential exactly as any other date-less delimited stretch does — two + // truthful strings rather than an invented "PMI, 2020". + expect( + certifications(["PMP · PMI · 2020 · CSM · Scrum Alliance"]), + ).toEqual([ + { title: "PMP · PMI", year: "2020", year_separator: "·" }, + { title: "CSM" }, + { title: "Scrum Alliance" }, + ]); + }); + + it("keeps a per-credential year list splitting when a dated ROW precedes it", () => { + // The two readings on ONE line: a dated row, then a list whose credentials + // each carry their own middot-separated year. Judged per run, the row + // collapses and the list stays a list — neither reading leaks into the + // other's half of the line. + expect( + certifications([ + "AWS Certified Solutions Architect · Amazon Web Services · 2024 · CKA · 2021", + ]), + ).toEqual([ + { + title: "AWS Certified Solutions Architect · Amazon Web Services", + year: "2024", + year_separator: "·", + }, + { title: "CKA", year: "2021", year_separator: "·" }, + ]); + }); + + it("drops a second leading date instead of picking one of the two", () => { + // A section opening on two bare dates has no block to re-join to and no + // credential yet to date, and the two cannot both be the credential's year. + // Silently keeping the last (what the held date used to do) dates "CKA" 2025 + // on a coin flip; the held date is flushed into a title-less block and + // dropped by `finalizeEntries` instead, leaving the credential undated. + expect(certifications(["2024 · 2025 · CKA"])).toEqual([{ title: "CKA" }]); + }); + + it("leaves a single undelimited credential exactly as it was", () => { + // No separator anywhere in the section, so nothing splits and the flag is + // inert — the one-certification résumé is byte-identical to pre-#899. + expect(certifications(["Certified Kubernetes Administrator (CKA), 2021"])).toEqual([ + { title: "Certified Kubernetes Administrator (CKA)", year: "2021", year_separator: "," }, + ]); + }); + + it("still folds a genuine wrapped tail while no line carries a separator", () => { + // The undelimited section keeps `parseFlatAwardList`'s original behaviour: + // a lowercase-led line is a wrapped tail of the award above it (#225). + const value = certifications([ + "Certified Information Systems Security Professional", + "issued by ISC2, 2022", + ]); + expect(value).toHaveLength(1); + expect(value[0].title).toBe( + "Certified Information Systems Security Professional issued by ISC2", + ); + }); + + it("absorbs the spacing variance a PDF extractor hands back", () => { + // The boundary is "middot with WHITESPACE on both sides", not one literal + // U+0020 either side: the extractor widens or narrows the drawn gap freely. + // `\s` is what expresses that, and it also covers the NBSP / thin spaces a + // PDF can carry — those need no case of their own, since a class this test + // already proves is applied cannot then exclude one of its own members. + const value = certifications([ + "AWS Certified Developer · CKA · Terraform Associate", + ]); + expect(value.map((v) => v.title)).toEqual([ + "AWS Certified Developer", + "CKA", + "Terraform Associate", + ]); + }); + + it("does not split a middot glued inside a token", () => { + // Whitespace is required on both sides, so a glyph carried INSIDE a + // credential name is not a boundary. + expect(certifications(["R·D Practitioner Certificate"])).toEqual([ + { title: "R·D Practitioner Certificate" }, + ]); + }); + + it("is OFF for achievements — a 'Type · title' header stays one award", () => { + // The default. An achievement header uses the middot as a DISPLAY joiner + // ("Patent · Foo", "keyword · statement · year", #307/#456), so splitting + // one would shred a single award into fragments. + const { value } = extractAchievements( + mkAchievements(["Patent · System and method for ranking catalogs, 2019"]), + ); + expect(value).toHaveLength(1); + expect(value[0].type).toBe("Patent"); + expect(value[0].title).toBe("System and method for ranking catalogs"); + }); + + it("reads an issuer middot as a boundary — the deliberate tradeoff", () => { + // "Issuer · Credential" on a source line is genuinely ambiguous against the + // compact list, and this reading is the one that has to win: over-segmenting + // a source quirk splits one credential into two truthful strings, while the + // other reading MERGES every credential of our own exported PDF into one and + // loses N-1 of them outright. Pinned so the choice is a decision, not drift. + expect(certifications(["AWS · Certified Solutions Architect"])).toEqual([ + { title: "AWS" }, + { title: "Certified Solutions Architect" }, + ]); + }); +}); + // ── A word that merely STARTS with a month prefix is not a month ───────────── // // The lone-date fallback and `stripDateRange` both key on a month regex. Keyed diff --git a/src/lib/heuristics/extract/achievements.ts b/src/lib/heuristics/extract/achievements.ts index c8dc05ec..0aabce3e 100644 --- a/src/lib/heuristics/extract/achievements.ts +++ b/src/lib/heuristics/extract/achievements.ts @@ -23,6 +23,31 @@ import { liftHeaderLabel } from "./projects.ts"; // `line-primitives.ts` (#283) so the achievements path and the entry-block // parser share one copy — see that module for the regex rationale. +/** + * The separator that joins several credentials onto ONE compact certifications + * line (#899). It is deliberately the same `" · "` every other multi-value line + * in the reconstructed PDF uses (the skills list, `Company · Location`), and the + * exporter imports THIS constant rather than spelling it a second time — + * `ats-resume-model.ts` builds `AtsSection.compactLine` with it and the renderer + * wraps that line on it ATOMICALLY (`MIDDOT_SEGMENT_SEP`, `wrapSegmentsToLines`). + * + * Atomic wrapping is what makes the compact line round-trip at all: the wrap + * point can only fall BETWEEN credentials, so every extracted `PdfLine` of the + * block starts at a credential boundary and {@link parseFlatAwardList} can split + * each line back into whole credentials without any flow-joining. The + * parse → export → re-parse hop over `google-docs-skia-proxy-certifications.pdf` + * (`corpus-roundtrip.test.ts`) is what pins the two ends to the same glyph. + */ +export const CREDENTIAL_LIST_SEPARATOR = " · "; + +/** + * The boundary {@link CREDENTIAL_LIST_SEPARATOR} draws, as the re-parser sees + * it. Whitespace is REQUIRED on both sides, so a middot glued inside a token is + * not a boundary, and `\s` (which covers the NBSP / thin spaces a PDF extractor + * emits, not just U+0020) absorbs whatever spacing the extraction hands back. + */ +export const CREDENTIAL_SPLIT_RE = /\s+·\s+/; + /** * Extract an Achievements / Accomplishments / Awards / Activities section into * `HeuristicAchievement[]`. @@ -57,10 +82,37 @@ import { liftHeaderLabel } from "./projects.ts"; */ export function extractAchievements( achievements: PdfSection | undefined, + options?: { + /** + * Whether to run {@link splitAchievementType} on each entry's header + * (#899). Certifications are name-led credential titles, not "Type · + * label" award headers — splitting one lops off a leading segment into a + * nonsensical `type` ("AWS · Certified Solutions Architect" → type + * "AWS"). Set `false` from the certifications call site + * (`openresume.ts`) so `type` always stays `undefined` and the full + * credential title is preserved in `title`. Defaults to `true`, so every + * achievements call site is byte-identical. + */ + splitType?: boolean; + /** + * Whether a flat-list line carrying {@link CREDENTIAL_LIST_SEPARATOR} is + * split back into one entry per credential (#899) — the inverse of the + * compact certifications line the PDF exporter draws. Set `true` from the + * certifications call site only: an ACHIEVEMENT header uses the very same + * middot as a display joiner ("Patent · Foo", "keyword · statement · year", + * #307/#456), so splitting one there would shred a single award into + * fragments. Defaults to `false`, so every achievements call site is + * byte-identical. See {@link parseFlatAwardList} for what the flag actually + * switches. + */ + splitCompactList?: boolean; + }, ): { value: HeuristicAchievement[]; confidence: number } { if (!achievements || achievements.lines.length === 0) { return { value: [], confidence: 0 }; } + const splitType = options?.splitType ?? true; + const splitCompactList = options?.splitCompactList ?? false; // Strip page running-header/footer furniture (#225) before any parsing — it // is neither an award nor part of one, on either path below. @@ -76,11 +128,11 @@ export function extractAchievements( anchor: "first_line", collectBody: true, }) - : parseFlatAwardList(lines); + : parseFlatAwardList(lines, splitCompactList); // Drop any date-only / title-less block (#145) before scoring. return finalizeEntries( - blocks.map(achievementFromBlock), + blocks.map((block) => achievementFromBlock(block, splitType)), (e) => e.title !== "", ); } @@ -119,25 +171,285 @@ function isAwardContinuation(text: string): boolean { * that reduces to nothing but a date is emitted as a title-less block and * dropped downstream by `finalizeEntries` (#145), preserving the date-only-drop * contract. + * + * `splitCompact` (#899, certifications only) adds the inverse of the exporter's + * compact credential line: when ANY line in the section carries + * {@link CREDENTIAL_LIST_SEPARATOR}, the section is DELIMITER-STRUCTURED and + * every line is split on that separator into one block per credential + * ({@link appendCredentialLine}), except where a stretch of it reads as a single + * dated credential row instead ({@link isDatedCredentialRow}). Two consequences + * follow from that reading and are deliberate: + * + * - **Line-wrap folding is switched off for the whole section.** The exporter + * wraps the compact line atomically, so each extracted line begins at a + * credential boundary and a "wrapped tail" cannot exist; leaving the fold on + * would instead swallow a lowercase-led credential ("iOS …") into the line + * above. Applying it section-wide rather than line-by-line is what covers + * the wrapped tail that happens to hold a single credential and therefore + * carries no separator of its own. It also means a genuinely wrapped tail on + * a separator-less line inside a delimited section does NOT fold — the two + * readings of a lowercase-led line are indistinguishable and this one is the + * load-bearing half, since the fold would lose a credential outright. That + * tradeoff is CHOSEN here, not inherited: the test that pins it + * ("opens an entry for a lowercase-led credential on a wrapped tail line") + * was written alongside this flag, so a future change is free to revisit the + * choice — it is a decision to argue with, not a prior contract. + * - **A date-only fragment re-joins the credential before it.** A source that + * wrote "CKA · 2021" means the middot as its YEAR separator, not as a list + * boundary, and that shape must keep parsing exactly as it did before this + * flag existed — so the fragment is re-joined verbatim instead of splitting + * the credential from its date. + * - **The dated-row reading is judged per RUN, not per line.** A two-column + * certifications block reaches line assembly as one `PdfLine` holding + * several "Credential · Issuer · Year" triples, and a whole-line reading + * refuses to collapse ANY of them the moment a second one supplies an + * earlier date — handing every trailing year back to the issuer beside it. + * {@link dateTerminatedRuns} cuts the line at its dates so each triple is + * judged on its own. + * + * With no separator anywhere in the section — a single certification, or one + * per line — nothing splits and this is the original function, unchanged. */ -function parseFlatAwardList(lines: PdfLine[]): EntryBlock[] { +function parseFlatAwardList( + lines: PdfLine[], + splitCompact: boolean, +): EntryBlock[] { + const texts = lines.map((l) => l.text.trim()); + const delimited = + splitCompact && texts.some((t) => CREDENTIAL_SPLIT_RE.test(t)); const blocks: EntryBlock[] = []; - for (const line of lines) { - const text = line.text.trim(); - if (blocks.length > 0 && isAwardContinuation(text)) { - const prev = blocks[blocks.length - 1]; - prev.headerLines[0] = `${prev.headerLines[0]} ${text}`.trim(); - } else { - blocks.push({ headerLines: [text], dates: {}, bulletCount: 0 }); + for (const text of texts) { + if (!delimited) { + appendAward(blocks, text); + continue; } + const segments = text + .split(CREDENTIAL_SPLIT_RE) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + appendCredentialLine(blocks, segments); } return blocks; } +/** + * AP-style two-digit year ("'19"). No date regex in the pipeline recognises one + * without a month in front of it — `YEAR_RE` wants four digits and + * `STRICT_MONTH_YEAR_RE` wants the month — so `stripDateRange` leaves it whole + * and the date-only test below has to name it explicitly. Two forms because the + * two questions differ: whether a segment IS one, and whether a segment CARRIES + * one somewhere inside it. + */ +const LONE_APOSTROPHE_YEAR_RE = /^'\d{2}$/; +const APOSTROPHE_YEAR_RE = /'\d{2}\b/; + +/** + * True when a `" · "` segment reduces to nothing but a date — the shape that + * means the source used the middot as its YEAR separator rather than as a list + * boundary. + * + * The discriminator is `stripDateRange` emptying the segment, NOT + * `isLoneDateRange`: that predicate deliberately admits only bare 4-digit years + * under `allowSingle` (see its docblock), so "May 2021" and "'19" fell through + * it, opened their own empty-titled block and were dropped by `finalizeEntries` + * — the credential lost its date outright on an ordinary source shape. Asking + * "does anything survive the date strip" is the same question the entry-block + * parser already asks of a header, and it admits every date form the pipeline + * can parse rather than a hand-picked subset. It does not over-catch a real + * credential or issuer name: `stripDateRange` only deletes what a date regex + * matched, so "CKA" and "Amazon Web Services" come back unchanged. + */ +function isDateOnlySegment(segment: string): boolean { + return ( + stripDateRange(segment) === "" || LONE_APOSTROPHE_YEAR_RE.test(segment) + ); +} + +/** True when a segment carries a date anywhere inside it ("CKA (2021)"), as + * opposed to BEING one. Reads the same strict regexes the title/date split + * downstream uses, so a false month ("Marketing") is not a date here either. */ +function carriesDate(segment: string): boolean { + return ( + parseDateRange(segment).start_date !== undefined || + APOSTROPHE_YEAR_RE.test(segment) + ); +} + +/** + * Cut a delimited line's segments into DATE-TERMINATED RUNS: each date-only + * segment ({@link isDateOnlySegment}) closes the run it sits at the end of, and + * whatever trails the last one is an unterminated final run. + * + * The run, not the line, is the unit {@link isDatedCredentialRow} judges (#899). + * A line holding one "Credential · Issuer · Year" triple and a line holding two + * of them are the same shape repeated, and a two-column certifications block + * that line assembly joined into one `PdfLine` produces exactly the second — + * but a whole-line reading sees the FIRST triple's year as "an earlier segment + * carries a date", refuses the collapse for the whole line, and hands every + * trailing year back to the issuer beside it. That is the very fabrication the + * collapse exists to stop, reintroduced by the line's length. + * + * Runs of zero non-date segments are kept rather than dropped: a leading date + * fragment ("2021 · CKA") closes one, and {@link appendCredentialLine} still has + * to hold that date for the credential it dates. + */ +function dateTerminatedRuns(segments: string[]): string[][] { + const runs: string[][] = []; + let run: string[] = []; + for (const segment of segments) { + run.push(segment); + if (isDateOnlySegment(segment)) { + runs.push(run); + run = []; + } + } + if (run.length > 0) runs.push(run); + return runs; +} + +/** + * True when one date-terminated run is ONE credential whose middots are + * internal — the everyday "Credential · Issuer · Year" row — rather than a list + * of credentials. Three conditions, all needed (#899): + * + * - the run has THREE or more segments, i.e. two or more credential-shaped + * segments before its date. At two ("CKA · 2021") the trailing date is + * re-joined to the one credential before it, which is the same entry either + * way, so there is nothing for this to decide; + * - the LAST segment is nothing but a date — an unterminated run has no date + * to bind wrongly in the first place; and + * - no EARLIER segment carries a date of its own. + * + * Without the third condition a genuine list whose credentials each carry their + * own year INSIDE the run ("AWS Certified Cloud Practitioner (2025) · CKA · + * 2021") would collapse into a single entry and lose every credential but the + * first. With it, the only shape that collapses is a run carrying exactly one + * date, at its end — which our own exporter never emits, since + * `compactCredentialHeader` parenthesises every year precisely so it cannot be + * read as a boundary. + * + * Collapsing is what stops the trailing year from binding to the ISSUER segment + * and inventing a dated certification the résumé never claimed ("Amazon Web + * Services, 2024"). The cost is that the entry's title then contains a `" · "` + * of its own, so a SECOND export hop re-splits it under the same + * issuer-middot tradeoff `achievements.test.ts` pins for the two-segment shape. + * Fabricating a credential on the first parse is the worse of the two. + */ +function isDatedCredentialRow(run: string[]): boolean { + if (run.length < 3) return false; + if (!isDateOnlySegment(run[run.length - 1]!)) return false; + return !run.slice(0, -1).some(carriesDate); +} + +/** Add one line of a NON-delimited flat award list: a continuation folds into + * the block above, anything else opens its own. */ +function appendAward(blocks: EntryBlock[], text: string): void { + const prev = blocks[blocks.length - 1]; + if (prev && isAwardContinuation(text)) { + prev.headerLines[0] = `${prev.headerLines[0]} ${text}`.trim(); + return; + } + blocks.push({ headerLines: [text], dates: {}, bulletCount: 0 }); +} + +/** + * Add one delimited line as blocks, one date-terminated run at a time (#899). + * + * A run that reads as a dated credential row ({@link isDatedCredentialRow}) + * becomes ONE block carrying the run verbatim; every other run falls through to + * {@link appendCredentialSegments}, which opens a block per credential. Judging + * run by run is what lets a line holding several "Credential · Issuer · Year" + * triples collapse each of them — see {@link dateTerminatedRuns}. + * + * `leadingDate` is threaded ACROSS the runs of the line because the shape it + * serves straddles them: "2021 · CKA" is a bare-date run followed by a + * credential run, and the date has to survive the boundary to reach the + * credential it dates. A collapsed run clears it instead — that run already + * carries its own trailing year, so a held date has no credential left to + * belong to on this line, and binding it to a LATER one would invent exactly + * the dated credential the collapse exists to prevent. + */ +function appendCredentialLine(blocks: EntryBlock[], segments: string[]): void { + let leadingDate: string | undefined; + for (const run of dateTerminatedRuns(segments)) { + if (isDatedCredentialRow(run)) { + blocks.push({ + headerLines: [run.join(CREDENTIAL_LIST_SEPARATOR)], + dates: {}, + bulletCount: 0, + }); + leadingDate = undefined; + continue; + } + leadingDate = appendCredentialSegments(blocks, run, leadingDate); + } +} + +/** + * Add one run's worth of `" · "`-delimited segments as one block per credential + * (#899). Returns the date still held for a credential that has not arrived yet, + * which {@link appendCredentialLine} carries into the next run. + * + * A segment that is nothing but a date ({@link isDateOnlySegment}) is a + * credential's YEAR, not a credential, so it is re-joined with the separator it + * was split on — leaving `achievementFromBlock` the same single string (and + * therefore the same `year` / `year_separator`) it would have read off an + * unsplit line. Which credential it dates depends on where it sits: + * + * - after one ("CKA · 2021 · …") it re-joins the block above it; + * - BEFORE any ("2021 · CKA · …", a source that dates its credentials on the + * left) it is held and appended to the credential that follows. Dropping it + * for want of a previous block — what the first cut did — lost the year, and + * appending rather than prepending is what keeps the date TRAILING, the only + * position `stripDateRange` + `liftHeaderLabel` can peel back off a title + * without stranding the separator in front of it. + * + * A held date is only ever displaced by a LATER one when the section opens on + * two bare dates in a row ("2024 · 2025 · CKA") — there is no block to re-join + * to and no credential yet to date, and the two cannot both be the credential's + * year. Keeping the FIRST is arbitrary in exactly the way keeping the last is, + * so neither is defensible; the held date is flushed into a block of its own + * instead, which is title-less and therefore dropped by `finalizeEntries` (#145) + * exactly as an undated stray date always has been. + */ +function appendCredentialSegments( + blocks: EntryBlock[], + segments: string[], + held: string | undefined, +): string | undefined { + let leadingDate = held; + for (const segment of segments) { + if (isDateOnlySegment(segment)) { + if (leadingDate !== undefined) { + blocks.push({ headerLines: [leadingDate], dates: {}, bulletCount: 0 }); + leadingDate = undefined; + } + const prev = blocks[blocks.length - 1]; + if (prev) { + prev.headerLines[0] = + `${prev.headerLines[0]}${CREDENTIAL_LIST_SEPARATOR}${segment}`; + } else { + leadingDate = segment; + } + continue; + } + const header = leadingDate + ? `${segment}${CREDENTIAL_LIST_SEPARATOR}${leadingDate}` + : segment; + leadingDate = undefined; + blocks.push({ headerLines: [header], dates: {}, bulletCount: 0 }); + } + return leadingDate; +} + /** Map one entry block to a `HeuristicAchievement` and its confidence score. * Extracted from `extractAchievements` to keep each function below the - * complexity threshold; mirrors `projectFromBlock`. */ -function achievementFromBlock(block: EntryBlock): { + * complexity threshold; mirrors `projectFromBlock`. `splitType` is threaded + * straight from `extractAchievements`'s option (#899) — see its docblock. */ +function achievementFromBlock( + block: EntryBlock, + splitType: boolean, +): { entry: HeuristicAchievement; score: number; } { @@ -161,8 +473,10 @@ function achievementFromBlock(block: EntryBlock): { // Lift the leading "Patent · …" type label off the header into its own field // — the ONE place that split happens (#456). Storing it here is what lets the // edit surface and the PDF exporter agree on the emphasized run without - // re-splitting a composed string (which is not a round-trip). - const split = splitAchievementType(label); + // re-splitting a composed string (which is not a round-trip). Certifications + // opt out (#899, `splitType`): a credential title is never "Type · label" + // shaped, so `type` stays undefined and the full title survives untouched. + const split = splitType ? splitAchievementType(label) : null; const type = split?.type; const title = split ? split.rest : label; diff --git a/src/lib/heuristics/openresume.ts b/src/lib/heuristics/openresume.ts index e5271c59..5114cb86 100644 --- a/src/lib/heuristics/openresume.ts +++ b/src/lib/heuristics/openresume.ts @@ -421,7 +421,16 @@ function buildHeuristicResult( const education = extractEducation(educationSection); const projects = extractProjects(projectsSection); const achievements = extractAchievements(achievementsSection); - const certifications = extractAchievements(certificationsSection); + // `splitType: false` (#899): a certification's title is a credential name, + // never a "Type · label" award header, so it must never lose a leading word + // to `splitAchievementType` — see `extractAchievements`'s docblock. + // `splitCompactList: true` is the other half of the same issue: this is the + // ONE bucket the exporter compresses onto a single middot-joined line, so it + // is the one bucket that must split such a line back apart. + const certifications = extractAchievements(certificationsSection, { + splitType: false, + splitCompactList: true, + }); const parsed: HeuristicParsedResume = { ...(name.value ? { full_name: name.value } : {}), diff --git a/src/lib/pdf/ats-resume-model.test.ts b/src/lib/pdf/ats-resume-model.test.ts index 2bd59844..974fe391 100644 --- a/src/lib/pdf/ats-resume-model.test.ts +++ b/src/lib/pdf/ats-resume-model.test.ts @@ -4,11 +4,13 @@ import { describe, expect, it } from "vitest"; import { bulletId } from "../score/bullet-id.ts"; import { buildAtsResumeModel } from "./ats-resume-model.ts"; +import type { AtsSection } from "./ats-resume-model.ts"; import { EMPHASIS_OPEN, EMPHASIS_CLOSE } from "./auto-bold-metrics.ts"; import type { CascadeResult, HeuristicParsedResume, } from "../heuristics/types.ts"; +import type { HeuristicAchievement } from "../score/types.ts"; import { ACCOMPLISHMENT_SECTION_NAMES } from "../heuristics/sections.ts"; import type { AnonymousAtsScore, BulletObservation } from "../score/score.ts"; import { countWords } from "../score/score.ts"; @@ -858,4 +860,152 @@ describe("buildAtsResumeModel — certifications as their own section (#884)", ( expect(entry.fields?.title).toBe("AWS · Solutions Architect – Professional"); expect(entry.fields?.startDate).toBe("2022"); }); + + it("emits no dangling separator when a certification has no year (#899)", () => { + const result = makeResult({ + heuristic_achievements: [], + heuristic_certifications: [{ title: "Patent Bar Registration" }], + }); + const entry = buildAtsResumeModel(result, makeScore([])).sections.find( + (s) => s.kind === "certifications", + )!.entries[0]; + // `joinHeader` (buildAchievementHeader) already drops a falsy part rather + // than joining it with the separator, so a dateless credential draws its + // title alone — no trailing "·" left dangling in front of nothing. + expect(entry.headerLine).toBe("Patent Bar Registration"); + }); +}); + +describe("buildAtsResumeModel — compact certifications line (#899)", () => { + const certs = ( + heuristic_certifications: HeuristicAchievement[], + ): AtsSection => + buildAtsResumeModel( + makeResult({ heuristic_achievements: [], heuristic_certifications }), + makeScore([]), + ).sections.find((s) => s.kind === "certifications")!; + + it("joins two or more credentials onto one middot-separated line", () => { + const section = certs([ + { title: "AWS Certified Cloud Practitioner", year: "2025" }, + { title: "AWS Certified Solutions Architect", year: "2026" }, + { title: "CKA" }, + ]); + expect(section.compactLine).toBe( + "AWS Certified Cloud Practitioner (2025) · AWS Certified Solutions Architect (2026) · CKA", + ); + // A dateless credential contributes its bare title — no parenthesised + // nothing, no dangling separator (#899). + expect(section.entries[2].headerLine).toBe("CKA"); + }); + + it("keeps every entry, so the JSON/Markdown exports still see N credentials", () => { + // The compaction is LAYOUT. `projectAtsExport` reads `entries`, never + // `compactLine`, so collapsing the drawn rows must not collapse the export. + const section = certs([ + { title: "AWS Certified Cloud Practitioner", year: "2025" }, + { title: "CKA", year: "2021", url: "https://verify.example.com/cka" }, + ]); + expect(section.entries).toHaveLength(2); + expect(section.entries.map((e) => e.fields?.title)).toEqual([ + "AWS Certified Cloud Practitioner", + "CKA", + ]); + expect(section.entries.map((e) => e.fields?.startDate)).toEqual([ + "2025", + "2021", + ]); + expect(section.entries[1].fields?.url).toBe("https://verify.example.com/cka"); + }); + + it("re-emits the source's own year punctuation when it is not the middot", () => { + // #380 fidelity survives the compaction: a résumé that wrote a comma keeps + // its comma, because a comma cannot be mistaken for the list separator. + const section = certs([ + { title: "AWS Certified Developer", year: "2022", year_separator: "," }, + { title: "CKA", year: "2021", year_separator: "," }, + ]); + expect(section.compactLine).toBe( + "AWS Certified Developer, 2022 · CKA, 2021", + ); + }); + + it("parenthesises a year the source set off with a MIDDOT", () => { + // The one separator that cannot survive verbatim: on the compact line the + // middot is the credential boundary, so re-emitting it around a year would + // re-parse "CKA · 2021" as a credential plus a stray date. The parser has a + // date-only re-join for exactly that source shape, but the exporter must not + // manufacture the ambiguity in the first place. + const section = certs([ + { title: "AWS Certified Developer", year: "2022", year_separator: "·" }, + { title: "CKA", year: "2021" }, + ]); + expect(section.compactLine).toBe( + "AWS Certified Developer (2022) · CKA (2021)", + ); + }); + + it("leaves a lone certification as its own row", () => { + const section = certs([{ title: "CKA", year: "2021" }]); + expect(section.compactLine).toBeUndefined(); + // Unchanged from pre-#899: the middot fallback still sets the year off. + expect(section.entries[0].headerLine).toBe("CKA · 2021"); + }); + + it("does NOT compact when a credential carries a bullet body", () => { + // Folding it would either lose the bullets or hang them under whichever + // credential drew last (`latex/awesome-cv-cv.pdf` is the corpus case). + const section = certs([ + { title: "AWS Certified Developer", year: "2022" }, + { title: "Speaker Certification", description: "Presented at re:Invent." }, + ]); + expect(section.compactLine).toBeUndefined(); + expect(section.entries[1].bullets).toEqual(["Presented at re:Invent."]); + }); + + it("does NOT compact when a credential carries a type label", () => { + // A `type` draws with emphasis sentinels, and the renderer picks the + // run-measuring path off their presence — which does NOT wrap atomically, + // so the joined line could break inside a credential. + const section = certs([ + { type: "AWS", title: "Certified Developer", year: "2022" }, + { title: "CKA", year: "2021" }, + ]); + expect(section.compactLine).toBeUndefined(); + }); + + it("does NOT compact when a run-collapsed credential's title itself carries the list separator (#899)", () => { + // `isDatedCredentialRow` (achievements.ts) legitimately collapses a dated + // multi-segment source row like "Google Cloud Architect · Google · Mar + // 2023" into ONE credential titled "Google Cloud Architect · Google". + // Compacting it onto a shared line would put an unescaped + // CREDENTIAL_LIST_SEPARATOR *inside* that credential, and the re-parser + // cannot tell that boundary apart from a boundary BETWEEN credentials — + // it would fabricate a second, dated entry named "Google" (2023). + const section = certs([ + { title: "Google Cloud Architect · Google", year: "2023" }, + { title: "CKA · CNCF", year: "2021" }, + ]); + expect(section.compactLine).toBeUndefined(); + // Kept as ordinary per-row entries instead — re-parses to the same title. + expect(section.entries[0].headerLine).toBe( + "Google Cloud Architect · Google · 2023", + ); + expect(section.entries[1].headerLine).toBe("CKA · CNCF · 2021"); + }); + + it("leaves the achievements section alone", () => { + const model = buildAtsResumeModel( + makeResult({ + heuristic_achievements: [ + { title: "Best Paper Award", year: "2021" }, + { title: "Innovation Prize", year: "2023" }, + ], + }), + makeScore([]), + ); + expect( + model.sections.find((s) => s.kind === "achievements")?.compactLine, + ).toBeUndefined(); + }); }); diff --git a/src/lib/pdf/ats-resume-model.ts b/src/lib/pdf/ats-resume-model.ts index 39a40cd2..2e85380e 100644 --- a/src/lib/pdf/ats-resume-model.ts +++ b/src/lib/pdf/ats-resume-model.ts @@ -50,10 +50,16 @@ import { import { achievementYearJoiner, buildProjectDates, + joinAchievementType, + DEFAULT_ACHIEVEMENT_YEAR_SEPARATOR, } from "../score/entry-dates.ts"; import { isLoneDateRange } from "../heuristics/line-primitives.ts"; import { isEntryHeaderShape } from "../heuristics/entry-blocks.ts"; import { formatGradeNote } from "../heuristics/extract/education-grade.ts"; +import { + CREDENTIAL_LIST_SEPARATOR, + CREDENTIAL_SPLIT_RE, +} from "../heuristics/extract/achievements.ts"; import { buildEducationDates, educationDateAnchors, @@ -213,9 +219,12 @@ export interface AtsEntry { * When `true`, `headerLine` must wrap with each `" · "`-delimited segment * kept atomic (never split mid-segment) — required for the skills list, * where a multi-word skill re-parses as two skills if the wrap point lands - * inside it (#301). Every other entry's middot is a display joiner only - * (e.g. "keyword · statement · year" achievement headers, #307) and must - * word-wrap normally, so this defaults to `false`/unset everywhere else. + * inside it (#301), and for the compact certifications line the renderer + * synthesizes from {@link AtsSection.compactLine} (#899), where a credential + * split across a wrap re-parses as two credentials. Every other entry's + * middot is a display joiner only (e.g. "keyword · statement · year" + * achievement headers, #307) and must word-wrap normally, so this defaults to + * `false`/unset everywhere else. */ atomicSegments?: boolean; /** @@ -250,6 +259,18 @@ export interface AtsSection { /** JSON-Resume mapping hint (#334); absent on sections not modeled by the * export. Display code ignores it. */ kind?: AtsSectionKind; + /** + * Draw the WHOLE section as this one wrapped line instead of one row per + * entry (#899) — the compact certifications list, its entries' headers joined + * by {@link CREDENTIAL_LIST_SEPARATOR}. Set only by the certifications block + * below, and only when compressing is safe (see there). + * + * LAYOUT ONLY. `entries` is untouched and stays the export-semantic source, so + * `projectAtsExport` — which never reads this field — still hands the + * JSON-Resume `certificates[]` and the Markdown export one entry per + * credential. Only `render-ats-pdf.ts` reads it, via `sectionDrawEntries`. + */ + compactLine?: string; } export interface AtsResumeModel { @@ -571,7 +592,59 @@ function groupExperienceEntriesByLabel( * what keeps "Patent · Foo" from exporting as bare "Foo". */ function credentialTitle(item: HeuristicAchievement): string { - return [item.type?.trim(), item.title].filter(Boolean).join(" · "); + return joinAchievementType(item.type, item.title); +} + +/** + * One credential's segment inside a COMPACT certifications line (#899): + * `"Title (Year)"`, or the source's own year punctuation when it wrote some that + * is not the middot. + * + * The middot is the LIST separator on that line, so a year set off by one would + * re-parse as a CREDENTIAL boundary — splitting one dated credential into a + * credential plus a stray date. Two sources hit that: a résumé that punctuated + * with whitespace alone (where `achievementYearJoiner`'s fallback IS the middot) + * and one that genuinely wrote a middot. Both parenthesise the year instead: + * `stripDateRange` already removes a paren pair left empty by the year it just + * deleted, so "CKA (2021)" re-parses to title "CKA" + year "2021" with nothing + * dangling. Every other source separator is re-emitted verbatim through + * `achievementYearJoiner`, keeping the #380 punctuation fidelity the + * one-row-per-credential header has. The `year_separator` of the two + * parenthesised cases does not survive the hop — there is no glyph left to read + * it back off — but the shape is idempotent from the first hop on, since the + * compact form is composed from `year`, not from the separator. + * + * The EDIT surface draws the SAME form (`AchievementYearSlot` in + * `ReconstructedResume.tsx`). It used to be exempt on the grounds that its + * credentials were structurally separate rows — true until #899 joined them onto + * one line with the same glyph, at which point the view was drawing a line the + * PDF does not and reintroducing the very ambiguity this function exists to + * remove. Where the credentials are NOT joined (a lone one, or a row of its + * own), both sides fall back to the source separator and #380's punctuation + * fidelity is untouched. + * + * The two surfaces do NOT share the condition, though, and can disagree about + * which credentials are joined at all. The view routes row by row + * (`joinsCompactLine`: this row has no bullets AND is a PARSED credential), + * while the section below compacts all-or-nothing (`certifications.every(…)`, + * which does not distinguish an added credential from a parsed one). So a + * user-ADDED credential draws on its own full-width row in the view and joins + * the compact line in the PDF, and one credential carrying a description keeps + * its own row in the view while switching compaction off for the whole section + * in the PDF. Both disagreements are cosmetic — the compact line round-trips + * either way — and unifying the predicates is a change of its own; this is here + * so the gap is known rather than rediscovered. + */ +function compactCredentialHeader(item: HeuristicAchievement): string { + const title = credentialTitle(item); + if (!item.year) return title; + // Degenerate (a user blanked the title): match what `joinHeader` does on the + // non-compact path and emit the year alone rather than inventing a shape. + if (!title) return item.year; + const separator = item.year_separator?.trim(); + return separator && separator !== DEFAULT_ACHIEVEMENT_YEAR_SEPARATOR + ? `${title}${achievementYearJoiner(separator)}${item.year}` + : `${title} (${item.year})`; } /** @@ -581,13 +654,31 @@ function credentialTitle(item: HeuristicAchievement): string { * once here and each caller supplies only what genuinely differs: the bullet * body (pooled vs description-only, see the Certifications block) and the * structured `fields` its JSON Resume array wants. + * + * `compact` (#899) swaps the header for its {@link compactCredentialHeader} + * segment form — the entry is about to be joined onto one shared line rather + * than drawn as a row of its own, so it must carry no emphasis run and no + * middot the join would be mistaken for. `fields` is identical either way: the + * compaction is a layout decision and must not reach the JSON/Markdown exports. */ function buildCredentialEntry( item: HeuristicAchievement, bullets: string[], fields: AtsEntryFields, fallbackHeader: string, + compact = false, ): AtsEntry { + if (compact) { + return { + headerLine: compactCredentialHeader(item) || fallbackHeader, + // Regular weight, like the skills list this line wraps exactly as (#425): + // a bold run per credential would read as N headers crowded onto one row. + headerBold: false, + subLine: undefined, + bullets, + fields, + }; + } // Bold only the `type` label ("Patent", "Publication"); the rest of the header // stays regular. A type-less item keeps the whole header bold. const { headerLine, emphasized } = buildAchievementHeader( @@ -806,6 +897,44 @@ export function buildAtsResumeModel( // nothing in `bulletsByIndex` to attribute to it. That is also why the // certification entries are absent from the `combined` grouping above — they // could only take a bullet AWAY from an achievement that legitimately owns it. + // + // COMPACT (#899). Credentials are short, and one vertical row each spends a + // disproportionate share of the page on them, so two or more compress onto a + // single wrapped middot-joined line (`compactLine` on the section below). + // Both guards are load-bearing, not conservatism: + // - a credential carrying a BULLET body cannot be folded onto a shared line + // at all — its bullets would either be lost or land under whichever + // credential happened to be drawn last (`latex/awesome-cv-cv.pdf` is the + // corpus case, and it keeps the per-row shape); + // - a credential carrying a `type` LABEL draws with emphasis sentinels, and + // the renderer picks the run-measuring path off the presence of one — + // which does not wrap atomically, so the joined line could break inside a + // credential and re-parse as two. Nothing upstream produces one any more: + // extraction does not (`splitType: false`), the edit surface offers no + // picker for a credential, and the legacy label a pre-#899 save left + // behind is folded back into the title by `applyOverrides` before this + // runs. The guard stays because the renderer's wrap path depends on it, + // not because a live path still reaches it. + // - a credential whose TITLE itself already contains + // {@link CREDENTIAL_LIST_SEPARATOR} cannot be folded onto a shared line + // either — the run-collapse in `achievements.ts` (`isDatedCredentialRow`) + // legitimately produces a title like "Google Cloud Architect · Google" + // for a dated multi-segment source row, and joining it onto a compact + // line the same way would put an unescaped separator inside one + // credential; the re-parser cannot tell that boundary apart from a + // boundary BETWEEN credentials, and would fabricate a second, dated + // entry named after the trailing segment. Excluded the same way as the + // `type`/`description` cases: kept as its own per-row entry instead. + // A lone credential is left exactly as it was: nothing to join it to, and no + // separator on the line means the re-parser's compact split is a no-op. + const compactCertifications = + certifications.length >= 2 && + certifications.every( + (c) => + !c.type?.trim() && + !c.description?.trim() && + !CREDENTIAL_SPLIT_RE.test(c.title), + ); const certificationEntries: AtsEntry[] = certifications.map((cert) => buildCredentialEntry( cert, @@ -818,6 +947,7 @@ export function buildAtsResumeModel( ...(cert.url ? { url: cert.url } : {}), }, "Certification", + compactCertifications, ), ); @@ -1054,6 +1184,16 @@ export function buildAtsResumeModel( heading: headings?.get("certifications") ?? "Certifications", entries: certificationEntries, kind: "certifications", + // The compact line is COMPOSED from the very entries it replaces on + // the page (#899), so the two can never drift into disagreeing about + // the same credential — and the entries stay the export source. + ...(compactCertifications + ? { + compactLine: certificationEntries + .map((e) => e.headerLine) + .join(CREDENTIAL_LIST_SEPARATOR), + } + : {}), } : null; // The two credential blocks are emitted TOGETHER, at whichever slot diff --git a/src/lib/pdf/render-ats-pdf.ts b/src/lib/pdf/render-ats-pdf.ts index 846054dd..9a5008c6 100644 --- a/src/lib/pdf/render-ats-pdf.ts +++ b/src/lib/pdf/render-ats-pdf.ts @@ -75,7 +75,11 @@ */ import { loadPdfLibOnce, type PdfLibParts } from "./load-pdf-lib.ts"; -import type { AtsResumeModel, AtsEntry } from "./ats-resume-model.ts"; +import type { + AtsResumeModel, + AtsEntry, + AtsSection, +} from "./ats-resume-model.ts"; import { autoBoldMetrics, EMPHASIS_OPEN, @@ -1167,10 +1171,11 @@ class Layout { * statement · year" achievement HEADER uses the middot purely as a display * joiner, so it opts OUT: atomic wrapping there would strand a whole segment * — the lone keyword or year — on its own line (#307). But the skills entry - * (re-parsed segment-by-segment, #301) and the "Company · Location Dates" / - * "Institution · Location Dates" sub-lines opt IN — there the middot is a + * (re-parsed segment-by-segment, #301), the "Company · Location Dates" / + * "Institution · Location Dates" sub-lines, and the compact certifications + * line (#899, {@link sectionDrawEntries}) opt IN — there the middot is a * re-parse-critical boundary and word-wrapping inside a multi-word location - * would fragment it on re-parse. + * or credential name would fragment it on re-parse. */ private wrap( text: string, @@ -2070,6 +2075,7 @@ async function renderAtsResumePdfAtSize( // ── Sections ── for (const section of model.sections) { + const drawn = sectionDrawEntries(section); // Keep-with-next (#629): the heading reserves its rule, its trailing gap AND // the whole keep-block of its first entry. Reserving only "heading + one // line" would let the entry's own reservation fire immediately afterwards and @@ -2077,18 +2083,21 @@ async function renderAtsResumePdfAtSize( drawSectionHeading( layout, section.heading, - section.entries.length > 0 - ? entryKeepHeight(layout, section.entries[0], muted) - : 0, + drawn.length > 0 ? entryKeepHeight(layout, drawn[0], muted) : 0, ); - for (let i = 0; i < section.entries.length; i++) { - drawEntry(layout, section.entries[i], muted, { + for (let i = 0; i < drawn.length; i++) { + drawEntry(layout, drawn[i], muted, { // The SAME label `collectModelTextFields` gives this entry, so a glyph - // finding and a pagination finding about one role name it identically. - entryPath: entryPathLabel(section.heading || "Section", section.entries[i], i), + // finding and a pagination finding about one role name it identically — + // EXCEPT for a compacted certifications section (#899): `drawn` here is + // `sectionDrawEntries`' one synthesized entry, while the glyph walk + // still labels each real credential in `section.entries`, so a + // pagination finding there names the whole joined line rather than the + // one credential a glyph finding about it would name. + entryPath: entryPathLabel(section.heading || "Section", drawn[i], i), findings, }); - if (i < section.entries.length - 1) layout.advance(layout.t.gapBetweenEntries); + if (i < drawn.length - 1) layout.advance(layout.t.gapBetweenEntries); } layout.advance(layout.t.gapBetweenEntries); } @@ -2415,6 +2424,40 @@ function trailingBulletKeepHeight(layout: Layout, entry: AtsEntry): number { return bulletKeepLines(lines) * layout.t.body * LINE_GAP; } +/** + * The entries a section actually DRAWS. Its own, normally — but a section + * carrying an {@link AtsSection.compactLine} (the compact certifications list, + * #899) draws exactly ONE synthesized body-text entry holding that whole joined + * line, so N credentials cost one wrapped row instead of N rows. + * + * `atomicSegments` is what makes the compression round-trip rather than merely + * look tidy: the line's `" · "` segments ARE the credentials, and + * {@link Layout.wrap} can only break BETWEEN segments — so every extracted line + * of the block starts at a credential boundary and the re-parser splits it back + * into N entries (`parseFlatAwardList`). It is the same guarantee the skills + * list has depended on since #301, reused rather than re-derived. + * + * The section's real `entries` are untouched: they remain the export-semantic + * source `projectAtsExport` hands the JSON-Resume and Markdown exports, which + * therefore still emit one certificate per credential. `entries` is also still + * the glyph AUDIT's source (`collectModelTextFields`) for those per-credential + * strings — the compact line itself is walked separately, since it is not one + * of them (see that function) — which is what puts a pagination finding about + * this synthesized entry out of step with a glyph finding about the same + * credential; see the call site in `renderAtsResumePdf`. + */ +function sectionDrawEntries(section: AtsSection): AtsEntry[] { + if (!section.compactLine) return section.entries; + return [ + { + headerLine: section.compactLine, + headerBold: false, + atomicSegments: true, + bullets: [], + }, + ]; +} + function drawEntry( layout: Layout, entry: AtsEntry, diff --git a/src/lib/pdf/render-findings.ts b/src/lib/pdf/render-findings.ts index b1161356..8ea8f952 100644 --- a/src/lib/pdf/render-findings.ts +++ b/src/lib/pdf/render-findings.ts @@ -195,6 +195,14 @@ export function collectModelTextFields( add(where, bullet, { path: `${path} → bullet ${b + 1}` }), ); }); + // The compact certifications line (#899) is a SEPARATE string from the + // section's own `entries` — `sectionDrawEntries` draws it instead of them — + // so without this it is the one string the page actually draws that the + // glyph audit never sees. Walked explicitly rather than trusted to already + // be covered via the entries it was joined from. + if (section.compactLine) { + add(where, section.compactLine, { path: `${where} → compact line` }); + } } return out; diff --git a/src/lib/score/entry-dates.ts b/src/lib/score/entry-dates.ts index cc2600c3..ffa60f5e 100644 --- a/src/lib/score/entry-dates.ts +++ b/src/lib/score/entry-dates.ts @@ -145,3 +145,18 @@ export function splitAchievementType( if (!type || type.length > ACHIEVEMENT_TYPE_MAX_LEN) return null; return { type, rest: title.slice(idx + 3) }; } + +/** + * Recompose a stored `type` + `title` into the one header string the source + * wrote — the inverse of {@link splitAchievementType}, and the reason it lives + * beside it: two call sites now need the composition (the PDF exporter's + * credential title, and the certifications fold in `apply-overrides.ts` that + * retires a legacy `type`), and a second spelling of the glue would be a second + * definition of what a "Type · title" header IS. + */ +export function joinAchievementType( + type: string | undefined, + title: string | undefined, +): string { + return [type?.trim(), title].filter(Boolean).join(" · "); +}