mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-25 01:15:26 +10:00
test: add unit and component tests across the monorepo
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.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user