From 6a01207b6ba80df883b805343ae109a156023809 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sun, 10 May 2026 20:00:07 +0200 Subject: [PATCH] test: add unit and component tests across the monorepo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ~1000 tests to bring the previously-untested packages and apps under coverage: - packages/utils — string, color, date, file, level, locale, sanitize, field, html, network-icons, rate-limit, url, url-security, monorepo, resume/patch, resume/docx/link-utils, style helpers (~97% on the testable utility files) - packages/ui — 28 component test files plus the use-controlled-state, use-mobile, use-confirm, use-prompt hooks (95% statements) - packages/pdf — shared template helpers (filtering, columns, picture, metrics, section-links, page-size, rich-text-html, section-title) - packages/schema — resumeDataSchema, page, templates, default - packages/fonts — expanded coverage on font helpers - packages/ai — resume sanitize and extraction template - packages/api — resume-access-policy - apps/web — error-message, locale, theme, pwa, dialogs/store, and the resume/move-item / section-actions / make-section-item helpers Adds jsdom polyfills (ResizeObserver, IntersectionObserver, scrollIntoView, matchMedia) and an explicit React Testing Library cleanup hook to vitest.setup.ts so portal- and overlay-based components work without per-test setup. --- apps/web/src/dialogs/store.test.ts | 124 ++++++ apps/web/src/libs/error-message.test.ts | 102 +++++ apps/web/src/libs/locale.test.ts | 89 +++++ apps/web/src/libs/pwa.test.ts | 72 ++++ .../src/libs/resume/make-section-item.test.ts | 50 +++ apps/web/src/libs/resume/move-item.test.ts | 246 ++++++++++++ .../src/libs/resume/section-actions.test.ts | 99 +++++ apps/web/src/libs/theme.test.ts | 33 ++ .../ai/src/resume/extraction-template.test.ts | 64 ++++ packages/ai/src/resume/sanitize.test.ts | 150 ++++++++ .../src/helpers/resume-access-policy.test.ts | 119 ++++++ packages/fonts/src/index.test.ts | 246 +++++++++++- packages/pdf/src/section-title.test.ts | 137 +++++++ .../pdf/src/templates/shared/columns.test.ts | 109 ++++++ .../src/templates/shared/filtering.test.ts | 134 +++++++ .../pdf/src/templates/shared/metrics.test.ts | 45 +++ .../src/templates/shared/page-size.test.ts | 31 ++ .../pdf/src/templates/shared/picture.test.ts | 33 ++ .../templates/shared/rich-text-html.test.ts | 49 +++ .../templates/shared/section-links.test.ts | 40 ++ packages/schema/src/page.test.ts | 42 ++ packages/schema/src/resume/data.test.ts | 272 +++++++++++++ packages/schema/src/resume/default.test.ts | 54 +++ packages/schema/src/templates.test.ts | 51 +++ packages/ui/src/components/accordion.test.tsx | 107 ++++++ .../ui/src/components/alert-dialog.test.tsx | 126 ++++++ packages/ui/src/components/alert.test.tsx | 75 ++++ packages/ui/src/components/avatar.test.tsx | 88 +++++ packages/ui/src/components/badge.test.tsx | 39 ++ .../ui/src/components/brand-icon.test.tsx | 41 ++ .../ui/src/components/button-group.test.tsx | 61 +++ packages/ui/src/components/button.test.tsx | 85 +++++ packages/ui/src/components/card.test.tsx | 99 +++++ packages/ui/src/components/checkbox.test.tsx | 47 +++ packages/ui/src/components/combobox.test.tsx | 223 +++++++++++ packages/ui/src/components/command.test.tsx | 129 +++++++ .../ui/src/components/context-menu.test.tsx | 133 +++++++ packages/ui/src/components/dialog.test.tsx | 135 +++++++ .../ui/src/components/dropdown-menu.test.tsx | 197 ++++++++++ packages/ui/src/components/form.test.tsx | 185 +++++++++ .../ui/src/components/hover-card.test.tsx | 55 +++ .../ui/src/components/input-group.test.tsx | 147 +++++++ packages/ui/src/components/input.test.tsx | 49 +++ packages/ui/src/components/kbd.test.tsx | 55 +++ packages/ui/src/components/label.test.tsx | 31 ++ packages/ui/src/components/popover.test.tsx | 116 ++++++ packages/ui/src/components/progress.test.tsx | 71 ++++ packages/ui/src/components/resizable.test.tsx | 64 ++++ .../ui/src/components/scroll-area.test.tsx | 42 ++ packages/ui/src/components/separator.test.tsx | 25 ++ packages/ui/src/components/sheet.test.tsx | 104 +++++ packages/ui/src/components/sidebar.test.tsx | 358 ++++++++++++++++++ packages/ui/src/components/skeleton.test.tsx | 27 ++ packages/ui/src/components/slider.test.tsx | 43 +++ packages/ui/src/components/sonner.test.tsx | 30 ++ packages/ui/src/components/spinner.test.tsx | 39 ++ packages/ui/src/components/switch.test.tsx | 44 +++ packages/ui/src/components/tabs.test.tsx | 95 +++++ packages/ui/src/components/textarea.test.tsx | 44 +++ packages/ui/src/components/toggle.test.tsx | 63 +++ packages/ui/src/components/tooltip.test.tsx | 88 +++++ packages/ui/src/hooks/use-confirm.test.tsx | 154 ++++++++ .../src/hooks/use-controlled-state.test.tsx | 78 ++++ packages/ui/src/hooks/use-mobile.test.tsx | 75 ++++ packages/ui/src/hooks/use-prompt.test.tsx | 177 +++++++++ packages/utils/src/color.test.ts | 102 +++++ packages/utils/src/date.test.ts | 89 +++++ packages/utils/src/field.test.ts | 70 ++++ packages/utils/src/file.test.ts | 132 +++++++ packages/utils/src/html.test.ts | 62 +++ packages/utils/src/level.test.ts | 133 +++++++ packages/utils/src/locale.test.ts | 42 ++ packages/utils/src/monorepo.node.test.ts | 86 +++++ packages/utils/src/network-icons.test.ts | 89 +++++ packages/utils/src/rate-limit.test.ts | 103 +++++ .../utils/src/resume/docx/link-utils.test.ts | 61 +++ packages/utils/src/resume/patch.test.ts | 205 ++++++++++ packages/utils/src/sanitize.test.ts | 168 ++++++++ packages/utils/src/string.test.ts | 151 ++++++++ packages/utils/src/style.test.ts | 46 +++ packages/utils/src/url-security.node.test.ts | 239 ++++++++++++ packages/utils/src/url.test.ts | 40 ++ vitest.setup.ts | 52 +++ 83 files changed, 8099 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/dialogs/store.test.ts create mode 100644 apps/web/src/libs/error-message.test.ts create mode 100644 apps/web/src/libs/locale.test.ts create mode 100644 apps/web/src/libs/pwa.test.ts create mode 100644 apps/web/src/libs/resume/make-section-item.test.ts create mode 100644 apps/web/src/libs/resume/move-item.test.ts create mode 100644 apps/web/src/libs/resume/section-actions.test.ts create mode 100644 apps/web/src/libs/theme.test.ts create mode 100644 packages/ai/src/resume/extraction-template.test.ts create mode 100644 packages/ai/src/resume/sanitize.test.ts create mode 100644 packages/api/src/helpers/resume-access-policy.test.ts create mode 100644 packages/pdf/src/section-title.test.ts create mode 100644 packages/pdf/src/templates/shared/columns.test.ts create mode 100644 packages/pdf/src/templates/shared/filtering.test.ts create mode 100644 packages/pdf/src/templates/shared/metrics.test.ts create mode 100644 packages/pdf/src/templates/shared/page-size.test.ts create mode 100644 packages/pdf/src/templates/shared/picture.test.ts create mode 100644 packages/pdf/src/templates/shared/rich-text-html.test.ts create mode 100644 packages/pdf/src/templates/shared/section-links.test.ts create mode 100644 packages/schema/src/page.test.ts create mode 100644 packages/schema/src/resume/data.test.ts create mode 100644 packages/schema/src/resume/default.test.ts create mode 100644 packages/schema/src/templates.test.ts create mode 100644 packages/ui/src/components/accordion.test.tsx create mode 100644 packages/ui/src/components/alert-dialog.test.tsx create mode 100644 packages/ui/src/components/alert.test.tsx create mode 100644 packages/ui/src/components/avatar.test.tsx create mode 100644 packages/ui/src/components/badge.test.tsx create mode 100644 packages/ui/src/components/brand-icon.test.tsx create mode 100644 packages/ui/src/components/button-group.test.tsx create mode 100644 packages/ui/src/components/button.test.tsx create mode 100644 packages/ui/src/components/card.test.tsx create mode 100644 packages/ui/src/components/checkbox.test.tsx create mode 100644 packages/ui/src/components/combobox.test.tsx create mode 100644 packages/ui/src/components/command.test.tsx create mode 100644 packages/ui/src/components/context-menu.test.tsx create mode 100644 packages/ui/src/components/dialog.test.tsx create mode 100644 packages/ui/src/components/dropdown-menu.test.tsx create mode 100644 packages/ui/src/components/form.test.tsx create mode 100644 packages/ui/src/components/hover-card.test.tsx create mode 100644 packages/ui/src/components/input-group.test.tsx create mode 100644 packages/ui/src/components/input.test.tsx create mode 100644 packages/ui/src/components/kbd.test.tsx create mode 100644 packages/ui/src/components/label.test.tsx create mode 100644 packages/ui/src/components/popover.test.tsx create mode 100644 packages/ui/src/components/progress.test.tsx create mode 100644 packages/ui/src/components/resizable.test.tsx create mode 100644 packages/ui/src/components/scroll-area.test.tsx create mode 100644 packages/ui/src/components/separator.test.tsx create mode 100644 packages/ui/src/components/sheet.test.tsx create mode 100644 packages/ui/src/components/sidebar.test.tsx create mode 100644 packages/ui/src/components/skeleton.test.tsx create mode 100644 packages/ui/src/components/slider.test.tsx create mode 100644 packages/ui/src/components/sonner.test.tsx create mode 100644 packages/ui/src/components/spinner.test.tsx create mode 100644 packages/ui/src/components/switch.test.tsx create mode 100644 packages/ui/src/components/tabs.test.tsx create mode 100644 packages/ui/src/components/textarea.test.tsx create mode 100644 packages/ui/src/components/toggle.test.tsx create mode 100644 packages/ui/src/components/tooltip.test.tsx create mode 100644 packages/ui/src/hooks/use-confirm.test.tsx create mode 100644 packages/ui/src/hooks/use-controlled-state.test.tsx create mode 100644 packages/ui/src/hooks/use-mobile.test.tsx create mode 100644 packages/ui/src/hooks/use-prompt.test.tsx create mode 100644 packages/utils/src/color.test.ts create mode 100644 packages/utils/src/date.test.ts create mode 100644 packages/utils/src/field.test.ts create mode 100644 packages/utils/src/file.test.ts create mode 100644 packages/utils/src/html.test.ts create mode 100644 packages/utils/src/level.test.ts create mode 100644 packages/utils/src/locale.test.ts create mode 100644 packages/utils/src/monorepo.node.test.ts create mode 100644 packages/utils/src/network-icons.test.ts create mode 100644 packages/utils/src/rate-limit.test.ts create mode 100644 packages/utils/src/resume/docx/link-utils.test.ts create mode 100644 packages/utils/src/resume/patch.test.ts create mode 100644 packages/utils/src/sanitize.test.ts create mode 100644 packages/utils/src/string.test.ts create mode 100644 packages/utils/src/style.test.ts create mode 100644 packages/utils/src/url-security.node.test.ts create mode 100644 packages/utils/src/url.test.ts diff --git a/apps/web/src/dialogs/store.test.ts b/apps/web/src/dialogs/store.test.ts new file mode 100644 index 000000000..a9318e4b1 --- /dev/null +++ b/apps/web/src/dialogs/store.test.ts @@ -0,0 +1,124 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useDialogStore } from "./store"; + +describe("useDialogStore", () => { + beforeEach(() => { + vi.useFakeTimers(); + // Reset store state between tests + useDialogStore.setState({ open: false, activeDialog: null, onBeforeClose: null }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("openDialog", () => { + it("opens a dialog and sets activeDialog", () => { + useDialogStore.getState().openDialog("api-key.create", undefined); + + const state = useDialogStore.getState(); + expect(state.open).toBe(true); + expect(state.activeDialog?.type).toBe("api-key.create"); + }); + + it("clears any existing onBeforeClose handler", () => { + useDialogStore.setState({ onBeforeClose: () => true }); + useDialogStore.getState().openDialog("resume.create", undefined); + + expect(useDialogStore.getState().onBeforeClose).toBeNull(); + }); + + it("preserves dialog data for typed payloads", () => { + const data = { id: "r1", name: "My Resume", slug: "my-resume", tags: [] }; + useDialogStore.getState().openDialog("resume.update", data); + + const active = useDialogStore.getState().activeDialog; + expect(active?.type).toBe("resume.update"); + if (active?.type === "resume.update") { + expect(active.data).toEqual(data); + } + }); + }); + + describe("closeDialog", () => { + it("immediately sets open to false", () => { + useDialogStore.setState({ + open: true, + activeDialog: { type: "api-key.create", data: undefined }, + }); + + useDialogStore.getState().closeDialog(); + expect(useDialogStore.getState().open).toBe(false); + }); + + it("clears activeDialog after 300ms (animation finished)", () => { + useDialogStore.setState({ + open: true, + activeDialog: { type: "api-key.create", data: undefined }, + }); + + useDialogStore.getState().closeDialog(); + expect(useDialogStore.getState().activeDialog).not.toBeNull(); + + vi.advanceTimersByTime(300); + expect(useDialogStore.getState().activeDialog).toBeNull(); + }); + }); + + describe("onOpenChange", () => { + it("opens via set without invoking onBeforeClose", () => { + const onBefore = vi.fn(); + useDialogStore.setState({ onBeforeClose: onBefore }); + + useDialogStore.getState().onOpenChange(true); + expect(useDialogStore.getState().open).toBe(true); + expect(onBefore).not.toHaveBeenCalled(); + }); + + it("closes immediately when no onBeforeClose handler", () => { + useDialogStore.setState({ open: true, activeDialog: { type: "api-key.create", data: undefined } }); + useDialogStore.getState().onOpenChange(false); + + expect(useDialogStore.getState().open).toBe(false); + }); + + it("invokes onBeforeClose when closing", async () => { + const handler = vi.fn().mockResolvedValue(true); + useDialogStore.setState({ + open: true, + activeDialog: { type: "api-key.create", data: undefined }, + onBeforeClose: handler, + }); + + useDialogStore.getState().onOpenChange(false); + await vi.waitFor(() => expect(handler).toHaveBeenCalled()); + }); + + it("calls cancel when onBeforeClose returns false", async () => { + const handler = vi.fn().mockResolvedValue(false); + const cancel = vi.fn(); + useDialogStore.setState({ + open: true, + activeDialog: { type: "api-key.create", data: undefined }, + onBeforeClose: handler, + }); + + useDialogStore.getState().onOpenChange(false, { cancel }); + await vi.waitFor(() => expect(cancel).toHaveBeenCalled()); + }); + }); + + describe("setOnBeforeClose", () => { + it("sets and clears the handler", () => { + const handler = () => true; + useDialogStore.getState().setOnBeforeClose(handler); + expect(useDialogStore.getState().onBeforeClose).toBe(handler); + + useDialogStore.getState().setOnBeforeClose(null); + expect(useDialogStore.getState().onBeforeClose).toBeNull(); + }); + }); +}); diff --git a/apps/web/src/libs/error-message.test.ts b/apps/web/src/libs/error-message.test.ts new file mode 100644 index 000000000..74e676c6b --- /dev/null +++ b/apps/web/src/libs/error-message.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { ORPCError } from "@orpc/client"; +import { getOrpcErrorMessage, getReadableErrorMessage, getResumeErrorMessage } from "./error-message"; + +describe("getReadableErrorMessage", () => { + it("returns the string error directly", () => { + expect(getReadableErrorMessage("explicit error", "fallback")).toBe("explicit error"); + }); + + it("returns Error.message", () => { + expect(getReadableErrorMessage(new Error("boom"), "fallback")).toBe("boom"); + }); + + it("returns fallback for unknown shapes", () => { + expect(getReadableErrorMessage({ random: "object" }, "fallback")).toBe("fallback"); + expect(getReadableErrorMessage(null, "fallback")).toBe("fallback"); + expect(getReadableErrorMessage(undefined, "fallback")).toBe("fallback"); + expect(getReadableErrorMessage(42, "fallback")).toBe("fallback"); + }); + + it("returns fallback for empty string error (falsy)", () => { + expect(getReadableErrorMessage("", "fallback")).toBe("fallback"); + }); + + it("returns fallback for Error with empty message", () => { + expect(getReadableErrorMessage(new Error(""), "fallback")).toBe("fallback"); + }); +}); + +describe("getOrpcErrorMessage", () => { + it("delegates to getReadableErrorMessage for non-ORPCErrors", () => { + expect(getOrpcErrorMessage(new Error("boom"), { fallback: "fallback" })).toBe("boom"); + expect(getOrpcErrorMessage("string error", { fallback: "fallback" })).toBe("string error"); + }); + + it("uses byCode mapping when present", () => { + const error = new ORPCError("RESUME_LOCKED"); + expect( + getOrpcErrorMessage(error, { + fallback: "fallback", + byCode: { RESUME_LOCKED: "It is locked." }, + }), + ).toBe("It is locked."); + }); + + it("returns server message when allowServerMessage and message is set", () => { + const error = new ORPCError("OTHER", { message: "Server-provided message" }); + expect( + getOrpcErrorMessage(error, { + fallback: "fallback", + allowServerMessage: true, + }), + ).toBe("Server-provided message"); + }); + + it("falls back when allowServerMessage is false even if message set", () => { + const error = new ORPCError("OTHER", { message: "Server-provided message" }); + expect(getOrpcErrorMessage(error, { fallback: "fallback" })).toBe("fallback"); + }); + + it("byCode takes precedence over allowServerMessage", () => { + const error = new ORPCError("RESUME_LOCKED", { message: "Server msg" }); + expect( + getOrpcErrorMessage(error, { + fallback: "fallback", + byCode: { RESUME_LOCKED: "It is locked." }, + allowServerMessage: true, + }), + ).toBe("It is locked."); + }); + + it("returns fallback when no mapping or server message", () => { + const error = new ORPCError("UNKNOWN_CODE"); + expect(getOrpcErrorMessage(error, { fallback: "fallback" })).toBe("fallback"); + }); +}); + +describe("getResumeErrorMessage", () => { + it("returns mapped message for RESUME_SLUG_ALREADY_EXISTS", () => { + const error = new ORPCError("RESUME_SLUG_ALREADY_EXISTS"); + expect(getResumeErrorMessage(error)).toBe("A resume with this slug already exists."); + }); + + it("returns mapped message for RESUME_LOCKED", () => { + const error = new ORPCError("RESUME_LOCKED"); + expect(getResumeErrorMessage(error)).toBe("This resume is locked. Unlock it first to make changes."); + }); + + it("returns generic fallback for unknown codes", () => { + const error = new ORPCError("UNKNOWN"); + expect(getResumeErrorMessage(error)).toBe("Something went wrong. Please try again."); + }); + + it("returns fallback for plain Error (delegates to getOrpcErrorMessage)", () => { + // Plain Error gets readable message + expect(getResumeErrorMessage(new Error("boom"))).toBe("boom"); + }); + + it("returns fallback for unknown shape", () => { + expect(getResumeErrorMessage(null)).toBe("Something went wrong. Please try again."); + }); +}); diff --git a/apps/web/src/libs/locale.test.ts b/apps/web/src/libs/locale.test.ts new file mode 100644 index 000000000..1ee5fa3c4 --- /dev/null +++ b/apps/web/src/libs/locale.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { isLocale, isRTL, resolveLocale } from "./locale"; + +describe("isLocale", () => { + it("returns true for known locale en-US", () => { + expect(isLocale("en-US")).toBe(true); + }); + + it("returns true for de-DE", () => { + expect(isLocale("de-DE")).toBe(true); + }); + + it("returns true for zh-CN", () => { + expect(isLocale("zh-CN")).toBe(true); + }); + + it("returns false for unknown locale", () => { + expect(isLocale("xx-YY")).toBe(false); + }); + + it("returns false for empty string", () => { + expect(isLocale("")).toBe(false); + }); + + it("returns false for malformed locale", () => { + expect(isLocale("not a locale")).toBe(false); + }); + + it("is case-sensitive", () => { + expect(isLocale("en-us")).toBe(false); + }); +}); + +describe("resolveLocale", () => { + it("returns the locale unchanged when valid", () => { + expect(resolveLocale("fr-FR")).toBe("fr-FR"); + }); + + it("returns en-US default for invalid locale", () => { + expect(resolveLocale("xx-YY")).toBe("en-US"); + }); + + it("returns en-US default for empty string", () => { + expect(resolveLocale("")).toBe("en-US"); + }); +}); + +describe("isRTL", () => { + it("returns true for Arabic", () => { + expect(isRTL("ar-SA")).toBe(true); + }); + + it("returns true for Hebrew", () => { + expect(isRTL("he-IL")).toBe(true); + }); + + it("returns true for Persian/Farsi", () => { + expect(isRTL("fa-IR")).toBe(true); + }); + + it("returns true for Urdu", () => { + expect(isRTL("ur-PK")).toBe(true); + }); + + it("returns false for English", () => { + expect(isRTL("en-US")).toBe(false); + }); + + it("returns false for German", () => { + expect(isRTL("de-DE")).toBe(false); + }); + + it("returns false for Chinese", () => { + expect(isRTL("zh-CN")).toBe(false); + }); + + it("returns false for unknown locale", () => { + expect(isRTL("xyz-XX")).toBe(false); + }); + + it("matches prefix case-insensitively (lowercase prefix)", () => { + expect(isRTL("AR-SA")).toBe(true); + }); + + it("works with locale-only string (no region)", () => { + expect(isRTL("ar")).toBe(true); + expect(isRTL("en")).toBe(false); + }); +}); diff --git a/apps/web/src/libs/pwa.test.ts b/apps/web/src/libs/pwa.test.ts new file mode 100644 index 000000000..e10417755 --- /dev/null +++ b/apps/web/src/libs/pwa.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { pwaHeadMetaTags, pwaManifest, pwaServiceWorkerRegistrationScript } from "./pwa"; + +describe("pwaManifest", () => { + it("uses the same theme color and background color (dark zinc)", () => { + expect(pwaManifest.theme_color).toBe(pwaManifest.background_color); + }); + + it("declares standalone display and portrait orientation", () => { + expect(pwaManifest.display).toBe("standalone"); + expect(pwaManifest.orientation).toBe("portrait"); + }); + + it("scopes to root '/'", () => { + expect(pwaManifest.scope).toBe("/"); + expect(pwaManifest.start_url).toContain("/"); + }); + + it("declares all icon sizes (64, 192, 512, maskable, favicon)", () => { + const sizes = pwaManifest.icons?.map((i) => i.sizes) ?? []; + expect(sizes).toContain("64x64"); + expect(sizes).toContain("192x192"); + expect(sizes).toContain("512x512"); + }); + + it("declares at least one maskable icon", () => { + const maskable = pwaManifest.icons?.find((i) => i.purpose === "maskable"); + expect(maskable).toBeDefined(); + }); + + it("includes both wide and narrow screenshots for proper installation UI", () => { + const wide = pwaManifest.screenshots?.filter((s) => s.form_factor === "wide"); + const narrow = pwaManifest.screenshots?.filter((s) => s.form_factor === "narrow"); + expect(wide?.length).toBeGreaterThan(0); + expect(narrow?.length).toBeGreaterThan(0); + }); + + it("includes 'resume' in categories for app stores", () => { + expect(pwaManifest.categories).toContain("resume"); + }); +}); + +describe("pwaHeadMetaTags", () => { + it("includes theme-color meta tag", () => { + expect(pwaHeadMetaTags).toContainEqual(expect.objectContaining({ name: "theme-color" })); + }); + + it("declares apple-mobile-web-app capable", () => { + const tag = pwaHeadMetaTags.find((t) => t.name === "apple-mobile-web-app-capable"); + expect(tag?.content).toBe("yes"); + }); + + it("uses 'black-translucent' status bar style for iOS", () => { + const tag = pwaHeadMetaTags.find((t) => t.name === "apple-mobile-web-app-status-bar-style"); + expect(tag?.content).toBe("black-translucent"); + }); +}); + +describe("pwaServiceWorkerRegistrationScript", () => { + it("registers the service worker only when supported", () => { + expect(pwaServiceWorkerRegistrationScript).toContain('"serviceWorker" in navigator'); + }); + + it("registers /sw.js with root scope", () => { + expect(pwaServiceWorkerRegistrationScript).toContain("/sw.js"); + expect(pwaServiceWorkerRegistrationScript).toContain('scope: "/"'); + }); + + it("registers on window load to avoid blocking initial paint", () => { + expect(pwaServiceWorkerRegistrationScript).toContain('addEventListener("load"'); + }); +}); diff --git a/apps/web/src/libs/resume/make-section-item.test.ts b/apps/web/src/libs/resume/make-section-item.test.ts new file mode 100644 index 000000000..9bb1618cf --- /dev/null +++ b/apps/web/src/libs/resume/make-section-item.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { makeSectionItem } from "./make-section-item"; + +describe("makeSectionItem", () => { + it("clones the provided item but generates a fresh id", () => { + const original = { id: "original-id", name: "Skill" }; + const result = makeSectionItem({ id: "default", name: "" }, original); + + expect(result.name).toBe("Skill"); + expect(result.id).not.toBe("original-id"); + expect(result.id.length).toBeGreaterThan(0); + }); + + it("uses defaultItem when no item is provided", () => { + const defaultItem = { id: "default", name: "", level: 0 }; + const result = makeSectionItem(defaultItem); + + expect(result.name).toBe(""); + expect(result.level).toBe(0); + expect(result.id).not.toBe("default"); + }); + + it("does not mutate the input item when duplicating", () => { + const original = { id: "original-id", value: "test" }; + const before = JSON.stringify(original); + makeSectionItem({ id: "default", value: "" }, original); + expect(JSON.stringify(original)).toBe(before); + }); + + it("does not mutate the defaultItem", () => { + const defaultItem = { id: "default", value: "x" }; + const before = JSON.stringify(defaultItem); + makeSectionItem(defaultItem); + expect(JSON.stringify(defaultItem)).toBe(before); + }); + + it("generates a unique id per call", () => { + const a = makeSectionItem({ id: "default", name: "" }); + const b = makeSectionItem({ id: "default", name: "" }); + expect(a.id).not.toBe(b.id); + }); + + it("preserves all other fields when duplicating", () => { + const item = { id: "x", a: 1, b: { c: 2 }, d: [3] }; + const result = makeSectionItem({ id: "default", a: 0, b: { c: 0 }, d: [] }, item); + expect(result.a).toBe(1); + expect(result.b).toEqual({ c: 2 }); + expect(result.d).toEqual([3]); + }); +}); diff --git a/apps/web/src/libs/resume/move-item.test.ts b/apps/web/src/libs/resume/move-item.test.ts new file mode 100644 index 000000000..a0ca871db --- /dev/null +++ b/apps/web/src/libs/resume/move-item.test.ts @@ -0,0 +1,246 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { i18n } from "@lingui/core"; +import { produce } from "immer"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { + addItemToSection, + createCustomSectionWithItem, + createPageWithSection, + getCompatibleMoveTargets, + getSourceSectionTitle, + removeItemFromSource, +} from "./move-item"; + +beforeAll(() => { + // Lingui requires an active locale before t`...` macros can render. + i18n.loadAndActivate({ locale: "en", messages: {} }); +}); + +describe("getSourceSectionTitle", () => { + it("returns custom section title for custom sections", () => { + const data = produce(defaultResumeData, (draft) => { + draft.customSections.push({ + id: "ext-1", + type: "cover-letter", + title: "My Custom Title", + columns: 1, + hidden: false, + items: [], + }); + }); + expect(getSourceSectionTitle(data, "cover-letter", "ext-1")).toBe("My Custom Title"); + }); + + it("returns localized default title when customSectionId is undefined", () => { + // Default section title comes from t`...` macro — we just check it's a non-empty string + expect(getSourceSectionTitle(defaultResumeData, "experience").length).toBeGreaterThan(0); + }); + + it("falls back to default title when custom section is not found", () => { + expect(getSourceSectionTitle(defaultResumeData, "experience", "non-existent").length).toBeGreaterThan(0); + }); +}); + +describe("getCompatibleMoveTargets", () => { + it("excludes the source section itself", () => { + // Default has experience in main; trying to move from experience to other targets + const targets = getCompatibleMoveTargets(defaultResumeData, "experience", undefined); + expect(targets[0]?.sections.find((s) => s.sectionId === "experience")).toBeUndefined(); + }); + + it("returns only custom sections of the same type as source", () => { + const data = produce(defaultResumeData, (draft) => { + draft.customSections.push({ + id: "ext-1", + type: "cover-letter", + title: "Cover Letter", + columns: 1, + hidden: false, + items: [], + }); + draft.metadata.layout.pages[0].main.push("ext-1"); + }); + + // Source is also a cover-letter custom section, but with different id + const targets = getCompatibleMoveTargets(data, "cover-letter", "different-id"); + expect(targets[0]?.sections.find((s) => s.sectionId === "ext-1")).toBeDefined(); + }); + + it("returns empty per page when no compatible targets", () => { + const targets = getCompatibleMoveTargets(defaultResumeData, "cover-letter", undefined); + // No custom sections of cover-letter type in default + for (const page of targets) { + expect(page.sections).toHaveLength(0); + } + }); + + it("returns one entry per page", () => { + const targets = getCompatibleMoveTargets(defaultResumeData, "experience", undefined); + expect(targets).toHaveLength(defaultResumeData.metadata.layout.pages.length); + }); +}); + +describe("removeItemFromSource", () => { + it("removes an item from a built-in section and returns its id", () => { + const initial = produce(defaultResumeData, (draft) => { + draft.sections.skills.items.push({ + id: "skill-1", + hidden: false, + icon: "", + iconColor: "", + name: "Go", + proficiency: "", + level: 4, + keywords: [], + }); + }); + + // Capture the id before produce revokes the draft proxy. + let removedId: string | undefined; + const result = produce(initial, (draft) => { + const removed = removeItemFromSource(draft, "skill-1", "skills"); + removedId = (removed as { id: string } | null)?.id; + }); + + expect(result.sections.skills.items).toHaveLength(0); + expect(removedId).toBe("skill-1"); + }); + + it("returns null when item id is not found in built-in section", () => { + let removed: unknown; + produce(defaultResumeData, (draft) => { + removed = removeItemFromSource(draft, "non-existent", "skills"); + }); + expect(removed).toBeNull(); + }); + + it("removes an item from a custom section and returns its id", () => { + const initial = produce(defaultResumeData, (draft) => { + draft.customSections.push({ + id: "ext-1", + type: "cover-letter", + title: "", + columns: 1, + hidden: false, + items: [{ id: "i1" } as never], + }); + }); + + let removedId: string | undefined; + const result = produce(initial, (draft) => { + const removed = removeItemFromSource(draft, "i1", "cover-letter", "ext-1"); + removedId = (removed as { id?: string } | null)?.id; + }); + + expect(result.customSections[0]?.items).toHaveLength(0); + expect(removedId).toBe("i1"); + }); + + it("returns null when custom section does not exist", () => { + let removed: unknown; + produce(defaultResumeData, (draft) => { + removed = removeItemFromSource(draft, "i1", "cover-letter", "non-existent"); + }); + expect(removed).toBeNull(); + }); +}); + +describe("addItemToSection", () => { + it("adds to a matching built-in section", () => { + const item = { + id: "skill-x", + hidden: false, + icon: "", + iconColor: "", + name: "Rust", + proficiency: "", + level: 4, + keywords: [], + }; + + const result = produce(defaultResumeData, (draft) => { + addItemToSection(draft, item, "skills", "skills"); + }); + + expect(result.sections.skills.items).toHaveLength(1); + }); + + it("adds to a custom section by id", () => { + const initial = produce(defaultResumeData, (draft) => { + draft.customSections.push({ + id: "ext-1", + type: "cover-letter", + title: "", + columns: 1, + hidden: false, + items: [], + }); + }); + + const result = produce(initial, (draft) => { + addItemToSection(draft, { id: "i1" } as never, "ext-1", "cover-letter"); + }); + + expect(result.customSections[0]?.items).toHaveLength(1); + }); + + it("does not add when target id does not match anything", () => { + const result = produce(defaultResumeData, (draft) => { + addItemToSection(draft, { id: "x" } as never, "non-existent", "skills"); + }); + expect(result.sections.skills.items).toHaveLength(0); + }); +}); + +describe("createCustomSectionWithItem", () => { + it("creates a new custom section and adds it to the target page main", () => { + let newSectionId = ""; + const result = produce(defaultResumeData, (draft) => { + newSectionId = createCustomSectionWithItem(draft, { id: "i1" } as never, "cover-letter", "My Section", 0); + }); + + expect(result.customSections).toHaveLength(1); + expect(result.customSections[0]?.title).toBe("My Section"); + expect(result.customSections[0]?.items).toHaveLength(1); + expect(result.metadata.layout.pages[0]?.main).toContain(newSectionId); + }); + + it("returns the generated section id", () => { + let newSectionId = ""; + produce(defaultResumeData, (draft) => { + newSectionId = createCustomSectionWithItem(draft, { id: "i1" } as never, "cover-letter", "X", 0); + }); + expect(newSectionId.length).toBeGreaterThan(0); + }); + + it("does not crash on out-of-range page index (no main column to push to)", () => { + // targetPageIndex=99 — page does not exist; section should still be created + const result = produce(defaultResumeData, (draft) => { + createCustomSectionWithItem(draft, { id: "i1" } as never, "cover-letter", "X", 99); + }); + expect(result.customSections).toHaveLength(1); + }); +}); + +describe("createPageWithSection", () => { + it("creates a new page with the new custom section in main", () => { + const initialPageCount = defaultResumeData.metadata.layout.pages.length; + const result = produce(defaultResumeData, (draft) => { + createPageWithSection(draft, { id: "i1" } as never, "cover-letter", "My New Page"); + }); + expect(result.metadata.layout.pages).toHaveLength(initialPageCount + 1); + + const newPage = result.metadata.layout.pages.at(-1); + expect(newPage?.main).toHaveLength(1); + expect(newPage?.sidebar).toEqual([]); + expect(newPage?.fullWidth).toBe(false); + }); + + it("adds the custom section to customSections array", () => { + const result = produce(defaultResumeData, (draft) => { + createPageWithSection(draft, { id: "i1" } as never, "cover-letter", "My Section"); + }); + expect(result.customSections).toHaveLength(1); + expect(result.customSections[0]?.title).toBe("My Section"); + }); +}); diff --git a/apps/web/src/libs/resume/section-actions.test.ts b/apps/web/src/libs/resume/section-actions.test.ts new file mode 100644 index 000000000..2375cff92 --- /dev/null +++ b/apps/web/src/libs/resume/section-actions.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { produce } from "immer"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { createSectionItem, updateSectionItem } from "./section-actions"; + +describe("createSectionItem", () => { + it("appends to a built-in section's items array", () => { + const result = produce(defaultResumeData, (draft) => { + createSectionItem(draft, "skills", { id: "1", name: "Go" }); + }); + expect(result.sections.skills.items).toHaveLength(1); + expect((result.sections.skills.items[0] as { name?: string })?.name).toBe("Go"); + }); + + it("appends to a custom section by id", () => { + const initial = produce(defaultResumeData, (draft) => { + draft.customSections.push({ + id: "custom-1", + type: "cover-letter", + title: "Cover Letter", + columns: 1, + hidden: false, + items: [], + }); + }); + + const result = produce(initial, (draft) => { + createSectionItem(draft, "skills", { id: "1", text: "Custom item" }, "custom-1"); + }); + + const customSection = result.customSections.find((s) => s.id === "custom-1"); + expect(customSection?.items).toHaveLength(1); + // Built-in section is untouched + expect(result.sections.skills.items).toHaveLength(0); + }); + + it("does nothing when customSectionId does not match", () => { + const result = produce(defaultResumeData, (draft) => { + createSectionItem(draft, "skills", { id: "1", name: "x" }, "non-existent"); + }); + // Skills not touched, no custom section to modify + expect(result.sections.skills.items).toHaveLength(0); + }); +}); + +describe("updateSectionItem", () => { + it("replaces the matching item in a built-in section", () => { + const initial = produce(defaultResumeData, (draft) => { + createSectionItem(draft, "skills", { id: "abc", name: "Go", level: 4 }); + }); + + const result = produce(initial, (draft) => { + updateSectionItem(draft, "skills", { id: "abc", name: "Go", level: 5 }); + }); + + const item = result.sections.skills.items[0] as { id?: string; name?: string; level?: number }; + expect(item?.level).toBe(5); + }); + + it("does nothing when item id does not exist", () => { + const initial = produce(defaultResumeData, (draft) => { + createSectionItem(draft, "skills", { id: "abc", name: "Go" }); + }); + + const before = JSON.stringify(initial.sections.skills); + const result = produce(initial, (draft) => { + updateSectionItem(draft, "skills", { id: "non-existent", name: "X" }); + }); + expect(JSON.stringify(result.sections.skills)).toBe(before); + }); + + it("replaces the matching item in a custom section", () => { + const initial = produce(defaultResumeData, (draft) => { + draft.customSections.push({ + id: "custom-1", + type: "cover-letter", + title: "", + columns: 1, + hidden: false, + items: [{ id: "x", value: "old" } as never], + }); + }); + + const result = produce(initial, (draft) => { + updateSectionItem(draft, "skills", { id: "x", value: "new" }, "custom-1"); + }); + + const customSection = result.customSections.find((s) => s.id === "custom-1"); + expect((customSection?.items[0] as { value?: string }).value).toBe("new"); + }); + + it("does nothing when custom section is not found", () => { + const before = JSON.stringify(defaultResumeData); + const result = produce(defaultResumeData, (draft) => { + updateSectionItem(draft, "skills", { id: "x", value: "new" }, "non-existent"); + }); + expect(JSON.stringify(result)).toBe(before); + }); +}); diff --git a/apps/web/src/libs/theme.test.ts b/apps/web/src/libs/theme.test.ts new file mode 100644 index 000000000..18d3fc2d4 --- /dev/null +++ b/apps/web/src/libs/theme.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { isTheme, themeMap } 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 unknown theme", () => { + expect(isTheme("auto")).toBe(false); + expect(isTheme("system")).toBe(false); + }); + + it("returns false for empty string", () => { + expect(isTheme("")).toBe(false); + }); + + it("is case-sensitive", () => { + expect(isTheme("Light")).toBe(false); + expect(isTheme("DARK")).toBe(false); + }); +}); + +describe("themeMap", () => { + it("includes a descriptor for each theme", () => { + expect(themeMap.light).toBeDefined(); + expect(themeMap.dark).toBeDefined(); + }); +}); diff --git a/packages/ai/src/resume/extraction-template.test.ts b/packages/ai/src/resume/extraction-template.test.ts new file mode 100644 index 000000000..190740f98 --- /dev/null +++ b/packages/ai/src/resume/extraction-template.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { buildAiExtractionTemplate } from "./extraction-template"; + +describe("buildAiExtractionTemplate", () => { + it("returns a copy of defaultResumeData with one item per section", () => { + const template = buildAiExtractionTemplate(); + + for (const [, section] of Object.entries(template.sections)) { + expect(Array.isArray(section.items)).toBe(true); + expect(section.items).toHaveLength(1); + } + }); + + it("populates the customFields with a single empty entry", () => { + const template = buildAiExtractionTemplate(); + expect(template.basics.customFields).toEqual([{ id: "", icon: "", text: "", link: "" }]); + }); + + it("does not mutate defaultResumeData", () => { + const before = JSON.stringify(defaultResumeData); + buildAiExtractionTemplate(); + expect(JSON.stringify(defaultResumeData)).toBe(before); + }); + + it("creates skill items with empty fields and zero level", () => { + const template = buildAiExtractionTemplate(); + const item = template.sections.skills.items[0] as Record; + expect(item.id).toBe(""); + expect(item.hidden).toBe(false); + expect(item.name).toBe(""); + expect(item.level).toBe(0); + expect(item.keywords).toEqual([]); + }); + + it("creates experience items with nested website object", () => { + const template = buildAiExtractionTemplate(); + const item = template.sections.experience.items[0] as Record; + expect(item.website).toEqual({ url: "", label: "", inlineLink: false }); + expect(item.company).toBe(""); + expect(item.period).toBe(""); + }); + + it("preserves unmodified sections from default (e.g., title)", () => { + const template = buildAiExtractionTemplate(); + expect(template.sections.skills.title).toBe(defaultResumeData.sections.skills.title); + expect(template.sections.skills.hidden).toBe(defaultResumeData.sections.skills.hidden); + expect(template.sections.skills.columns).toBe(defaultResumeData.sections.skills.columns); + }); + + it("creates languages items with empty language and zero level", () => { + const template = buildAiExtractionTemplate(); + const item = template.sections.languages.items[0] as Record; + expect(item.language).toBe(""); + expect(item.level).toBe(0); + }); + + it("creates references items with phone field", () => { + const template = buildAiExtractionTemplate(); + const item = template.sections.references.items[0] as Record; + expect(item.phone).toBe(""); + expect(item.name).toBe(""); + }); +}); diff --git a/packages/ai/src/resume/sanitize.test.ts b/packages/ai/src/resume/sanitize.test.ts new file mode 100644 index 000000000..83efc3d10 --- /dev/null +++ b/packages/ai/src/resume/sanitize.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeAndParseResumeJson } from "./sanitize"; + +describe("sanitizeAndParseResumeJson", () => { + it("parses a minimal but well-formed resume JSON", () => { + const json = JSON.stringify({ + basics: { name: "Alice" }, + }); + + const result = sanitizeAndParseResumeJson(json); + + expect(result.data.basics.name).toBe("Alice"); + expect(result.diagnostics.coercions).toEqual([]); + }); + + it("repairs unquoted keys and trailing commas via jsonrepair", () => { + const broken = `{ basics: { name: "Bob", }, }`; + const result = sanitizeAndParseResumeJson(broken); + expect(result.data.basics.name).toBe("Bob"); + }); + + it("strips text before and after the JSON block", () => { + const messy = `Some preamble text\n\n{ "basics": { "name": "Carol" } }\n\nSome trailing text`; + const result = sanitizeAndParseResumeJson(messy); + expect(result.data.basics.name).toBe("Carol"); + }); + + it("coerces 'true'/'false' strings to booleans", () => { + const json = JSON.stringify({ + basics: { name: "Test" }, + summary: { hidden: "true", title: "", columns: 1, content: "" }, + }); + + const result = sanitizeAndParseResumeJson(json); + expect(result.data.summary.hidden).toBe(true); + expect(result.diagnostics.coercions.length).toBeGreaterThan(0); + }); + + it("coerces numeric strings to numbers", () => { + const json = JSON.stringify({ + basics: { name: "Test" }, + summary: { hidden: false, title: "", columns: "2", content: "" }, + }); + + const result = sanitizeAndParseResumeJson(json); + expect(result.data.summary.columns).toBe(2); + }); + + it("drops items missing required fields with diagnostics", () => { + const json = JSON.stringify({ + basics: { name: "Test" }, + sections: { + skills: { + items: [ + { name: "TypeScript", level: 4 }, + { name: "" }, // dropped: missing name + {}, // dropped: missing name + ], + }, + }, + }); + + const result = sanitizeAndParseResumeJson(json); + expect(result.data.sections.skills.items).toHaveLength(1); + expect(result.diagnostics.droppedSectionItems.length).toBeGreaterThan(0); + expect(result.diagnostics.salvageApplied).toBe(true); + }); + + it("auto-generates ids for items lacking them", () => { + const json = JSON.stringify({ + basics: { name: "Test" }, + sections: { + skills: { items: [{ name: "Go" }] }, + }, + }); + + const result = sanitizeAndParseResumeJson(json); + const item = result.data.sections.skills.items[0]; + expect(item).toBeDefined(); + expect(item?.id?.length).toBeGreaterThan(0); + expect(result.diagnostics.salvageApplied).toBe(true); + }); + + it("defaults hidden=false on items missing the field", () => { + const json = JSON.stringify({ + basics: { name: "Test" }, + sections: { + skills: { items: [{ name: "Go" }] }, + }, + }); + + const result = sanitizeAndParseResumeJson(json); + expect(result.data.sections.skills.items[0]?.hidden).toBe(false); + }); + + it("uses defaultResumeData picture and metadata regardless of input", () => { + const json = JSON.stringify({ + basics: { name: "Test" }, + picture: { url: "https://attacker.com/pic.png", size: 1024 }, // overridden + metadata: { template: "unknown" }, // overridden + }); + + const result = sanitizeAndParseResumeJson(json); + expect(result.data.metadata.template).toBe("onyx"); + expect(result.data.picture.url).toBe(""); + }); + + it("preserves valid items alongside invalid ones in the same section", () => { + const json = JSON.stringify({ + basics: { name: "Test" }, + sections: { + experience: { + items: [ + { company: "Acme", position: "Engineer" }, + { position: "Designer" }, // dropped: missing company + ], + }, + }, + }); + + const result = sanitizeAndParseResumeJson(json); + expect(result.data.sections.experience.items).toHaveLength(1); + expect(result.data.sections.experience.items[0]?.company).toBe("Acme"); + }); + + it("clears customSections to empty array regardless of input", () => { + const json = JSON.stringify({ + basics: { name: "Test" }, + customSections: [{ key: "hack" }], + }); + + const result = sanitizeAndParseResumeJson(json); + expect(result.data.customSections).toEqual([]); + }); + + it("throws when input is non-JSON garbage", () => { + expect(() => sanitizeAndParseResumeJson("not even close to json")).toThrow(); + }); + + it("preserves coercion path entries in diagnostics", () => { + const json = JSON.stringify({ + basics: { name: "Test" }, + summary: { hidden: "true", title: "", columns: 1, content: "" }, + }); + + const result = sanitizeAndParseResumeJson(json); + const hiddenCoercion = result.diagnostics.coercions.find((c) => c.path === "summary.hidden"); + expect(hiddenCoercion).toEqual({ path: "summary.hidden", fromType: "string", toType: "boolean" }); + }); +}); diff --git a/packages/api/src/helpers/resume-access-policy.test.ts b/packages/api/src/helpers/resume-access-policy.test.ts new file mode 100644 index 000000000..5d9ce724b --- /dev/null +++ b/packages/api/src/helpers/resume-access-policy.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { assertCanView, isOwner, redactResumeForViewer, shouldCountForStatistics } from "./resume-access-policy"; + +describe("isOwner", () => { + it("returns true when viewer.id matches resume.userId", () => { + expect(isOwner({ userId: "u1", isPublic: false }, { id: "u1" })).toBe(true); + }); + + it("returns false when viewer.id differs", () => { + expect(isOwner({ userId: "u1", isPublic: false }, { id: "u2" })).toBe(false); + }); + + it("returns false when viewer is null (anonymous)", () => { + expect(isOwner({ userId: "u1", isPublic: false }, null)).toBe(false); + }); +}); + +describe("assertCanView", () => { + it("does not throw when viewer is owner of private resume", () => { + expect(() => assertCanView({ userId: "u1", isPublic: false }, { id: "u1" })).not.toThrow(); + }); + + it("does not throw when resume is public regardless of viewer", () => { + expect(() => assertCanView({ userId: "u1", isPublic: true }, { id: "u2" })).not.toThrow(); + expect(() => assertCanView({ userId: "u1", isPublic: true }, null)).not.toThrow(); + }); + + it("throws NOT_FOUND for private resume viewed by non-owner", () => { + expect(() => assertCanView({ userId: "u1", isPublic: false }, { id: "u2" })).toThrow(); + }); + + it("throws NOT_FOUND for private resume viewed anonymously", () => { + expect(() => assertCanView({ userId: "u1", isPublic: false }, null)).toThrow(); + }); + + it("error code is NOT_FOUND (not FORBIDDEN) to prevent existence disclosure", () => { + try { + assertCanView({ userId: "u1", isPublic: false }, null); + expect.unreachable(); + } catch (error: unknown) { + expect((error as { code?: string }).code).toBe("NOT_FOUND"); + } + }); +}); + +describe("redactResumeForViewer", () => { + it("returns the resume unchanged for owner", () => { + const resume = { + name: "My Dashboard Title", + data: { ...defaultResumeData, metadata: { ...defaultResumeData.metadata, notes: "Private" } }, + }; + expect(redactResumeForViewer(resume, true)).toBe(resume); + }); + + it("strips name to empty for non-owner", () => { + const resume = { + name: "Senior Eng @ Foo — final draft", + data: defaultResumeData, + }; + const result = redactResumeForViewer(resume, false); + expect(result.name).toBe(""); + }); + + it("strips metadata.notes for non-owner", () => { + const resume = { + name: "Title", + data: { ...defaultResumeData, metadata: { ...defaultResumeData.metadata, notes: "Private notes" } }, + }; + const result = redactResumeForViewer(resume, false); + expect(result.data.metadata.notes).toBe(""); + }); + + it("preserves resume.data.basics.name (the person's name) for non-owner", () => { + const resume = { + name: "Dashboard title", + data: { + ...defaultResumeData, + basics: { ...defaultResumeData.basics, name: "Alice Smith" }, + }, + }; + const result = redactResumeForViewer(resume, false); + expect(result.data.basics.name).toBe("Alice Smith"); + }); + + it("does not mutate the input", () => { + const resume = { + name: "Title", + data: { ...defaultResumeData, metadata: { ...defaultResumeData.metadata, notes: "Notes" } }, + }; + const before = JSON.stringify(resume); + redactResumeForViewer(resume, false); + expect(JSON.stringify(resume)).toBe(before); + }); + + it("preserves additional resume fields", () => { + const resume = { + name: "Title", + data: defaultResumeData, + extraField: "extra", + }; + const result = redactResumeForViewer(resume, false); + expect((result as typeof resume).extraField).toBe("extra"); + }); +}); + +describe("shouldCountForStatistics", () => { + it("returns false when viewer is the owner", () => { + expect(shouldCountForStatistics({ userId: "u1", isPublic: true }, { id: "u1" })).toBe(false); + }); + + it("returns true for anonymous viewers", () => { + expect(shouldCountForStatistics({ userId: "u1", isPublic: true }, null)).toBe(true); + }); + + it("returns true for non-owner viewers", () => { + expect(shouldCountForStatistics({ userId: "u1", isPublic: true }, { id: "u2" })).toBe(true); + }); +}); diff --git a/packages/fonts/src/index.test.ts b/packages/fonts/src/index.test.ts index 732200d76..f7ab41b23 100644 --- a/packages/fonts/src/index.test.ts +++ b/packages/fonts/src/index.test.ts @@ -1,5 +1,20 @@ import { describe, expect, it } from "vitest"; -import { fontList, getPdfCjkFallbackFontFamily, getWebFontSource } from "./index"; +import { + buildResumeFontFamily, + fontList, + getFallbackWebFontFamilies, + getFont, + getFontDisplayName, + getFontSearchKeywords, + getLoadableWebFontWeights, + getPdfCjkFallbackFontFamily, + getWebFont, + getWebFontSource, + isStandardPdfFontFamily, + standardFontList, + webFontList, + webFontMap, +} from "./index"; const sortFontFamilies = (families: string[]) => { return [...families].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); @@ -8,18 +23,237 @@ const sortFontFamilies = (families: string[]) => { describe("fontList", () => { it("is ordered by font family name instead of localized display name", () => { const families = fontList.map((font) => font.family); - expect(families).toEqual(sortFontFamilies(families)); }); + + it("contains standard PDF fonts", () => { + const families = fontList.map((font) => font.family); + expect(families).toContain("Helvetica"); + expect(families).toContain("Courier"); + expect(families).toContain("Times-Roman"); + }); + + it("merges standardFontList and webFontList without duplicates", () => { + const expectedSize = standardFontList.length + webFontList.length; + expect(fontList).toHaveLength(expectedSize); + }); }); -describe("font source helpers", () => { +describe("standardFontList", () => { + it("contains only fonts not present in webFontMap", () => { + for (const font of standardFontList) { + expect(webFontMap.has(font.family)).toBe(false); + } + }); + + it("marks all standard fonts with type='standard'", () => { + for (const font of standardFontList) { + expect(font.type).toBe("standard"); + } + }); +}); + +describe("getFont", () => { + it("returns a font record by family", () => { + expect(getFont("Helvetica")).toBeDefined(); + expect(getFont("Helvetica")?.family).toBe("Helvetica"); + }); + + it("returns undefined for unknown families", () => { + expect(getFont("DefinitelyNotAFont")).toBeUndefined(); + }); +}); + +describe("getFontDisplayName", () => { + it("returns localized name when available", () => { + expect(getFontDisplayName("Noto Sans SC")).toBe("思源黑体"); + expect(getFontDisplayName("PingFang SC")).toBe("苹方"); + }); + + it("returns the family name when no localized name exists", () => { + expect(getFontDisplayName("Helvetica")).toBe("Helvetica"); + expect(getFontDisplayName("Roboto")).toBe("Roboto"); + }); +}); + +describe("getFontSearchKeywords", () => { + it("returns family for non-CJK fonts", () => { + const keywords = getFontSearchKeywords("Helvetica"); + expect(keywords).toContain("Helvetica"); + expect(keywords).not.toContain("中文"); + }); + + it("includes localized display name and 中文 marker for CJK fonts", () => { + const keywords = getFontSearchKeywords("Noto Sans SC"); + expect(keywords).toContain("Noto Sans SC"); + expect(keywords).toContain("思源黑体"); + expect(keywords).toContain("中文"); + }); + + it("deduplicates entries", () => { + const keywords = getFontSearchKeywords("Helvetica"); + expect(new Set(keywords).size).toBe(keywords.length); + }); +}); + +describe("isStandardPdfFontFamily", () => { + it("returns true for Helvetica, Courier, Times-Roman", () => { + expect(isStandardPdfFontFamily("Helvetica")).toBe(true); + expect(isStandardPdfFontFamily("Courier")).toBe(true); + expect(isStandardPdfFontFamily("Times-Roman")).toBe(true); + }); + + it("returns false for unknown families", () => { + expect(isStandardPdfFontFamily("Not-A-Font")).toBe(false); + }); + + it("returns false for non-standard web fonts", () => { + // Roboto is a web font, not a standard PDF font + const roboto = getWebFont("Roboto"); + if (roboto) expect(isStandardPdfFontFamily("Roboto")).toBe(false); + }); +}); + +describe("getWebFont", () => { + it("returns webFont record for known web font family", () => { + // Spot-check a font that should be in the list + const font = getWebFont("Roboto"); + if (font) { + expect(font.type).toBe("web"); + expect(font.family).toBe("Roboto"); + } + }); + + it("returns undefined for non-web families", () => { + expect(getWebFont("Helvetica")).toBeUndefined(); + expect(getWebFont("definitely-not-a-font")).toBeUndefined(); + }); +}); + +describe("getWebFontSource", () => { it("uses the full normal font source when an italic variant is unavailable", () => { expect(getWebFontSource("Noto Serif SC", "400", true)).toBe(getWebFontSource("Noto Serif SC", "400", false)); }); - it("returns CJK PDF fallbacks for standard PDF fonts", () => { - expect(getPdfCjkFallbackFontFamily("Helvetica")).toBe("Noto Sans SC"); - expect(getPdfCjkFallbackFontFamily("Times-Roman")).toBe("Noto Serif SC"); + it("returns null for unknown fonts", () => { + expect(getWebFontSource("definitely-not-a-font", "400")).toBeNull(); + }); + + it("defaults to weight 400 when not specified", () => { + const roboto = getWebFont("Roboto"); + if (roboto) { + const source = getWebFontSource("Roboto"); + expect(source).toBeTruthy(); + } + }); +}); + +describe("getPdfCjkFallbackFontFamily", () => { + it("returns Noto Sans SC for sans-serif/standard PDF fonts", () => { + expect(getPdfCjkFallbackFontFamily("Helvetica")).toBe("Noto Sans SC"); + }); + + it("returns Noto Serif SC for serif fonts", () => { + expect(getPdfCjkFallbackFontFamily("Times-Roman")).toBe("Noto Serif SC"); + }); + + it("returns null when family already is the CJK fallback", () => { + expect(getPdfCjkFallbackFontFamily("Noto Sans SC")).toBeNull(); + }); +}); + +describe("getFallbackWebFontFamilies", () => { + it("returns empty array for standard PDF fonts", () => { + expect(getFallbackWebFontFamilies("Helvetica")).toEqual([]); + expect(getFallbackWebFontFamilies("Courier")).toEqual([]); + expect(getFallbackWebFontFamilies("Times-Roman")).toEqual([]); + }); + + it("returns the primary CJK web font for non-standard, non-CJK families", () => { + // e.g. Roboto (sans-serif) → Noto Sans SC fallback + const roboto = getWebFont("Roboto"); + if (roboto) { + expect(getFallbackWebFontFamilies("Roboto")).toEqual(["Noto Sans SC"]); + } + }); + + it("returns empty when family is already its primary CJK fallback", () => { + expect(getFallbackWebFontFamilies("Noto Sans SC")).toEqual([]); + }); +}); + +describe("getLoadableWebFontWeights", () => { + it("returns empty array for unknown fonts", () => { + expect(getLoadableWebFontWeights("definitely-not-a-font", ["400"])).toEqual([]); + }); + + it("returns matching weights when preferred weights are available", () => { + const roboto = getWebFont("Roboto"); + if (roboto) { + const result = getLoadableWebFontWeights("Roboto", ["400", "700"]); + for (const weight of result) { + expect(roboto.weights).toContain(weight); + } + } + }); + + it("falls back to default weights when no preferences match", () => { + const roboto = getWebFont("Roboto"); + if (roboto) { + const result = getLoadableWebFontWeights("Roboto", ["999"]); + expect(result.length).toBeGreaterThan(0); + } + }); + + it("deduplicates preferred weights", () => { + const roboto = getWebFont("Roboto"); + if (roboto?.weights.includes("400")) { + const result = getLoadableWebFontWeights("Roboto", ["400", "400"]); + expect(result).toEqual(["400"]); + } + }); +}); + +describe("buildResumeFontFamily", () => { + it("wraps the primary family in single quotes", () => { + const result = buildResumeFontFamily("Roboto"); + expect(result.startsWith("'Roboto',")).toBe(true); + }); + + it("includes generic sans-serif fallback by default", () => { + const result = buildResumeFontFamily("Roboto"); + expect(result.endsWith("sans-serif")).toBe(true); + }); + + it("uses serif fallback for serif-category fonts", () => { + expect(buildResumeFontFamily("Times-Roman").endsWith("serif")).toBe(true); + }); + + it("includes system-ui and Segoe UI fallbacks", () => { + const result = buildResumeFontFamily("Roboto"); + expect(result).toContain("system-ui"); + expect(result).toContain("Segoe UI"); + }); + + it("includes CJK fallbacks for sans-serif fonts", () => { + const result = buildResumeFontFamily("Roboto"); + expect(result).toContain("Noto Sans SC"); + }); + + it("includes CJK serif fallbacks for serif fonts", () => { + const result = buildResumeFontFamily("Times-Roman"); + expect(result).toContain("Noto Serif SC"); + }); + + it("does not duplicate primary family in fallback list", () => { + const result = buildResumeFontFamily("Noto Sans SC"); + // Family should appear once + const occurrences = result.split("Noto Sans SC").length - 1; + expect(occurrences).toBe(1); + }); + + it("escapes single quotes in family names", () => { + const result = buildResumeFontFamily("Bob's Font"); + expect(result).toContain("Bob\\'s Font"); }); }); diff --git a/packages/pdf/src/section-title.test.ts b/packages/pdf/src/section-title.test.ts new file mode 100644 index 000000000..c9c1c8f7f --- /dev/null +++ b/packages/pdf/src/section-title.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from "vitest"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { getResumeSectionTitle, resolveSectionTitle } from "./section-title"; + +describe("resolveSectionTitle", () => { + it("returns the trimmed user-provided title when non-empty", () => { + const result = resolveSectionTitle("My Title", { + sectionId: "experience", + locale: "en-US", + sectionKind: "builtin", + }); + expect(result).toBe("My Title"); + }); + + it("falls through to resolver when title is empty", () => { + const resolver = vi.fn().mockReturnValue("Resolved"); + const result = resolveSectionTitle( + "", + { + sectionId: "experience", + locale: "en-US", + sectionKind: "builtin", + }, + resolver, + ); + expect(resolver).toHaveBeenCalled(); + expect(result).toBe("Resolved"); + }); + + it("falls through to legacyFallback when title and resolver return blank", () => { + const resolver = vi.fn().mockReturnValue(""); + const result = resolveSectionTitle( + "", + { sectionId: "experience", locale: "en-US", sectionKind: "builtin" }, + resolver, + "Legacy Title", + ); + expect(result).toBe("Legacy Title"); + }); + + it("falls through to defaultEnglishTitle when no other source available", () => { + const result = resolveSectionTitle("", { + sectionId: "experience", + locale: "en-US", + sectionKind: "builtin", + defaultEnglishTitle: "Experience", + }); + expect(result).toBe("Experience"); + }); + + it("falls through to sectionId as last resort", () => { + const result = resolveSectionTitle("", { + sectionId: "experience", + locale: "en-US", + sectionKind: "builtin", + }); + expect(result).toBe("experience"); + }); + + it("treats whitespace-only title as empty", () => { + const result = resolveSectionTitle(" ", { + sectionId: "x", + locale: "en-US", + sectionKind: "builtin", + defaultEnglishTitle: "Default", + }); + expect(result).toBe("Default"); + }); +}); + +describe("getResumeSectionTitle", () => { + it("uses summary's user-provided title when set", () => { + const data = { + ...defaultResumeData, + summary: { ...defaultResumeData.summary, title: "Profile Summary" }, + }; + expect(getResumeSectionTitle(data, "summary")).toBe("Profile Summary"); + }); + + it("uses default English title for summary when title is empty", () => { + expect(getResumeSectionTitle(defaultResumeData, "summary")).toBe("Summary"); + }); + + it("uses default English title for built-in sections", () => { + expect(getResumeSectionTitle(defaultResumeData, "experience")).toBe("Experience"); + expect(getResumeSectionTitle(defaultResumeData, "skills")).toBe("Skills"); + }); + + it("falls back to legacyFallback for unknown section ids", () => { + expect(getResumeSectionTitle(defaultResumeData, "unknown-id", "Custom Legacy")).toBe("Custom Legacy"); + }); + + it("falls back to sectionId for unknown sections without legacyFallback", () => { + expect(getResumeSectionTitle(defaultResumeData, "unknown-section")).toBe("unknown-section"); + }); + + it("calls user's resolveSectionTitle when present", () => { + const resolver = vi.fn().mockReturnValue("Translated"); + const data = { ...defaultResumeData, resolveSectionTitle: resolver }; + expect(getResumeSectionTitle(data, "experience")).toBe("Translated"); + expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ sectionId: "experience", sectionKind: "builtin" })); + }); + + it("uses custom section's title when defined", () => { + const data = { + ...defaultResumeData, + customSections: [ + { + id: "ext-1", + type: "cover-letter", + title: "My Cover Letter", + columns: 1, + hidden: false, + items: [], + }, + ], + }; + expect(getResumeSectionTitle(data, "ext-1")).toBe("My Cover Letter"); + }); + + it("uses default English title 'Cover Letter' for cover-letter custom sections", () => { + const data = { + ...defaultResumeData, + customSections: [ + { + id: "ext-1", + type: "cover-letter", + title: "", + columns: 1, + hidden: false, + items: [], + }, + ], + }; + expect(getResumeSectionTitle(data, "ext-1")).toBe("Cover Letter"); + }); +}); diff --git a/packages/pdf/src/templates/shared/columns.test.ts b/packages/pdf/src/templates/shared/columns.test.ts new file mode 100644 index 000000000..afd6599ee --- /dev/null +++ b/packages/pdf/src/templates/shared/columns.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { getSectionItemRows, getSectionItemsLayout, shouldUseSectionTimeline } from "./columns"; + +describe("getSectionItemsLayout", () => { + it("returns single-column layout for columns=1", () => { + const layout = getSectionItemsLayout({ columns: 1, rowGap: 4, columnGap: 6 }); + expect(layout.columns).toBe(1); + expect(layout.isGrid).toBe(false); + expect(layout.rowStyle).toBeUndefined(); + expect(layout.itemStyle).toBeUndefined(); + }); + + it("returns grid layout for columns=2", () => { + const layout = getSectionItemsLayout({ columns: 2, rowGap: 4, columnGap: 6 }); + expect(layout.columns).toBe(2); + expect(layout.isGrid).toBe(true); + expect(layout.rowStyle).toEqual({ flexDirection: "row", columnGap: 6 }); + expect(layout.itemStyle).toMatchObject({ + flexBasis: 0, + flexGrow: 1, + flexShrink: 1, + minWidth: 0, + maxWidth: "100%", + overflow: "hidden", + }); + }); + + it("clamps columns above 6 to 6", () => { + const layout = getSectionItemsLayout({ columns: 99, rowGap: 4, columnGap: 6 }); + expect(layout.columns).toBe(6); + }); + + it("clamps columns below 1 to 1", () => { + const layout = getSectionItemsLayout({ columns: 0, rowGap: 4, columnGap: 6 }); + expect(layout.columns).toBe(1); + }); + + it("treats non-integer columns as 1", () => { + const layout = getSectionItemsLayout({ columns: 2.5, rowGap: 4, columnGap: 6 }); + expect(layout.columns).toBe(1); + }); + + it("treats non-numeric columns as 1", () => { + const layout = getSectionItemsLayout({ columns: "2", rowGap: 4, columnGap: 6 }); + expect(layout.columns).toBe(1); + }); + + it("treats Infinity as 1", () => { + const layout = getSectionItemsLayout({ columns: Number.POSITIVE_INFINITY, rowGap: 4, columnGap: 6 }); + expect(layout.columns).toBe(1); + }); + + it("includes containerStyle.rowGap", () => { + const layout = getSectionItemsLayout({ columns: 1, rowGap: 8, columnGap: 6 }); + expect(layout.containerStyle).toEqual({ rowGap: 8 }); + }); +}); + +describe("getSectionItemRows", () => { + it("groups items into rows of N columns", () => { + expect(getSectionItemRows([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); + }); + + it("returns single-item rows for columns=1", () => { + expect(getSectionItemRows([1, 2, 3], 1)).toEqual([[1], [2], [3]]); + }); + + it("returns one row when items fit in one row", () => { + expect(getSectionItemRows([1, 2], 3)).toEqual([[1, 2]]); + }); + + it("returns empty array for empty input", () => { + expect(getSectionItemRows([], 2)).toEqual([]); + }); + + it("clamps invalid columns to 1", () => { + expect(getSectionItemRows([1, 2], 0)).toEqual([[1], [2]]); + expect(getSectionItemRows([1, 2], "not-a-number")).toEqual([[1], [2]]); + }); + + it("clamps columns above 6 to 6", () => { + expect(getSectionItemRows([1, 2, 3, 4, 5, 6, 7, 8], 99)).toEqual([ + [1, 2, 3, 4, 5, 6], + [7, 8], + ]); + }); +}); + +describe("shouldUseSectionTimeline", () => { + it("returns true only for sectionTimeline=true, placement='main', columns=1", () => { + expect(shouldUseSectionTimeline({ sectionTimeline: true, placement: "main", columns: 1 })).toBe(true); + }); + + it("returns false when placement is not 'main'", () => { + expect(shouldUseSectionTimeline({ sectionTimeline: true, placement: "sidebar", columns: 1 })).toBe(false); + }); + + it("returns false when columns > 1 (after normalization)", () => { + expect(shouldUseSectionTimeline({ sectionTimeline: true, placement: "main", columns: 2 })).toBe(false); + }); + + it("returns false when sectionTimeline=false", () => { + expect(shouldUseSectionTimeline({ sectionTimeline: false, placement: "main", columns: 1 })).toBe(false); + }); + + it("returns true when columns is invalid (normalized to 1)", () => { + expect(shouldUseSectionTimeline({ sectionTimeline: true, placement: "main", columns: 0 })).toBe(true); + }); +}); diff --git a/packages/pdf/src/templates/shared/filtering.test.ts b/packages/pdf/src/templates/shared/filtering.test.ts new file mode 100644 index 000000000..1c51a903d --- /dev/null +++ b/packages/pdf/src/templates/shared/filtering.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { filterItems, filterSections, hasVisibleItems, isSectionVisible, isVisibleSummary } from "./filtering"; + +describe("filterItems", () => { + it("returns only items where hidden is false", () => { + const items = [ + { id: 1, hidden: false }, + { id: 2, hidden: true }, + { id: 3, hidden: false }, + ]; + expect(filterItems(items)).toEqual([ + { id: 1, hidden: false }, + { id: 3, hidden: false }, + ]); + }); + + it("returns empty array when all items are hidden", () => { + const items = [{ hidden: true }, { hidden: true }]; + expect(filterItems(items)).toEqual([]); + }); + + it("returns empty array for empty input", () => { + expect(filterItems([])).toEqual([]); + }); + + it("preserves additional properties on items", () => { + const items = [{ hidden: false, name: "Alice", level: 4 }]; + expect(filterItems(items)).toEqual([{ hidden: false, name: "Alice", level: 4 }]); + }); +}); + +describe("hasVisibleItems", () => { + it("returns false when section.hidden is true", () => { + expect(hasVisibleItems({ hidden: true, items: [{ hidden: false }] })).toBe(false); + }); + + it("returns false when no items are visible", () => { + expect(hasVisibleItems({ hidden: false, items: [{ hidden: true }] })).toBe(false); + }); + + it("returns true when at least one item is visible and section not hidden", () => { + expect(hasVisibleItems({ hidden: false, items: [{ hidden: false }] })).toBe(true); + }); + + it("returns false for empty items", () => { + expect(hasVisibleItems({ hidden: false, items: [] })).toBe(false); + }); +}); + +describe("isVisibleSummary", () => { + it("returns true when not hidden and content is non-empty", () => { + expect(isVisibleSummary({ hidden: false, content: "

Some text

" })).toBe(true); + }); + + it("returns false when summary is hidden", () => { + expect(isVisibleSummary({ hidden: true, content: "

Text

" })).toBe(false); + }); + + it("returns false when content is empty after trimming", () => { + expect(isVisibleSummary({ hidden: false, content: " \n " })).toBe(false); + }); + + it("returns false when content is empty", () => { + expect(isVisibleSummary({ hidden: false, content: "" })).toBe(false); + }); +}); + +describe("isSectionVisible", () => { + const data = { + summary: { hidden: false, content: "

Hi

" }, + sections: { + experience: { hidden: false, items: [{ hidden: false }] }, + skills: { hidden: false, items: [] }, + education: { hidden: true, items: [{ hidden: false }] }, + }, + customSections: [{ id: "ext-1", hidden: false, items: [{ hidden: false }] }], + }; + + it("returns true for visible summary", () => { + expect(isSectionVisible("summary", data)).toBe(true); + }); + + it("returns false for hidden summary", () => { + expect(isSectionVisible("summary", { ...data, summary: { hidden: true, content: "

x

" } })).toBe(false); + }); + + it("returns true for built-in section with visible items", () => { + expect(isSectionVisible("experience", data)).toBe(true); + }); + + it("returns false for built-in section with no items", () => { + expect(isSectionVisible("skills", data)).toBe(false); + }); + + it("returns false for built-in section with hidden flag", () => { + expect(isSectionVisible("education", data)).toBe(false); + }); + + it("returns true for matching custom section by id", () => { + expect(isSectionVisible("ext-1", data)).toBe(true); + }); + + it("returns false for unknown section id", () => { + expect(isSectionVisible("does-not-exist", data)).toBe(false); + }); +}); + +describe("filterSections", () => { + const data = { + summary: { hidden: false, content: "

Hi

" }, + sections: { + experience: { hidden: false, items: [{ hidden: false }] }, + skills: { hidden: false, items: [] }, + }, + customSections: [], + }; + + it("returns only visible section ids in input order", () => { + expect(filterSections(["summary", "experience", "skills"], data)).toEqual(["summary", "experience"]); + }); + + it("returns empty array when no sections are visible", () => { + const empty = { + summary: { hidden: false, content: "" }, + sections: { skills: { hidden: false, items: [] } }, + customSections: [], + }; + expect(filterSections(["summary", "skills"], empty)).toEqual([]); + }); + + it("preserves order of input section ids", () => { + expect(filterSections(["experience", "summary"], data)).toEqual(["experience", "summary"]); + }); +}); diff --git a/packages/pdf/src/templates/shared/metrics.test.ts b/packages/pdf/src/templates/shared/metrics.test.ts new file mode 100644 index 000000000..14eedfca7 --- /dev/null +++ b/packages/pdf/src/templates/shared/metrics.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { getTemplateMetrics } from "./metrics"; + +describe("getTemplateMetrics", () => { + it("maps margins to page paddings", () => { + const metrics = getTemplateMetrics({ gapX: 4, gapY: 6, marginX: 14, marginY: 12 }); + expect(metrics.page.paddingHorizontal).toBe(14); + expect(metrics.page.paddingVertical).toBe(12); + }); + + it("uses marginY as headerGap and sectionGap", () => { + const metrics = getTemplateMetrics({ gapX: 4, gapY: 6, marginX: 14, marginY: 20 }); + expect(metrics.headerGap).toBe(20); + expect(metrics.sectionGap).toBe(20); + }); + + it("uses marginX as columnGap", () => { + const metrics = getTemplateMetrics({ gapX: 4, gapY: 6, marginX: 30, marginY: 12 }); + expect(metrics.columnGap).toBe(30); + }); + + it("preserves itemGapX and itemGapY directly from gapX/gapY", () => { + const metrics = getTemplateMetrics({ gapX: 7, gapY: 9, marginX: 14, marginY: 12 }); + expect(metrics.itemGapX).toBe(7); + expect(metrics.itemGapY).toBe(9); + }); + + it("provides gapX/gapY scaling functions", () => { + const metrics = getTemplateMetrics({ gapX: 4, gapY: 6, marginX: 14, marginY: 12 }); + expect(metrics.gapX(2)).toBe(8); + expect(metrics.gapY(3)).toBe(18); + }); + + it("scaling by 0.5 returns half-gap", () => { + const metrics = getTemplateMetrics({ gapX: 10, gapY: 20, marginX: 14, marginY: 12 }); + expect(metrics.gapX(0.5)).toBe(5); + expect(metrics.gapY(0.5)).toBe(10); + }); + + it("scaling by 0 returns 0", () => { + const metrics = getTemplateMetrics({ gapX: 4, gapY: 6, marginX: 14, marginY: 12 }); + expect(metrics.gapX(0)).toBe(0); + expect(metrics.gapY(0)).toBe(0); + }); +}); diff --git a/packages/pdf/src/templates/shared/page-size.test.ts b/packages/pdf/src/templates/shared/page-size.test.ts new file mode 100644 index 000000000..7a2155468 --- /dev/null +++ b/packages/pdf/src/templates/shared/page-size.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "./page-size"; + +describe("getTemplatePageSize", () => { + it("returns 'A4' for a4 format", () => { + expect(getTemplatePageSize("a4")).toBe("A4"); + }); + + it("returns 'LETTER' for letter format", () => { + expect(getTemplatePageSize("letter")).toBe("LETTER"); + }); + + it("returns custom width for free-form format", () => { + const result = getTemplatePageSize("free-form"); + expect(typeof result).toBe("object"); + if (typeof result === "object") { + expect(result.width).toBeCloseTo(595.28, 2); + } + }); +}); + +describe("getTemplatePageMinHeightStyle", () => { + it("returns undefined for non-free-form formats", () => { + expect(getTemplatePageMinHeightStyle("a4")).toBeUndefined(); + expect(getTemplatePageMinHeightStyle("letter")).toBeUndefined(); + }); + + it("returns A4-style minHeight for free-form", () => { + expect(getTemplatePageMinHeightStyle("free-form")).toEqual({ minHeight: 841.89 }); + }); +}); diff --git a/packages/pdf/src/templates/shared/picture.test.ts b/packages/pdf/src/templates/shared/picture.test.ts new file mode 100644 index 000000000..def533ff7 --- /dev/null +++ b/packages/pdf/src/templates/shared/picture.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { hasTemplatePicture } from "./picture"; + +const basePicture = { + hidden: false, + url: "", + size: 80, + rotation: 0, + aspectRatio: 1, + borderRadius: 0, + borderColor: "rgba(0, 0, 0, 0.5)", + borderWidth: 0, + shadowColor: "rgba(0, 0, 0, 0.5)", + shadowWidth: 0, +} as const; + +describe("hasTemplatePicture", () => { + it("returns true when not hidden and url is non-empty", () => { + expect(hasTemplatePicture({ ...basePicture, url: "/uploads/me.png" })).toBe(true); + }); + + it("returns false when hidden", () => { + expect(hasTemplatePicture({ ...basePicture, url: "/uploads/me.png", hidden: true })).toBe(false); + }); + + it("returns false when url is empty", () => { + expect(hasTemplatePicture(basePicture)).toBe(false); + }); + + it("returns false when url is whitespace only", () => { + expect(hasTemplatePicture({ ...basePicture, url: " " })).toBe(false); + }); +}); diff --git a/packages/pdf/src/templates/shared/rich-text-html.test.ts b/packages/pdf/src/templates/shared/rich-text-html.test.ts new file mode 100644 index 000000000..9c4c4512c --- /dev/null +++ b/packages/pdf/src/templates/shared/rich-text-html.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { normalizeRichTextHtml } from "./rich-text-html"; + +describe("normalizeRichTextHtml", () => { + it("wraps loose inline content in a

", () => { + expect(normalizeRichTextHtml("hello world")).toBe("

hello world

"); + }); + + it("wraps inline tags in a

", () => { + expect(normalizeRichTextHtml("bold text")).toBe("

bold text

"); + }); + + it("preserves block-level

as-is", () => { + expect(normalizeRichTextHtml("

Already wrapped

")).toBe("

Already wrapped

"); + }); + + it("preserves block-level