mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-16 07:58:10 +10:00
📦 v5.0.15 - https://docs.rxresu.me/changelog
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { defaultResumeData } from "@/schema/resume/data";
|
||||
|
||||
import { buildAiExtractionTemplate } from "./ai-template";
|
||||
|
||||
describe("buildAiExtractionTemplate", () => {
|
||||
const template = buildAiExtractionTemplate();
|
||||
|
||||
it("produces a valid ResumeData structure", () => {
|
||||
// The template should pass schema validation (items have empty strings which satisfy .min(1) checks,
|
||||
// so we validate the overall shape matches)
|
||||
expect(template).toBeDefined();
|
||||
expect(template.basics).toBeDefined();
|
||||
expect(template.sections).toBeDefined();
|
||||
expect(template.metadata).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes a sample custom field in basics", () => {
|
||||
expect(template.basics.customFields).toHaveLength(1);
|
||||
expect(template.basics.customFields[0]).toEqual({ id: "", icon: "", text: "", link: "" });
|
||||
});
|
||||
|
||||
it("populates each section with exactly one example item", () => {
|
||||
const sectionKeys = [
|
||||
"profiles",
|
||||
"experience",
|
||||
"education",
|
||||
"projects",
|
||||
"skills",
|
||||
"languages",
|
||||
"interests",
|
||||
"awards",
|
||||
"certifications",
|
||||
"publications",
|
||||
"volunteer",
|
||||
"references",
|
||||
] as const;
|
||||
|
||||
for (const key of sectionKeys) {
|
||||
const section = template.sections[key];
|
||||
expect(section.items).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves section metadata from defaultResumeData", () => {
|
||||
const sectionKeys = Object.keys(defaultResumeData.sections) as Array<keyof typeof defaultResumeData.sections>;
|
||||
|
||||
for (const key of sectionKeys) {
|
||||
expect(template.sections[key].title).toBe(defaultResumeData.sections[key].title);
|
||||
expect(template.sections[key].columns).toBe(defaultResumeData.sections[key].columns);
|
||||
expect(template.sections[key].hidden).toBe(defaultResumeData.sections[key].hidden);
|
||||
}
|
||||
});
|
||||
|
||||
it("has empty string values in example items (not default data)", () => {
|
||||
const expItem = template.sections.experience.items[0];
|
||||
expect(expItem.company).toBe("");
|
||||
expect(expItem.position).toBe("");
|
||||
expect(expItem.id).toBe("");
|
||||
});
|
||||
|
||||
it("has zeroed numeric values in example items", () => {
|
||||
const skillItem = template.sections.skills.items[0];
|
||||
expect(skillItem.level).toBe(0);
|
||||
});
|
||||
|
||||
it("has nested objects with empty values", () => {
|
||||
const expItem = template.sections.experience.items[0];
|
||||
expect(expItem.website).toEqual({ url: "", label: "" });
|
||||
});
|
||||
|
||||
it("covers all sections present in defaultResumeData", () => {
|
||||
const defaultKeys = Object.keys(defaultResumeData.sections).sort();
|
||||
const templateKeys = Object.keys(template.sections).sort();
|
||||
expect(templateKeys).toEqual(defaultKeys);
|
||||
});
|
||||
|
||||
it("preserves metadata and picture from defaultResumeData", () => {
|
||||
expect(template.metadata).toEqual(defaultResumeData.metadata);
|
||||
expect(template.picture).toEqual(defaultResumeData.picture);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { defaultResumeData } from "@/schema/resume/data";
|
||||
|
||||
/**
|
||||
* Generates an AI extraction template item for a section by creating an object
|
||||
* with the same keys as the section's default items but with zeroed/empty values.
|
||||
* This template tells the AI what fields to extract for each section type.
|
||||
*/
|
||||
function makeEmptyItem(shape: Record<string, unknown>): Record<string, unknown> {
|
||||
const item: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(shape)) {
|
||||
if (typeof value === "string") item[key] = "";
|
||||
else if (typeof value === "number") item[key] = 0;
|
||||
else if (typeof value === "boolean") item[key] = false;
|
||||
else if (Array.isArray(value)) item[key] = [];
|
||||
else if (value !== null && typeof value === "object") {
|
||||
item[key] = makeEmptyItem(value as Record<string, unknown>);
|
||||
} else {
|
||||
item[key] = "";
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section item shape definitions. Each describes the fields the AI should extract.
|
||||
* These are the canonical shapes — if a schema field is added, add it here too.
|
||||
*/
|
||||
const SECTION_ITEM_SHAPES: Record<string, Record<string, unknown>> = {
|
||||
profiles: { id: "", hidden: false, icon: "", network: "", username: "", website: { url: "", label: "" } },
|
||||
experience: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
company: "",
|
||||
position: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
education: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
school: "",
|
||||
degree: "",
|
||||
area: "",
|
||||
grade: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
projects: { id: "", hidden: false, name: "", period: "", website: { url: "", label: "" }, description: "" },
|
||||
skills: { id: "", hidden: false, icon: "", name: "", proficiency: "", level: 0, keywords: [] },
|
||||
languages: { id: "", hidden: false, language: "", fluency: "", level: 0 },
|
||||
interests: { id: "", hidden: false, icon: "", name: "", keywords: [] },
|
||||
awards: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
title: "",
|
||||
awarder: "",
|
||||
date: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
certifications: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
title: "",
|
||||
issuer: "",
|
||||
date: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
publications: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
title: "",
|
||||
publisher: "",
|
||||
date: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
volunteer: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
organization: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
references: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
name: "",
|
||||
position: "",
|
||||
website: { url: "", label: "" },
|
||||
phone: "",
|
||||
description: "",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the AI extraction template programmatically from defaultResumeData.
|
||||
* This eliminates manual duplication and ensures the template stays in sync with the schema.
|
||||
*/
|
||||
export function buildAiExtractionTemplate() {
|
||||
const sections: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, shape] of Object.entries(SECTION_ITEM_SHAPES)) {
|
||||
const sectionKey = key as keyof typeof defaultResumeData.sections;
|
||||
sections[key] = {
|
||||
...defaultResumeData.sections[sectionKey],
|
||||
items: [makeEmptyItem(shape)],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...defaultResumeData,
|
||||
basics: {
|
||||
...defaultResumeData.basics,
|
||||
customFields: [{ id: "", icon: "", text: "", link: "" }],
|
||||
},
|
||||
sections: {
|
||||
...defaultResumeData.sections,
|
||||
...sections,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -38,4 +38,55 @@ describe("parseColorString", () => {
|
||||
it("should return null for incomplete hex", () => {
|
||||
expect(parseColorString("#ff")).toBeNull();
|
||||
});
|
||||
|
||||
// --- Additional branch coverage ---
|
||||
|
||||
it("should parse rgba() without alpha (defaults to 1)", () => {
|
||||
expect(parseColorString("rgba(100, 200, 50)")).toEqual({ r: 100, g: 200, b: 50, a: 1 });
|
||||
});
|
||||
|
||||
it("should parse rgb() with max values", () => {
|
||||
expect(parseColorString("rgb(255, 255, 255)")).toEqual({ r: 255, g: 255, b: 255, a: 1 });
|
||||
});
|
||||
|
||||
it("should parse rgba() with alpha of 0", () => {
|
||||
expect(parseColorString("rgba(0, 0, 0, 0)")).toEqual({ r: 0, g: 0, b: 0, a: 0 });
|
||||
});
|
||||
|
||||
it("should parse rgba() with alpha of 1", () => {
|
||||
expect(parseColorString("rgba(0, 0, 0, 1)")).toEqual({ r: 0, g: 0, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("should parse 3-digit hex #000", () => {
|
||||
expect(parseColorString("#000")).toEqual({ r: 0, g: 0, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("should parse 3-digit hex #fff", () => {
|
||||
expect(parseColorString("#fff")).toEqual({ r: 255, g: 255, b: 255, a: 1 });
|
||||
});
|
||||
|
||||
it("should return null for 4-digit hex", () => {
|
||||
expect(parseColorString("#ffff")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for 8-digit hex (with alpha)", () => {
|
||||
expect(parseColorString("#ff880080")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for non-hex characters after #", () => {
|
||||
expect(parseColorString("#gggggg")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for named colors", () => {
|
||||
expect(parseColorString("red")).toBeNull();
|
||||
expect(parseColorString("blue")).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle rgb with extra spaces", () => {
|
||||
expect(parseColorString("rgb( 10 , 20 , 30 )")).toEqual({ r: 10, g: 20, b: 30, a: 1 });
|
||||
});
|
||||
|
||||
it("should parse rgba with decimal alpha", () => {
|
||||
expect(parseColorString("rgba(255, 0, 0, 0.75)")).toEqual({ r: 255, g: 0, b: 0, a: 0.75 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { formatDate, formatPeriod, formatSingleDate } from "./date";
|
||||
|
||||
describe("formatDate", () => {
|
||||
it("formats YYYY-MM-DD to month year by default", () => {
|
||||
expect(formatDate("2024-01-15")).toBe("January 2024");
|
||||
});
|
||||
|
||||
it("formats YYYY-MM to month year", () => {
|
||||
expect(formatDate("2024-06")).toBe("June 2024");
|
||||
});
|
||||
|
||||
it("returns year only for YYYY", () => {
|
||||
expect(formatDate("2024")).toBe("2024");
|
||||
});
|
||||
|
||||
it("includes day when includeDay is true", () => {
|
||||
expect(formatDate("2024-01-15", true)).toBe("January 15, 2024");
|
||||
});
|
||||
|
||||
it("formats YYYY-MM with includeDay (no day available)", () => {
|
||||
expect(formatDate("2024-06", true)).toBe("June 2024");
|
||||
});
|
||||
|
||||
it("handles December correctly", () => {
|
||||
expect(formatDate("2023-12-25")).toBe("December 2023");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPeriod", () => {
|
||||
it("returns empty string when both dates are missing", () => {
|
||||
expect(formatPeriod()).toBe("");
|
||||
expect(formatPeriod(undefined, undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("returns end date when start is missing", () => {
|
||||
expect(formatPeriod(undefined, "2024-06")).toBe("2024-06");
|
||||
});
|
||||
|
||||
it("returns 'Start - Present' when end is missing", () => {
|
||||
expect(formatPeriod("2024-01")).toBe("January 2024 - Present");
|
||||
});
|
||||
|
||||
it("formats both dates", () => {
|
||||
expect(formatPeriod("2020-01", "2024-06")).toBe("January 2020 - June 2024");
|
||||
});
|
||||
|
||||
it("formats full dates to month-year", () => {
|
||||
expect(formatPeriod("2020-01-15", "2024-06-30")).toBe("January 2020 - June 2024");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSingleDate", () => {
|
||||
it("returns empty string for undefined", () => {
|
||||
expect(formatSingleDate()).toBe("");
|
||||
expect(formatSingleDate(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("formats YYYY-MM-DD with day included", () => {
|
||||
expect(formatSingleDate("2024-03-15")).toBe("March 15, 2024");
|
||||
});
|
||||
|
||||
it("formats YYYY-MM without day", () => {
|
||||
expect(formatSingleDate("2024-03")).toBe("March 2024");
|
||||
});
|
||||
|
||||
it("returns year only for YYYY", () => {
|
||||
expect(formatSingleDate("2024")).toBe("2024");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
const MONTH_NAMES = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
/**
|
||||
* Formats a partial ISO 8601 date string (YYYY, YYYY-MM, or YYYY-MM-DD)
|
||||
* into a human-readable format like "January 2024" or "January 15, 2024".
|
||||
*/
|
||||
export function formatDate(date: string, includeDay = false): string {
|
||||
const parts = date.split("-");
|
||||
|
||||
if (parts.length >= 2) {
|
||||
const [year, month] = parts;
|
||||
const monthName = MONTH_NAMES[parseInt(month, 10) - 1];
|
||||
|
||||
if (parts.length === 3 && includeDay) {
|
||||
return `${monthName} ${parts[2]}, ${year}`;
|
||||
}
|
||||
|
||||
return `${monthName} ${year}`;
|
||||
}
|
||||
|
||||
// YYYY only
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date range from start and end dates.
|
||||
* Returns "Start - End", "Start - Present" if no end, or just the end date if no start.
|
||||
*/
|
||||
export function formatPeriod(startDate?: string, endDate?: string): string {
|
||||
if (!startDate && !endDate) return "";
|
||||
if (!startDate) return endDate || "";
|
||||
if (!endDate) return `${formatDate(startDate)} - Present`;
|
||||
|
||||
return `${formatDate(startDate)} - ${formatDate(endDate)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a single date with day included (e.g., "January 15, 2024").
|
||||
* Falls back to month-year or year-only for partial dates.
|
||||
*/
|
||||
export function formatSingleDate(date?: string): string {
|
||||
if (!date) return "";
|
||||
return formatDate(date, true);
|
||||
}
|
||||
@@ -32,6 +32,10 @@ export const env = createEnv({
|
||||
GITHUB_CLIENT_ID: z.string().min(1).optional(),
|
||||
GITHUB_CLIENT_SECRET: z.string().min(1).optional(),
|
||||
|
||||
// Social Auth (LinkedIn)
|
||||
LINKEDIN_CLIENT_ID: z.string().min(1).optional(),
|
||||
LINKEDIN_CLIENT_SECRET: z.string().min(1).optional(),
|
||||
|
||||
// Custom OAuth Provider
|
||||
OAUTH_PROVIDER_NAME: z.string().min(1).optional(),
|
||||
OAUTH_CLIENT_ID: z.string().min(1).optional(),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { arrayToHtmlList, toHtmlDescription } from "./html";
|
||||
|
||||
describe("toHtmlDescription", () => {
|
||||
it("returns empty string when both are missing", () => {
|
||||
expect(toHtmlDescription()).toBe("");
|
||||
expect(toHtmlDescription(undefined, undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("wraps summary in <p>", () => {
|
||||
expect(toHtmlDescription("Hello")).toBe("<p>Hello</p>");
|
||||
});
|
||||
|
||||
it("creates <ul> from highlights", () => {
|
||||
expect(toHtmlDescription(undefined, ["a", "b"])).toBe("<ul><li>a</li><li>b</li></ul>");
|
||||
});
|
||||
|
||||
it("combines summary and highlights", () => {
|
||||
expect(toHtmlDescription("Summary", ["a", "b"])).toBe("<p>Summary</p><ul><li>a</li><li>b</li></ul>");
|
||||
});
|
||||
|
||||
it("ignores empty highlights array", () => {
|
||||
expect(toHtmlDescription("Summary", [])).toBe("<p>Summary</p>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("arrayToHtmlList", () => {
|
||||
it("returns empty string for empty array", () => {
|
||||
expect(arrayToHtmlList([])).toBe("");
|
||||
});
|
||||
|
||||
it("creates <ul> list from items", () => {
|
||||
expect(arrayToHtmlList(["a", "b", "c"])).toBe("<ul><li>a</li><li>b</li><li>c</li></ul>");
|
||||
});
|
||||
|
||||
it("handles single item", () => {
|
||||
expect(arrayToHtmlList(["only"])).toBe("<ul><li>only</li></ul>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Converts a summary string and optional highlights array into an HTML description.
|
||||
* Summary becomes a <p> tag, highlights become a <ul> list.
|
||||
*/
|
||||
export function toHtmlDescription(summary?: string, highlights?: string[]): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (summary) {
|
||||
parts.push(`<p>${summary}</p>`);
|
||||
}
|
||||
|
||||
if (highlights && highlights.length > 0) {
|
||||
parts.push("<ul>");
|
||||
|
||||
for (const highlight of highlights) {
|
||||
parts.push(`<li>${highlight}</li>`);
|
||||
}
|
||||
|
||||
parts.push("</ul>");
|
||||
}
|
||||
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an array of strings into an HTML unordered list.
|
||||
*/
|
||||
export function arrayToHtmlList(items: string[]): string {
|
||||
if (items.length === 0) return "";
|
||||
return `<ul>${items.map((item) => `<li>${item}</li>`).join("")}</ul>`;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { parseLevel } from "./level";
|
||||
|
||||
describe("parseLevel", () => {
|
||||
it("returns 0 for undefined/empty", () => {
|
||||
expect(parseLevel()).toBe(0);
|
||||
expect(parseLevel(undefined)).toBe(0);
|
||||
expect(parseLevel("")).toBe(0);
|
||||
});
|
||||
|
||||
it("parses numeric values within range", () => {
|
||||
expect(parseLevel("0")).toBe(0);
|
||||
expect(parseLevel("3")).toBe(3);
|
||||
expect(parseLevel("5")).toBe(5);
|
||||
});
|
||||
|
||||
it("returns 0 for numeric values out of range", () => {
|
||||
expect(parseLevel("6")).toBe(0);
|
||||
expect(parseLevel("-1")).toBe(0);
|
||||
});
|
||||
|
||||
it("maps expert-level text to 5", () => {
|
||||
expect(parseLevel("Native")).toBe(5);
|
||||
expect(parseLevel("Expert")).toBe(5);
|
||||
expect(parseLevel("Master")).toBe(5);
|
||||
});
|
||||
|
||||
it("maps advanced-level text to 4", () => {
|
||||
expect(parseLevel("Fluent")).toBe(4);
|
||||
expect(parseLevel("Advanced")).toBe(4);
|
||||
expect(parseLevel("Proficient")).toBe(4);
|
||||
});
|
||||
|
||||
it("maps intermediate-level text to 3", () => {
|
||||
expect(parseLevel("Intermediate")).toBe(3);
|
||||
expect(parseLevel("Conversational")).toBe(3);
|
||||
});
|
||||
|
||||
it("maps beginner-level text to 2", () => {
|
||||
expect(parseLevel("Beginner")).toBe(2);
|
||||
expect(parseLevel("Basic")).toBe(2);
|
||||
expect(parseLevel("Elementary")).toBe(2);
|
||||
});
|
||||
|
||||
it("maps novice to 1", () => {
|
||||
expect(parseLevel("Novice")).toBe(1);
|
||||
});
|
||||
|
||||
it("maps CEFR levels correctly", () => {
|
||||
expect(parseLevel("C2")).toBe(5);
|
||||
expect(parseLevel("C1")).toBe(4);
|
||||
expect(parseLevel("B2")).toBe(3);
|
||||
expect(parseLevel("B1")).toBe(2);
|
||||
expect(parseLevel("A2")).toBe(1);
|
||||
expect(parseLevel("A1")).toBe(1);
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(parseLevel("NATIVE")).toBe(5);
|
||||
expect(parseLevel("beginner")).toBe(2);
|
||||
expect(parseLevel("c2")).toBe(5);
|
||||
});
|
||||
|
||||
it("matches partial strings", () => {
|
||||
expect(parseLevel("Native speaker")).toBe(5);
|
||||
expect(parseLevel("Advanced level")).toBe(4);
|
||||
});
|
||||
|
||||
it("returns 0 for unrecognized text", () => {
|
||||
expect(parseLevel("unknown")).toBe(0);
|
||||
expect(parseLevel("xyz")).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Parses a skill/language level string to a numeric value (0-5).
|
||||
* Supports numeric values, text levels (beginner/intermediate/advanced/expert),
|
||||
* and CEFR levels (A1-C2).
|
||||
*/
|
||||
export function parseLevel(level?: string): number {
|
||||
if (!level) return 0;
|
||||
|
||||
const levelLower = level.toLowerCase();
|
||||
|
||||
// Try to parse numeric values
|
||||
const numeric = parseInt(levelLower, 10);
|
||||
if (!Number.isNaN(numeric) && numeric >= 0 && numeric <= 5) return numeric;
|
||||
|
||||
// Map text levels to numbers
|
||||
if (levelLower.includes("native") || levelLower.includes("expert") || levelLower.includes("master")) return 5;
|
||||
if (levelLower.includes("fluent") || levelLower.includes("advanced") || levelLower.includes("proficient")) return 4;
|
||||
if (levelLower.includes("intermediate") || levelLower.includes("conversational")) return 3;
|
||||
if (levelLower.includes("beginner") || levelLower.includes("basic") || levelLower.includes("elementary")) return 2;
|
||||
if (levelLower.includes("novice")) return 1;
|
||||
|
||||
// CEFR levels
|
||||
if (levelLower.includes("c2")) return 5;
|
||||
if (levelLower.includes("c1")) return 4;
|
||||
if (levelLower.includes("b2")) return 3;
|
||||
if (levelLower.includes("b1")) return 2;
|
||||
if (levelLower.includes("a2")) return 1;
|
||||
if (levelLower.includes("a1")) return 1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { getNetworkIcon } from "./network-icons";
|
||||
|
||||
describe("getNetworkIcon", () => {
|
||||
it("returns 'star' for undefined/empty", () => {
|
||||
expect(getNetworkIcon()).toBe("star");
|
||||
expect(getNetworkIcon(undefined)).toBe("star");
|
||||
});
|
||||
|
||||
it("maps GitHub correctly", () => {
|
||||
expect(getNetworkIcon("GitHub")).toBe("github-logo");
|
||||
expect(getNetworkIcon("github")).toBe("github-logo");
|
||||
});
|
||||
|
||||
it("maps LinkedIn correctly", () => {
|
||||
expect(getNetworkIcon("LinkedIn")).toBe("linkedin-logo");
|
||||
});
|
||||
|
||||
it("maps Twitter and X.com", () => {
|
||||
expect(getNetworkIcon("Twitter")).toBe("twitter-logo");
|
||||
expect(getNetworkIcon("x.com")).toBe("twitter-logo");
|
||||
});
|
||||
|
||||
it("maps other social networks", () => {
|
||||
expect(getNetworkIcon("Facebook")).toBe("facebook-logo");
|
||||
expect(getNetworkIcon("Instagram")).toBe("instagram-logo");
|
||||
expect(getNetworkIcon("YouTube")).toBe("youtube-logo");
|
||||
expect(getNetworkIcon("Medium")).toBe("medium-logo");
|
||||
expect(getNetworkIcon("Dribbble")).toBe("dribbble-logo");
|
||||
expect(getNetworkIcon("Behance")).toBe("behance-logo");
|
||||
});
|
||||
|
||||
it("maps dev platforms", () => {
|
||||
expect(getNetworkIcon("StackOverflow")).toBe("stack-overflow-logo");
|
||||
expect(getNetworkIcon("Stack-Overflow")).toBe("stack-overflow-logo");
|
||||
expect(getNetworkIcon("dev.to")).toBe("code");
|
||||
expect(getNetworkIcon("devto")).toBe("code");
|
||||
expect(getNetworkIcon("GitLab")).toBe("git-branch");
|
||||
expect(getNetworkIcon("Bitbucket")).toBe("code");
|
||||
expect(getNetworkIcon("CodePen")).toBe("code");
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(getNetworkIcon("GITHUB")).toBe("github-logo");
|
||||
expect(getNetworkIcon("linkedin")).toBe("linkedin-logo");
|
||||
});
|
||||
|
||||
it("returns 'star' for unknown networks", () => {
|
||||
expect(getNetworkIcon("MySpace")).toBe("star");
|
||||
expect(getNetworkIcon("Unknown")).toBe("star");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { IconName } from "@/schema/icons";
|
||||
|
||||
const NETWORK_ICON_MAP: Array<{ match: string[]; icon: IconName }> = [
|
||||
{ match: ["github"], icon: "github-logo" },
|
||||
{ match: ["linkedin"], icon: "linkedin-logo" },
|
||||
{ match: ["twitter", "x", "x.com"], icon: "twitter-logo" },
|
||||
{ match: ["facebook"], icon: "facebook-logo" },
|
||||
{ match: ["instagram"], icon: "instagram-logo" },
|
||||
{ match: ["youtube"], icon: "youtube-logo" },
|
||||
{ match: ["stackoverflow", "stack-overflow"], icon: "stack-overflow-logo" },
|
||||
{ match: ["medium"], icon: "medium-logo" },
|
||||
{ match: ["dev.to", "devto"], icon: "code" },
|
||||
{ match: ["dribbble"], icon: "dribbble-logo" },
|
||||
{ match: ["behance"], icon: "behance-logo" },
|
||||
{ match: ["gitlab"], icon: "git-branch" },
|
||||
{ match: ["bitbucket", "codepen"], icon: "code" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Maps a social network name to a Phosphor icon name.
|
||||
* Returns "star" as default if no match is found.
|
||||
*/
|
||||
export function getNetworkIcon(network?: string): IconName {
|
||||
if (!network) return "star";
|
||||
|
||||
const networkLower = network.toLowerCase();
|
||||
|
||||
for (const entry of NETWORK_ICON_MAP) {
|
||||
if (entry.match.some((keyword) => networkLower.includes(keyword))) {
|
||||
return entry.icon;
|
||||
}
|
||||
}
|
||||
|
||||
return "star";
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { hashPassword, verifyPassword } from "./password";
|
||||
|
||||
describe("hashPassword", () => {
|
||||
it("returns a bcrypt hash string", async () => {
|
||||
const hash = await hashPassword("my-password");
|
||||
expect(hash).toMatch(/^\$2[aby]?\$\d+\$/);
|
||||
});
|
||||
|
||||
it("produces different hashes for the same password (salted)", async () => {
|
||||
const hash1 = await hashPassword("same-password");
|
||||
const hash2 = await hashPassword("same-password");
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it("produces a hash of expected length (~60 chars)", async () => {
|
||||
const hash = await hashPassword("test");
|
||||
expect(hash.length).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyPassword", () => {
|
||||
it("returns true for correct password", async () => {
|
||||
const hash = await hashPassword("correct-password");
|
||||
const result = await verifyPassword("correct-password", hash);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for incorrect password", async () => {
|
||||
const hash = await hashPassword("correct-password");
|
||||
const result = await verifyPassword("wrong-password", hash);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("handles empty passwords", async () => {
|
||||
const hash = await hashPassword("");
|
||||
expect(await verifyPassword("", hash)).toBe(true);
|
||||
expect(await verifyPassword("notempty", hash)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles special characters", async () => {
|
||||
const password = "p@$$w0rd!#%&*(){}[]|\\/<>?";
|
||||
const hash = await hashPassword(password);
|
||||
expect(await verifyPassword(password, hash)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles unicode characters", async () => {
|
||||
const password = "パスワード123";
|
||||
const hash = await hashPassword(password);
|
||||
expect(await verifyPassword(password, hash)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// We can't easily mock createIsomorphicFn, so we reimplement the core logic
|
||||
// directly in the test to verify the token generation/verification algorithm.
|
||||
// This tests the same code paths without depending on the TanStack wrapper.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TEST_SECRET = "test-secret-key-for-hmac";
|
||||
const PRINTER_TOKEN_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Reimplementation of generatePrinterToken for testing */
|
||||
function generateToken(resumeId: string): string {
|
||||
const timestamp = Date.now();
|
||||
const payload = `${resumeId}:${timestamp}`;
|
||||
const payloadBase64 = Buffer.from(payload).toString("base64url");
|
||||
const signature = createHmac("sha256", TEST_SECRET).update(payloadBase64).digest("hex");
|
||||
return `${payloadBase64}.${signature}`;
|
||||
}
|
||||
|
||||
/** Reimplementation of verifyPrinterToken for testing */
|
||||
function verifyToken(token: string): string {
|
||||
const { timingSafeEqual } = require("node:crypto");
|
||||
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 2) throw new Error("Invalid token format");
|
||||
|
||||
const [payloadBase64, signature] = parts;
|
||||
|
||||
const expectedSignature = createHmac("sha256", TEST_SECRET).update(payloadBase64).digest("hex");
|
||||
const signatureBuffer = Buffer.from(signature);
|
||||
const expectedBuffer = Buffer.from(expectedSignature);
|
||||
|
||||
if (signatureBuffer.length !== expectedBuffer.length || !timingSafeEqual(signatureBuffer, expectedBuffer)) {
|
||||
throw new Error("Invalid token signature");
|
||||
}
|
||||
|
||||
const payload = Buffer.from(payloadBase64, "base64url").toString("utf-8");
|
||||
const [resumeId, timestampStr] = payload.split(":");
|
||||
if (!resumeId || !timestampStr) throw new Error("Invalid token payload");
|
||||
|
||||
const timestamp = Number.parseInt(timestampStr, 10);
|
||||
if (Number.isNaN(timestamp)) throw new Error("Invalid timestamp");
|
||||
|
||||
const age = Date.now() - timestamp;
|
||||
if (age < 0 || age > PRINTER_TOKEN_TTL_MS) throw new Error("Token expired");
|
||||
|
||||
return resumeId;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createValidToken(resumeId: string, timestamp: number): string {
|
||||
const payload = `${resumeId}:${timestamp}`;
|
||||
const payloadBase64 = Buffer.from(payload).toString("base64url");
|
||||
const signature = createHmac("sha256", TEST_SECRET).update(payloadBase64).digest("hex");
|
||||
return `${payloadBase64}.${signature}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — these validate the algorithm used by printer-token.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("generatePrinterToken (algorithm)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns a token with format payload.signature", () => {
|
||||
const token = generateToken("resume-123");
|
||||
const parts = token.split(".");
|
||||
expect(parts).toHaveLength(2);
|
||||
expect(parts[0].length).toBeGreaterThan(0);
|
||||
expect(parts[1].length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("encodes the resume ID and timestamp in the payload", () => {
|
||||
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
|
||||
const token = generateToken("my-resume-id");
|
||||
const [payloadBase64] = token.split(".");
|
||||
const decoded = Buffer.from(payloadBase64, "base64url").toString("utf-8");
|
||||
|
||||
expect(decoded).toContain("my-resume-id");
|
||||
expect(decoded).toContain(String(Date.now()));
|
||||
});
|
||||
|
||||
it("produces a valid HMAC-SHA256 signature", () => {
|
||||
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
|
||||
const token = generateToken("resume-abc");
|
||||
const [payloadBase64, signature] = token.split(".");
|
||||
|
||||
const expectedSignature = createHmac("sha256", TEST_SECRET).update(payloadBase64).digest("hex");
|
||||
expect(signature).toBe(expectedSignature);
|
||||
});
|
||||
|
||||
it("generates different tokens for different resume IDs", () => {
|
||||
const token1 = generateToken("resume-1");
|
||||
const token2 = generateToken("resume-2");
|
||||
expect(token1).not.toBe(token2);
|
||||
});
|
||||
|
||||
it("generates different tokens at different timestamps", () => {
|
||||
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
|
||||
const token1 = generateToken("same-resume");
|
||||
|
||||
vi.setSystemTime(new Date("2025-01-01T00:01:00Z"));
|
||||
const token2 = generateToken("same-resume");
|
||||
|
||||
expect(token1).not.toBe(token2);
|
||||
});
|
||||
|
||||
it("uses base64url encoding (no +, /, or = characters in payload)", () => {
|
||||
const token = generateToken("resume/with+special=chars");
|
||||
const [payloadBase64] = token.split(".");
|
||||
|
||||
expect(payloadBase64).not.toMatch(/[+/=]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyPrinterToken (algorithm)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns the resume ID for a valid, non-expired token", () => {
|
||||
const now = new Date("2025-06-15T12:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const token = createValidToken("resume-123", now.getTime());
|
||||
const resumeId = verifyToken(token);
|
||||
|
||||
expect(resumeId).toBe("resume-123");
|
||||
});
|
||||
|
||||
it("accepts a token that is just under 5 minutes old", () => {
|
||||
const createdAt = new Date("2025-06-15T12:00:00Z");
|
||||
vi.setSystemTime(new Date("2025-06-15T12:04:59Z"));
|
||||
|
||||
const token = createValidToken("resume-456", createdAt.getTime());
|
||||
const resumeId = verifyToken(token);
|
||||
|
||||
expect(resumeId).toBe("resume-456");
|
||||
});
|
||||
|
||||
it("rejects a token exactly at the 5-minute boundary", () => {
|
||||
const createdAt = new Date("2025-06-15T12:00:00Z");
|
||||
// 5 minutes + 1ms after creation
|
||||
vi.setSystemTime(new Date(createdAt.getTime() + PRINTER_TOKEN_TTL_MS + 1));
|
||||
|
||||
const token = createValidToken("resume-789", createdAt.getTime());
|
||||
expect(() => verifyToken(token)).toThrow("Token expired");
|
||||
});
|
||||
|
||||
it("throws for a token older than 5 minutes", () => {
|
||||
const createdAt = new Date("2025-06-15T12:00:00Z");
|
||||
vi.setSystemTime(new Date("2025-06-15T12:06:00Z"));
|
||||
|
||||
const token = createValidToken("resume-789", createdAt.getTime());
|
||||
expect(() => verifyToken(token)).toThrow("Token expired");
|
||||
});
|
||||
|
||||
it("throws for a token with a future timestamp (negative age)", () => {
|
||||
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
|
||||
const futureTime = new Date("2025-06-15T13:00:00Z").getTime();
|
||||
|
||||
const token = createValidToken("resume-future", futureTime);
|
||||
expect(() => verifyToken(token)).toThrow("Token expired");
|
||||
});
|
||||
|
||||
it("throws for invalid token format (no dot separator)", () => {
|
||||
expect(() => verifyToken("nodothere")).toThrow("Invalid token format");
|
||||
});
|
||||
|
||||
it("throws for invalid token format (too many dots)", () => {
|
||||
expect(() => verifyToken("a.b.c")).toThrow("Invalid token format");
|
||||
});
|
||||
|
||||
it("throws for empty token", () => {
|
||||
expect(() => verifyToken("")).toThrow("Invalid token format");
|
||||
});
|
||||
|
||||
it("throws for a token with tampered signature", () => {
|
||||
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
|
||||
const token = createValidToken("resume-123", Date.now());
|
||||
const [payload] = token.split(".");
|
||||
|
||||
const tamperedToken = `${payload}.deadbeef0000000000000000000000000000000000000000000000000000dead`;
|
||||
expect(() => verifyToken(tamperedToken)).toThrow("Invalid token signature");
|
||||
});
|
||||
|
||||
it("throws for a token with tampered payload", () => {
|
||||
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
|
||||
const token = createValidToken("resume-123", Date.now());
|
||||
const [, signature] = token.split(".");
|
||||
|
||||
const tamperedPayload = Buffer.from("hacked-resume:0").toString("base64url");
|
||||
expect(() => verifyToken(`${tamperedPayload}.${signature}`)).toThrow("Invalid token signature");
|
||||
});
|
||||
|
||||
it("throws for a token with invalid base64 payload (no colon separator)", () => {
|
||||
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
|
||||
const payloadBase64 = Buffer.from("nocolon").toString("base64url");
|
||||
const signature = createHmac("sha256", TEST_SECRET).update(payloadBase64).digest("hex");
|
||||
|
||||
// "nocolon".split(":") => ["nocolon"], so timestampStr is undefined
|
||||
expect(() => verifyToken(`${payloadBase64}.${signature}`)).toThrow("Invalid token payload");
|
||||
});
|
||||
|
||||
it("throws for payload with non-numeric timestamp", () => {
|
||||
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
|
||||
const payloadBase64 = Buffer.from("resume-id:not-a-number").toString("base64url");
|
||||
const signature = createHmac("sha256", TEST_SECRET).update(payloadBase64).digest("hex");
|
||||
|
||||
expect(() => verifyToken(`${payloadBase64}.${signature}`)).toThrow("Invalid timestamp");
|
||||
});
|
||||
|
||||
it("round-trips: generate then verify returns original resume ID", () => {
|
||||
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
|
||||
|
||||
const token = generateToken("my-resume-uuid-v7");
|
||||
const result = verifyToken(token);
|
||||
|
||||
expect(result).toBe("my-resume-uuid-v7");
|
||||
});
|
||||
|
||||
it("handles resume IDs with dashes and underscores", () => {
|
||||
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
|
||||
|
||||
const id = "resume-with-dashes_and_underscores";
|
||||
const token = generateToken(id);
|
||||
const result = verifyToken(token);
|
||||
|
||||
expect(result).toBe(id);
|
||||
});
|
||||
|
||||
it("resume IDs with colons cause verification to fail", () => {
|
||||
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
|
||||
|
||||
// Documents a known limitation: IDs with ":" break the payload format
|
||||
// because verifyToken splits on ":" to separate ID from timestamp,
|
||||
// so the second segment becomes part of the ID, not the timestamp.
|
||||
const token = generateToken("resume:with:colons");
|
||||
expect(() => verifyToken(token)).toThrow("Invalid timestamp");
|
||||
});
|
||||
|
||||
it("accepts a token at exactly age 0 (just created)", () => {
|
||||
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
|
||||
const token = createValidToken("fresh", Date.now());
|
||||
expect(verifyToken(token)).toBe("fresh");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createIsomorphicFn } from "@tanstack/react-start";
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
@@ -15,7 +15,7 @@ export const generatePrinterToken = createIsomorphicFn().server((resumeId: strin
|
||||
const payloadBase64 = Buffer.from(payload).toString("base64url");
|
||||
|
||||
// Create HMAC signature using AUTH_SECRET
|
||||
const signature = createHash("sha256").update(`${payloadBase64}.${env.AUTH_SECRET}`).digest("hex");
|
||||
const signature = createHmac("sha256", env.AUTH_SECRET).update(payloadBase64).digest("hex");
|
||||
|
||||
return `${payloadBase64}.${signature}`;
|
||||
});
|
||||
@@ -31,7 +31,7 @@ export const verifyPrinterToken = createIsomorphicFn().server((token: string) =>
|
||||
const [payloadBase64, signature] = parts;
|
||||
|
||||
// Verify signature
|
||||
const expectedSignature = createHash("sha256").update(`${payloadBase64}.${env.AUTH_SECRET}`).digest("hex");
|
||||
const expectedSignature = createHmac("sha256", env.AUTH_SECRET).update(payloadBase64).digest("hex");
|
||||
const signatureBuffer = Buffer.from(signature);
|
||||
const expectedBuffer = Buffer.from(expectedSignature);
|
||||
|
||||
|
||||
@@ -288,6 +288,150 @@ describe("buildDocument", () => {
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
// --- Template configs ---
|
||||
|
||||
it("renders with chikorita template (sidebar-only header, solid sidebar bg)", () => {
|
||||
const data = makeResumeData({
|
||||
basics: { ...defaultResumeData.basics, name: "Jane", headline: "Dev", email: "j@e.com" },
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
template: "chikorita",
|
||||
layout: {
|
||||
sidebarWidth: 35,
|
||||
pages: [{ fullWidth: false, main: ["experience"], sidebar: ["skills"] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("renders with ditgar template (sidebar-only header, tint sidebar bg)", () => {
|
||||
const data = makeResumeData({
|
||||
basics: { ...defaultResumeData.basics, name: "Alice", headline: "PM" },
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
template: "ditgar",
|
||||
layout: {
|
||||
sidebarWidth: 30,
|
||||
pages: [{ fullWidth: false, main: ["experience"], sidebar: ["skills"] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("renders with pikachu template (main-only header)", () => {
|
||||
const data = makeResumeData({
|
||||
basics: { ...defaultResumeData.basics, name: "Bob" },
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
template: "pikachu",
|
||||
layout: {
|
||||
sidebarWidth: 35,
|
||||
pages: [{ fullWidth: false, main: ["experience"], sidebar: ["skills"] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("renders with unknown template (falls back to default config)", () => {
|
||||
const data = makeResumeData({
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
template: "unknown-template" as any,
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
// --- Header edge cases ---
|
||||
|
||||
it("renders header with custom fields (with links)", () => {
|
||||
const data = makeResumeData({
|
||||
basics: {
|
||||
...defaultResumeData.basics,
|
||||
name: "Jane",
|
||||
customFields: [
|
||||
{ id: "cf1", icon: "star", text: "Portfolio", link: "https://portfolio.dev" },
|
||||
{ id: "cf2", icon: "", text: "Plain text field", link: "" },
|
||||
{ id: "cf3", icon: "", text: "", link: "" }, // empty text — skipped
|
||||
{ id: "cf4", icon: "", text: "Bad link", link: "javascript:alert(1)" }, // unsafe link — rendered as text
|
||||
],
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("renders header without name (no title paragraph)", () => {
|
||||
const data = makeResumeData({
|
||||
basics: { ...defaultResumeData.basics, name: "" },
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("renders header without headline", () => {
|
||||
const data = makeResumeData({
|
||||
basics: { ...defaultResumeData.basics, name: "Jane", headline: "" },
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
// --- Multi-page layout ---
|
||||
|
||||
it("renders multi-page layout", () => {
|
||||
const data = makeResumeData({
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
layout: {
|
||||
sidebarWidth: 35,
|
||||
pages: [
|
||||
{ fullWidth: false, main: ["experience"], sidebar: ["skills"] },
|
||||
{ fullWidth: true, main: ["education"], sidebar: [] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
// --- Section rendering with unknown section ID ---
|
||||
|
||||
it("handles unknown section IDs in layout gracefully", () => {
|
||||
const data = makeResumeData({
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
layout: {
|
||||
sidebarWidth: 35,
|
||||
pages: [{ fullWidth: false, main: ["nonexistent-section"], sidebar: [] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
// --- Free-form page format ---
|
||||
|
||||
it("handles free-form page format", () => {
|
||||
const data = makeResumeData({
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
page: { ...defaultResumeData.metadata.page, format: "free-form" },
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("produces a valid DOCX blob via Packer", async () => {
|
||||
const { Packer } = await import("docx");
|
||||
const data = makeResumeData({
|
||||
|
||||
@@ -140,4 +140,148 @@ describe("htmlToParagraphs", () => {
|
||||
const result = htmlToParagraphs("<p><u>under</u></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
// --- Additional coverage: block elements ---
|
||||
|
||||
it("parses blockquote with italic styling", () => {
|
||||
const result = htmlToParagraphs("<blockquote>Quote text</blockquote>");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBeInstanceOf(Paragraph);
|
||||
});
|
||||
|
||||
it("parses pre/code block with monospace font", () => {
|
||||
const result = htmlToParagraphs("<pre>const x = 42;</pre>");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBeInstanceOf(Paragraph);
|
||||
});
|
||||
|
||||
it("parses hr as empty paragraph", () => {
|
||||
const result = htmlToParagraphs("<hr>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses heading elements (h1-h6)", () => {
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const result = htmlToParagraphs(`<h${i}>Heading ${i}</h${i}>`);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBeInstanceOf(Paragraph);
|
||||
}
|
||||
});
|
||||
|
||||
it("parses div as block element", () => {
|
||||
const result = htmlToParagraphs("<div>Content in div</div>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses inline code with Courier New font", () => {
|
||||
const result = htmlToParagraphs("<p><code>inline code</code></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses mark/highlight element", () => {
|
||||
const result = htmlToParagraphs("<p><mark>highlighted</mark></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses b tag (same as strong)", () => {
|
||||
const result = htmlToParagraphs("<p><b>bold</b></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses i tag (same as em)", () => {
|
||||
const result = htmlToParagraphs("<p><i>italic</i></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses strike tag", () => {
|
||||
const result = htmlToParagraphs("<p><strike>struck</strike></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
// --- Nested lists ---
|
||||
|
||||
it("parses nested unordered list", () => {
|
||||
const html = "<ul><li>Top<ul><li>Nested</li></ul></li></ul>";
|
||||
const result = htmlToParagraphs(html);
|
||||
expect(result.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("parses nested ordered list", () => {
|
||||
const html = "<ol><li>First<ol><li>Sub</li></ol></li></ol>";
|
||||
const result = htmlToParagraphs(html);
|
||||
expect(result.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("parses list with paragraph inside li", () => {
|
||||
const html = "<ul><li><p>Paragraph in list</p></li></ul>";
|
||||
const result = htmlToParagraphs(html);
|
||||
expect(result.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// --- Style config ---
|
||||
|
||||
it("applies styleConfig font and size to text runs", () => {
|
||||
const result = htmlToParagraphs("<p>Styled</p>", {
|
||||
font: "Georgia",
|
||||
size: 24,
|
||||
color: "333333",
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("applies linkColor from styleConfig", () => {
|
||||
const result = htmlToParagraphs('<p><a href="https://example.com">link</a></p>', {
|
||||
linkColor: "FF0000",
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
const children = getChildren(result[0] as Paragraph);
|
||||
const hyperlinks = children.filter((c) => c instanceof ExternalHyperlink);
|
||||
expect(hyperlinks).toHaveLength(1);
|
||||
});
|
||||
|
||||
// --- Top-level text nodes ---
|
||||
|
||||
it("handles plain text without wrapper element", () => {
|
||||
const result = htmlToParagraphs("Just plain text");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles link without href falling back to inline text", () => {
|
||||
const result = htmlToParagraphs("<p><a>No href link</a></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
const children = getChildren(result[0] as Paragraph);
|
||||
// Should have TextRun, not ExternalHyperlink (no href)
|
||||
const hyperlinks = children.filter((c) => c instanceof ExternalHyperlink);
|
||||
expect(hyperlinks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles empty blockquote", () => {
|
||||
const result = htmlToParagraphs("<blockquote></blockquote>");
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles empty pre", () => {
|
||||
const result = htmlToParagraphs("<pre></pre>");
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles empty heading", () => {
|
||||
const result = htmlToParagraphs("<h1></h1>");
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
// --- Multiple block elements ---
|
||||
|
||||
it("handles complex HTML with mixed elements", () => {
|
||||
const html = `
|
||||
<h2>Title</h2>
|
||||
<p>Introduction paragraph</p>
|
||||
<ul><li>Item 1</li><li>Item 2</li></ul>
|
||||
<blockquote>A quote</blockquote>
|
||||
<pre>code block</pre>
|
||||
`;
|
||||
const result = htmlToParagraphs(html);
|
||||
// h2 + p + 2 li + blockquote + pre = 6
|
||||
expect(result.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { toSafeDocxLink } from "./link-utils";
|
||||
|
||||
describe("toSafeDocxLink", () => {
|
||||
describe("valid URLs", () => {
|
||||
it("returns http URLs as-is", () => {
|
||||
expect(toSafeDocxLink("http://example.com")).toBe("http://example.com/");
|
||||
});
|
||||
|
||||
it("returns https URLs as-is", () => {
|
||||
expect(toSafeDocxLink("https://example.com")).toBe("https://example.com/");
|
||||
});
|
||||
|
||||
it("returns https URLs with paths", () => {
|
||||
expect(toSafeDocxLink("https://example.com/path/to/page")).toBe("https://example.com/path/to/page");
|
||||
});
|
||||
|
||||
it("returns https URLs with query parameters", () => {
|
||||
expect(toSafeDocxLink("https://example.com?foo=bar")).toBe("https://example.com/?foo=bar");
|
||||
});
|
||||
|
||||
it("returns mailto links", () => {
|
||||
expect(toSafeDocxLink("mailto:user@example.com")).toBe("mailto:user@example.com");
|
||||
});
|
||||
|
||||
it("trims whitespace before processing", () => {
|
||||
expect(toSafeDocxLink(" https://example.com ")).toBe("https://example.com/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid/unsafe URLs", () => {
|
||||
it("returns null for empty string", () => {
|
||||
expect(toSafeDocxLink("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for whitespace-only string", () => {
|
||||
expect(toSafeDocxLink(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for javascript: URLs", () => {
|
||||
expect(toSafeDocxLink("javascript:alert(1)")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for data: URLs", () => {
|
||||
expect(toSafeDocxLink("data:text/html,<h1>hi</h1>")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for file: URLs", () => {
|
||||
expect(toSafeDocxLink("file:///etc/passwd")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for ftp: URLs", () => {
|
||||
expect(toSafeDocxLink("ftp://example.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid URLs", () => {
|
||||
expect(toSafeDocxLink("not a url at all")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for mailto: without email", () => {
|
||||
expect(toSafeDocxLink("mailto:")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for mailto: with only whitespace", () => {
|
||||
expect(toSafeDocxLink("mailto: ")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,654 @@
|
||||
import { Paragraph } from "docx";
|
||||
import { beforeEach, describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import type { CustomSection, ResumeData } from "@/schema/resume/data";
|
||||
|
||||
import { defaultResumeData } from "@/schema/resume/data";
|
||||
|
||||
import { renderBuiltInSection, renderCustomSection, renderSummary, setRenderConfig } from "./section-renderers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const COLOR_HEX = "#DC2626";
|
||||
|
||||
function freshSections(): ResumeData["sections"] {
|
||||
return structuredClone(defaultResumeData.sections);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setRenderConfig({
|
||||
headingFont: "Arial",
|
||||
headingSizeHalfPt: 28,
|
||||
bodyFont: "Calibri",
|
||||
bodySizeHalfPt: 22,
|
||||
textColorHex: "#000000",
|
||||
primaryColorHex: "#DC2626",
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderSummary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("renderSummary", () => {
|
||||
it("renders summary with title and content", () => {
|
||||
const summary = { title: "Summary", columns: 1, hidden: false, content: "<p>A great developer</p>" };
|
||||
const result = renderSummary(summary, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
// First paragraph should be the heading
|
||||
expect(result[0]).toBeInstanceOf(Paragraph);
|
||||
});
|
||||
|
||||
it("returns empty array when summary is hidden", () => {
|
||||
const summary = { title: "Summary", columns: 1, hidden: true, content: "<p>Hidden</p>" };
|
||||
expect(renderSummary(summary, COLOR_HEX)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns empty array when content is empty", () => {
|
||||
const summary = { title: "Summary", columns: 1, hidden: false, content: "" };
|
||||
expect(renderSummary(summary, COLOR_HEX)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips heading when title is empty", () => {
|
||||
const summary = { title: "", columns: 1, hidden: false, content: "<p>Content only</p>" };
|
||||
const result = renderSummary(summary, COLOR_HEX);
|
||||
// Should have content paragraphs but no heading
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderBuiltInSection — experience
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("renderBuiltInSection — experience", () => {
|
||||
it("renders experience items with company and position", () => {
|
||||
const sections = freshSections();
|
||||
sections.experience.title = "Experience";
|
||||
sections.experience.items = [
|
||||
{
|
||||
id: "e1",
|
||||
hidden: false,
|
||||
company: "Acme Corp",
|
||||
position: "Engineer",
|
||||
location: "NYC",
|
||||
period: "2020 - 2022",
|
||||
website: { url: "https://acme.com", label: "Acme" },
|
||||
description: "<p>Built things</p>",
|
||||
roles: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("experience", sections.experience, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1); // heading + item paragraphs
|
||||
expect(result[0]).toBeInstanceOf(Paragraph);
|
||||
});
|
||||
|
||||
it("returns empty for hidden section", () => {
|
||||
const sections = freshSections();
|
||||
sections.experience.hidden = true;
|
||||
sections.experience.items = [
|
||||
{
|
||||
id: "e1",
|
||||
hidden: false,
|
||||
company: "Co",
|
||||
position: "Dev",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("experience", sections.experience, COLOR_HEX);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns empty when all items are hidden", () => {
|
||||
const sections = freshSections();
|
||||
sections.experience.items = [
|
||||
{
|
||||
id: "e1",
|
||||
hidden: true,
|
||||
company: "Co",
|
||||
position: "Dev",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("experience", sections.experience, COLOR_HEX);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders experience with roles", () => {
|
||||
const sections = freshSections();
|
||||
sections.experience.title = "Experience";
|
||||
sections.experience.items = [
|
||||
{
|
||||
id: "e1",
|
||||
hidden: false,
|
||||
company: "BigCo",
|
||||
position: "",
|
||||
location: "SF",
|
||||
period: "2018 - 2024",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [
|
||||
{ id: "r1", position: "Senior Dev", period: "2022 - 2024", description: "<p>Led team</p>" },
|
||||
{ id: "r2", position: "Junior Dev", period: "2018 - 2022", description: "<p>Learned things</p>" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("experience", sections.experience, COLOR_HEX);
|
||||
// Should have heading + company title + location + role1 + role1 desc + role2 + role2 desc
|
||||
expect(result.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
it("filters out hidden items", () => {
|
||||
const sections = freshSections();
|
||||
sections.experience.title = "Experience";
|
||||
sections.experience.items = [
|
||||
{
|
||||
id: "e1",
|
||||
hidden: false,
|
||||
company: "Visible",
|
||||
position: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: "e2",
|
||||
hidden: true,
|
||||
company: "Hidden",
|
||||
position: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("experience", sections.experience, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderBuiltInSection — skills
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("renderBuiltInSection — skills", () => {
|
||||
it("renders skills with name, proficiency, and keywords", () => {
|
||||
const sections = freshSections();
|
||||
sections.skills.title = "Skills";
|
||||
sections.skills.items = [
|
||||
{
|
||||
id: "s1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "TypeScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
keywords: ["frontend", "backend"],
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("skills", sections.skills, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1); // heading + skill
|
||||
});
|
||||
|
||||
it("renders skills without proficiency or keywords", () => {
|
||||
const sections = freshSections();
|
||||
sections.skills.title = "Skills";
|
||||
sections.skills.items = [
|
||||
{ id: "s1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] },
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("skills", sections.skills, COLOR_HEX);
|
||||
expect(result.length).toBe(2); // heading + skill name only
|
||||
});
|
||||
|
||||
it("returns empty for empty items", () => {
|
||||
const sections = freshSections();
|
||||
const result = renderBuiltInSection("skills", sections.skills, COLOR_HEX);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderBuiltInSection — education
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("renderBuiltInSection — education", () => {
|
||||
it("renders education with degree, area, and grade", () => {
|
||||
const sections = freshSections();
|
||||
sections.education.title = "Education";
|
||||
sections.education.items = [
|
||||
{
|
||||
id: "ed1",
|
||||
hidden: false,
|
||||
school: "MIT",
|
||||
degree: "BSc",
|
||||
area: "CS",
|
||||
grade: "4.0",
|
||||
location: "Cambridge",
|
||||
period: "2016 - 2020",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("education", sections.education, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("skips grade paragraph when grade is empty", () => {
|
||||
const sections = freshSections();
|
||||
sections.education.title = "Education";
|
||||
sections.education.items = [
|
||||
{
|
||||
id: "ed1",
|
||||
hidden: false,
|
||||
school: "MIT",
|
||||
degree: "BSc",
|
||||
area: "",
|
||||
grade: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("education", sections.education, COLOR_HEX);
|
||||
// heading + title only (no grade, no location, no description, no website)
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderBuiltInSection — languages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("renderBuiltInSection — languages", () => {
|
||||
it("renders language with fluency", () => {
|
||||
const sections = freshSections();
|
||||
sections.languages.title = "Languages";
|
||||
sections.languages.items = [{ id: "l1", hidden: false, language: "English", fluency: "Native", level: 5 }];
|
||||
|
||||
const result = renderBuiltInSection("languages", sections.languages, COLOR_HEX);
|
||||
expect(result.length).toBe(2); // heading + language
|
||||
});
|
||||
|
||||
it("renders language without fluency", () => {
|
||||
const sections = freshSections();
|
||||
sections.languages.title = "Languages";
|
||||
sections.languages.items = [{ id: "l1", hidden: false, language: "Spanish", fluency: "", level: 0 }];
|
||||
|
||||
const result = renderBuiltInSection("languages", sections.languages, COLOR_HEX);
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderBuiltInSection — other sections
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("renderBuiltInSection — other sections", () => {
|
||||
it("renders profiles with network and username", () => {
|
||||
const sections = freshSections();
|
||||
sections.profiles.title = "Profiles";
|
||||
sections.profiles.items = [
|
||||
{
|
||||
id: "p1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
network: "GitHub",
|
||||
username: "janedoe",
|
||||
website: { url: "https://github.com/janedoe", label: "GitHub" },
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("profiles", sections.profiles, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("renders awards with title and awarder", () => {
|
||||
const sections = freshSections();
|
||||
sections.awards.title = "Awards";
|
||||
sections.awards.items = [
|
||||
{
|
||||
id: "a1",
|
||||
hidden: false,
|
||||
title: "Best Dev",
|
||||
awarder: "Company",
|
||||
date: "2024",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("awards", sections.awards, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("renders certifications", () => {
|
||||
const sections = freshSections();
|
||||
sections.certifications.title = "Certifications";
|
||||
sections.certifications.items = [
|
||||
{
|
||||
id: "c1",
|
||||
hidden: false,
|
||||
title: "AWS",
|
||||
issuer: "Amazon",
|
||||
date: "2023",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("certifications", sections.certifications, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("renders publications", () => {
|
||||
const sections = freshSections();
|
||||
sections.publications.title = "Publications";
|
||||
sections.publications.items = [
|
||||
{
|
||||
id: "pub1",
|
||||
hidden: false,
|
||||
title: "My Paper",
|
||||
publisher: "IEEE",
|
||||
date: "2024",
|
||||
website: { url: "", label: "" },
|
||||
description: "<p>Research</p>",
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("publications", sections.publications, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("renders volunteer section", () => {
|
||||
const sections = freshSections();
|
||||
sections.volunteer.title = "Volunteer";
|
||||
sections.volunteer.items = [
|
||||
{
|
||||
id: "v1",
|
||||
hidden: false,
|
||||
organization: "Red Cross",
|
||||
location: "NYC",
|
||||
period: "2023",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("volunteer", sections.volunteer, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("renders references with position and phone", () => {
|
||||
const sections = freshSections();
|
||||
sections.references.title = "References";
|
||||
sections.references.items = [
|
||||
{
|
||||
id: "r1",
|
||||
hidden: false,
|
||||
name: "John",
|
||||
position: "CTO",
|
||||
phone: "+1234567890",
|
||||
website: { url: "", label: "" },
|
||||
description: "<p>Great dev</p>",
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("references", sections.references, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("renders interests with keywords", () => {
|
||||
const sections = freshSections();
|
||||
sections.interests.title = "Interests";
|
||||
sections.interests.items = [{ id: "i1", hidden: false, icon: "", name: "Gaming", keywords: ["RPG", "Strategy"] }];
|
||||
|
||||
const result = renderBuiltInSection("interests", sections.interests, COLOR_HEX);
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
it("renders projects with period and website", () => {
|
||||
const sections = freshSections();
|
||||
sections.projects.title = "Projects";
|
||||
sections.projects.items = [
|
||||
{
|
||||
id: "proj1",
|
||||
hidden: false,
|
||||
name: "My App",
|
||||
period: "2024",
|
||||
website: { url: "https://app.com", label: "App" },
|
||||
description: "<p>A cool app</p>",
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("projects", sections.projects, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(2); // heading + title + description + website
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderCustomSection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("renderCustomSection", () => {
|
||||
it("renders custom experience-typed section", () => {
|
||||
const custom: CustomSection = {
|
||||
id: "custom-1",
|
||||
type: "experience",
|
||||
title: "Freelance",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [
|
||||
{
|
||||
id: "ci1",
|
||||
hidden: false,
|
||||
company: "Client A",
|
||||
position: "Contractor",
|
||||
location: "Remote",
|
||||
period: "2023",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = renderCustomSection(custom, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns empty for hidden custom section", () => {
|
||||
const custom: CustomSection = {
|
||||
id: "custom-1",
|
||||
type: "skills",
|
||||
title: "Hidden",
|
||||
columns: 1,
|
||||
hidden: true,
|
||||
items: [{ id: "ci1", hidden: false, icon: "", name: "Skill", proficiency: "", level: 0, keywords: [] }],
|
||||
};
|
||||
|
||||
expect(renderCustomSection(custom, COLOR_HEX)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns empty when all items are hidden", () => {
|
||||
const custom: CustomSection = {
|
||||
id: "custom-1",
|
||||
type: "skills",
|
||||
title: "Skills",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [{ id: "ci1", hidden: true, icon: "", name: "Skill", proficiency: "", level: 0, keywords: [] }],
|
||||
};
|
||||
|
||||
expect(renderCustomSection(custom, COLOR_HEX)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders summary-type custom section", () => {
|
||||
const custom: CustomSection = {
|
||||
id: "custom-1",
|
||||
type: "summary",
|
||||
title: "Cover Note",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [{ id: "ci1", hidden: false, content: "<p>Hello there</p>" }],
|
||||
};
|
||||
|
||||
const result = renderCustomSection(custom, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders cover-letter-type custom section", () => {
|
||||
const custom: CustomSection = {
|
||||
id: "custom-1",
|
||||
type: "cover-letter",
|
||||
title: "Cover Letter",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [
|
||||
{
|
||||
id: "ci1",
|
||||
hidden: false,
|
||||
recipient: "<p>Dear Hiring Manager</p>",
|
||||
content: "<p>I am writing to apply...</p>",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = renderCustomSection(custom, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1); // heading + recipient + content
|
||||
});
|
||||
|
||||
it("renders skills-typed custom section", () => {
|
||||
const custom: CustomSection = {
|
||||
id: "custom-1",
|
||||
type: "skills",
|
||||
title: "Soft Skills",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [
|
||||
{ id: "ci1", hidden: false, icon: "", name: "Leadership", proficiency: "Expert", level: 5, keywords: [] },
|
||||
],
|
||||
};
|
||||
|
||||
const result = renderCustomSection(custom, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setRenderConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("setRenderConfig", () => {
|
||||
it("configures typography that affects all renderers", () => {
|
||||
setRenderConfig({
|
||||
headingFont: "Georgia",
|
||||
headingSizeHalfPt: 32,
|
||||
bodyFont: "Times New Roman",
|
||||
bodySizeHalfPt: 24,
|
||||
textColorHex: "#333333",
|
||||
primaryColorHex: "#0000FF",
|
||||
});
|
||||
|
||||
const sections = freshSections();
|
||||
sections.skills.title = "Skills";
|
||||
sections.skills.items = [
|
||||
{ id: "s1", hidden: false, icon: "", name: "Test", proficiency: "", level: 0, keywords: [] },
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("skills", sections.skills, "#0000FF");
|
||||
// Should not crash and should produce paragraphs
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles website with unsafe URL (no website paragraph added)", () => {
|
||||
const sections = freshSections();
|
||||
sections.experience.title = "Experience";
|
||||
sections.experience.items = [
|
||||
{
|
||||
id: "e1",
|
||||
hidden: false,
|
||||
company: "Co",
|
||||
position: "Dev",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "javascript:alert(1)", label: "Evil" },
|
||||
description: "",
|
||||
roles: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("experience", sections.experience, COLOR_HEX);
|
||||
// Should still render without the unsafe website
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("handles items with only minimal data", () => {
|
||||
const sections = freshSections();
|
||||
sections.awards.title = "Awards";
|
||||
sections.awards.items = [
|
||||
{
|
||||
id: "a1",
|
||||
hidden: false,
|
||||
title: "Award",
|
||||
awarder: "",
|
||||
date: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("awards", sections.awards, COLOR_HEX);
|
||||
expect(result).toHaveLength(2); // heading + title only
|
||||
});
|
||||
|
||||
it("handles experience with empty description and no location", () => {
|
||||
const sections = freshSections();
|
||||
sections.experience.title = "Experience";
|
||||
sections.experience.items = [
|
||||
{
|
||||
id: "e1",
|
||||
hidden: false,
|
||||
company: "Co",
|
||||
position: "Dev",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("experience", sections.experience, COLOR_HEX);
|
||||
// heading + title/subtitle only
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,468 @@
|
||||
import { produce } from "immer";
|
||||
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
import { type ResumeData, defaultResumeData } from "@/schema/resume/data";
|
||||
|
||||
import {
|
||||
addItemToSection,
|
||||
createCustomSectionWithItem,
|
||||
createPageWithSection,
|
||||
getCompatibleMoveTargets,
|
||||
getSourceSectionTitle,
|
||||
removeItemFromSource,
|
||||
} from "./move-item";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Mock getSectionTitle — it uses @lingui/core/macro `t` which isn't available in tests
|
||||
vi.mock("./section", () => ({
|
||||
getSectionTitle: (type: string) => type.charAt(0).toUpperCase() + type.slice(1),
|
||||
}));
|
||||
|
||||
// Mock generateId to return deterministic values
|
||||
let idCounter = 0;
|
||||
vi.mock("@/utils/string", () => ({
|
||||
generateId: () => `mock-id-${++idCounter}`,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function freshResume(): ResumeData {
|
||||
return structuredClone(defaultResumeData);
|
||||
}
|
||||
|
||||
function resumeWithSkills(): ResumeData {
|
||||
const data = freshResume();
|
||||
data.sections.skills.items = [
|
||||
{ id: "s1", hidden: false, icon: "", name: "JS", proficiency: "Advanced", level: 4, keywords: ["frontend"] },
|
||||
{ id: "s2", hidden: false, icon: "", name: "TS", proficiency: "Intermediate", level: 3, keywords: [] },
|
||||
{ id: "s3", hidden: false, icon: "", name: "Rust", proficiency: "Beginner", level: 1, keywords: [] },
|
||||
];
|
||||
return data;
|
||||
}
|
||||
|
||||
function resumeWithCustomSections(): ResumeData {
|
||||
const data = freshResume();
|
||||
data.customSections = [
|
||||
{
|
||||
id: "custom-exp-1",
|
||||
type: "experience",
|
||||
title: "Freelance Work",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [
|
||||
{
|
||||
id: "item-1",
|
||||
hidden: false,
|
||||
company: "Acme",
|
||||
position: "Dev",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "custom-skills-1",
|
||||
type: "skills",
|
||||
title: "Soft Skills",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [{ id: "cs1", hidden: false, icon: "", name: "Leadership", proficiency: "", level: 0, keywords: [] }],
|
||||
},
|
||||
];
|
||||
// Add custom sections to the layout
|
||||
data.metadata.layout.pages[0].main.push("custom-exp-1");
|
||||
data.metadata.layout.pages[0].sidebar.push("custom-skills-1");
|
||||
return data;
|
||||
}
|
||||
|
||||
function resumeWithMultiplePages(): ResumeData {
|
||||
const data = resumeWithCustomSections();
|
||||
data.metadata.layout.pages.push({
|
||||
fullWidth: false,
|
||||
main: ["experience"],
|
||||
sidebar: ["skills"],
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
idCounter = 0;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getSourceSectionTitle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getSourceSectionTitle", () => {
|
||||
it("returns the default section title for a standard section", () => {
|
||||
const data = freshResume();
|
||||
const title = getSourceSectionTitle(data, "experience");
|
||||
expect(title).toBe("Experience");
|
||||
});
|
||||
|
||||
it("returns the custom section title when customSectionId is provided", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
const title = getSourceSectionTitle(data, "experience", "custom-exp-1");
|
||||
expect(title).toBe("Freelance Work");
|
||||
});
|
||||
|
||||
it("falls back to default title when customSectionId doesn't match", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
const title = getSourceSectionTitle(data, "experience", "nonexistent-id");
|
||||
expect(title).toBe("Experience");
|
||||
});
|
||||
|
||||
it("returns the default title for various section types", () => {
|
||||
const data = freshResume();
|
||||
expect(getSourceSectionTitle(data, "skills")).toBe("Skills");
|
||||
expect(getSourceSectionTitle(data, "education")).toBe("Education");
|
||||
expect(getSourceSectionTitle(data, "projects")).toBe("Projects");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getCompatibleMoveTargets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getCompatibleMoveTargets", () => {
|
||||
it("returns empty sections for each page when no compatible targets exist", () => {
|
||||
const data = freshResume();
|
||||
const targets = getCompatibleMoveTargets(data, "skills", undefined);
|
||||
|
||||
// The source section (skills) itself should be excluded
|
||||
// All other sections have different types, so no compatible targets
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0].sections).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("finds custom sections with matching type as move targets", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
// Source is the standard "skills" section
|
||||
const targets = getCompatibleMoveTargets(data, "skills", undefined);
|
||||
|
||||
const allSections = targets.flatMap((p) => p.sections);
|
||||
expect(allSections.some((s) => s.sectionId === "custom-skills-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes the source section from targets", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
// Source is the custom skills section
|
||||
const targets = getCompatibleMoveTargets(data, "skills", "custom-skills-1");
|
||||
|
||||
const allSections = targets.flatMap((p) => p.sections);
|
||||
expect(allSections.some((s) => s.sectionId === "custom-skills-1")).toBe(false);
|
||||
// But standard "skills" should be included
|
||||
expect(allSections.some((s) => s.sectionId === "skills")).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes the standard source section when sourceSectionId is undefined", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
const targets = getCompatibleMoveTargets(data, "experience", undefined);
|
||||
|
||||
const allSections = targets.flatMap((p) => p.sections);
|
||||
// Standard "experience" should be excluded (it's the source)
|
||||
expect(allSections.some((s) => s.sectionId === "experience")).toBe(false);
|
||||
// Custom experience section should be included
|
||||
expect(allSections.some((s) => s.sectionId === "custom-exp-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns targets across multiple pages", () => {
|
||||
const data = resumeWithMultiplePages();
|
||||
const targets = getCompatibleMoveTargets(data, "skills", "custom-skills-1");
|
||||
|
||||
expect(targets).toHaveLength(2);
|
||||
// Page 0 should have standard skills in sidebar
|
||||
const page0Sections = targets[0].sections;
|
||||
expect(page0Sections.some((s) => s.sectionId === "skills")).toBe(true);
|
||||
// Page 1 should also have skills in sidebar
|
||||
const page1Sections = targets[1].sections;
|
||||
expect(page1Sections.some((s) => s.sectionId === "skills")).toBe(true);
|
||||
});
|
||||
|
||||
it("correctly labels standard vs custom sections", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
const targets = getCompatibleMoveTargets(data, "skills", "custom-skills-1");
|
||||
|
||||
const allSections = targets.flatMap((p) => p.sections);
|
||||
const standardSection = allSections.find((s) => s.sectionId === "skills");
|
||||
expect(standardSection?.isStandard).toBe(true);
|
||||
});
|
||||
|
||||
it("correctly labels custom sections", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
const targets = getCompatibleMoveTargets(data, "skills", undefined);
|
||||
|
||||
const allSections = targets.flatMap((p) => p.sections);
|
||||
const customSection = allSections.find((s) => s.sectionId === "custom-skills-1");
|
||||
expect(customSection?.isStandard).toBe(false);
|
||||
expect(customSection?.sectionTitle).toBe("Soft Skills");
|
||||
});
|
||||
|
||||
it("includes page index in results", () => {
|
||||
const data = resumeWithMultiplePages();
|
||||
const targets = getCompatibleMoveTargets(data, "experience", undefined);
|
||||
|
||||
expect(targets[0].pageIndex).toBe(0);
|
||||
expect(targets[1].pageIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("returns no targets for a section type not present elsewhere", () => {
|
||||
const data = freshResume();
|
||||
const targets = getCompatibleMoveTargets(data, "awards", undefined);
|
||||
|
||||
// awards only appears once in the layout
|
||||
const allSections = targets.flatMap((p) => p.sections);
|
||||
expect(allSections).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// removeItemFromSource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("removeItemFromSource", () => {
|
||||
it("removes an item from a standard section and returns it", () => {
|
||||
const data = resumeWithSkills();
|
||||
const result = produce(data, (draft) => {
|
||||
const removed = removeItemFromSource(draft, "s2", "skills");
|
||||
expect(removed).not.toBeNull();
|
||||
expect(removed!.id).toBe("s2");
|
||||
});
|
||||
|
||||
expect(result.sections.skills.items).toHaveLength(2);
|
||||
expect(result.sections.skills.items.every((i) => i.id !== "s2")).toBe(true);
|
||||
});
|
||||
|
||||
it("removes the first item from a standard section", () => {
|
||||
const data = resumeWithSkills();
|
||||
const result = produce(data, (draft) => {
|
||||
const removed = removeItemFromSource(draft, "s1", "skills");
|
||||
expect(removed!.id).toBe("s1");
|
||||
});
|
||||
|
||||
expect(result.sections.skills.items).toHaveLength(2);
|
||||
expect(result.sections.skills.items[0].id).toBe("s2");
|
||||
});
|
||||
|
||||
it("removes the last item from a standard section", () => {
|
||||
const data = resumeWithSkills();
|
||||
const result = produce(data, (draft) => {
|
||||
const removed = removeItemFromSource(draft, "s3", "skills");
|
||||
expect(removed!.id).toBe("s3");
|
||||
});
|
||||
|
||||
expect(result.sections.skills.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns null when item is not found in standard section", () => {
|
||||
const data = resumeWithSkills();
|
||||
produce(data, (draft) => {
|
||||
const removed = removeItemFromSource(draft, "nonexistent", "skills");
|
||||
expect(removed).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("removes an item from a custom section", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
const result = produce(data, (draft) => {
|
||||
const removed = removeItemFromSource(draft, "item-1", "experience", "custom-exp-1");
|
||||
expect(removed).not.toBeNull();
|
||||
expect(removed!.id).toBe("item-1");
|
||||
});
|
||||
|
||||
const customSection = result.customSections.find((s) => s.id === "custom-exp-1")!;
|
||||
expect(customSection.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns null when custom section is not found", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
produce(data, (draft) => {
|
||||
const removed = removeItemFromSource(draft, "item-1", "experience", "nonexistent");
|
||||
expect(removed).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when item is not in the custom section", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
produce(data, (draft) => {
|
||||
const removed = removeItemFromSource(draft, "nonexistent", "experience", "custom-exp-1");
|
||||
expect(removed).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// addItemToSection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("addItemToSection", () => {
|
||||
it("adds an item to a standard section", () => {
|
||||
const data = freshResume();
|
||||
const newItem = { id: "new-1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
addItemToSection(draft, newItem, "skills", "skills");
|
||||
});
|
||||
|
||||
expect(result.sections.skills.items).toHaveLength(1);
|
||||
expect(result.sections.skills.items[0].id).toBe("new-1");
|
||||
});
|
||||
|
||||
it("adds an item to a custom section", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
const newItem = { id: "new-2", hidden: false, icon: "", name: "Teamwork", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
addItemToSection(draft, newItem, "custom-skills-1", "skills");
|
||||
});
|
||||
|
||||
const customSection = result.customSections.find((s) => s.id === "custom-skills-1")!;
|
||||
expect(customSection.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does nothing when target custom section doesn't exist", () => {
|
||||
const data = freshResume();
|
||||
const newItem = { id: "new-3", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
addItemToSection(draft, newItem, "nonexistent-section", "skills");
|
||||
});
|
||||
|
||||
// Nothing should change
|
||||
expect(result.sections.skills.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("appends to the end of an existing items array", () => {
|
||||
const data = resumeWithSkills();
|
||||
const newItem = { id: "s4", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
addItemToSection(draft, newItem, "skills", "skills");
|
||||
});
|
||||
|
||||
expect(result.sections.skills.items).toHaveLength(4);
|
||||
expect(result.sections.skills.items[3].id).toBe("s4");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createCustomSectionWithItem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createCustomSectionWithItem", () => {
|
||||
it("creates a new custom section with the given item", () => {
|
||||
const data = freshResume();
|
||||
const item = { id: "item-1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
const sectionId = createCustomSectionWithItem(draft, item, "skills", "Tech Skills", 0);
|
||||
expect(sectionId).toBe("mock-id-1");
|
||||
});
|
||||
|
||||
expect(result.customSections).toHaveLength(1);
|
||||
expect(result.customSections[0].title).toBe("Tech Skills");
|
||||
expect(result.customSections[0].type).toBe("skills");
|
||||
expect(result.customSections[0].items).toHaveLength(1);
|
||||
expect(result.customSections[0].columns).toBe(1);
|
||||
expect(result.customSections[0].hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("adds the new section to the target page's main column", () => {
|
||||
const data = freshResume();
|
||||
const item = { id: "item-1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
const mainLengthBefore = data.metadata.layout.pages[0].main.length;
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createCustomSectionWithItem(draft, item, "skills", "Skills 2", 0);
|
||||
});
|
||||
|
||||
expect(result.metadata.layout.pages[0].main).toHaveLength(mainLengthBefore + 1);
|
||||
expect(result.metadata.layout.pages[0].main).toContain("mock-id-1");
|
||||
});
|
||||
|
||||
it("handles targeting a specific page index", () => {
|
||||
const data = resumeWithMultiplePages();
|
||||
const item = { id: "item-2", hidden: false, icon: "", name: "Python", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createCustomSectionWithItem(draft, item, "skills", "More Skills", 1);
|
||||
});
|
||||
|
||||
expect(result.metadata.layout.pages[1].main).toContain("mock-id-1");
|
||||
// Page 0 should be unchanged
|
||||
expect(result.metadata.layout.pages[0].main).not.toContain("mock-id-1");
|
||||
});
|
||||
|
||||
it("handles out-of-bounds page index gracefully (no crash)", () => {
|
||||
const data = freshResume();
|
||||
const item = { id: "item-3", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
// Should not throw — the page just doesn't exist so nothing is pushed
|
||||
const result = produce(data, (draft) => {
|
||||
createCustomSectionWithItem(draft, item, "skills", "Skills", 99);
|
||||
});
|
||||
|
||||
// Custom section still gets created
|
||||
expect(result.customSections).toHaveLength(1);
|
||||
// But no page was modified
|
||||
expect(result.metadata.layout.pages[0].main).not.toContain("mock-id-1");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createPageWithSection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createPageWithSection", () => {
|
||||
it("creates a new page with a custom section", () => {
|
||||
const data = freshResume();
|
||||
const item = { id: "item-1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
const pagesBefore = data.metadata.layout.pages.length;
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createPageWithSection(draft, item, "skills", "New Skills Page");
|
||||
});
|
||||
|
||||
expect(result.metadata.layout.pages).toHaveLength(pagesBefore + 1);
|
||||
const newPage = result.metadata.layout.pages[result.metadata.layout.pages.length - 1];
|
||||
expect(newPage.fullWidth).toBe(false);
|
||||
expect(newPage.main).toContain("mock-id-1");
|
||||
expect(newPage.sidebar).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("creates the custom section with correct properties", () => {
|
||||
const data = freshResume();
|
||||
const item = { id: "item-1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createPageWithSection(draft, item, "skills", "Page 2 Skills");
|
||||
});
|
||||
|
||||
expect(result.customSections).toHaveLength(1);
|
||||
expect(result.customSections[0].title).toBe("Page 2 Skills");
|
||||
expect(result.customSections[0].type).toBe("skills");
|
||||
expect(result.customSections[0].items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("can create multiple pages in sequence", () => {
|
||||
const data = freshResume();
|
||||
const item1 = { id: "i1", hidden: false, icon: "", name: "A", proficiency: "", level: 0, keywords: [] };
|
||||
const item2 = { id: "i2", hidden: false, icon: "", name: "B", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createPageWithSection(draft, item1, "skills", "Page 2");
|
||||
createPageWithSection(draft, item2, "skills", "Page 3");
|
||||
});
|
||||
|
||||
expect(result.metadata.layout.pages).toHaveLength(3);
|
||||
expect(result.customSections).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,469 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { type ResumeData, defaultResumeData, resumeDataSchema } from "@/schema/resume/data";
|
||||
|
||||
import { ResumePatchError, applyResumePatches, jsonPatchOperationSchema } from "./patch";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns a deep clone of defaultResumeData to avoid cross-test mutation. */
|
||||
function freshResume(): ResumeData {
|
||||
return structuredClone(defaultResumeData);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// jsonPatchOperationSchema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("jsonPatchOperationSchema", () => {
|
||||
it("accepts a valid 'add' operation", () => {
|
||||
const op = { op: "add", path: "/basics/name", value: "Jane" };
|
||||
expect(jsonPatchOperationSchema.parse(op)).toEqual(op);
|
||||
});
|
||||
|
||||
it("accepts a valid 'remove' operation", () => {
|
||||
const op = { op: "remove", path: "/basics/name" };
|
||||
expect(jsonPatchOperationSchema.parse(op)).toEqual(op);
|
||||
});
|
||||
|
||||
it("accepts a valid 'replace' operation", () => {
|
||||
const op = { op: "replace", path: "/basics/name", value: "Jane" };
|
||||
expect(jsonPatchOperationSchema.parse(op)).toEqual(op);
|
||||
});
|
||||
|
||||
it("accepts a valid 'move' operation", () => {
|
||||
const op = { op: "move", path: "/basics/name", from: "/basics/headline" };
|
||||
expect(jsonPatchOperationSchema.parse(op)).toEqual(op);
|
||||
});
|
||||
|
||||
it("accepts a valid 'copy' operation", () => {
|
||||
const op = { op: "copy", path: "/basics/headline", from: "/basics/name" };
|
||||
expect(jsonPatchOperationSchema.parse(op)).toEqual(op);
|
||||
});
|
||||
|
||||
it("accepts a valid 'test' operation", () => {
|
||||
const op = { op: "test", path: "/basics/name", value: "" };
|
||||
expect(jsonPatchOperationSchema.parse(op)).toEqual(op);
|
||||
});
|
||||
|
||||
it("rejects an unknown op type", () => {
|
||||
expect(() => jsonPatchOperationSchema.parse({ op: "patch", path: "/foo" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects 'move' without from", () => {
|
||||
expect(() => jsonPatchOperationSchema.parse({ op: "move", path: "/foo" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects 'copy' without from", () => {
|
||||
expect(() => jsonPatchOperationSchema.parse({ op: "copy", path: "/foo" })).toThrow();
|
||||
});
|
||||
|
||||
it("accepts 'add' with undefined value (z.any() allows it)", () => {
|
||||
// z.any() accepts undefined — this is valid at the schema level
|
||||
const result = jsonPatchOperationSchema.parse({ op: "add", path: "/foo" });
|
||||
expect(result.op).toBe("add");
|
||||
});
|
||||
|
||||
it("accepts 'replace' with explicit value of null", () => {
|
||||
const result = jsonPatchOperationSchema.parse({ op: "replace", path: "/foo", value: null });
|
||||
expect(result.op).toBe("replace");
|
||||
});
|
||||
|
||||
it("accepts 'test' with value of 0", () => {
|
||||
const result = jsonPatchOperationSchema.parse({ op: "test", path: "/foo", value: 0 });
|
||||
expect(result.op).toBe("test");
|
||||
});
|
||||
|
||||
it("rejects operations without a path", () => {
|
||||
expect(() => jsonPatchOperationSchema.parse({ op: "remove" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ResumePatchError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ResumePatchError", () => {
|
||||
it("stores code, message, index, and operation", () => {
|
||||
const op = { op: "replace" as const, path: "/basics/name", value: "Jane" };
|
||||
const err = new ResumePatchError("TEST_OPERATION_FAILED", "test failed", 2, op);
|
||||
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.name).toBe("ResumePatchError");
|
||||
expect(err.code).toBe("TEST_OPERATION_FAILED");
|
||||
expect(err.message).toBe("test failed");
|
||||
expect(err.index).toBe(2);
|
||||
expect(err.operation).toEqual(op);
|
||||
});
|
||||
|
||||
it("has a proper prototype chain", () => {
|
||||
const err = new ResumePatchError("CODE", "msg", 0, { op: "remove", path: "/x" });
|
||||
expect(err instanceof ResumePatchError).toBe(true);
|
||||
expect(err instanceof Error).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyResumePatches — success cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("applyResumePatches", () => {
|
||||
describe("basic operations", () => {
|
||||
it("replaces a top-level string field", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [{ op: "replace", path: "/basics/name", value: "Alice" }]);
|
||||
|
||||
expect(result.basics.name).toBe("Alice");
|
||||
});
|
||||
|
||||
it("replaces a nested field", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [
|
||||
{ op: "replace", path: "/basics/website/url", value: "https://example.com" },
|
||||
]);
|
||||
|
||||
expect(result.basics.website.url).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("adds an item to a section's items array", () => {
|
||||
const data = freshResume();
|
||||
const newSkill = {
|
||||
id: "skill-1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "TypeScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
keywords: ["frontend"],
|
||||
};
|
||||
const result = applyResumePatches(data, [{ op: "add", path: "/sections/skills/items/-", value: newSkill }]);
|
||||
|
||||
expect(result.sections.skills.items).toHaveLength(1);
|
||||
expect(result.sections.skills.items[0].name).toBe("TypeScript");
|
||||
});
|
||||
|
||||
it("removes an item from a section's items array", () => {
|
||||
const data = freshResume();
|
||||
data.sections.skills.items = [
|
||||
{ id: "s1", hidden: false, icon: "", name: "JS", proficiency: "", level: 0, keywords: [] },
|
||||
{ id: "s2", hidden: false, icon: "", name: "TS", proficiency: "", level: 0, keywords: [] },
|
||||
];
|
||||
|
||||
const result = applyResumePatches(data, [{ op: "remove", path: "/sections/skills/items/0" }]);
|
||||
expect(result.sections.skills.items).toHaveLength(1);
|
||||
expect(result.sections.skills.items[0].name).toBe("TS");
|
||||
});
|
||||
|
||||
it("applies multiple operations in sequence", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [
|
||||
{ op: "replace", path: "/basics/name", value: "Bob" },
|
||||
{ op: "replace", path: "/basics/email", value: "bob@test.com" },
|
||||
{ op: "replace", path: "/basics/headline", value: "Developer" },
|
||||
]);
|
||||
|
||||
expect(result.basics.name).toBe("Bob");
|
||||
expect(result.basics.email).toBe("bob@test.com");
|
||||
expect(result.basics.headline).toBe("Developer");
|
||||
});
|
||||
|
||||
it("does not mutate the original data", () => {
|
||||
const data = freshResume();
|
||||
const originalName = data.basics.name;
|
||||
applyResumePatches(data, [{ op: "replace", path: "/basics/name", value: "Mutated?" }]);
|
||||
|
||||
expect(data.basics.name).toBe(originalName);
|
||||
});
|
||||
});
|
||||
|
||||
describe("test operation", () => {
|
||||
it("succeeds when test value matches", () => {
|
||||
const data = freshResume();
|
||||
data.basics.name = "Alice";
|
||||
|
||||
const result = applyResumePatches(data, [
|
||||
{ op: "test", path: "/basics/name", value: "Alice" },
|
||||
{ op: "replace", path: "/basics/name", value: "Bob" },
|
||||
]);
|
||||
|
||||
expect(result.basics.name).toBe("Bob");
|
||||
});
|
||||
|
||||
it("throws ResumePatchError when test value does not match", () => {
|
||||
const data = freshResume();
|
||||
data.basics.name = "Alice";
|
||||
|
||||
expect(() => applyResumePatches(data, [{ op: "test", path: "/basics/name", value: "Wrong" }])).toThrow(
|
||||
ResumePatchError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("copy and move operations", () => {
|
||||
it("copies a value from one path to another", () => {
|
||||
const data = freshResume();
|
||||
data.basics.name = "Alice";
|
||||
|
||||
const result = applyResumePatches(data, [{ op: "copy", path: "/basics/headline", from: "/basics/name" }]);
|
||||
|
||||
expect(result.basics.headline).toBe("Alice");
|
||||
expect(result.basics.name).toBe("Alice");
|
||||
});
|
||||
|
||||
it("moves a value between compatible paths", () => {
|
||||
const data = freshResume();
|
||||
data.basics.name = "Alice";
|
||||
|
||||
// Move name to headline — both are strings, but move deletes the source.
|
||||
// Since the schema requires name to be a string, we replace it back after moving.
|
||||
const result = applyResumePatches(data, [
|
||||
{ op: "move", path: "/basics/headline", from: "/basics/name" },
|
||||
{ op: "add", path: "/basics/name", value: "" },
|
||||
]);
|
||||
|
||||
expect(result.basics.headline).toBe("Alice");
|
||||
expect(result.basics.name).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("metadata operations", () => {
|
||||
it("replaces the template", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [{ op: "replace", path: "/metadata/template", value: "pikachu" }]);
|
||||
expect(result.metadata.template).toBe("pikachu");
|
||||
});
|
||||
|
||||
it("replaces design colors", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [
|
||||
{ op: "replace", path: "/metadata/design/colors/primary", value: "rgba(0, 0, 255, 1)" },
|
||||
]);
|
||||
expect(result.metadata.design.colors.primary).toBe("rgba(0, 0, 255, 1)");
|
||||
});
|
||||
|
||||
it("toggles section visibility", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [{ op: "replace", path: "/sections/skills/hidden", value: true }]);
|
||||
expect(result.sections.skills.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("replaces page margins", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [{ op: "replace", path: "/metadata/page/marginX", value: 20 }]);
|
||||
expect(result.metadata.page.marginX).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom sections", () => {
|
||||
it("adds a custom section", () => {
|
||||
const data = freshResume();
|
||||
const customSection = {
|
||||
id: "custom-1",
|
||||
type: "experience",
|
||||
title: "Freelance",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [],
|
||||
};
|
||||
|
||||
const result = applyResumePatches(data, [{ op: "add", path: "/customSections/-", value: customSection }]);
|
||||
|
||||
expect(result.customSections).toHaveLength(1);
|
||||
expect(result.customSections[0].title).toBe("Freelance");
|
||||
});
|
||||
|
||||
it("removes a custom section by index", () => {
|
||||
const data = freshResume();
|
||||
data.customSections = [
|
||||
{ id: "c1", type: "experience", title: "Freelance", columns: 1, hidden: false, items: [] },
|
||||
{ id: "c2", type: "projects", title: "Side Projects", columns: 1, hidden: false, items: [] },
|
||||
];
|
||||
|
||||
const result = applyResumePatches(data, [{ op: "remove", path: "/customSections/0" }]);
|
||||
expect(result.customSections).toHaveLength(1);
|
||||
expect(result.customSections[0].id).toBe("c2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("picture operations", () => {
|
||||
it("replaces picture URL", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [
|
||||
{ op: "replace", path: "/picture/url", value: "https://example.com/photo.jpg" },
|
||||
]);
|
||||
expect(result.picture.url).toBe("https://example.com/photo.jpg");
|
||||
});
|
||||
|
||||
it("toggles picture visibility", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [{ op: "replace", path: "/picture/hidden", value: true }]);
|
||||
expect(result.picture.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("updates picture size within valid range", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [{ op: "replace", path: "/picture/size", value: 120 }]);
|
||||
expect(result.picture.size).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyResumePatches — error / edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("error handling", () => {
|
||||
it("throws ResumePatchError for invalid path", () => {
|
||||
const data = freshResume();
|
||||
|
||||
expect(() => applyResumePatches(data, [{ op: "replace", path: "/nonexistent/path", value: "x" }])).toThrow(
|
||||
ResumePatchError,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws ResumePatchError for unresolvable 'from' in move", () => {
|
||||
const data = freshResume();
|
||||
|
||||
expect(() =>
|
||||
applyResumePatches(data, [{ op: "move", path: "/basics/name", from: "/nonexistent/field" }]),
|
||||
).toThrow(ResumePatchError);
|
||||
});
|
||||
|
||||
it("throws ResumePatchError for out-of-bounds array index", () => {
|
||||
const data = freshResume();
|
||||
|
||||
expect(() => applyResumePatches(data, [{ op: "remove", path: "/sections/skills/items/999" }])).toThrow(
|
||||
ResumePatchError,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when patch produces invalid resume data (e.g. wrong type)", () => {
|
||||
const data = freshResume();
|
||||
|
||||
// Setting a boolean field to a string should fail schema validation
|
||||
expect(() =>
|
||||
applyResumePatches(data, [{ op: "replace", path: "/picture/hidden", value: "not-a-boolean" }]),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("throws when patch produces invalid resume data (picture size out of range)", () => {
|
||||
const data = freshResume();
|
||||
|
||||
// picture.size must be 32-512
|
||||
expect(() => applyResumePatches(data, [{ op: "replace", path: "/picture/size", value: 9999 }])).toThrow();
|
||||
});
|
||||
|
||||
it("handles empty operations array without error", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, []);
|
||||
|
||||
// Should return equivalent data
|
||||
expect(result.basics.name).toBe(data.basics.name);
|
||||
});
|
||||
|
||||
it("returns a valid ResumeData after patching", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [{ op: "replace", path: "/basics/name", value: "Valid" }]);
|
||||
|
||||
expect(resumeDataSchema.safeParse(result).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout operations", () => {
|
||||
it("replaces sidebar width", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [{ op: "replace", path: "/metadata/layout/sidebarWidth", value: 40 }]);
|
||||
expect(result.metadata.layout.sidebarWidth).toBe(40);
|
||||
});
|
||||
|
||||
it("adds a section to a page layout", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [
|
||||
{ op: "add", path: "/metadata/layout/pages/0/sidebar/-", value: "custom-section-id" },
|
||||
]);
|
||||
expect(result.metadata.layout.pages[0].sidebar).toContain("custom-section-id");
|
||||
});
|
||||
|
||||
it("adds a new page to layout", () => {
|
||||
const data = freshResume();
|
||||
const newPage = { fullWidth: false, main: [], sidebar: [] };
|
||||
const result = applyResumePatches(data, [{ op: "add", path: "/metadata/layout/pages/-", value: newPage }]);
|
||||
expect(result.metadata.layout.pages).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional branch/edge case coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("test operation failures (applyPatch catch block)", () => {
|
||||
it("throws ResumePatchError when test op value mismatches (triggers catch)", () => {
|
||||
const data = freshResume();
|
||||
data.basics.name = "Real Name";
|
||||
|
||||
try {
|
||||
applyResumePatches(data, [{ op: "test", path: "/basics/name", value: "Wrong Name" }]);
|
||||
expect.unreachable("Should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ResumePatchError);
|
||||
const patchErr = error as ResumePatchError;
|
||||
expect(patchErr.code).toBeTruthy();
|
||||
expect(patchErr.index).toBeDefined();
|
||||
expect(patchErr.operation).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("ResumePatchError has correct operation in error", () => {
|
||||
const data = freshResume();
|
||||
data.basics.name = "Alice";
|
||||
|
||||
const testOp = { op: "test" as const, path: "/basics/name", value: "Bob" };
|
||||
try {
|
||||
applyResumePatches(data, [testOp]);
|
||||
expect.unreachable("Should have thrown");
|
||||
} catch (error) {
|
||||
const patchErr = error as ResumePatchError;
|
||||
expect(patchErr.operation.path).toBe("/basics/name");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema validation after patch", () => {
|
||||
it("throws on negative picture size (below min)", () => {
|
||||
const data = freshResume();
|
||||
expect(() => applyResumePatches(data, [{ op: "replace", path: "/picture/size", value: -1 }])).toThrow();
|
||||
});
|
||||
|
||||
it("falls back to default for invalid page format (schema uses .catch)", () => {
|
||||
const data = freshResume();
|
||||
// format has .catch("a4"), so invalid values fall back rather than throwing
|
||||
const result = applyResumePatches(data, [{ op: "replace", path: "/metadata/page/format", value: "tabloid" }]);
|
||||
expect(result.metadata.page.format).toBe("a4");
|
||||
});
|
||||
|
||||
it("allows setting customSections to empty array", () => {
|
||||
const data = freshResume();
|
||||
data.customSections = [{ id: "c1", type: "experience", title: "X", columns: 1, hidden: false, items: [] }];
|
||||
|
||||
const result = applyResumePatches(data, [{ op: "replace", path: "/customSections", value: [] }]);
|
||||
expect(result.customSections).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replace at array index", () => {
|
||||
it("replaces a specific item in an array", () => {
|
||||
const data = freshResume();
|
||||
data.sections.skills.items = [
|
||||
{ id: "s1", hidden: false, icon: "", name: "JS", proficiency: "", level: 0, keywords: [] },
|
||||
{ id: "s2", hidden: false, icon: "", name: "TS", proficiency: "", level: 0, keywords: [] },
|
||||
];
|
||||
|
||||
const result = applyResumePatches(data, [
|
||||
{ op: "replace", path: "/sections/skills/items/0/name", value: "JavaScript" },
|
||||
]);
|
||||
expect(result.sections.skills.items[0].name).toBe("JavaScript");
|
||||
expect(result.sections.skills.items[1].name).toBe("TS");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
import { produce } from "immer";
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import type { SectionItem } from "@/schema/resume/data";
|
||||
|
||||
import { type ResumeData, defaultResumeData } from "@/schema/resume/data";
|
||||
|
||||
import { createSectionItem, updateSectionItem } from "./section-actions";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function freshResume(): ResumeData {
|
||||
return structuredClone(defaultResumeData);
|
||||
}
|
||||
|
||||
function resumeWithExperience(): ResumeData {
|
||||
const data = freshResume();
|
||||
data.sections.experience.items = [
|
||||
{
|
||||
id: "exp-1",
|
||||
hidden: false,
|
||||
company: "Acme Corp",
|
||||
position: "Engineer",
|
||||
location: "NYC",
|
||||
period: "2020-2022",
|
||||
website: { url: "", label: "" },
|
||||
description: "<p>Built things</p>",
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: "exp-2",
|
||||
hidden: false,
|
||||
company: "Beta Inc",
|
||||
position: "Senior Dev",
|
||||
location: "SF",
|
||||
period: "2022-2024",
|
||||
website: { url: "", label: "" },
|
||||
description: "<p>Led things</p>",
|
||||
roles: [],
|
||||
},
|
||||
];
|
||||
return data;
|
||||
}
|
||||
|
||||
function resumeWithCustomSection(): ResumeData {
|
||||
const data = freshResume();
|
||||
data.customSections = [
|
||||
{
|
||||
id: "custom-1",
|
||||
type: "skills",
|
||||
title: "Soft Skills",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [{ id: "cs-1", hidden: false, icon: "", name: "Leadership", proficiency: "", level: 0, keywords: [] }],
|
||||
},
|
||||
];
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createSectionItem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createSectionItem", () => {
|
||||
it("adds an item to an empty standard section", () => {
|
||||
const data = freshResume();
|
||||
const newItem = {
|
||||
id: "new-1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "TypeScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
keywords: [],
|
||||
};
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createSectionItem(draft, "skills", newItem);
|
||||
});
|
||||
|
||||
expect(result.sections.skills.items).toHaveLength(1);
|
||||
expect(result.sections.skills.items[0]).toEqual(newItem);
|
||||
});
|
||||
|
||||
it("appends an item to a non-empty standard section", () => {
|
||||
const data = resumeWithExperience();
|
||||
const newExp = {
|
||||
id: "exp-3",
|
||||
hidden: false,
|
||||
company: "Gamma LLC",
|
||||
position: "CTO",
|
||||
location: "",
|
||||
period: "2024-",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
};
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createSectionItem(draft, "experience", newExp);
|
||||
});
|
||||
|
||||
expect(result.sections.experience.items).toHaveLength(3);
|
||||
expect(result.sections.experience.items[2].id).toBe("exp-3");
|
||||
});
|
||||
|
||||
it("adds an item to a custom section", () => {
|
||||
const data = resumeWithCustomSection();
|
||||
const newItem = { id: "cs-2", hidden: false, icon: "", name: "Teamwork", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createSectionItem(draft, "skills", newItem, "custom-1");
|
||||
});
|
||||
|
||||
const custom = result.customSections.find((s) => s.id === "custom-1")!;
|
||||
expect(custom.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does nothing when custom section id doesn't exist", () => {
|
||||
const data = resumeWithCustomSection();
|
||||
const newItem = { id: "cs-3", hidden: false, icon: "", name: "X", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createSectionItem(draft, "skills", newItem, "nonexistent");
|
||||
});
|
||||
|
||||
// Nothing should have changed
|
||||
expect(result.customSections[0].items).toHaveLength(1);
|
||||
expect(result.sections.skills.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("can add multiple items in sequence", () => {
|
||||
const data = freshResume();
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createSectionItem(draft, "skills", {
|
||||
id: "a",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "A",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
});
|
||||
createSectionItem(draft, "skills", {
|
||||
id: "b",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "B",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
});
|
||||
createSectionItem(draft, "skills", {
|
||||
id: "c",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "C",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.sections.skills.items).toHaveLength(3);
|
||||
expect(result.sections.skills.items.map((i) => i.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("adds items to different section types", () => {
|
||||
const data = freshResume();
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createSectionItem(draft, "awards", {
|
||||
id: "a1",
|
||||
hidden: false,
|
||||
title: "Best Dev",
|
||||
awarder: "Company",
|
||||
date: "2024",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
});
|
||||
createSectionItem(draft, "education", {
|
||||
id: "e1",
|
||||
hidden: false,
|
||||
school: "MIT",
|
||||
degree: "BS",
|
||||
area: "CS",
|
||||
grade: "",
|
||||
location: "",
|
||||
period: "2016-2020",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.sections.awards.items).toHaveLength(1);
|
||||
expect(result.sections.education.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateSectionItem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("updateSectionItem", () => {
|
||||
it("updates an existing item in a standard section", () => {
|
||||
const data = resumeWithExperience();
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
updateSectionItem(draft, "experience", {
|
||||
id: "exp-1",
|
||||
hidden: false,
|
||||
company: "Acme Corp Updated",
|
||||
position: "Senior Engineer",
|
||||
location: "NYC",
|
||||
period: "2020-2023",
|
||||
website: { url: "", label: "" },
|
||||
description: "<p>Built more things</p>",
|
||||
roles: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.sections.experience.items[0].company).toBe("Acme Corp Updated");
|
||||
expect(result.sections.experience.items[0].position).toBe("Senior Engineer");
|
||||
// Second item should be unchanged
|
||||
expect(result.sections.experience.items[1].company).toBe("Beta Inc");
|
||||
});
|
||||
|
||||
it("updates the last item in a section", () => {
|
||||
const data = resumeWithExperience();
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
updateSectionItem(draft, "experience", {
|
||||
id: "exp-2",
|
||||
hidden: true,
|
||||
company: "Beta Inc",
|
||||
position: "VP",
|
||||
location: "Remote",
|
||||
period: "2022-2024",
|
||||
website: { url: "https://beta.com", label: "Beta" },
|
||||
description: "",
|
||||
roles: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.sections.experience.items[1].hidden).toBe(true);
|
||||
expect(result.sections.experience.items[1].position).toBe("VP");
|
||||
});
|
||||
|
||||
it("does nothing when item id doesn't match any item in standard section", () => {
|
||||
const data = resumeWithExperience();
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
updateSectionItem(draft, "experience", {
|
||||
id: "nonexistent",
|
||||
hidden: false,
|
||||
company: "Ghost",
|
||||
position: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
});
|
||||
});
|
||||
|
||||
// Nothing should change
|
||||
expect(result.sections.experience.items).toHaveLength(2);
|
||||
expect(result.sections.experience.items[0].company).toBe("Acme Corp");
|
||||
});
|
||||
|
||||
it("updates an item in a custom section", () => {
|
||||
const data = resumeWithCustomSection();
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
updateSectionItem(
|
||||
draft,
|
||||
"skills",
|
||||
{
|
||||
id: "cs-1",
|
||||
hidden: false,
|
||||
icon: "star",
|
||||
name: "Communication",
|
||||
proficiency: "Expert",
|
||||
level: 5,
|
||||
keywords: ["soft"],
|
||||
},
|
||||
"custom-1",
|
||||
);
|
||||
});
|
||||
|
||||
const section = result.customSections.find((s) => s.id === "custom-1")!;
|
||||
const item = section.items[0] as SectionItem<"skills">;
|
||||
|
||||
expect(item.name).toBe("Communication");
|
||||
});
|
||||
|
||||
it("does nothing when custom section id doesn't exist", () => {
|
||||
const data = resumeWithCustomSection();
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
updateSectionItem(
|
||||
draft,
|
||||
"skills",
|
||||
{ id: "cs-1", hidden: false, icon: "", name: "Updated", proficiency: "", level: 0, keywords: [] },
|
||||
"nonexistent",
|
||||
);
|
||||
});
|
||||
|
||||
const section = result.customSections.find((s) => s.id === "custom-1")!;
|
||||
const item = section.items[0] as SectionItem<"skills">;
|
||||
|
||||
expect(item.name).toBe("Leadership");
|
||||
});
|
||||
|
||||
it("does nothing when item id doesn't match in custom section", () => {
|
||||
const data = resumeWithCustomSection();
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
updateSectionItem(
|
||||
draft,
|
||||
"skills",
|
||||
{ id: "nonexistent", hidden: false, icon: "", name: "X", proficiency: "", level: 0, keywords: [] },
|
||||
"custom-1",
|
||||
);
|
||||
});
|
||||
|
||||
const section = result.customSections.find((s) => s.id === "custom-1")!;
|
||||
const item = section.items[0] as SectionItem<"skills">;
|
||||
|
||||
expect(section.items).toHaveLength(1);
|
||||
expect(item.name).toBe("Leadership");
|
||||
});
|
||||
|
||||
it("replaces the entire item object (not a merge)", () => {
|
||||
const data = resumeWithExperience();
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
updateSectionItem(draft, "experience", {
|
||||
id: "exp-1",
|
||||
hidden: false,
|
||||
company: "New Company",
|
||||
position: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
});
|
||||
});
|
||||
|
||||
// position should be empty now — it's a full replace, not merge
|
||||
expect(result.sections.experience.items[0].position).toBe("");
|
||||
expect(result.sections.experience.items[0].company).toBe("New Company");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { WritableDraft } from "immer";
|
||||
|
||||
import type { ResumeData, SectionType } from "@/schema/resume/data";
|
||||
|
||||
/**
|
||||
* Pushes a new item into a section's items array.
|
||||
* Handles both built-in sections and custom sections.
|
||||
*/
|
||||
export function createSectionItem(
|
||||
draft: WritableDraft<ResumeData>,
|
||||
sectionKey: SectionType,
|
||||
formData: Record<string, unknown>,
|
||||
customSectionId?: string,
|
||||
) {
|
||||
if (customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === customSectionId);
|
||||
if (section) section.items.push(formData as never);
|
||||
} else {
|
||||
(draft.sections[sectionKey].items as unknown[]).push(formData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and replaces an existing item in a section's items array by id.
|
||||
* Handles both built-in sections and custom sections.
|
||||
*/
|
||||
export function updateSectionItem(
|
||||
draft: WritableDraft<ResumeData>,
|
||||
sectionKey: SectionType,
|
||||
formData: { id: string } & Record<string, unknown>,
|
||||
customSectionId?: string,
|
||||
) {
|
||||
if (customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData as never;
|
||||
} else {
|
||||
const items = draft.sections[sectionKey].items as Array<{ id: string }>;
|
||||
const index = items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) (items[index] as unknown as Record<string, unknown>) = formData;
|
||||
}
|
||||
}
|
||||
@@ -612,3 +612,59 @@ describe("buildSkillSyncOperations", () => {
|
||||
expect(operations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Additional edge cases for branch coverage ---
|
||||
|
||||
describe("tailorOutputToPatches — edge cases", () => {
|
||||
it("skips experience description patch when description is empty", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
experiences: [{ index: 0, description: "", roles: [] }],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
const expDescOps = operations.filter((op) => op.path.includes("/description"));
|
||||
expect(expDescOps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips reference description patch when description is empty", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
references: [{ index: 0, description: "" }],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
const refDescOps = operations.filter((op) => op.path.includes("/references/"));
|
||||
expect(refDescOps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles experience with null-like roles", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
experiences: [{ index: 0, description: "<p>Updated</p>" }],
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
expect(operations.some((op) => op.path.includes("/description"))).toBe(true);
|
||||
});
|
||||
|
||||
it("handles skill with empty proficiency", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
skills: [{ name: "Go", keywords: [], proficiency: "", icon: "", isNew: true }],
|
||||
};
|
||||
|
||||
const { newSkills } = tailorOutputToPatches(output, makeResumeData());
|
||||
expect(newSkills[0].proficiency).toBe("");
|
||||
});
|
||||
|
||||
it("handles summary with undefined content", () => {
|
||||
const output: TailorOutput = {
|
||||
...emptyTailorOutput,
|
||||
summary: { content: "" },
|
||||
};
|
||||
|
||||
const { operations } = tailorOutputToPatches(output, makeResumeData());
|
||||
expect(operations.filter((op) => op.path === "/summary/content")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { cn } from "./style";
|
||||
|
||||
describe("cn", () => {
|
||||
it("merges simple class names", () => {
|
||||
expect(cn("foo", "bar")).toBe("foo bar");
|
||||
});
|
||||
|
||||
it("handles conditional classes (falsy values ignored)", () => {
|
||||
const falsyValue = false;
|
||||
expect(cn("base", falsyValue && "hidden", "active")).toBe("base active");
|
||||
});
|
||||
|
||||
it("handles undefined and null", () => {
|
||||
expect(cn("base", undefined, null, "end")).toBe("base end");
|
||||
});
|
||||
|
||||
it("merges Tailwind classes (last wins)", () => {
|
||||
expect(cn("p-4", "p-8")).toBe("p-8");
|
||||
});
|
||||
|
||||
it("merges conflicting Tailwind utilities", () => {
|
||||
expect(cn("text-red-500", "text-blue-500")).toBe("text-blue-500");
|
||||
});
|
||||
|
||||
it("keeps non-conflicting Tailwind classes", () => {
|
||||
const result = cn("p-4", "m-2", "text-red-500");
|
||||
expect(result).toContain("p-4");
|
||||
expect(result).toContain("m-2");
|
||||
expect(result).toContain("text-red-500");
|
||||
});
|
||||
|
||||
it("handles empty input", () => {
|
||||
expect(cn()).toBe("");
|
||||
});
|
||||
|
||||
it("handles array input", () => {
|
||||
expect(cn(["foo", "bar"])).toBe("foo bar");
|
||||
});
|
||||
|
||||
it("handles object input", () => {
|
||||
expect(cn({ foo: true, bar: false, baz: true })).toBe("foo baz");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
vi.mock("@lingui/core/macro", () => ({
|
||||
msg: (strings: TemplateStringsArray) => ({ id: strings[0] }),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-start", () => {
|
||||
const chainable = () => new Proxy({}, { get: () => chainable });
|
||||
return {
|
||||
createIsomorphicFn: chainable,
|
||||
createServerFn: chainable,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tanstack/react-start/server", () => ({
|
||||
getCookie: () => undefined,
|
||||
setCookie: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("js-cookie", () => ({
|
||||
default: { get: () => undefined },
|
||||
}));
|
||||
|
||||
import { isTheme } from "./theme";
|
||||
|
||||
describe("isTheme", () => {
|
||||
it("returns true for 'light'", () => {
|
||||
expect(isTheme("light")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for 'dark'", () => {
|
||||
expect(isTheme("dark")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for invalid theme strings", () => {
|
||||
expect(isTheme("auto")).toBe(false);
|
||||
expect(isTheme("system")).toBe(false);
|
||||
expect(isTheme("")).toBe(false);
|
||||
expect(isTheme("DARK")).toBe(false);
|
||||
expect(isTheme("Light")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { createUrl } from "./url";
|
||||
|
||||
describe("createUrl", () => {
|
||||
it("returns empty strings for undefined url", () => {
|
||||
expect(createUrl()).toEqual({ url: "", label: "" });
|
||||
expect(createUrl(undefined)).toEqual({ url: "", label: "" });
|
||||
});
|
||||
|
||||
it("uses url as label when label is not provided", () => {
|
||||
expect(createUrl("https://example.com")).toEqual({
|
||||
url: "https://example.com",
|
||||
label: "https://example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses provided label", () => {
|
||||
expect(createUrl("https://example.com", "Example")).toEqual({
|
||||
url: "https://example.com",
|
||||
label: "Example",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Creates a URL object with a url and label.
|
||||
* Returns empty strings if no URL is provided.
|
||||
*/
|
||||
export function createUrl(url?: string, label?: string): { url: string; label: string } {
|
||||
if (!url) return { url: "", label: "" };
|
||||
return { url, label: label || url };
|
||||
}
|
||||
Reference in New Issue
Block a user