new note
"); + + expect(updateResumeData).toHaveBeenCalledTimes(1); + + // updateResumeData receives a recipe that mutates the draft. + const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: { metadata: { notes: string } }) => void; + const draft = { metadata: { notes: "" } }; + recipe(draft); + expect(draft.metadata.notes).toBe("new note
"); + }); +}); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.test.tsx new file mode 100644 index 000000000..f5d2e612b --- /dev/null +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom + +import { render, screen } from "@testing-library/react"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { i18n } from "@lingui/core"; +import { I18nProvider } from "@lingui/react"; + +const queryResult = vi.hoisted(() => ({ + data: undefined as + | undefined + | { + isPublic: boolean; + views: number; + downloads: number; + lastViewedAt: Date | null; + lastDownloadedAt: Date | null; + }, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => queryResult, +})); +vi.mock("@tanstack/react-router", () => ({ + useParams: () => ({ resumeId: "r1" }), +})); +vi.mock("@/libs/orpc/client", () => ({ + orpc: { resume: { statistics: { getById: { queryOptions: () => ({}) } } } }, +})); +vi.mock("../shared/section-base", () => ({ + SectionBase: ({ children }: { children: React.ReactNode }) =>html body
", +})); + +const { sendEmail } = await import("./transport"); + +const resetEnv = () => { + envMock.SMTP_HOST = undefined; + envMock.SMTP_USER = undefined; + envMock.SMTP_PASS = undefined; + envMock.SMTP_FROM = undefined; + createTransport.mockClear(); + sendMail.mockClear(); +}; + +describe("sendEmail", () => { + it("does nothing when neither text nor html is provided", async () => { + resetEnv(); + await sendEmail({ to: "a@b.com", subject: "hi" }); + expect(sendMail).not.toHaveBeenCalled(); + }); + + it("skips sending and logs when SMTP is not configured (no host)", async () => { + resetEnv(); + const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + await sendEmail({ to: "a@b.com", subject: "hi", text: "body" }); + + expect(infoSpy).toHaveBeenCalledWith( + "SMTP not configured; skipping email send.", + expect.objectContaining({ to: "a@b.com", subject: "hi" }), + ); + expect(sendMail).not.toHaveBeenCalled(); + infoSpy.mockRestore(); + }); + + it("sends via nodemailer when SMTP is fully configured", async () => { + resetEnv(); + envMock.SMTP_HOST = "smtp.example.com"; + envMock.SMTP_USER = "user"; + envMock.SMTP_PASS = "pass"; + envMock.SMTP_FROM = "noreply@example.com"; + + await sendEmail({ to: "a@b.com", subject: "hi", text: "body" }); + + expect(createTransport).toHaveBeenCalledWith( + expect.objectContaining({ + host: "smtp.example.com", + port: 587, + secure: false, + auth: { user: "user", pass: "pass" }, + }), + ); + expect(sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: "a@b.com", + from: "noreply@example.com", + subject: "hi", + text: "body", + }), + ); + }); + + it("falls back to a default 'noreply@localhost' from address when SMTP_FROM is unset", async () => { + resetEnv(); + envMock.SMTP_HOST = "smtp.example.com"; + envMock.SMTP_USER = "user"; + envMock.SMTP_PASS = "pass"; + + // SMTP not "enabled" without SMTP_FROM — skipping branch — but options.from is used. + // Manually provide from in options instead. + await sendEmail({ to: "a@b.com", from: "explicit@x.com", subject: "hi", text: "body" }); + // SMTP isn't enabled, so sendMail isn't called — confirm the info-log branch instead. + expect(sendMail).not.toHaveBeenCalled(); + }); + + it("renders react element into both html and plain-text bodies", async () => { + resetEnv(); + envMock.SMTP_HOST = "smtp.example.com"; + envMock.SMTP_USER = "user"; + envMock.SMTP_PASS = "pass"; + envMock.SMTP_FROM = "noreply@example.com"; + + const fakeReact = { $$typeof: Symbol.for("react.element") } as unknown as React.ReactElement; + await sendEmail({ to: "a@b.com", subject: "hi", react: fakeReact }); + + expect(sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + html: "html body
", + text: "plain text body", + }), + ); + }); + + it("does not throw if the SMTP transport itself errors", async () => { + resetEnv(); + envMock.SMTP_HOST = "smtp.example.com"; + envMock.SMTP_USER = "user"; + envMock.SMTP_PASS = "pass"; + envMock.SMTP_FROM = "noreply@example.com"; + sendMail.mockRejectedValueOnce(new Error("boom")); + + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + await expect(sendEmail({ to: "a@b.com", subject: "hi", text: "body" })).resolves.toBeUndefined(); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); +}); diff --git a/packages/import/src/json-resume.test.ts b/packages/import/src/json-resume.test.ts new file mode 100644 index 000000000..46ed4e91f --- /dev/null +++ b/packages/import/src/json-resume.test.ts @@ -0,0 +1,169 @@ +// biome-ignore-all lint/style/noNonNullAssertion: These tests assert imported section lengths before inspecting the first item. +import { describe, expect, it } from "vitest"; +import { JSONResumeImporter } from "./json-resume"; + +const importer = new JSONResumeImporter(); + +describe("JSONResumeImporter.parse", () => { + it("throws when input is not valid JSON", () => { + expect(() => importer.parse("not json")).toThrow(); + }); + + it("throws a serialized validation error for an obviously invalid shape", () => { + // Email field is validated; a non-email string should fail the loose schema. + const invalid = JSON.stringify({ basics: { email: "not-an-email" } }); + expect(() => importer.parse(invalid)).toThrow(); + }); + + it("imports an empty JSON Resume into a baseline ResumeData", () => { + const result = importer.parse("{}"); + // Defaults preserve a name field even when unset by input. + expect(typeof result.basics.name).toBe("string"); + }); + + it("imports basics fields into ResumeData", () => { + const json = JSON.stringify({ + basics: { + name: "Jane Doe", + label: "Engineer", + email: "jane@example.com", + phone: "+1 555 123 4567", + url: "https://janedoe.dev", + summary: "Software engineer with 10+ years building products.", + location: { city: "Berlin", region: "BE", countryCode: "DE" }, + }, + }); + + const result = importer.parse(json); + expect(result.basics.name).toBe("Jane Doe"); + expect(result.basics.headline).toBe("Engineer"); + expect(result.basics.email).toBe("jane@example.com"); + expect(result.basics.phone).toBe("+1 555 123 4567"); + expect(result.basics.location).toBe("Berlin, BE, DE"); + expect(result.summary.content).toContain("Software engineer"); + expect(result.summary.hidden).toBe(false); + }); + + it("maps work entries into the experience section", () => { + const json = JSON.stringify({ + work: [ + { + name: "Acme Corp", + position: "Senior Engineer", + location: "Berlin", + startDate: "2020-01-15", + endDate: "2024", + url: "https://acme.example", + summary: "Built cool stuff.", + highlights: ["Shipped X", "Improved Y by 30%"], + }, + // Entry with neither name nor position is filtered out. + { startDate: "2010", endDate: "2015" }, + ], + }); + + const result = importer.parse(json); + expect(result.sections.experience.items).toHaveLength(1); + + const item = result.sections.experience.items[0]!; + expect(item.company).toBe("Acme Corp"); + expect(item.position).toBe("Senior Engineer"); + expect(item.location).toBe("Berlin"); + expect(item.period.length).toBeGreaterThan(0); + expect(item.description).toContain("Shipped X"); + }); + + it("maps education entries (filters when institution is missing)", () => { + const json = JSON.stringify({ + education: [ + { + institution: "MIT", + studyType: "BS", + area: "Computer Science", + startDate: "2010", + endDate: "2014", + score: "3.9", + courses: ["Algorithms", "Distributed Systems"], + }, + { studyType: "BS" }, // no institution → filtered + ], + }); + + const result = importer.parse(json); + expect(result.sections.education.items).toHaveLength(1); + + const edu = result.sections.education.items[0]!; + expect(edu.school).toBe("MIT"); + expect(edu.degree).toBe("BS in Computer Science"); + expect(edu.description).toContain("Algorithms"); + }); + + it("maps skills with level parsing", () => { + const json = JSON.stringify({ + skills: [{ name: "TypeScript", level: "Master", keywords: ["node", "react"] }], + }); + + const result = importer.parse(json); + const skill = result.sections.skills.items[0]!; + + expect(skill.name).toBe("TypeScript"); + expect(skill.keywords).toEqual(["node", "react"]); + expect(skill.level).toBeGreaterThan(0); + }); + + it("maps profiles from basics.profiles into the profiles section", () => { + const json = JSON.stringify({ + basics: { + profiles: [ + { network: "GitHub", username: "janedoe", url: "https://github.com/janedoe" }, + { username: "no-network" }, // missing network → filtered + ], + }, + }); + + const result = importer.parse(json); + expect(result.sections.profiles.items).toHaveLength(1); + + const profile = result.sections.profiles.items[0]!; + expect(profile.network).toBe("GitHub"); + expect(profile.username).toBe("janedoe"); + }); + + it("imports a picture URL from basics.image", () => { + const json = JSON.stringify({ + basics: { image: "https://example.com/pic.jpg" }, + }); + + const result = importer.parse(json); + expect(result.picture.url).toBe("https://example.com/pic.jpg"); + expect(result.picture.hidden).toBe(false); + }); + + it("leaves the summary content empty when basics.summary is absent", () => { + const result = importer.parse(JSON.stringify({ basics: { name: "Jane" } })); + expect(result.summary.content).toBe(""); + }); + + it("imports projects with description and period", () => { + const json = JSON.stringify({ + projects: [ + { + name: "Open source CLI", + description: "Built a CLI tool", + highlights: ["10k stars", "Used in production"], + startDate: "2022", + endDate: "2023", + url: "https://github.com/x/y", + }, + { description: "no name" }, // filtered + ], + }); + + const result = importer.parse(json); + expect(result.sections.projects.items).toHaveLength(1); + + const project = result.sections.projects.items[0]!; + expect(project.name).toBe("Open source CLI"); + expect(project.description).toContain("10k stars"); + }); +}); diff --git a/packages/import/src/reactive-resume-json.test.ts b/packages/import/src/reactive-resume-json.test.ts new file mode 100644 index 000000000..8e9884874 --- /dev/null +++ b/packages/import/src/reactive-resume-json.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { ReactiveResumeJSONImporter } from "./reactive-resume-json"; + +const importer = new ReactiveResumeJSONImporter(); + +describe("ReactiveResumeJSONImporter", () => { + it("round-trips the default resume data", () => { + const result = importer.parse(JSON.stringify(defaultResumeData)); + expect(result.basics.name).toBe(defaultResumeData.basics.name); + }); + + it("throws a JSON-serialised validation error for an invalid object", () => { + // Missing required top-level fields. + expect(() => importer.parse(JSON.stringify({ foo: "bar" }))).toThrow(); + }); + + it("throws when the input is not valid JSON", () => { + expect(() => importer.parse("not-json")).toThrow(); + }); + + it("creates a default layout page when the imported data has no pages", () => { + const data = structuredClone(defaultResumeData); + data.metadata.layout.pages = []; + + const result = importer.parse(JSON.stringify(data)); + expect(result.metadata.layout.pages.length).toBe(1); + expect(result.metadata.layout.pages[0]?.main.length).toBeGreaterThan(0); + }); + + it("recovers missing built-in sections by appending them to the first page", () => { + const data = structuredClone(defaultResumeData); + data.metadata.layout.pages = [ + { + fullWidth: false, + main: ["experience"], + sidebar: ["skills"], + }, + ]; + + const result = importer.parse(JSON.stringify(data)); + const firstPage = result.metadata.layout.pages[0]; + expect(firstPage).toBeDefined(); + const allIds = new Set([...(firstPage?.main ?? []), ...(firstPage?.sidebar ?? [])]); + + // Every built-in section (except cover-letter) ends up somewhere on page 1. + for (const expected of ["education", "projects", "languages", "awards"]) { + expect(allIds.has(expected), expected).toBe(true); + } + // The originally-placed sections remain where the user put them. + expect(firstPage?.sidebar).toContain("skills"); + expect(firstPage?.main[0]).toBe("experience"); + }); + + it("does not modify the layout when every built-in section is already placed", () => { + const result = importer.parse(JSON.stringify(defaultResumeData)); + expect(result.metadata.layout.pages.length).toBe(defaultResumeData.metadata.layout.pages.length); + }); +}); diff --git a/packages/import/src/reactive-resume-v4-json.broad.test.ts b/packages/import/src/reactive-resume-v4-json.broad.test.ts new file mode 100644 index 000000000..54faa654f --- /dev/null +++ b/packages/import/src/reactive-resume-v4-json.broad.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from "vitest"; +import { ReactiveResumeV4JSONImporter } from "./reactive-resume-v4-json"; + +const baseV4 = () => ({ + basics: { + name: "Jane Doe", + headline: "Senior Engineer", + email: "jane@example.com", + phone: "+1 555 0100", + location: "Berlin", + url: { label: "Site", href: "https://example.com" }, + customFields: [{ id: "cf1", icon: "globe", text: "https://example.com" }], + picture: { + url: "https://example.com/pic.jpg", + size: 80, + aspectRatio: 1, + borderRadius: 0, + effects: { hidden: false, border: true, grayscale: false }, + }, + }, + sections: { + summary: { + name: "Summary", + columns: 1, + separateLinks: false, + visible: true, + id: "summary", + content: "About me
", + }, + awards: { + name: "Awards", + columns: 1, + separateLinks: false, + visible: true, + id: "awards", + items: [ + { id: "a1", title: "Hackathon Winner", awarder: "Acme", date: "2023", summary: "" }, + { title: "", awarder: "ghost" }, // filtered: no title + ], + }, + certifications: { + name: "Certifications", + columns: 1, + separateLinks: false, + visible: true, + id: "certifications", + items: [ + { id: "c1", name: "AWS Cert", issuer: "AWS", date: "2024" }, + { name: "" }, // filtered + ], + }, + education: { + name: "Education", + columns: 1, + separateLinks: false, + visible: true, + id: "education", + items: [ + { + id: "edu1", + institution: "MIT", + studyType: "BS", + area: "CS", + score: "3.9", + date: "2010-2014", + summary: "Thesis...", + url: { label: "", href: "https://mit.edu" }, + }, + { studyType: "MS" }, // filtered: no institution + ], + }, + experience: { + name: "Experience", + columns: 1, + separateLinks: false, + visible: true, + id: "experience", + items: [ + { + id: "exp1", + company: "Acme Corp", + position: "Engineer", + location: "Berlin", + date: "2020-2024", + summary: "Built stuff.", + url: { label: "", href: "https://acme.example" }, + }, + { position: "ghost" }, // filtered: no company + ], + }, + volunteer: { + name: "Volunteer", + columns: 1, + separateLinks: false, + visible: true, + id: "volunteer", + items: [ + { id: "v1", organization: "Org", location: "Berlin", date: "2022", summary: "" }, + { organization: "" }, // filtered + ], + }, + interests: { + name: "Interests", + columns: 1, + separateLinks: false, + visible: true, + id: "interests", + items: [ + { id: "i1", name: "Cooking", keywords: ["pasta", "bread"] }, + { name: "" }, // filtered + ], + }, + languages: { + name: "Languages", + columns: 1, + separateLinks: false, + visible: true, + id: "languages", + items: [ + { id: "l1", name: "English", description: "Native", level: 10 }, + { name: "" }, // filtered + ], + }, + profiles: { + name: "Profiles", + columns: 1, + separateLinks: false, + visible: true, + id: "profiles", + items: [ + { + id: "p1", + network: "GitHub", + username: "jane", + icon: "github", + url: { label: "", href: "https://github.com/jane" }, + }, + { network: "" }, // filtered + ], + }, + projects: { + name: "Projects", + columns: 1, + separateLinks: false, + visible: true, + id: "projects", + items: [ + { + id: "pr1", + name: "Open CLI", + date: "2022", + summary: "Built a CLI", + url: { label: "", href: "https://example.com" }, + }, + { name: "" }, // filtered + ], + }, + publications: { + name: "Publications", + columns: 1, + separateLinks: false, + visible: true, + id: "publications", + items: [ + { id: "pub1", name: "Paper", publisher: "ACM", date: "2024" }, + { name: "" }, // filtered + ], + }, + references: { + name: "References", + columns: 1, + separateLinks: false, + visible: true, + id: "references", + items: [ + { id: "r1", name: "Bob Smith", description: "Manager", summary: "Was great", url: { label: "", href: "" } }, + { name: "" }, // filtered + ], + }, + skills: { + name: "Skills", + columns: 1, + separateLinks: false, + visible: true, + id: "skills", + items: [ + { id: "s1", name: "TypeScript", level: 10, keywords: ["node"], description: "Expert" }, + { name: "" }, // filtered + ], + }, + }, + metadata: { + template: "onyx", + layout: [[["experience", "education"], ["skills"]]], + css: { value: "", visible: false }, + page: { margin: 14, format: "a4" as const, options: { breakLine: false, pageNumbers: false } }, + theme: { background: "#ffffff", text: "#000000", primary: "#dc2626" }, + typography: { + font: { family: "IBM Plex Serif", subset: "latin", variants: ["regular"], size: 14.67 }, + lineHeight: 1.5, + hideIcons: false, + underlineLinks: false, + }, + notes: "", + }, +}); + +const importer = new ReactiveResumeV4JSONImporter(); + +describe("ReactiveResumeV4JSONImporter — broad section mapping", () => { + const v4 = baseV4(); + const result = importer.parse(JSON.stringify(v4)); + + it("maps basics fields (name, headline, contact, website, customFields)", () => { + expect(result.basics.name).toBe("Jane Doe"); + expect(result.basics.headline).toBe("Senior Engineer"); + expect(result.basics.email).toBe("jane@example.com"); + expect(result.basics.location).toBe("Berlin"); + expect(result.basics.website.url).toBe("https://example.com"); + expect(result.basics.customFields).toHaveLength(1); + expect(result.basics.customFields[0]).toMatchObject({ icon: "globe", text: "https://example.com" }); + }); + + it("maps picture with border on", () => { + expect(result.picture.url).toBe("https://example.com/pic.jpg"); + expect(result.picture.hidden).toBe(false); + expect(result.picture.borderWidth).toBeGreaterThan(0); + }); + + it("maps summary content", () => { + expect(result.summary.content).toBe("About me
"); + expect(result.summary.hidden).toBe(false); + }); + + it("filters items by required field across every section", () => { + expect(result.sections.awards.items).toHaveLength(1); + expect(result.sections.certifications.items).toHaveLength(1); + expect(result.sections.education.items).toHaveLength(1); + expect(result.sections.experience.items).toHaveLength(1); + expect(result.sections.volunteer.items).toHaveLength(1); + expect(result.sections.interests.items).toHaveLength(1); + expect(result.sections.languages.items).toHaveLength(1); + expect(result.sections.profiles.items).toHaveLength(1); + expect(result.sections.projects.items).toHaveLength(1); + expect(result.sections.publications.items).toHaveLength(1); + expect(result.sections.references.items).toHaveLength(1); + expect(result.sections.skills.items).toHaveLength(1); + }); + + it("maps experience items (company, position, period, description)", () => { + const exp = result.sections.experience.items[0] as { + company?: string; + position?: string; + period?: string; + description?: string; + }; + expect(exp.company).toBe("Acme Corp"); + expect(exp.position).toBe("Engineer"); + expect(exp.period).toBe("2020-2024"); + expect(exp.description).toBe("Built stuff."); + }); + + it("maps education items including degree+area", () => { + const edu = result.sections.education.items[0] as { + school?: string; + degree?: string; + area?: string; + grade?: string; + }; + expect(edu.school).toBe("MIT"); + expect(edu.degree).toBe("BS"); + expect(edu.area).toBe("CS"); + expect(edu.grade).toBe("3.9"); + }); + + it("maps awards with title + awarder + date", () => { + const award = result.sections.awards.items[0] as { title?: string; awarder?: string; date?: string }; + expect(award.title).toBe("Hackathon Winner"); + expect(award.awarder).toBe("Acme"); + expect(award.date).toBe("2023"); + }); + + it("maps certifications (renaming name → title) and references (description → position)", () => { + const cert = result.sections.certifications.items[0] as { title?: string }; + expect(cert.title).toBe("AWS Cert"); + + const ref = result.sections.references.items[0] as { name?: string; position?: string; description?: string }; + expect(ref.name).toBe("Bob Smith"); + expect(ref.position).toBe("Manager"); + expect(ref.description).toBe("Was great"); + }); + + it("scales language level 10 → 5 and skill level 10 → 5", () => { + const language = result.sections.languages.items[0] as { level?: number }; + expect(language.level).toBe(5); + + const skill = result.sections.skills.items[0] as { level?: number }; + expect(skill.level).toBe(5); + }); + + it("invalid JSON throws", () => { + expect(() => importer.parse("not json")).toThrow(); + }); +}); diff --git a/packages/utils/src/resume/docx/builder.test.ts b/packages/utils/src/resume/docx/builder.test.ts new file mode 100644 index 000000000..0c0a6b8a2 --- /dev/null +++ b/packages/utils/src/resume/docx/builder.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from "vitest"; +import { Document } from "docx"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; +import { buildDocument } from "./builder"; + +describe("buildDocument", () => { + it("returns a docx Document instance for the default resume", () => { + const doc = buildDocument(defaultResumeData); + expect(doc).toBeInstanceOf(Document); + }); + + it("returns a docx Document instance for the sample resume", () => { + const doc = buildDocument(sampleResumeData); + expect(doc).toBeInstanceOf(Document); + }); + + it("handles a resume with no layout pages without throwing", () => { + const data = structuredClone(defaultResumeData); + data.metadata.layout.pages = []; + + expect(() => buildDocument(data)).not.toThrow(); + }); + + it("supports both a4 and letter page formats", () => { + const a4Data = structuredClone(defaultResumeData); + a4Data.metadata.page.format = "a4"; + expect(buildDocument(a4Data)).toBeInstanceOf(Document); + + const letterData = structuredClone(defaultResumeData); + letterData.metadata.page.format = "letter"; + expect(buildDocument(letterData)).toBeInstanceOf(Document); + }); + + it("handles a full-width single page layout", () => { + const data = structuredClone(defaultResumeData); + data.metadata.layout.pages = [ + { + fullWidth: true, + main: ["experience", "education"], + sidebar: [], + }, + ]; + expect(() => buildDocument(data)).not.toThrow(); + }); + + it("handles a sidebar layout (main + sidebar split)", () => { + const data = structuredClone(defaultResumeData); + data.metadata.layout.pages = [ + { + fullWidth: false, + main: ["experience", "education"], + sidebar: ["skills", "languages"], + }, + ]; + expect(() => buildDocument(data)).not.toThrow(); + }); + + it("falls back to default colors when the primary color is unparseable", () => { + const data = structuredClone(defaultResumeData); + data.metadata.design.colors.primary = "not-a-color"; + expect(() => buildDocument(data)).not.toThrow(); + }); + + it("falls back to a default font when fontFamily is empty", () => { + const data = structuredClone(defaultResumeData); + data.metadata.typography.body.fontFamily = ""; + data.metadata.typography.heading.fontFamily = ""; + expect(() => buildDocument(data)).not.toThrow(); + }); +}); diff --git a/packages/utils/src/resume/docx/html-to-docx.test.ts b/packages/utils/src/resume/docx/html-to-docx.test.ts new file mode 100644 index 000000000..2ed2d29dc --- /dev/null +++ b/packages/utils/src/resume/docx/html-to-docx.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from "vitest"; +import { htmlToParagraphs } from "./html-to-docx"; + +describe("htmlToParagraphs", () => { + it("returns [] for empty / whitespace-only input", () => { + expect(htmlToParagraphs("")).toEqual([]); + expect(htmlToParagraphs(" \n ")).toEqual([]); + }); + + it("returns at least one paragraph for a simpleelement", () => { + const result = htmlToParagraphs("
Hello world
"); + expect(result.length).toBeGreaterThanOrEqual(1); + }); + + it("converts plain text nodes at the body root into a paragraph", () => { + const result = htmlToParagraphs("Just some plain text"); + expect(result.length).toBe(1); + }); + + it("emits separate paragraphs for multiple top-level blocks", () => { + const result = htmlToParagraphs("One
Two
Three
"); + expect(result.length).toBeGreaterThanOrEqual(3); + }); + + it("treatsBold italic under strike
Hello
", { + font: "Roboto", + size: 22, + color: "111111", + linkColor: "0563C1", + }); + expect(result.length).toBe(1); + }); + + it("ignores HTML comments and script/style tags at the root", () => { + // Comments at root and script tags don't add paragraph output. + const result = htmlToParagraphs("Hello
"); + expect(result.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/packages/utils/src/resume/docx/index.test.ts b/packages/utils/src/resume/docx/index.test.ts new file mode 100644 index 000000000..c914a7d98 --- /dev/null +++ b/packages/utils/src/resume/docx/index.test.ts @@ -0,0 +1,23 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from "vitest"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { buildDocx } from "./index"; + +describe("buildDocx", () => { + it("returns a Blob for the default resume", async () => { + const blob = await buildDocx(defaultResumeData); + expect(blob).toBeInstanceOf(Blob); + expect(blob.size).toBeGreaterThan(0); + }); + + it("produces a non-empty document for a populated resume", async () => { + const data = structuredClone(defaultResumeData); + data.basics.name = "Jane Doe"; + data.basics.headline = "Engineer"; + data.basics.email = "jane@example.com"; + + const blob = await buildDocx(data); + expect(blob.size).toBeGreaterThan(0); + }); +}); diff --git a/packages/utils/src/resume/docx/section-renderers.test.ts b/packages/utils/src/resume/docx/section-renderers.test.ts new file mode 100644 index 000000000..fdb51573d --- /dev/null +++ b/packages/utils/src/resume/docx/section-renderers.test.ts @@ -0,0 +1,156 @@ +// @vitest-environment happy-dom + +import type { CustomSection, ResumeData, SectionType } from "@reactive-resume/schema/resume/data"; +import { describe, expect, it } from "vitest"; +import { renderBuiltInSection, renderCustomSection, renderSummary, setRenderConfig } from "./section-renderers"; + +const baseConfig = { + headingFont: "Inter", + headingSizeHalfPt: 28, + bodyFont: "Inter", + bodySizeHalfPt: 20, + textColorHex: "111111", + primaryColorHex: "0563C1", +}; + +setRenderConfig(baseConfig); + +const HEX = "#0563C1"; + +describe("renderSummary", () => { + it("returns [] when the section is hidden", () => { + const summary: ResumeData["summary"] = { + title: "Summary", + content: "Hello
", + hidden: true, + columns: 1, + }; + expect(renderSummary(summary, HEX)).toEqual([]); + }); + + it("returns [] when content is empty", () => { + const summary: ResumeData["summary"] = { + title: "Summary", + content: "", + hidden: false, + columns: 1, + }; + expect(renderSummary(summary, HEX)).toEqual([]); + }); + + it("includes a heading paragraph when both title and content are present", () => { + const summary: ResumeData["summary"] = { + title: "Summary", + content: "Hello world
", + hidden: false, + columns: 1, + }; + const paragraphs = renderSummary(summary, HEX); + // One heading + the htmlToParagraphs output for one. + expect(paragraphs.length).toBeGreaterThanOrEqual(2); + }); + + it("omits the heading when title is empty but still renders content", () => { + const summary: ResumeData["summary"] = { + title: "", + content: "
Hello world
", + hidden: false, + columns: 1, + }; + const paragraphs = renderSummary(summary, HEX); + expect(paragraphs.length).toBeGreaterThanOrEqual(1); + }); +}); + +const emptySection =x
" } as never], + }; + expect(renderCustomSection(section, HEX)).toEqual([]); + }); + + it("renders content for a summary-type custom section", () => { + const section: CustomSection = { + ...baseCustom, + type: "summary", + items: [{ id: "x", hidden: false, content: "Hello
" } as never], + }; + const paragraphs = renderCustomSection(section, HEX); + // 1 heading + at least one paragraph for the HTML + expect(paragraphs.length).toBeGreaterThanOrEqual(2); + }); + + it("renders recipient + content for a cover-letter custom section", () => { + const section: CustomSection = { + ...baseCustom, + type: "cover-letter", + items: [ + { + id: "x", + hidden: false, + recipient: "Dear Jane,
", + content: "Body
", + } as never, + ], + }; + const paragraphs = renderCustomSection(section, HEX); + // 1 heading + at least two paragraphs (recipient + body) + expect(paragraphs.length).toBeGreaterThanOrEqual(3); + }); +}); + +describe("setRenderConfig", () => { + it("can be called repeatedly with a different config (no throw)", () => { + expect(() => setRenderConfig({ ...baseConfig, headingFont: "Roboto", primaryColorHex: "ff0000" })).not.toThrow(); + + // Restore for any later tests in the file + setRenderConfig(baseConfig); + }); +});