Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ Strict 3-tier architecture. Primitives + shared-composed live in `src/design-sys

**The Reuse Gate (soft).** Before adding a new *workflow surface*, search for an existing surface that already owns that capability and extend it. A parallel surface is allowed only with a written "Reuse analysis" justifying why (genuinely different interaction model, or isolation requirement). A hook (`scripts/hooks/reuse_surface_reminder.sh`) warns on new files under `src/components/`.

**Size.** Keep feature components under ~200 LOC; decompose past that. ⚠️ **Known debt — do not imitate:** `ReconstructedResume.tsx` (1338), `SectionRewrite.tsx` (607), `ModelSelector.tsx` (556), `ReconstructedRole.tsx` (483) all violate this. If you are editing one, prefer extracting your change into a new sibling over growing the file further.
**Size.** Keep feature components under ~200 LOC; decompose past that. ⚠️ **Known debt — do not imitate:** `ReconstructedResume.tsx` (1489), `SectionRewrite.tsx` (607), `ModelSelector.tsx` (556), `ReconstructedRole.tsx` (490) all violate this. If you are editing one, prefer extracting your change into a new sibling over growing the file further.

## Styling & tokens

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,9 @@ function Harness() {
addedBullets: edit.addedBullets,
addedExperience: edit.addedEntries.filter((e) => e.section === "experience"),
originalCount: 1,
// Identity: no parsed entry is deleted here, so a render position IS its
// parsed index (#856).
parsedIndices: [0],
onAddEntry: () => edit.addEntry("experience"),
onRemoveEntry: edit.removeEntry,
onEntryField: edit.setEntryField,
Expand Down
3 changes: 3 additions & 0 deletions src/components/features/ExperienceSection.prune-hold.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ function Harness() {
addedExperience: edit.addedEntries.filter((e) => e.section === "experience"),
// Index 0 is the parsed role; indices 1+ are the user-added ones.
originalCount: 1,
// Identity: no parsed entry is deleted here, so a render position IS its
// parsed index (#856).
parsedIndices: [0],
onAddEntry: () => edit.addEntry("experience"),
onRemoveEntry: edit.removeEntry,
onEntryField: edit.setEntryField,
Expand Down
3 changes: 3 additions & 0 deletions src/components/features/ExperienceSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ function Harness() {
addedBullets: {},
addedExperience: [],
originalCount: EXPERIENCES.length,
// Identity: nothing is deleted in this harness, so a render position IS its
// parsed index (#856).
parsedIndices: EXPERIENCES.map((_, i) => i),
onAddEntry: () => {},
onRemoveEntry: () => {},
onEntryField: () => {},
Expand Down
3 changes: 3 additions & 0 deletions src/components/features/ReconstructedEducationSkills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ describe("EducationSection date-row symmetry (issue 376)", () => {
onEducationFieldChange: () => {},
addedEducation: [],
originalCount: 1,
// Identity: nothing is deleted in this harness, so a render position IS
// its parsed index (#856).
parsedIndices: [0],
onAddEntry: () => {},
onRemoveEntry: () => {},
onEntryField: () => {},
Expand Down
34 changes: 27 additions & 7 deletions src/components/features/ReconstructedEducationSkills.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
AddedEntry,
AddedEntryField,
} from "../../hooks/useEditableParse.ts";
import { parsedEntryKey } from "../../hooks/useEditableParse.ts";
import { buildEducationDates } from "../../lib/score/entry-dates.ts";
import { EditableField, SectionHeading } from "@design-system";
import { validateDate } from "../../lib/edit/field-validators.ts";
Expand Down Expand Up @@ -105,7 +106,8 @@ function EducationEntry({
edu: ResumeEducation;
overrides: EducationFieldOverrides | undefined;
onFieldChange: (field: keyof EducationFieldOverrides, value: string) => void;
/** Remove this entry (only set for user-ADDED entries). */
/** Remove this entry. Set for a PARSED entry too since #856 — "is this
* user-added?" is {@link isAdded}, never this prop's presence. */
onRemove?: () => void;
/** User-added entries carry no `field` (major) slot, so the major affordance
* renders on PARSED entries only. */
Expand Down Expand Up @@ -200,6 +202,7 @@ export function EducationSection({
onEducationFieldChange,
addedEducation,
originalCount,
parsedIndices,
onAddEntry,
onRemoveEntry,
onEntryField,
Expand All @@ -209,17 +212,23 @@ export function EducationSection({
heading?: string;
education: ResumeEducation[];
educationOverrides: Record<number, EducationFieldOverrides>;
/** `index` is the entry's PARSED index — the key space `educationOverrides`
* uses — not its render position (#856). */
onEducationFieldChange: (
index: number,
field: keyof EducationFieldOverrides,
value: string,
) => void;
/** User-added education entries, append-aligned to indices ≥ originalCount. */
addedEducation: AddedEntry[];
/** Count of PARSED education entries; indices at/above this are user-added. */
/** Count of PARSED education entries still rendered; indices at/above this
* are user-added. */
originalCount: number;
/** Render position → PARSED index for the surviving parsed entries (#856),
* from `survivingParsedIndices`. Identity until one is deleted. */
parsedIndices: readonly number[];
onAddEntry: () => void;
onRemoveEntry: (id: string) => void;
onRemoveEntry: (key: string) => void;
onEntryField: (id: string, field: AddedEntryField, value: string) => void;
/** Drop a blank added entry when focus leaves the section (#379). */
onPruneEmpty: () => void;
Expand All @@ -239,14 +248,22 @@ export function EducationSection({
i >= originalCount
? addedEducation[i - originalCount]
: undefined;
// PARSED index, not the render position — see `parsedIndices`.
const parsedIdx = parsedIndices[i] ?? i;
const entryKey = added
? added.id
: parsedEntryKey("education", parsedIdx);
return (
<EducationEntry
key={added ? added.id : i}
// The ENTRY key, not the render position (#856): a deletion
// shifts every later row up one, and a position key would hand
// the deleted row's in-flight edit state to its successor.
key={entryKey}
edu={edu}
overrides={added ? undefined : educationOverrides[i]}
overrides={added ? undefined : educationOverrides[parsedIdx]}
onFieldChange={(field, value) => {
if (!added) {
onEducationFieldChange(i, field, value);
onEducationFieldChange(parsedIdx, field, value);
return;
}
// Added entries carry no major slot; the `field` edit can't
Expand All @@ -255,7 +272,10 @@ export function EducationSection({
if (field !== "field")
onEntryField(added.id, EDUCATION_FIELD_MAP[field], value);
}}
onRemove={added ? () => onRemoveEntry(added.id) : undefined}
// Education carries no bullets, so this is the one section whose
// delete is the bare `removeEntry` (#856) rather than the
// bullets-first `removeEntryWithBullets`.
onRemove={() => onRemoveEntry(entryKey)}
isAdded={Boolean(added)}
/>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 The offlinecv Authors

// @vitest-environment jsdom

/**
* #856 — the rendered delete affordance on a PARSED entry, and the index it
* writes with.
*
* Two things can only be caught here, above the hook and below the pipeline:
*
* 1. The affordance EXISTS on a parsed entry. Before #856 every section gated
* its `RemoveButton` on the entry being user-added, which is the whole
* report: a phantom achievement could be blanked field by field but never
* dropped, and still shipped into the Download PDF.
* 2. The key that crosses the component boundary is the PARSED index, not the
* render position. `applyOverrides` filters a deleted entry out of the array
* this section maps over, so from the first deletion on the two diverge —
* and `achievementOverrides`, `descriptionOverrides` and the tombstone set
* are all keyed by the parsed one. Writing a render position into any of them
* rebinds a survivor's edits to its neighbour's, silently and plausibly.
*
* Achievements is the section the report came from, and the one where all three
* of those channels meet, so it is the one rendered. Spies rather than a live
* hook: the claim is about which key crosses the boundary, which a spy states
* directly and a re-graded pipeline only implies.
*
* jsdom + raw `createRoot`, matching `ExperienceSection.test.tsx`.
*/

import { describe, expect, it, afterEach, beforeEach, vi } from "vitest";
import { createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { act } from "react";

(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;

import { AchievementsSection } from "./ReconstructedResume.tsx";
import { survivingParsedIndices } from "../../hooks/useEditableParse.ts";
import type { AddedEntry } from "../../hooks/useEditableParse.ts";
import { bulletId } from "../../lib/score/bullet-id.ts";
import type { BulletGroup } from "../../lib/score/group-bullets.ts";
import type { BulletObservation } from "../../lib/score/score.ts";
import type { HeuristicAchievement } from "../../lib/score/types.ts";

function bullet(index: number, text: string): BulletObservation {
return {
text,
id: bulletId(text, 0),
index,
hasMetric: true,
startsWithActionVerb: true,
wellFormedLength: true,
wordCount: 9,
};
}

const PARSED: readonly HeuristicAchievement[] = [
{ type: "Patent", title: "Phantom method", year: "2019" },
{ type: "Award", title: "Best Paper", year: "2020" },
{ type: "Talk", title: "Scaling parsers", year: "2021" },
];

const CITED = "Cited by 40 downstream filings.";

/** Index-aligned groups, as `buildEntryGroups` hands them over. Only the middle
* entry carries bullets, so the cascade has something to be wrong about. */
function groupsFor(achievements: readonly HeuristicAchievement[]): BulletGroup[] {
return achievements.map((a, i) => ({
experienceIndex: i,
experience: { title: a.title },
bullets: a.title === "Best Paper" ? [bullet(0, CITED)] : [],
}));
}

let container: HTMLDivElement;
let root: Root;

interface Spies {
onRemoveEntry: ReturnType<typeof vi.fn>;
onRemoveBullet: ReturnType<typeof vi.fn>;
onAchievementField: ReturnType<typeof vi.fn>;
}

/**
* Render the section over `achievements` with `removedEntries` already applied
* — i.e. exactly the state the container is in on the render AFTER a deletion:
* the array is filtered, and `parsedIndices` is the map back.
*/
function render(
achievements: readonly HeuristicAchievement[],
removedEntries: ReadonlySet<string> = new Set(),
added: AddedEntry[] = [],
): Spies {
const spies: Spies = {
onRemoveEntry: vi.fn(),
onRemoveBullet: vi.fn(() => true),
onAchievementField: vi.fn(),
};
const originalCount = achievements.length - added.length;
act(() =>
root.render(
createElement(AchievementsSection, {
achievements: [...achievements],
groups: groupsFor(achievements),
addedAchievements: added,
originalCount,
parsedIndices: survivingParsedIndices(
"achievements",
removedEntries,
originalCount,
),
onAddEntry: () => {},
onEntryField: () => {},
onAddBullet: () => {},
onPruneEmpty: () => {},
...spies,
}),
),
);
return spies;
}

function removeButtons(): HTMLElement[] {
return [
...container.querySelectorAll<HTMLElement>('[aria-label="Remove achievement"]'),
];
}

beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});

afterEach(() => {
act(() => root.unmount());
container.remove();
});

describe("the parsed-entry remove affordance (#856)", () => {
it("renders one on EVERY entry, parsed and added alike", () => {
render(PARSED);
expect(removeButtons()).toHaveLength(3);
});

it("deletes by the entry's parsedEntryKey, and takes its bullets", () => {
const spies = render(PARSED);
act(() => removeButtons()[1].click());

expect(spies.onRemoveEntry).toHaveBeenCalledExactlyOnceWith(
"achievements:1",
);
// Bullets go through `removeBullet` — dropping the entry cannot take them
// out of `sections`, which is the pool the anonymous scorer grades.
expect(spies.onRemoveBullet).toHaveBeenCalledExactlyOnceWith(
bulletId(CITED, 0),
{ entryKey: "achievements:1", text: CITED },
);
});

it("deletes a bullet-less entry with no bullet writes at all", () => {
const spies = render(PARSED);
act(() => removeButtons()[0].click());
expect(spies.onRemoveEntry).toHaveBeenCalledExactlyOnceWith(
"achievements:0",
);
expect(spies.onRemoveBullet).not.toHaveBeenCalled();
});

it("still removes a user-ADDED entry by its own id", () => {
const added: AddedEntry = {
id: "added:7",
section: "achievements",
title: "Hand-typed award",
};
const spies = render(
[...PARSED, { title: added.title }],
new Set(),
[added],
);
act(() => removeButtons()[3].click());
expect(spies.onRemoveEntry).toHaveBeenCalledExactlyOnceWith("added:7");
});
});

describe("index resolution after a deletion (#856)", () => {
// The render AFTER deleting parsed index 0: the array the section maps over
// has been filtered, so render position 0 is now parsed index 1.
const AFTER = PARSED.slice(1);
const REMOVED = new Set(["achievements:0"]);

it("deletes the NEXT entry by its parsed index, not its render position", () => {
const spies = render(AFTER, REMOVED);
expect(removeButtons()).toHaveLength(2);

act(() => removeButtons()[0].click());
// Render position 0 — "achievements:0" here would be a no-op re-delete of
// the entry that is already gone, leaving this one un-deletable forever.
expect(spies.onRemoveEntry).toHaveBeenCalledExactlyOnceWith(
"achievements:1",
);
expect(spies.onRemoveBullet).toHaveBeenCalledExactlyOnceWith(
bulletId(CITED, 0),
{ entryKey: "achievements:1", text: CITED },
);
});

it("files a surviving entry's field edit under its parsed index", () => {
const spies = render(AFTER, REMOVED);
// The year cell of the SECOND surviving entry — parsed index 2. Read mode
// names the control "Edit <label>" (`EditableField`, WCAG 2.5.3).
const years = [
...container.querySelectorAll<HTMLElement>('[aria-label="Edit Year"]'),
];
expect(years).toHaveLength(2);

act(() => years[1].click());
const input = container.querySelector<HTMLInputElement>("input");
expect(input).not.toBeNull();
act(() => {
// Through the prototype setter, so React's own value tracker sees the
// change and does not swallow the synthetic `input` event.
Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)!.set!.call(input!, "2023");
input!.dispatchEvent(new Event("input", { bubbles: true }));
});
act(() =>
input!.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
),
);

// Render position 1 — `(1, …)` here would overwrite the OTHER survivor.
expect(spies.onAchievementField).toHaveBeenCalledExactlyOnceWith(
2,
"year",
"2023",
);
});
});
Loading
Loading