Files
Reactive-Resume/packages/import/src/reactive-resume-v4-json.test.ts
T
bad431b2fc fix(import): auto-detect JSON format and show readable errors (#1) (#3296)
* fix(import): auto-detect JSON format and show readable errors (#1)

Readable import errors, a fail-soft v4 parser, and auto-detect of the JSON format so uploads just work. The format dropdown becomes an optional override. PDF and DOCX (AI) paths are untouched.

* fix(import): address review feedback on the v4 guard and error message

- reactive-resume-v4-json.tsx: reject arrays in isRecord so array-valued
  basics, sections, or metadata no longer pass the v4 shape guard.
- reactive-resume-v4-json.tsx: reuse the guard's error instance in the
  catch arm instead of allocating a duplicate NOT_V4_MESSAGE.
- error.ts: use a singular "Problem" label for root-level Zod issues so
  the message stays grammatical.
- add a regression test for array-valued v4 branches.

* fix(import): preserve selected JSON format

* test(import): cover selected JSON parser

---------

Co-authored-by: MrTig-afk <MrTig-afk@users.noreply.github.com>
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-08-13 23:01:06 +02:00

427 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import { parseReactiveResumeV4JSON } from "./reactive-resume-v4-json";
// Minimal v4 resume JSON to use as base in all tests.
// V4 layout is Array<Array<string[]>>: [ [mainColumn, sidebarColumn] ]
function makeV4Base(overrides: Record<string, unknown> = {}) {
return {
basics: {
name: "Jane Doe",
headline: "",
email: "jane@example.com",
phone: "",
location: "",
url: { label: "", href: "" },
customFields: [],
picture: {
url: "",
size: 64,
aspectRatio: 1,
borderRadius: 0,
effects: { hidden: false, border: false, grayscale: false },
},
},
sections: {
summary: { name: "Summary", columns: 1, separateLinks: false, visible: false, id: "summary", content: "" },
awards: { name: "Awards", columns: 1, separateLinks: false, visible: false, id: "awards", items: [] },
certifications: {
name: "Certifications",
columns: 1,
separateLinks: false,
visible: false,
id: "certifications",
items: [],
},
education: {
name: "Education",
columns: 1,
separateLinks: false,
visible: false,
id: "education",
items: [],
},
experience: {
name: "Experience",
columns: 1,
separateLinks: false,
visible: false,
id: "experience",
items: [],
},
volunteer: {
name: "Volunteer",
columns: 1,
separateLinks: false,
visible: false,
id: "volunteer",
items: [],
},
interests: {
name: "Interests",
columns: 1,
separateLinks: false,
visible: false,
id: "interests",
items: [],
},
languages: {
name: "Languages",
columns: 1,
separateLinks: false,
visible: false,
id: "languages",
items: [],
},
profiles: {
name: "Profiles",
columns: 1,
separateLinks: false,
visible: false,
id: "profiles",
items: [],
},
projects: {
name: "Projects",
columns: 1,
separateLinks: false,
visible: false,
id: "projects",
items: [],
},
publications: {
name: "Publications",
columns: 1,
separateLinks: false,
visible: false,
id: "publications",
items: [],
},
references: {
name: "References",
columns: 1,
separateLinks: false,
visible: false,
id: "references",
items: [],
},
skills: { name: "Skills", columns: 1, separateLinks: false, visible: false, id: "skills", items: [] },
...overrides,
},
metadata: {
template: "onyx",
layout: [[["experience", "education"], ["skills"]]],
css: { value: "", visible: false },
page: { margin: 14, format: "a4" as const, options: { breakLine: false, pageNumbers: false } },
theme: { background: "#ffffff", text: "#000000", primary: "#dc2626" },
typography: {
font: { family: "IBM Plex Serif", subset: "latin", variants: ["regular"], size: 14.67 },
lineHeight: 1.5,
hideIcons: false,
underlineLinks: false,
},
notes: "",
},
};
}
// ─── Bug #2726-A: Custom section items without a name ("description-only") ───
describe("parseReactiveResumeV4JSON custom section description-only items", () => {
it("puts description in the body (not the title) when item has no name", () => {
const v4 = makeV4Base({
custom: {
techstack: {
name: "Tech Stack",
columns: 1,
separateLinks: false,
visible: true,
id: "techstack",
items: [
{
id: "item-1",
visible: true,
// No 'name' — this is a description-only item
description: "React, TypeScript, GraphQL",
},
],
},
},
});
const result = parseReactiveResumeV4JSON(JSON.stringify(v4));
const section = result.customSections[0];
expect(section).toBeDefined();
const item = section?.items[0] as { company: string; position: string; description: string };
// company is a required field on experience items; #1 is the expected fallback for nameless items
expect(item.company).toBe("#1");
// The description content must appear in the BODY field (not silently discarded)
expect(item.description).toBe("React, TypeScript, GraphQL");
// position (subtitle) must be empty — description IS the whole content, not a subtitle
expect(item.position).toBe("");
});
it("uses name as title and description as subtitle when item has a name", () => {
const v4 = makeV4Base({
custom: {
awards: {
name: "Recognitions",
columns: 1,
separateLinks: false,
visible: true,
id: "custom-awards",
items: [
{
id: "item-2",
visible: true,
name: "Best Developer Award",
description: "Received for outstanding contributions",
},
],
},
},
});
const result = parseReactiveResumeV4JSON(JSON.stringify(v4));
const section = result.customSections[0];
const item = section?.items[0] as { company: string; position: string; description: string };
expect(item.company).toBe("Best Developer Award");
expect(item.position).toBe("Received for outstanding contributions");
});
it("falls back to summary for the body when both summary and description are present", () => {
const v4 = makeV4Base({
custom: {
extra: {
name: "Extra",
columns: 1,
separateLinks: false,
visible: true,
id: "extra",
items: [
{
id: "item-3",
visible: true,
// No name — description-only
description: "plain text fallback",
summary: "<p>Rich HTML content</p>",
},
],
},
},
});
const result = parseReactiveResumeV4JSON(JSON.stringify(v4));
const item = result.customSections[0]?.items[0] as { description: string };
// summary takes priority over description
expect(item.description).toBe("<p>Rich HTML content</p>");
});
it("treats whitespace-only name the same as missing name", () => {
const v4 = makeV4Base({
custom: {
ws: {
name: "Whitespace",
columns: 1,
separateLinks: false,
visible: true,
id: "ws",
items: [
{
id: "item-ws",
visible: true,
name: " ",
description: "Content goes here",
},
],
},
},
});
const result = parseReactiveResumeV4JSON(JSON.stringify(v4));
const item = result.customSections[0]?.items[0] as { company: string; position: string; description: string };
expect(item.company).toBe("#1");
expect(item.position).toBe("");
expect(item.description).toBe("Content goes here");
});
it("defaults item to visible when 'visible' field is missing in v4 data", () => {
const v4 = makeV4Base({
custom: {
test: {
name: "Test",
columns: 1,
separateLinks: false,
visible: true,
id: "test",
items: [
{
id: "item-no-vis",
// 'visible' intentionally omitted
name: "Some Item",
},
],
},
},
});
const result = parseReactiveResumeV4JSON(JSON.stringify(v4));
const item = result.customSections[0]?.items[0] as { hidden: boolean };
// Should default to visible (hidden=false), consistent with all other sections
expect(item.hidden).toBe(false);
});
it("preserves hidden items as hidden=true instead of dropping them", () => {
const v4 = makeV4Base({
custom: {
test: {
name: "Test",
columns: 1,
separateLinks: false,
visible: true,
id: "test",
items: [
{ id: "visible-item", visible: true, name: "Visible Item" },
{ id: "hidden-item", visible: false, name: "Hidden Item" },
],
},
},
});
const result = parseReactiveResumeV4JSON(JSON.stringify(v4));
const section = result.customSections[0];
// Both items must migrate — hidden items must NOT be dropped
expect(section?.items).toHaveLength(2);
const hiddenItem = section?.items.find((i) => (i as { id: string }).id === "hidden-item") as
| { hidden: boolean }
| undefined;
expect(hiddenItem?.hidden).toBe(true);
});
});
// ─── Bug #2726-B: Skill / Language levels incorrectly clamped to max ──────────
describe("parseReactiveResumeV4JSON skill level scaling (v4: 0-10 → v5: 0-5)", () => {
function makeWithSkills(items: Array<{ id: string; visible: boolean; name: string; level: number }>) {
return makeV4Base({
skills: {
name: "Skills",
columns: 1,
separateLinks: false,
visible: true,
id: "skills",
items,
},
});
}
it("scales level 10 → 5", () => {
const result = parseReactiveResumeV4JSON(
JSON.stringify(makeWithSkills([{ id: "s1", visible: true, name: "TypeScript", level: 10 }])),
);
expect(result.sections.skills.items[0]?.level).toBe(5);
});
it("scales level 8 → 4 (not 5)", () => {
const result = parseReactiveResumeV4JSON(
JSON.stringify(makeWithSkills([{ id: "s2", visible: true, name: "React", level: 8 }])),
);
expect(result.sections.skills.items[0]?.level).toBe(4);
});
it("scales level 6 → 3 (not 5)", () => {
const result = parseReactiveResumeV4JSON(
JSON.stringify(makeWithSkills([{ id: "s3", visible: true, name: "GraphQL", level: 6 }])),
);
expect(result.sections.skills.items[0]?.level).toBe(3);
});
it("scales level 5 → 3 (rounds 2.5 up)", () => {
const result = parseReactiveResumeV4JSON(
JSON.stringify(makeWithSkills([{ id: "s4", visible: true, name: "Node", level: 5 }])),
);
expect(result.sections.skills.items[0]?.level).toBe(3);
});
it("keeps level 0 → 0 (hides the visual indicator)", () => {
const result = parseReactiveResumeV4JSON(
JSON.stringify(makeWithSkills([{ id: "s5", visible: true, name: "Rust", level: 0 }])),
);
expect(result.sections.skills.items[0]?.level).toBe(0);
});
it("clamps negative level to 0", () => {
const result = parseReactiveResumeV4JSON(
JSON.stringify(makeWithSkills([{ id: "s6", visible: true, name: "Go", level: -3 }])),
);
expect(result.sections.skills.items[0]?.level).toBe(0);
});
it("clamps level above 10 to 5", () => {
const result = parseReactiveResumeV4JSON(
JSON.stringify(makeWithSkills([{ id: "s7", visible: true, name: "C++", level: 15 }])),
);
expect(result.sections.skills.items[0]?.level).toBe(5);
});
});
describe("parseReactiveResumeV4JSON language level scaling (v4: 0-10 → v5: 0-5)", () => {
function makeWithLanguages(items: Array<{ id: string; visible: boolean; name: string; level: number }>) {
return makeV4Base({
languages: {
name: "Languages",
columns: 1,
separateLinks: false,
visible: true,
id: "languages",
items,
},
});
}
it("scales level 10 → 5", () => {
const result = parseReactiveResumeV4JSON(
JSON.stringify(makeWithLanguages([{ id: "l1", visible: true, name: "Spanish", level: 10 }])),
);
expect(result.sections.languages.items[0]?.level).toBe(5);
});
it("scales level 6 → 3 (not 5)", () => {
const result = parseReactiveResumeV4JSON(
JSON.stringify(makeWithLanguages([{ id: "l2", visible: true, name: "French", level: 6 }])),
);
expect(result.sections.languages.items[0]?.level).toBe(3);
});
});
// ─── Guard: non-v4 JSON must fail with a readable error, not a raw TypeError ───
describe("parseReactiveResumeV4JSON rejects non-v4 input gracefully", () => {
it("throws a readable error (not a TypeError) for an empty object", () => {
expect(() => parseReactiveResumeV4JSON("{}")).toThrow(/v4/i);
});
it("throws for a JSON Resume shaped file (basics but no sections/metadata)", () => {
expect(() => parseReactiveResumeV4JSON(JSON.stringify({ basics: { name: "Jane" } }))).toThrow(/v4/i);
});
it("throws for a top-level JSON array", () => {
expect(() => parseReactiveResumeV4JSON("[]")).toThrow(/v4/i);
});
it("throws for a current-format export whose metadata.layout is not a v4 array", () => {
const currentFormat = {
basics: { name: "Jane" },
sections: {},
metadata: { layout: { sidebarWidth: 35, pages: [] } },
};
expect(() => parseReactiveResumeV4JSON(JSON.stringify(currentFormat))).toThrow(/v4/i);
});
it("throws when basics, sections, or metadata are arrays rather than objects", () => {
const arrayBranches = JSON.stringify({ basics: [], sections: [], metadata: [] });
expect(() => parseReactiveResumeV4JSON(arrayBranches)).toThrow(/v4/i);
});
});