mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-13 22:37:14 +10:00
📦 v5.0.15 - https://docs.rxresu.me/changelog
This commit is contained in:
@@ -69,28 +69,18 @@ export const CometCard = ({
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ scale: 1, z: 0 }}
|
||||
className="relative rounded-md"
|
||||
style={{
|
||||
rotateX: rotateX,
|
||||
rotateY: rotateY,
|
||||
translateX: translateX,
|
||||
translateY: translateY,
|
||||
willChange: "transform",
|
||||
}}
|
||||
whileHover={{ z: 50, scale: scaleFactor, transition: { duration: 0.2 } }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="relative rounded-md will-change-transform"
|
||||
whileHover={{ z: 50, scale: scaleFactor, transition: { duration: 0.2 } }}
|
||||
style={{ rotateX: rotateX, rotateY: rotateY, translateX: translateX, translateY: translateY }}
|
||||
>
|
||||
{children}
|
||||
|
||||
<motion.div
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{
|
||||
background: glareBackground,
|
||||
opacity: glareOpacity,
|
||||
willChange: "opacity",
|
||||
}}
|
||||
className="pointer-events-none absolute inset-0 z-50 h-full w-full rounded-md mix-blend-overlay"
|
||||
style={{ background: glareBackground, opacity: glareOpacity }}
|
||||
className="pointer-events-none absolute inset-0 z-50 h-full w-full rounded-md mix-blend-overlay will-change-[opacity]"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -33,8 +33,7 @@ export const Spotlight = ({
|
||||
<motion.div
|
||||
animate={{ x: [0, xOffset, 0] }}
|
||||
transition={{ duration, repeat: Infinity, repeatType: "reverse", ease: "easeInOut" }}
|
||||
className="pointer-events-none absolute inset-s-0 top-0 z-40 h-svh w-svw"
|
||||
style={{ willChange: "transform" }}
|
||||
className="pointer-events-none absolute inset-s-0 top-0 z-40 h-svh w-svw will-change-transform"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-s-0 top-0"
|
||||
@@ -69,14 +68,8 @@ export const Spotlight = ({
|
||||
|
||||
<motion.div
|
||||
animate={{ x: [0, -xOffset, 0] }}
|
||||
className="pointer-events-none absolute inset-e-0 top-0 z-40 h-svh w-svw"
|
||||
transition={{
|
||||
duration,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
style={{ willChange: "transform" }}
|
||||
transition={{ duration, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
|
||||
className="pointer-events-none absolute inset-e-0 top-0 z-40 h-svh w-svw will-change-transform"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-e-0 top-0"
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// Mock dnd-kit
|
||||
vi.mock("@dnd-kit/core", () => ({
|
||||
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
closestCenter: vi.fn(),
|
||||
KeyboardSensor: vi.fn(),
|
||||
PointerSensor: vi.fn(),
|
||||
useSensor: vi.fn(() => ({})),
|
||||
useSensors: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("@dnd-kit/sortable", () => ({
|
||||
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
horizontalListSortingStrategy: vi.fn(),
|
||||
sortableKeyboardCoordinates: vi.fn(),
|
||||
useSortable: () => ({
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: vi.fn(),
|
||||
transform: null,
|
||||
transition: null,
|
||||
isDragging: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@dnd-kit/utilities", () => ({
|
||||
CSS: { Transform: { toString: () => "" } },
|
||||
}));
|
||||
|
||||
// Mock framer motion
|
||||
vi.mock("motion/react", () => ({
|
||||
motion: {
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock lingui
|
||||
vi.mock("@lingui/react/macro", () => ({
|
||||
Trans: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
// Mock phosphor icons
|
||||
vi.mock("@phosphor-icons/react", () => ({
|
||||
PencilSimpleIcon: ({ className }: any) => <span data-testid="edit-icon" className={className} />,
|
||||
XIcon: ({ className }: any) => <span data-testid="remove-icon" className={className} />,
|
||||
}));
|
||||
|
||||
import { ChipInput } from "./chip-input";
|
||||
|
||||
describe("ChipInput", () => {
|
||||
describe("rendering", () => {
|
||||
it("renders the input field", () => {
|
||||
render(<ChipInput />);
|
||||
expect(screen.getByRole("textbox")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders existing chips", () => {
|
||||
render(<ChipInput value={["React", "TypeScript", "Node"]} />);
|
||||
expect(screen.getByText("React")).toBeDefined();
|
||||
expect(screen.getByText("TypeScript")).toBeDefined();
|
||||
expect(screen.getByText("Node")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders no chips when value is empty", () => {
|
||||
render(<ChipInput value={[]} />);
|
||||
|
||||
// Only the input area, no chip badges
|
||||
expect(screen.queryByText("React")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows description by default", () => {
|
||||
render(<ChipInput />);
|
||||
expect(screen.getByText(/Enter/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides description when hideDescription is true", () => {
|
||||
render(<ChipInput hideDescription />);
|
||||
expect(screen.queryByText(/Enter/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("adding chips via Enter key", () => {
|
||||
it("adds a chip when Enter is pressed", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChipInput value={[]} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "React" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["React"]);
|
||||
});
|
||||
|
||||
it("does not add empty chips", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChipInput value={[]} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: " " } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("trims whitespace from chips", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChipInput value={[]} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: " React " } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["React"]);
|
||||
});
|
||||
|
||||
it("prevents duplicate chips", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChipInput value={["React"]} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "React" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
// Set is used, so duplicates are filtered — onChange should be called with same array
|
||||
expect(onChange).toHaveBeenCalledWith(["React"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("adding chips via comma", () => {
|
||||
it("adds a chip when comma is typed in the input", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChipInput value={[]} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "React," } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["React"]);
|
||||
});
|
||||
|
||||
it("handles multiple comma-separated values", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChipInput value={[]} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "React,TypeScript," } });
|
||||
|
||||
// First call adds "React", second call adds "TypeScript"
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removing chips", () => {
|
||||
it("calls onChange without the removed chip when remove button is clicked", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChipInput value={["React", "Vue", "Angular"]} onChange={onChange} />);
|
||||
|
||||
// Find all remove buttons
|
||||
const removeButtons = screen.getAllByLabelText(/Remove/);
|
||||
fireEvent.click(removeButtons[1]); // Remove "Vue"
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["React", "Angular"]);
|
||||
});
|
||||
|
||||
it("removes the first chip correctly", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChipInput value={["A", "B", "C"]} onChange={onChange} />);
|
||||
|
||||
const removeButtons = screen.getAllByLabelText(/Remove/);
|
||||
fireEvent.click(removeButtons[0]);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["B", "C"]);
|
||||
});
|
||||
|
||||
it("removes the last chip correctly", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChipInput value={["A", "B", "C"]} onChange={onChange} />);
|
||||
|
||||
const removeButtons = screen.getAllByLabelText(/Remove/);
|
||||
fireEvent.click(removeButtons[2]);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["A", "B"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editing chips", () => {
|
||||
it("enters edit mode when edit button is clicked", () => {
|
||||
render(<ChipInput value={["React", "Vue"]} />);
|
||||
|
||||
const editButtons = screen.getAllByLabelText(/Edit/);
|
||||
fireEvent.click(editButtons[0]);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
expect((input as HTMLInputElement).value).toBe("React");
|
||||
expect(input.getAttribute("aria-label")).toBe("Edit keyword");
|
||||
});
|
||||
|
||||
it("exits edit mode with Escape key", () => {
|
||||
render(<ChipInput value={["React"]} />);
|
||||
|
||||
const editButton = screen.getByLabelText("Edit React");
|
||||
fireEvent.click(editButton);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
|
||||
expect((input as HTMLInputElement).value).toBe("");
|
||||
expect(input.getAttribute("aria-label")).toBe("Add keyword");
|
||||
});
|
||||
|
||||
it("saves edit when Enter is pressed", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChipInput value={["React"]} onChange={onChange} />);
|
||||
|
||||
const editButton = screen.getByLabelText("Edit React");
|
||||
fireEvent.click(editButton);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "React.js" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["React.js"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("input placeholder", () => {
|
||||
it("shows 'Add a keyword...' by default", () => {
|
||||
render(<ChipInput />);
|
||||
expect(screen.getByPlaceholderText("Add a keyword...")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'Editing keyword...' in edit mode", () => {
|
||||
render(<ChipInput value={["Test"]} />);
|
||||
|
||||
const editButton = screen.getByLabelText("Edit Test");
|
||||
fireEvent.click(editButton);
|
||||
|
||||
expect(screen.getByPlaceholderText("Editing keyword...")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("uncontrolled mode", () => {
|
||||
it("works with defaultValue", () => {
|
||||
render(<ChipInput defaultValue={["Initial"]} />);
|
||||
expect(screen.getByText("Initial")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -70,11 +70,10 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
|
||||
>
|
||||
<span className="max-w-32 truncate">{chip}</span>
|
||||
<motion.div
|
||||
initial={false}
|
||||
initial="false"
|
||||
animate={isHovered ? { opacity: 1, scaleX: 1, x: 0 } : { opacity: 0, scaleX: 0.95, x: -3 }}
|
||||
transition={{ duration: 0.12, ease: "easeOut" }}
|
||||
className="ms-2 flex w-10 shrink-0 origin-left items-center gap-x-1 overflow-hidden"
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="ms-2 flex w-10 shrink-0 origin-left items-center gap-x-1 overflow-hidden will-change-[transform,opacity]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// Mock lingui macros
|
||||
vi.mock("@lingui/core/macro", () => ({
|
||||
t: (strings: TemplateStringsArray) => strings[0],
|
||||
}));
|
||||
vi.mock("@lingui/react/macro", () => ({
|
||||
Trans: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
// Mock phosphor icons
|
||||
vi.mock("@phosphor-icons/react", () => ({
|
||||
TagIcon: () => <span data-testid="tag-icon" />,
|
||||
}));
|
||||
|
||||
import { URLInput } from "./url-input";
|
||||
|
||||
describe("URLInput", () => {
|
||||
describe("URL prefix handling", () => {
|
||||
it("displays URL without https:// prefix", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<URLInput value={{ url: "https://example.com", label: "" }} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByDisplayValue("example.com");
|
||||
expect(input).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays empty string when URL is empty", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} />);
|
||||
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
const urlInput = inputs[0];
|
||||
expect((urlInput as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("adds https:// prefix when user types", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} />);
|
||||
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
fireEvent.change(inputs[0], { target: { value: "example.com" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
url: "https://example.com",
|
||||
label: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not double-prefix when user types https://", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} />);
|
||||
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
fireEvent.change(inputs[0], { target: { value: "https://example.com" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
url: "https://example.com",
|
||||
label: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends empty URL when input is cleared", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<URLInput value={{ url: "https://example.com", label: "" }} onChange={onChange} />);
|
||||
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
fireEvent.change(inputs[0], { target: { value: "" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
url: "",
|
||||
label: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("label preservation", () => {
|
||||
it("preserves label when URL changes", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<URLInput value={{ url: "https://old.com", label: "My Site" }} onChange={onChange} />);
|
||||
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
fireEvent.change(inputs[0], { target: { value: "new.com" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
url: "https://new.com",
|
||||
label: "My Site",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hideLabelButton prop", () => {
|
||||
it("hides the label button when hideLabelButton is true", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} hideLabelButton={true} />);
|
||||
|
||||
expect(screen.queryByTestId("tag-icon")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the label button by default", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} />);
|
||||
|
||||
expect(screen.getByTestId("tag-icon")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("displays the https:// prefix text", () => {
|
||||
it("shows https:// as static text", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} />);
|
||||
|
||||
expect(screen.getByText("https://")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// Mock @lingui/core/macro
|
||||
vi.mock("@lingui/core/macro", () => ({
|
||||
t: (strings: TemplateStringsArray, ...values: unknown[]) => {
|
||||
let result = strings[0];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
result += String(values[i]) + strings[i + 1];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock PageIcon to avoid deep dependency chain
|
||||
vi.mock("../resume/shared/page-icon", () => ({
|
||||
PageIcon: ({ icon, className }: { icon: string; className?: string }) => (
|
||||
<span data-testid="page-icon" data-icon={icon} className={className} />
|
||||
),
|
||||
}));
|
||||
|
||||
import { LevelDisplay } from "./display";
|
||||
|
||||
describe("LevelDisplay", () => {
|
||||
describe("returns null for invalid states", () => {
|
||||
it("returns null when level is 0", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="circle" level={0} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when type is 'hidden'", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="hidden" level={3} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when icon is empty string", () => {
|
||||
const { container } = render(<LevelDisplay icon="" type="circle" level={3} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("renders correct number of elements", () => {
|
||||
it("always renders 5 elements for non-zero level", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="circle" level={3} />);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper.children).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("renders 5 elements for level 1", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="square" level={1} />);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper.children).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("renders 5 elements for level 5", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="circle" level={5} />);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper.children).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("active state for circle/square/rectangle types", () => {
|
||||
it("marks correct number of items as active for level 3", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="circle" level={3} />);
|
||||
const items = Array.from((container.firstChild as HTMLElement).children);
|
||||
const activeItems = items.filter((el) => el.getAttribute("data-active") === "true");
|
||||
expect(activeItems).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("marks 1 item active for level 1", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="square" level={1} />);
|
||||
const items = Array.from((container.firstChild as HTMLElement).children);
|
||||
const activeItems = items.filter((el) => el.getAttribute("data-active") === "true");
|
||||
expect(activeItems).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("marks all 5 items active for level 5", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="circle" level={5} />);
|
||||
const items = Array.from((container.firstChild as HTMLElement).children);
|
||||
const activeItems = items.filter((el) => el.getAttribute("data-active") === "true");
|
||||
expect(activeItems).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("progress-bar type", () => {
|
||||
it("renders progress bar elements", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="progress-bar" level={3} />);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper.children).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("has correct active count for progress-bar", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="progress-bar" level={2} />);
|
||||
const items = Array.from((container.firstChild as HTMLElement).children);
|
||||
const activeItems = items.filter((el) => el.getAttribute("data-active") === "true");
|
||||
expect(activeItems).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("icon type", () => {
|
||||
it("renders PageIcon components", () => {
|
||||
render(<LevelDisplay icon="star" type="icon" level={3} />);
|
||||
const icons = screen.getAllByTestId("page-icon");
|
||||
expect(icons).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("passes the icon name to PageIcon", () => {
|
||||
render(<LevelDisplay icon="star" type="icon" level={2} />);
|
||||
const icons = screen.getAllByTestId("page-icon");
|
||||
expect(icons[0].getAttribute("data-icon")).toBe("star");
|
||||
});
|
||||
|
||||
it("applies opacity to inactive icons", () => {
|
||||
render(<LevelDisplay icon="star" type="icon" level={2} />);
|
||||
const icons = screen.getAllByTestId("page-icon");
|
||||
// First 2 should be active (no opacity-40), last 3 inactive (opacity-40)
|
||||
expect(icons[0].className).not.toContain("opacity-40");
|
||||
expect(icons[1].className).not.toContain("opacity-40");
|
||||
expect(icons[2].className).toContain("opacity-40");
|
||||
expect(icons[3].className).toContain("opacity-40");
|
||||
expect(icons[4].className).toContain("opacity-40");
|
||||
});
|
||||
});
|
||||
|
||||
describe("aria attributes", () => {
|
||||
it("has aria-label showing level out of 5", () => {
|
||||
render(<LevelDisplay icon="star" type="circle" level={3} />);
|
||||
const el = screen.getByRole("presentation");
|
||||
expect(el.getAttribute("aria-label")).toContain("3");
|
||||
expect(el.getAttribute("aria-label")).toContain("5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("className prop", () => {
|
||||
it("applies custom className to the wrapper", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="circle" level={3} className="custom-level" />);
|
||||
expect((container.firstChild as HTMLElement).className).toContain("custom-level");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vite-plus/test";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
import { LinkedTitle } from "./linked-title";
|
||||
|
||||
describe("LinkedTitle", () => {
|
||||
describe("renders as link when showLinkInTitle and website.url are provided", () => {
|
||||
it("renders an anchor tag", () => {
|
||||
render(
|
||||
<LinkedTitle title="Acme Corp" website={{ url: "https://acme.com", label: "Acme" }} showLinkInTitle={true} />,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: "Acme Corp" });
|
||||
expect(link).toBeDefined();
|
||||
expect(link.getAttribute("href")).toBe("https://acme.com");
|
||||
});
|
||||
|
||||
it("sets target=_blank and rel=noopener on the link", () => {
|
||||
render(
|
||||
<LinkedTitle title="Company" website={{ url: "https://example.com", label: "" }} showLinkInTitle={true} />,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link");
|
||||
expect(link.getAttribute("target")).toBe("_blank");
|
||||
expect(link.getAttribute("rel")).toBe("noopener");
|
||||
});
|
||||
|
||||
it("wraps the title in a strong tag inside the link", () => {
|
||||
render(
|
||||
<LinkedTitle title="Bold Title" website={{ url: "https://example.com", label: "" }} showLinkInTitle={true} />,
|
||||
);
|
||||
|
||||
const strong = screen.getByText("Bold Title");
|
||||
expect(strong.tagName).toBe("STRONG");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renders as plain strong tag otherwise", () => {
|
||||
it("renders strong when showLinkInTitle is false", () => {
|
||||
render(
|
||||
<LinkedTitle title="Plain Title" website={{ url: "https://example.com", label: "" }} showLinkInTitle={false} />,
|
||||
);
|
||||
|
||||
const strong = screen.getByText("Plain Title");
|
||||
expect(strong.tagName).toBe("STRONG");
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders strong when showLinkInTitle is undefined", () => {
|
||||
render(<LinkedTitle title="No Link" />);
|
||||
|
||||
expect(screen.getByText("No Link").tagName).toBe("STRONG");
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders strong when website is undefined", () => {
|
||||
render(<LinkedTitle title="No Website" showLinkInTitle={true} />);
|
||||
|
||||
expect(screen.getByText("No Website").tagName).toBe("STRONG");
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders strong when website.url is empty", () => {
|
||||
render(<LinkedTitle title="Empty URL" website={{ url: "", label: "" }} showLinkInTitle={true} />);
|
||||
|
||||
expect(screen.getByText("Empty URL").tagName).toBe("STRONG");
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("className prop", () => {
|
||||
it("applies className to the link element", () => {
|
||||
render(
|
||||
<LinkedTitle
|
||||
title="Styled"
|
||||
website={{ url: "https://x.com", label: "" }}
|
||||
showLinkInTitle={true}
|
||||
className="custom-class"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link");
|
||||
expect(link.className).toContain("custom-class");
|
||||
});
|
||||
|
||||
it("applies className to the strong element", () => {
|
||||
render(<LinkedTitle title="Styled Strong" className="my-class" />);
|
||||
|
||||
const strong = screen.getByText("Styled Strong");
|
||||
expect(strong.className).toContain("my-class");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
// Mock dependencies before importing the store
|
||||
vi.mock("@lingui/core/macro", () => ({
|
||||
t: (strings: TemplateStringsArray) => strings[0],
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
error: vi.fn(() => "toast-id"),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/integrations/orpc/client", () => ({
|
||||
orpc: {
|
||||
resume: {
|
||||
update: {
|
||||
call: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { defaultResumeData } from "@/schema/resume/data";
|
||||
|
||||
import { useResumeStore } from "./resume";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeResume(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
id: "test-resume-id",
|
||||
name: "Test Resume",
|
||||
slug: "test-resume",
|
||||
tags: ["test"],
|
||||
isLocked: false,
|
||||
data: structuredClone(defaultResumeData),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// Reset store between tests
|
||||
useResumeStore.getState().initialize(null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// initialize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useResumeStore — initialize", () => {
|
||||
it("sets resume and isReady to true", () => {
|
||||
const resume = makeResume();
|
||||
useResumeStore.getState().initialize(resume);
|
||||
|
||||
const state = useResumeStore.getState();
|
||||
expect(state.isReady).toBe(true);
|
||||
expect(state.resume.id).toBe("test-resume-id");
|
||||
expect(state.resume.name).toBe("Test Resume");
|
||||
});
|
||||
|
||||
it("sets isReady to false when initialized with null", () => {
|
||||
useResumeStore.getState().initialize(null);
|
||||
|
||||
const state = useResumeStore.getState();
|
||||
expect(state.isReady).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces previous resume data", () => {
|
||||
useResumeStore.getState().initialize(makeResume({ name: "First" }));
|
||||
expect(useResumeStore.getState().resume.name).toBe("First");
|
||||
|
||||
useResumeStore.getState().initialize(makeResume({ name: "Second" }));
|
||||
expect(useResumeStore.getState().resume.name).toBe("Second");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateResumeData
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useResumeStore — updateResumeData", () => {
|
||||
it("updates resume data via immer draft", () => {
|
||||
useResumeStore.getState().initialize(makeResume());
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "Jane Doe";
|
||||
});
|
||||
|
||||
expect(useResumeStore.getState().resume.data.basics.name).toBe("Jane Doe");
|
||||
});
|
||||
|
||||
it("can update nested fields", () => {
|
||||
useResumeStore.getState().initialize(makeResume());
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.website.url = "https://example.com";
|
||||
draft.basics.email = "jane@example.com";
|
||||
});
|
||||
|
||||
expect(useResumeStore.getState().resume.data.basics.website.url).toBe("https://example.com");
|
||||
expect(useResumeStore.getState().resume.data.basics.email).toBe("jane@example.com");
|
||||
});
|
||||
|
||||
it("can add items to sections", () => {
|
||||
useResumeStore.getState().initialize(makeResume());
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.sections.skills.items.push({
|
||||
id: "s1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "TypeScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
keywords: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(useResumeStore.getState().resume.data.sections.skills.items).toHaveLength(1);
|
||||
expect(useResumeStore.getState().resume.data.sections.skills.items[0].name).toBe("TypeScript");
|
||||
});
|
||||
|
||||
it("does not update when resume is not initialized", () => {
|
||||
// Store starts with null resume
|
||||
useResumeStore.getState().initialize(null);
|
||||
|
||||
// Should not throw
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "Should not apply";
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks updates when resume is locked", async () => {
|
||||
const { toast } = await import("sonner");
|
||||
useResumeStore.getState().initialize(makeResume({ isLocked: true }));
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "Should not apply";
|
||||
});
|
||||
|
||||
// Name should remain unchanged
|
||||
expect(useResumeStore.getState().resume.data.basics.name).toBe("");
|
||||
// Should show error toast
|
||||
expect(toast.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not block updates on unlocked resume", () => {
|
||||
useResumeStore.getState().initialize(makeResume({ isLocked: false }));
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "Updated";
|
||||
});
|
||||
|
||||
expect(useResumeStore.getState().resume.data.basics.name).toBe("Updated");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multiple updates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useResumeStore — multiple updates", () => {
|
||||
it("applies sequential updates correctly", () => {
|
||||
useResumeStore.getState().initialize(makeResume());
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "First Update";
|
||||
});
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.headline = "Developer";
|
||||
});
|
||||
|
||||
const data = useResumeStore.getState().resume.data;
|
||||
expect(data.basics.name).toBe("First Update");
|
||||
expect(data.basics.headline).toBe("Developer");
|
||||
});
|
||||
|
||||
it("preserves unmodified sections across updates", () => {
|
||||
useResumeStore.getState().initialize(makeResume());
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "Jane";
|
||||
});
|
||||
|
||||
// Metadata and other sections should be untouched
|
||||
expect(useResumeStore.getState().resume.data.metadata.template).toBe("onyx");
|
||||
expect(useResumeStore.getState().resume.data.sections.skills.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Temporal store (undo/redo)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useResumeStore — temporal", () => {
|
||||
it("has a temporal store accessible", () => {
|
||||
expect(useResumeStore.temporal).toBeDefined();
|
||||
expect(useResumeStore.temporal.getState).toBeDefined();
|
||||
});
|
||||
|
||||
it("temporal store has undo/redo actions", () => {
|
||||
const temporal = useResumeStore.temporal.getState();
|
||||
expect(typeof temporal.undo).toBe("function");
|
||||
expect(typeof temporal.redo).toBe("function");
|
||||
expect(typeof temporal.clear).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useResumeStore — edge cases", () => {
|
||||
it("can update metadata fields", () => {
|
||||
useResumeStore.getState().initialize(makeResume());
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.metadata.template = "pikachu";
|
||||
});
|
||||
|
||||
expect(useResumeStore.getState().resume.data.metadata.template).toBe("pikachu");
|
||||
});
|
||||
|
||||
it("can update picture settings", () => {
|
||||
useResumeStore.getState().initialize(makeResume());
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.picture.url = "https://example.com/photo.jpg";
|
||||
draft.picture.size = 120;
|
||||
});
|
||||
|
||||
expect(useResumeStore.getState().resume.data.picture.url).toBe("https://example.com/photo.jpg");
|
||||
expect(useResumeStore.getState().resume.data.picture.size).toBe(120);
|
||||
});
|
||||
|
||||
it("can remove all items from a section", () => {
|
||||
const resume = makeResume();
|
||||
resume.data.sections.skills.items = [
|
||||
{ id: "s1", hidden: false, icon: "", name: "JS", proficiency: "", level: 0, keywords: [] },
|
||||
];
|
||||
useResumeStore.getState().initialize(resume);
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.sections.skills.items = [];
|
||||
});
|
||||
|
||||
expect(useResumeStore.getState().resume.data.sections.skills.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("can update custom sections", () => {
|
||||
useResumeStore.getState().initialize(makeResume());
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.customSections.push({
|
||||
id: "custom-1",
|
||||
type: "experience",
|
||||
title: "Freelance",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(useResumeStore.getState().resume.data.customSections).toHaveLength(1);
|
||||
expect(useResumeStore.getState().resume.data.customSections[0].title).toBe("Freelance");
|
||||
});
|
||||
});
|
||||
@@ -32,12 +32,37 @@ type ResumeStore = ResumeStoreState & ResumeStoreActions;
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
|
||||
const _syncResume = (resume: Resume) => {
|
||||
void orpc.resume.update.call({ id: resume.id, data: resume.data }, { signal });
|
||||
let syncErrorToastId: string | number | undefined;
|
||||
|
||||
const _syncResume = async (resume: Resume) => {
|
||||
try {
|
||||
await orpc.resume.update.call({ id: resume.id, data: resume.data }, { signal });
|
||||
|
||||
// Dismiss error toast on successful sync
|
||||
if (syncErrorToastId !== undefined) {
|
||||
toast.dismiss(syncErrorToastId);
|
||||
syncErrorToastId = undefined;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Ignore aborted requests (e.g. page navigation)
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
|
||||
syncErrorToastId = toast.error(
|
||||
t`Your latest changes could not be saved. Please make sure you are connected to the internet and try again.`,
|
||||
{ id: syncErrorToastId, duration: Infinity },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const syncResume = debounce(_syncResume, 500, { signal });
|
||||
|
||||
// Flush pending sync before the page unloads to prevent data loss
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("beforeunload", () => {
|
||||
syncResume.flush();
|
||||
});
|
||||
}
|
||||
|
||||
let errorToastId: string | number | undefined;
|
||||
|
||||
type PartializedState = { resume: Resume | null };
|
||||
|
||||
@@ -38,7 +38,7 @@ function AccordionContent({ className, children, ...props }: AccordionPrimitive.
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-(--accordion-panel-height) pt-0 pb-4 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
"h-(--accordion-panel-height) pt-0 pb-4 data-ending-style:h-0 data-starting-style:h-0 [&_p:not(:last-child)]:mb-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { awardItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
const formSchema = awardItemSchema;
|
||||
@@ -44,12 +45,7 @@ export function CreateAwardDialog({ data }: DialogProps<"resume.sections.awards.
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.awards.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "awards", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -105,15 +101,7 @@ export function UpdateAwardDialog({ data }: DialogProps<"resume.sections.awards.
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.awards.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.awards.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "awards", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { certificationItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
const formSchema = certificationItemSchema;
|
||||
@@ -44,12 +45,7 @@ export function CreateCertificationDialog({ data }: DialogProps<"resume.sections
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.certifications.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "certifications", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -105,15 +101,7 @@ export function UpdateCertificationDialog({ data }: DialogProps<"resume.sections
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.certifications.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.certifications.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "certifications", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { educationItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
const formSchema = educationItemSchema;
|
||||
@@ -47,12 +48,7 @@ export function CreateEducationDialog({ data }: DialogProps<"resume.sections.edu
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.education.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "education", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -111,15 +107,7 @@ export function UpdateEducationDialog({ data }: DialogProps<"resume.sections.edu
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.education.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.education.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "education", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { experienceItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
const formSchema = experienceItemSchema;
|
||||
@@ -49,14 +50,8 @@ export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.ex
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.experience.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "experience", formData, data?.customSectionId);
|
||||
});
|
||||
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -113,17 +108,8 @@ export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.ex
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.experience.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.experience.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "experience", formData, data?.customSectionId);
|
||||
});
|
||||
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { interestItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
@@ -42,12 +43,7 @@ export function CreateInterestDialog({ data }: DialogProps<"resume.sections.inte
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.interests.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "interests", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -100,15 +96,7 @@ export function UpdateInterestDialog({ data }: DialogProps<"resume.sections.inte
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.interests.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.interests.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "interests", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Slider } from "@/components/ui/slider";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { languageItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
const formSchema = languageItemSchema;
|
||||
@@ -40,12 +41,7 @@ export function CreateLanguageDialog({ data }: DialogProps<"resume.sections.lang
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.languages.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "languages", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -98,15 +94,7 @@ export function UpdateLanguageDialog({ data }: DialogProps<"resume.sections.lang
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.languages.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.languages.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "languages", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { type DialogProps, useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { profileItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
@@ -44,12 +45,7 @@ export function CreateProfileDialog({ data }: DialogProps<"resume.sections.profi
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.profiles.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "profiles", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -104,15 +100,7 @@ export function UpdateProfileDialog({ data }: DialogProps<"resume.sections.profi
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.profiles.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.profiles.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "profiles", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { projectItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
const formSchema = projectItemSchema;
|
||||
@@ -43,12 +44,7 @@ export function CreateProjectDialog({ data }: DialogProps<"resume.sections.proje
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.projects.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "projects", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -103,15 +99,7 @@ export function UpdateProjectDialog({ data }: DialogProps<"resume.sections.proje
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.projects.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.projects.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "projects", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { publicationItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
const formSchema = publicationItemSchema;
|
||||
@@ -44,12 +45,7 @@ export function CreatePublicationDialog({ data }: DialogProps<"resume.sections.p
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.publications.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "publications", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -105,15 +101,7 @@ export function UpdatePublicationDialog({ data }: DialogProps<"resume.sections.p
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.publications.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.publications.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "publications", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { referenceItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
const formSchema = referenceItemSchema;
|
||||
@@ -44,12 +45,7 @@ export function CreateReferenceDialog({ data }: DialogProps<"resume.sections.ref
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.references.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "references", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -105,15 +101,7 @@ export function UpdateReferenceDialog({ data }: DialogProps<"resume.sections.ref
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.references.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.references.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "references", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Slider } from "@/components/ui/slider";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { skillItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
@@ -46,12 +47,7 @@ export function CreateSkillDialog({ data }: DialogProps<"resume.sections.skills.
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.skills.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "skills", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -106,15 +102,7 @@ export function UpdateSkillDialog({ data }: DialogProps<"resume.sections.skills.
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.skills.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.skills.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "skills", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { volunteerItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
const formSchema = volunteerItemSchema;
|
||||
@@ -44,12 +45,7 @@ export function CreateVolunteerDialog({ data }: DialogProps<"resume.sections.vol
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.volunteer.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "volunteer", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -105,15 +101,7 @@ export function UpdateVolunteerDialog({ data }: DialogProps<"resume.sections.vol
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.volunteer.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.volunteer.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "volunteer", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
import { useDialogStore } from "./store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
useDialogStore.setState({ open: false, activeDialog: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DialogStore — initial state", () => {
|
||||
it("starts closed with no active dialog", () => {
|
||||
const state = useDialogStore.getState();
|
||||
expect(state.open).toBe(false);
|
||||
expect(state.activeDialog).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// openDialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DialogStore — openDialog", () => {
|
||||
it("opens dialog with correct type and data", () => {
|
||||
useDialogStore.getState().openDialog("resume.create", undefined);
|
||||
|
||||
const state = useDialogStore.getState();
|
||||
expect(state.open).toBe(true);
|
||||
expect(state.activeDialog?.type).toBe("resume.create");
|
||||
});
|
||||
|
||||
it("opens dialog with data payload", () => {
|
||||
useDialogStore.getState().openDialog("resume.update", {
|
||||
id: "r1",
|
||||
name: "My Resume",
|
||||
slug: "my-resume",
|
||||
tags: ["tag1"],
|
||||
});
|
||||
|
||||
const state = useDialogStore.getState();
|
||||
expect(state.open).toBe(true);
|
||||
expect(state.activeDialog?.type).toBe("resume.update");
|
||||
expect((state.activeDialog as any)?.data.name).toBe("My Resume");
|
||||
});
|
||||
|
||||
it("opens section create dialog without data", () => {
|
||||
useDialogStore.getState().openDialog("resume.sections.skills.create", undefined);
|
||||
|
||||
const state = useDialogStore.getState();
|
||||
expect(state.open).toBe(true);
|
||||
expect(state.activeDialog?.type).toBe("resume.sections.skills.create");
|
||||
});
|
||||
|
||||
it("opens section update dialog with item data", () => {
|
||||
useDialogStore.getState().openDialog("resume.sections.skills.update", {
|
||||
item: {
|
||||
id: "s1",
|
||||
hidden: false,
|
||||
icon: "star",
|
||||
name: "TypeScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
keywords: ["frontend"],
|
||||
},
|
||||
});
|
||||
|
||||
const state = useDialogStore.getState();
|
||||
expect(state.open).toBe(true);
|
||||
expect((state.activeDialog as any)?.data.item.name).toBe("TypeScript");
|
||||
});
|
||||
|
||||
it("replaces previous dialog when opening a new one", () => {
|
||||
useDialogStore.getState().openDialog("resume.create", undefined);
|
||||
expect(useDialogStore.getState().activeDialog?.type).toBe("resume.create");
|
||||
|
||||
useDialogStore.getState().openDialog("resume.import", undefined);
|
||||
expect(useDialogStore.getState().activeDialog?.type).toBe("resume.import");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// closeDialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DialogStore — closeDialog", () => {
|
||||
it("sets open to false immediately", () => {
|
||||
useDialogStore.getState().openDialog("resume.create", undefined);
|
||||
expect(useDialogStore.getState().open).toBe(true);
|
||||
|
||||
useDialogStore.getState().closeDialog();
|
||||
expect(useDialogStore.getState().open).toBe(false);
|
||||
});
|
||||
|
||||
it("clears activeDialog after 300ms delay (animation)", () => {
|
||||
useDialogStore.getState().openDialog("resume.create", undefined);
|
||||
useDialogStore.getState().closeDialog();
|
||||
|
||||
// activeDialog should still be set immediately after close
|
||||
expect(useDialogStore.getState().activeDialog).not.toBeNull();
|
||||
|
||||
// After 300ms, it should be cleared
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(useDialogStore.getState().activeDialog).toBeNull();
|
||||
});
|
||||
|
||||
it("does not clear activeDialog before 300ms", () => {
|
||||
useDialogStore.getState().openDialog("resume.create", undefined);
|
||||
useDialogStore.getState().closeDialog();
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(useDialogStore.getState().activeDialog).not.toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(useDialogStore.getState().activeDialog).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// onOpenChange
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DialogStore — onOpenChange", () => {
|
||||
it("sets open state directly", () => {
|
||||
useDialogStore.getState().onOpenChange(true);
|
||||
expect(useDialogStore.getState().open).toBe(true);
|
||||
|
||||
useDialogStore.getState().onOpenChange(false);
|
||||
expect(useDialogStore.getState().open).toBe(false);
|
||||
});
|
||||
|
||||
it("clears activeDialog after 300ms when set to false", () => {
|
||||
useDialogStore.getState().openDialog("resume.create", undefined);
|
||||
useDialogStore.getState().onOpenChange(false);
|
||||
|
||||
expect(useDialogStore.getState().activeDialog).not.toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(useDialogStore.getState().activeDialog).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT clear activeDialog when set to true", () => {
|
||||
useDialogStore.getState().openDialog("resume.create", undefined);
|
||||
useDialogStore.getState().onOpenChange(true);
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
// activeDialog should still be set
|
||||
expect(useDialogStore.getState().activeDialog).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DialogStore — edge cases", () => {
|
||||
it("handles close when already closed", () => {
|
||||
useDialogStore.getState().closeDialog();
|
||||
expect(useDialogStore.getState().open).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(useDialogStore.getState().activeDialog).toBeNull();
|
||||
});
|
||||
|
||||
it("handles rapid open/close cycles", () => {
|
||||
useDialogStore.getState().openDialog("resume.create", undefined);
|
||||
useDialogStore.getState().closeDialog();
|
||||
useDialogStore.getState().openDialog("resume.import", undefined);
|
||||
|
||||
expect(useDialogStore.getState().open).toBe(true);
|
||||
expect(useDialogStore.getState().activeDialog?.type).toBe("resume.import");
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
// The close timeout from the first close might fire, but the new open should have
|
||||
// set a new activeDialog, so the current type should still be "resume.import"
|
||||
// Note: This exposes a potential race condition in the store
|
||||
});
|
||||
|
||||
it("supports all dialog types", () => {
|
||||
const dialogTypes = [
|
||||
"auth.change-password",
|
||||
"auth.two-factor.enable",
|
||||
"auth.two-factor.disable",
|
||||
"api-key.create",
|
||||
"resume.create",
|
||||
"resume.import",
|
||||
"resume.template.gallery",
|
||||
] as const;
|
||||
|
||||
for (const type of dialogTypes) {
|
||||
useDialogStore.getState().openDialog(type, undefined);
|
||||
expect(useDialogStore.getState().activeDialog?.type).toBe(type);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { act, render, renderHook, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { afterEach, describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { ConfirmDialogProvider, useConfirm } from "./use-confirm";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <ConfirmDialogProvider>{children}</ConfirmDialogProvider>;
|
||||
}
|
||||
|
||||
/** A test component that triggers confirm and stores the result */
|
||||
function ConfirmTester() {
|
||||
const confirm = useConfirm();
|
||||
const [result, setResult] = React.useState<boolean | null>(null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const confirmed = await confirm("Delete this?", {
|
||||
description: "This action cannot be undone.",
|
||||
confirmText: "Delete",
|
||||
cancelText: "Keep",
|
||||
});
|
||||
setResult(confirmed);
|
||||
}}
|
||||
>
|
||||
Trigger
|
||||
</button>
|
||||
{result !== null && <span data-testid="result">{String(result)}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useConfirm", () => {
|
||||
it("throws when used outside provider", () => {
|
||||
expect(() => {
|
||||
renderHook(() => useConfirm());
|
||||
}).toThrow("useConfirm must be used within a <ConfirmDialogProvider />");
|
||||
});
|
||||
|
||||
it("returns a function when used inside provider", () => {
|
||||
const { result } = renderHook(() => useConfirm(), { wrapper });
|
||||
expect(typeof result.current).toBe("function");
|
||||
});
|
||||
|
||||
it("confirm() returns a promise", () => {
|
||||
const { result } = renderHook(() => useConfirm(), { wrapper });
|
||||
|
||||
let promise: Promise<boolean> | undefined;
|
||||
act(() => {
|
||||
promise = result.current("Are you sure?");
|
||||
});
|
||||
|
||||
expect(promise).toBeInstanceOf(Promise);
|
||||
});
|
||||
|
||||
it("opens dialog with title and description when confirm is called", async () => {
|
||||
render(
|
||||
<ConfirmDialogProvider>
|
||||
<ConfirmTester />
|
||||
</ConfirmDialogProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Trigger").click();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Delete this?")).toBeDefined();
|
||||
expect(screen.getByText("This action cannot be undone.")).toBeDefined();
|
||||
expect(screen.getByText("Delete")).toBeDefined();
|
||||
expect(screen.getByText("Keep")).toBeDefined();
|
||||
});
|
||||
|
||||
it("resolves true when confirm button is clicked", async () => {
|
||||
render(
|
||||
<ConfirmDialogProvider>
|
||||
<ConfirmTester />
|
||||
</ConfirmDialogProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Trigger").click();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Delete").click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("result").textContent).toBe("true");
|
||||
});
|
||||
|
||||
it("resolves false when cancel button is clicked", async () => {
|
||||
render(
|
||||
<ConfirmDialogProvider>
|
||||
<ConfirmTester />
|
||||
</ConfirmDialogProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Trigger").click();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Keep").click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("result").textContent).toBe("false");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
import { useControlledState } from "./use-controlled-state";
|
||||
|
||||
describe("useControlledState", () => {
|
||||
describe("uncontrolled mode (no value prop)", () => {
|
||||
it("uses defaultValue as initial state", () => {
|
||||
const { result } = renderHook(() => useControlledState({ defaultValue: "hello" }));
|
||||
expect(result.current[0]).toBe("hello");
|
||||
});
|
||||
|
||||
it("updates internal state when setter is called", () => {
|
||||
const { result } = renderHook(() => useControlledState({ defaultValue: 0 }));
|
||||
|
||||
act(() => {
|
||||
result.current[1](42);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(42);
|
||||
});
|
||||
|
||||
it("calls onChange when setter is called", () => {
|
||||
const onChange = vi.fn();
|
||||
const { result } = renderHook(() => useControlledState({ defaultValue: "a", onChange }));
|
||||
|
||||
act(() => {
|
||||
result.current[1]("b");
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("b");
|
||||
});
|
||||
|
||||
it("passes extra args to onChange", () => {
|
||||
const onChange = vi.fn();
|
||||
const { result } = renderHook(() => useControlledState<string, [number]>({ defaultValue: "a", onChange }));
|
||||
|
||||
act(() => {
|
||||
result.current[1]("b", 99);
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("b", 99);
|
||||
});
|
||||
});
|
||||
|
||||
describe("controlled mode (value prop provided)", () => {
|
||||
it("uses value prop as initial state", () => {
|
||||
const { result } = renderHook(() => useControlledState({ value: "controlled" }));
|
||||
expect(result.current[0]).toBe("controlled");
|
||||
});
|
||||
|
||||
it("syncs internal state when value prop changes", () => {
|
||||
let value = "first";
|
||||
const { result, rerender } = renderHook(() => useControlledState({ value }));
|
||||
|
||||
expect(result.current[0]).toBe("first");
|
||||
|
||||
value = "second";
|
||||
rerender();
|
||||
|
||||
expect(result.current[0]).toBe("second");
|
||||
});
|
||||
|
||||
it("still calls onChange when setter is called in controlled mode", () => {
|
||||
const onChange = vi.fn();
|
||||
const { result } = renderHook(() => useControlledState({ value: "x", onChange }));
|
||||
|
||||
act(() => {
|
||||
result.current[1]("y");
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("y");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles undefined defaultValue", () => {
|
||||
const { result } = renderHook(() => useControlledState({}));
|
||||
expect(result.current[0]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles boolean values", () => {
|
||||
const { result } = renderHook(() => useControlledState({ defaultValue: false }));
|
||||
expect(result.current[0]).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current[1](true);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(true);
|
||||
});
|
||||
|
||||
it("handles object values", () => {
|
||||
const obj = { key: "value" };
|
||||
const { result } = renderHook(() => useControlledState({ defaultValue: obj }));
|
||||
expect(result.current[0]).toBe(obj);
|
||||
});
|
||||
|
||||
it("works without onChange callback", () => {
|
||||
const { result } = renderHook(() => useControlledState({ defaultValue: 5 }));
|
||||
|
||||
act(() => {
|
||||
result.current[1](10);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
import { useIsMobile } from "./use-mobile";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock window.matchMedia
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type MockMatchMedia = {
|
||||
matches: boolean;
|
||||
listeners: Array<(e: { matches: boolean }) => void>;
|
||||
trigger: (matches: boolean) => void;
|
||||
};
|
||||
|
||||
let mockMql: MockMatchMedia;
|
||||
|
||||
beforeEach(() => {
|
||||
mockMql = {
|
||||
matches: false,
|
||||
listeners: [],
|
||||
trigger(matches: boolean) {
|
||||
this.matches = matches;
|
||||
for (const listener of this.listeners) {
|
||||
listener({ matches });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn(() => ({
|
||||
get matches() {
|
||||
return mockMql.matches;
|
||||
},
|
||||
addEventListener: (_event: string, cb: (e: { matches: boolean }) => void) => {
|
||||
mockMql.listeners.push(cb);
|
||||
},
|
||||
removeEventListener: (_event: string, cb: (e: { matches: boolean }) => void) => {
|
||||
mockMql.listeners = mockMql.listeners.filter((l) => l !== cb);
|
||||
},
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useIsMobile", () => {
|
||||
it("returns false on desktop-sized screens", () => {
|
||||
mockMql.matches = false;
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true on mobile-sized screens", () => {
|
||||
mockMql.matches = true;
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("updates when screen size changes to mobile", () => {
|
||||
mockMql.matches = false;
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
mockMql.trigger(true);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("updates when screen size changes to desktop", () => {
|
||||
mockMql.matches = true;
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
mockMql.trigger(false);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("cleans up event listener on unmount", () => {
|
||||
mockMql.matches = false;
|
||||
const { unmount } = renderHook(() => useIsMobile());
|
||||
|
||||
expect(mockMql.listeners).toHaveLength(1);
|
||||
unmount();
|
||||
expect(mockMql.listeners).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses 768px breakpoint (max-width: 767px)", () => {
|
||||
renderHook(() => useIsMobile());
|
||||
expect(window.matchMedia).toHaveBeenCalledWith("(max-width: 767px)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { act, fireEvent, render, renderHook, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
vi.mock("@lingui/core/macro", () => ({
|
||||
t: (strings: TemplateStringsArray) => strings[0],
|
||||
}));
|
||||
|
||||
import { PromptDialogProvider, usePrompt } from "./use-prompt";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <PromptDialogProvider>{children}</PromptDialogProvider>;
|
||||
}
|
||||
|
||||
function PromptTester() {
|
||||
const prompt = usePrompt();
|
||||
const [result, setResult] = React.useState<string | null | undefined>(undefined);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const value = await prompt("Enter name", {
|
||||
description: "Provide your full name",
|
||||
defaultValue: "John",
|
||||
confirmText: "Submit",
|
||||
cancelText: "Skip",
|
||||
});
|
||||
setResult(value);
|
||||
}}
|
||||
>
|
||||
Open Prompt
|
||||
</button>
|
||||
{result !== undefined && <span data-testid="result">{result === null ? "null" : result}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("usePrompt", () => {
|
||||
it("throws when used outside provider", () => {
|
||||
expect(() => {
|
||||
renderHook(() => usePrompt());
|
||||
}).toThrow("usePrompt must be used within a <PromptDialogProvider />");
|
||||
});
|
||||
|
||||
it("returns a function when used inside provider", () => {
|
||||
const { result } = renderHook(() => usePrompt(), { wrapper });
|
||||
expect(typeof result.current).toBe("function");
|
||||
});
|
||||
|
||||
it("prompt() returns a promise", () => {
|
||||
const { result } = renderHook(() => usePrompt(), { wrapper });
|
||||
|
||||
let promise: Promise<string | null> | undefined;
|
||||
act(() => {
|
||||
promise = result.current("Enter value");
|
||||
});
|
||||
|
||||
expect(promise).toBeInstanceOf(Promise);
|
||||
});
|
||||
|
||||
it("opens dialog with title, description, and default value", async () => {
|
||||
render(
|
||||
<PromptDialogProvider>
|
||||
<PromptTester />
|
||||
</PromptDialogProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Open Prompt").click();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Enter name")).toBeDefined();
|
||||
expect(screen.getByText("Provide your full name")).toBeDefined();
|
||||
expect(screen.getByText("Submit")).toBeDefined();
|
||||
expect(screen.getByText("Skip")).toBeDefined();
|
||||
|
||||
// Check default value in input
|
||||
const input = screen.getByDisplayValue("John");
|
||||
expect(input).toBeDefined();
|
||||
});
|
||||
|
||||
it("resolves with input value when confirm is clicked", async () => {
|
||||
render(
|
||||
<PromptDialogProvider>
|
||||
<PromptTester />
|
||||
</PromptDialogProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Open Prompt").click();
|
||||
});
|
||||
|
||||
// Change the input value
|
||||
const input = screen.getByDisplayValue("John");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Jane Doe" } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Submit").click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("result").textContent).toBe("Jane Doe");
|
||||
});
|
||||
|
||||
it("resolves with null when cancel is clicked", async () => {
|
||||
render(
|
||||
<PromptDialogProvider>
|
||||
<PromptTester />
|
||||
</PromptDialogProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Open Prompt").click();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Skip").click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("result").textContent).toBe("null");
|
||||
});
|
||||
|
||||
it("resolves with value when Enter key is pressed", async () => {
|
||||
render(
|
||||
<PromptDialogProvider>
|
||||
<PromptTester />
|
||||
</PromptDialogProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Open Prompt").click();
|
||||
});
|
||||
|
||||
const input = screen.getByDisplayValue("John");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Enter User" } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("result").textContent).toBe("Enter User");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { afterEach, describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { useAIStore } from "./store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reset store between tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
afterEach(() => {
|
||||
useAIStore.getState().reset();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("AI Store — initial state", () => {
|
||||
it("starts with default values", () => {
|
||||
const state = useAIStore.getState();
|
||||
expect(state.enabled).toBe(false);
|
||||
expect(state.provider).toBe("openai");
|
||||
expect(state.model).toBe("");
|
||||
expect(state.apiKey).toBe("");
|
||||
expect(state.baseURL).toBe("");
|
||||
expect(state.testStatus).toBe("unverified");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("AI Store — set()", () => {
|
||||
it("updates provider", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.provider = "anthropic";
|
||||
});
|
||||
expect(useAIStore.getState().provider).toBe("anthropic");
|
||||
});
|
||||
|
||||
it("updates model and apiKey", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.model = "gpt-4";
|
||||
draft.apiKey = "sk-test-key";
|
||||
});
|
||||
expect(useAIStore.getState().model).toBe("gpt-4");
|
||||
expect(useAIStore.getState().apiKey).toBe("sk-test-key");
|
||||
});
|
||||
|
||||
it("resets testStatus to unverified when provider changes", () => {
|
||||
// First set testStatus to success
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
// Now change provider — should reset
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.provider = "gemini";
|
||||
});
|
||||
expect(useAIStore.getState().testStatus).toBe("unverified");
|
||||
});
|
||||
|
||||
it("resets testStatus to unverified when model changes", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.model = "new-model";
|
||||
});
|
||||
expect(useAIStore.getState().testStatus).toBe("unverified");
|
||||
});
|
||||
|
||||
it("resets testStatus to unverified when apiKey changes", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.apiKey = "new-key";
|
||||
});
|
||||
expect(useAIStore.getState().testStatus).toBe("unverified");
|
||||
});
|
||||
|
||||
it("resets testStatus to unverified when baseURL changes", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.baseURL = "https://new-url.com";
|
||||
});
|
||||
expect(useAIStore.getState().testStatus).toBe("unverified");
|
||||
});
|
||||
|
||||
it("disables when config changes", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
useAIStore.getState().setEnabled(true);
|
||||
expect(useAIStore.getState().enabled).toBe(true);
|
||||
|
||||
// Change provider — should disable
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.provider = "ollama";
|
||||
});
|
||||
expect(useAIStore.getState().enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT reset testStatus when non-config fields change", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
// Changing testStatus itself shouldn't trigger the reset logic
|
||||
// (since provider/model/apiKey/baseURL didn't change)
|
||||
expect(useAIStore.getState().testStatus).toBe("success");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// canEnable()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("AI Store — canEnable()", () => {
|
||||
it("returns false when testStatus is unverified", () => {
|
||||
expect(useAIStore.getState().canEnable()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when testStatus is failure", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "failure";
|
||||
});
|
||||
expect(useAIStore.getState().canEnable()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when testStatus is success", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
expect(useAIStore.getState().canEnable()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setEnabled()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("AI Store — setEnabled()", () => {
|
||||
it("enables when testStatus is success", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
useAIStore.getState().setEnabled(true);
|
||||
expect(useAIStore.getState().enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses to enable when testStatus is not success", () => {
|
||||
useAIStore.getState().setEnabled(true);
|
||||
expect(useAIStore.getState().enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("can disable regardless of testStatus", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
useAIStore.getState().setEnabled(true);
|
||||
expect(useAIStore.getState().enabled).toBe(true);
|
||||
|
||||
useAIStore.getState().setEnabled(false);
|
||||
expect(useAIStore.getState().enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses to enable after failure", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "failure";
|
||||
});
|
||||
useAIStore.getState().setEnabled(true);
|
||||
expect(useAIStore.getState().enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reset()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("AI Store — reset()", () => {
|
||||
it("restores all state to initial values", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.provider = "anthropic";
|
||||
draft.model = "claude-3";
|
||||
draft.apiKey = "sk-key";
|
||||
draft.baseURL = "https://api.anthropic.com";
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
useAIStore.getState().setEnabled(true);
|
||||
|
||||
useAIStore.getState().reset();
|
||||
|
||||
const state = useAIStore.getState();
|
||||
expect(state.enabled).toBe(false);
|
||||
expect(state.provider).toBe("openai");
|
||||
expect(state.model).toBe("");
|
||||
expect(state.apiKey).toBe("");
|
||||
expect(state.baseURL).toBe("");
|
||||
expect(state.testStatus).toBe("unverified");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,13 @@ import { apiKeyClient } from "@better-auth/api-key/client";
|
||||
import { dashClient } from "@better-auth/infra/client";
|
||||
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
|
||||
import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client";
|
||||
import { genericOAuthClient, inferAdditionalFields, twoFactorClient, usernameClient } from "better-auth/client/plugins";
|
||||
import {
|
||||
adminClient,
|
||||
genericOAuthClient,
|
||||
inferAdditionalFields,
|
||||
twoFactorClient,
|
||||
usernameClient,
|
||||
} from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
import type { auth } from "./config";
|
||||
@@ -11,6 +17,7 @@ const getAuthClient = () => {
|
||||
return createAuthClient({
|
||||
plugins: [
|
||||
dashClient(),
|
||||
adminClient(),
|
||||
apiKeyClient(),
|
||||
usernameClient(),
|
||||
twoFactorClient({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { GenericOAuthConfig } from "better-auth/plugins";
|
||||
import type { JWTPayload } from "jose";
|
||||
|
||||
import { apiKey } from "@better-auth/api-key";
|
||||
@@ -7,7 +6,7 @@ import { dash } from "@better-auth/infra";
|
||||
import { oauthProvider } from "@better-auth/oauth-provider";
|
||||
import { BetterAuthError, betterAuth } from "better-auth";
|
||||
import { verifyAccessToken } from "better-auth/oauth2";
|
||||
import { jwt, openAPI } from "better-auth/plugins";
|
||||
import { admin, jwt, openAPI, type GenericOAuthConfig } from "better-auth/plugins";
|
||||
import { genericOAuth } from "better-auth/plugins/generic-oauth";
|
||||
import { twoFactor } from "better-auth/plugins/two-factor";
|
||||
import { username } from "better-auth/plugins/username";
|
||||
@@ -124,7 +123,7 @@ const getAuthConfig = () => {
|
||||
emailAndPassword: {
|
||||
enabled: !env.FLAG_DISABLE_EMAIL_AUTH,
|
||||
autoSignIn: true,
|
||||
minPasswordLength: 6,
|
||||
minPasswordLength: 8,
|
||||
maxPasswordLength: 64,
|
||||
requireEmailVerification: false,
|
||||
disableSignUp: env.FLAG_DISABLE_SIGNUPS || env.FLAG_DISABLE_EMAIL_AUTH,
|
||||
@@ -175,7 +174,7 @@ const getAuthConfig = () => {
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
trustedProviders: ["google", "github"],
|
||||
trustedProviders: ["google", "github", "linkedin"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -249,10 +248,38 @@ const getAuthConfig = () => {
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
linkedin: {
|
||||
enabled: !!env.LINKEDIN_CLIENT_ID && !!env.LINKEDIN_CLIENT_SECRET,
|
||||
disableSignUp: env.FLAG_DISABLE_SIGNUPS,
|
||||
clientId: env.LINKEDIN_CLIENT_ID!,
|
||||
clientSecret: env.LINKEDIN_CLIENT_SECRET!,
|
||||
mapProfileToUser: async (profile) => {
|
||||
if (!profile.email) {
|
||||
throw new BetterAuthError(
|
||||
"LinkedIn provider did not return an email address. This is required for user creation.",
|
||||
{ cause: "EMAIL_REQUIRED" },
|
||||
);
|
||||
}
|
||||
|
||||
const username = profile.email.split("@")[0];
|
||||
const name = profile.name ?? username;
|
||||
|
||||
return {
|
||||
name,
|
||||
email: profile.email,
|
||||
image: profile.picture,
|
||||
username,
|
||||
displayUsername: username,
|
||||
emailVerified: true,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
jwt(),
|
||||
admin(),
|
||||
openAPI(),
|
||||
genericOAuth({ config: authConfigs }),
|
||||
twoFactor({ issuer: "Reactive Resume" }),
|
||||
|
||||
@@ -7,6 +7,6 @@ export type AuthSession = {
|
||||
user: typeof auth.$Infer.Session.user;
|
||||
};
|
||||
|
||||
const authProviderSchema = z.enum(["credential", "google", "github", "custom"]);
|
||||
const authProviderSchema = z.enum(["credential", "google", "github", "linkedin", "custom"]);
|
||||
|
||||
export type AuthProvider = z.infer<typeof authProviderSchema>;
|
||||
|
||||
@@ -20,6 +20,10 @@ export const user = pg.pgTable(
|
||||
displayUsername: pg.text("display_username").notNull().unique(),
|
||||
twoFactorEnabled: pg.boolean("two_factor_enabled").notNull().default(false),
|
||||
lastActiveAt: pg.timestamp("last_active_at", { withTimezone: true }),
|
||||
role: pg.text("role").default("user"),
|
||||
banned: pg.boolean("banned").default(false),
|
||||
banReason: pg.text("ban_reason"),
|
||||
banExpires: pg.timestamp("ban_expires", { precision: 6, withTimezone: true }),
|
||||
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: pg
|
||||
.timestamp("updated_at", { withTimezone: true })
|
||||
@@ -41,6 +45,7 @@ export const session = pg.pgTable(
|
||||
token: pg.text("token").notNull().unique(),
|
||||
ipAddress: pg.text("ip_address"),
|
||||
userAgent: pg.text("user_agent"),
|
||||
impersonatedBy: pg.text("impersonated_by"),
|
||||
userId: pg
|
||||
.uuid("user_id")
|
||||
.notNull()
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { resumeDataSchema } from "@/schema/resume/data";
|
||||
|
||||
import { JSONResumeImporter } from "./json-resume";
|
||||
|
||||
const importer = new JSONResumeImporter();
|
||||
|
||||
function makeMinimalJsonResume(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
basics: {
|
||||
name: "John Doe",
|
||||
label: "Software Engineer",
|
||||
email: "john@example.com",
|
||||
phone: "+1-555-0100",
|
||||
summary: "A passionate developer.",
|
||||
location: { city: "San Francisco", region: "CA", countryCode: "US" },
|
||||
url: "https://johndoe.com",
|
||||
profiles: [{ network: "GitHub", username: "johndoe", url: "https://github.com/johndoe" }],
|
||||
},
|
||||
work: [
|
||||
{
|
||||
name: "Acme Corp",
|
||||
position: "Senior Developer",
|
||||
startDate: "2020-01",
|
||||
endDate: "2024-06",
|
||||
summary: "Led a team of 5.",
|
||||
highlights: ["Built CI/CD pipeline", "Reduced build time by 50%"],
|
||||
},
|
||||
],
|
||||
education: [
|
||||
{
|
||||
institution: "MIT",
|
||||
studyType: "Bachelor",
|
||||
area: "Computer Science",
|
||||
score: "3.9",
|
||||
startDate: "2016-09",
|
||||
endDate: "2020-05",
|
||||
},
|
||||
],
|
||||
skills: [{ name: "TypeScript", level: "Advanced", keywords: ["React", "Node.js"] }],
|
||||
languages: [{ language: "English", fluency: "Native" }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("JSONResumeImporter", () => {
|
||||
describe("parse", () => {
|
||||
it("parses a valid JSON Resume and produces valid ResumeData", () => {
|
||||
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
|
||||
expect(resumeDataSchema.safeParse(result).success).toBe(true);
|
||||
});
|
||||
|
||||
it("throws on invalid JSON", () => {
|
||||
expect(() => importer.parse("not json")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("convert - basics", () => {
|
||||
it("maps name and headline", () => {
|
||||
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
|
||||
expect(result.basics.name).toBe("John Doe");
|
||||
expect(result.basics.headline).toBe("Software Engineer");
|
||||
});
|
||||
|
||||
it("maps email and phone", () => {
|
||||
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
|
||||
expect(result.basics.email).toBe("john@example.com");
|
||||
expect(result.basics.phone).toBe("+1-555-0100");
|
||||
});
|
||||
|
||||
it("formats location from object to string", () => {
|
||||
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
|
||||
expect(result.basics.location).toBe("San Francisco, CA, US");
|
||||
});
|
||||
|
||||
it("maps website URL", () => {
|
||||
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
|
||||
expect(result.basics.website.url).toBe("https://johndoe.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convert - summary", () => {
|
||||
it("wraps summary in HTML", () => {
|
||||
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
|
||||
expect(result.summary.content).toBe("<p>A passionate developer.</p>");
|
||||
expect(result.summary.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("handles missing summary", () => {
|
||||
const data = makeMinimalJsonResume();
|
||||
delete (data.basics as Record<string, unknown>).summary;
|
||||
const result = importer.parse(JSON.stringify(data));
|
||||
expect(result.summary.content).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convert - experience", () => {
|
||||
it("maps work to experience items", () => {
|
||||
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
|
||||
expect(result.sections.experience.items).toHaveLength(1);
|
||||
const exp = result.sections.experience.items[0];
|
||||
expect(exp.company).toBe("Acme Corp");
|
||||
expect(exp.position).toBe("Senior Developer");
|
||||
expect(exp.period).toBe("January 2020 - June 2024");
|
||||
});
|
||||
|
||||
it("converts summary+highlights to HTML description", () => {
|
||||
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
|
||||
const exp = result.sections.experience.items[0];
|
||||
expect(exp.description).toContain("<p>Led a team of 5.</p>");
|
||||
expect(exp.description).toContain("<li>Built CI/CD pipeline</li>");
|
||||
});
|
||||
|
||||
it("filters out entries without name or position", () => {
|
||||
const data = makeMinimalJsonResume({ work: [{ location: "Nowhere" }] });
|
||||
const result = importer.parse(JSON.stringify(data));
|
||||
expect(result.sections.experience.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convert - education", () => {
|
||||
it("maps education items", () => {
|
||||
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
|
||||
const edu = result.sections.education.items[0];
|
||||
expect(edu.school).toBe("MIT");
|
||||
expect(edu.degree).toBe("Bachelor in Computer Science");
|
||||
expect(edu.grade).toBe("3.9");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convert - skills", () => {
|
||||
it("maps skills with level parsing", () => {
|
||||
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
|
||||
const skill = result.sections.skills.items[0];
|
||||
expect(skill.name).toBe("TypeScript");
|
||||
expect(skill.level).toBe(4); // "Advanced" maps to 4
|
||||
expect(skill.keywords).toEqual(["React", "Node.js"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convert - languages", () => {
|
||||
it("maps languages with fluency level", () => {
|
||||
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
|
||||
const lang = result.sections.languages.items[0];
|
||||
expect(lang.language).toBe("English");
|
||||
expect(lang.level).toBe(5); // "Native" maps to 5
|
||||
});
|
||||
});
|
||||
|
||||
describe("convert - profiles", () => {
|
||||
it("maps profiles with network icons", () => {
|
||||
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
|
||||
const profile = result.sections.profiles.items[0];
|
||||
expect(profile.network).toBe("GitHub");
|
||||
expect(profile.icon).toBe("github-logo");
|
||||
expect(profile.username).toBe("johndoe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convert - awards", () => {
|
||||
it("maps awards with formatted dates", () => {
|
||||
const data = makeMinimalJsonResume({
|
||||
awards: [{ title: "Best Paper", awarder: "IEEE", date: "2023-06-15", summary: "Great work" }],
|
||||
});
|
||||
const result = importer.parse(JSON.stringify(data));
|
||||
const award = result.sections.awards.items[0];
|
||||
expect(award.title).toBe("Best Paper");
|
||||
expect(award.awarder).toBe("IEEE");
|
||||
expect(award.date).toBe("June 15, 2023");
|
||||
expect(award.description).toBe("<p>Great work</p>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convert - volunteer", () => {
|
||||
it("maps volunteer items with period and description", () => {
|
||||
const data = makeMinimalJsonResume({
|
||||
volunteer: [
|
||||
{
|
||||
organization: "Red Cross",
|
||||
position: "Volunteer",
|
||||
startDate: "2022-01",
|
||||
endDate: "2023-06",
|
||||
summary: "Helped with logistics",
|
||||
highlights: ["Organized events"],
|
||||
url: "https://redcross.org",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = importer.parse(JSON.stringify(data));
|
||||
const vol = result.sections.volunteer.items[0];
|
||||
expect(vol.organization).toBe("Red Cross");
|
||||
expect(vol.period).toBe("January 2022 - June 2023");
|
||||
expect(vol.description).toContain("Helped with logistics");
|
||||
expect(vol.description).toContain("Organized events");
|
||||
});
|
||||
|
||||
it("filters out volunteer items without organization", () => {
|
||||
const data = makeMinimalJsonResume({
|
||||
volunteer: [
|
||||
{ organization: "Valid Org", position: "Vol" },
|
||||
{ position: "No Org" }, // missing organization
|
||||
],
|
||||
});
|
||||
const result = importer.parse(JSON.stringify(data));
|
||||
expect(result.sections.volunteer.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convert - references", () => {
|
||||
it("maps references with name and reference text", () => {
|
||||
const data = makeMinimalJsonResume({
|
||||
references: [{ name: "John Manager", reference: "Great developer and team player" }],
|
||||
});
|
||||
const result = importer.parse(JSON.stringify(data));
|
||||
const ref = result.sections.references.items[0];
|
||||
expect(ref.name).toBe("John Manager");
|
||||
expect(ref.description).toBe("<p>Great developer and team player</p>");
|
||||
});
|
||||
|
||||
it("handles references without reference text", () => {
|
||||
const data = makeMinimalJsonResume({
|
||||
references: [{ name: "Jane CTO" }],
|
||||
});
|
||||
const result = importer.parse(JSON.stringify(data));
|
||||
expect(result.sections.references.items[0].description).toBe("");
|
||||
});
|
||||
|
||||
it("filters references without name or reference", () => {
|
||||
const data = makeMinimalJsonResume({
|
||||
references: [
|
||||
{ name: "Valid" },
|
||||
{}, // no name or reference
|
||||
],
|
||||
});
|
||||
const result = importer.parse(JSON.stringify(data));
|
||||
expect(result.sections.references.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convert - publications", () => {
|
||||
it("maps publications with formatted date", () => {
|
||||
const data = makeMinimalJsonResume({
|
||||
publications: [
|
||||
{
|
||||
name: "My Paper",
|
||||
publisher: "IEEE",
|
||||
releaseDate: "2024-03-15",
|
||||
summary: "Research findings",
|
||||
url: "https://doi.org/paper",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = importer.parse(JSON.stringify(data));
|
||||
const pub = result.sections.publications.items[0];
|
||||
expect(pub.title).toBe("My Paper");
|
||||
expect(pub.publisher).toBe("IEEE");
|
||||
expect(pub.date).toBe("March 15, 2024");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convert - certifications", () => {
|
||||
it("maps certificates with issuer and date", () => {
|
||||
const data = makeMinimalJsonResume({
|
||||
certificates: [{ name: "AWS Certified", issuer: "Amazon", date: "2023-06" }],
|
||||
});
|
||||
const result = importer.parse(JSON.stringify(data));
|
||||
const cert = result.sections.certifications.items[0];
|
||||
expect(cert.title).toBe("AWS Certified");
|
||||
expect(cert.issuer).toBe("Amazon");
|
||||
expect(cert.date).toBe("June 2023");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convert - interests", () => {
|
||||
it("maps interests with keywords", () => {
|
||||
const data = makeMinimalJsonResume({
|
||||
interests: [{ name: "Gaming", keywords: ["RPG", "Strategy"] }],
|
||||
});
|
||||
const result = importer.parse(JSON.stringify(data));
|
||||
const interest = result.sections.interests.items[0];
|
||||
expect(interest.name).toBe("Gaming");
|
||||
expect(interest.keywords).toEqual(["RPG", "Strategy"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convert - empty sections", () => {
|
||||
it("handles resume with no optional sections", () => {
|
||||
const minimal = { basics: { name: "Jane" } };
|
||||
const result = importer.parse(JSON.stringify(minimal));
|
||||
expect(resumeDataSchema.safeParse(result).success).toBe(true);
|
||||
expect(result.basics.name).toBe("Jane");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("throws on invalid JSON", () => {
|
||||
expect(() => importer.parse("not json")).toThrow();
|
||||
});
|
||||
|
||||
it("handles missing basics.location gracefully", () => {
|
||||
const data = { basics: { name: "Jane" } };
|
||||
const result = importer.parse(JSON.stringify(data));
|
||||
expect(result.basics.location).toBe("");
|
||||
});
|
||||
|
||||
it("allows work items with position but no name (falls back to position)", () => {
|
||||
const data = makeMinimalJsonResume();
|
||||
// Work item with position but no name — filter passes because position is truthy
|
||||
// The importer maps name -> company, so company will be empty
|
||||
// This is a known limitation: the filter checks (name || position) but company requires min(1)
|
||||
// The resumeDataSchema.parse() will reject it
|
||||
(data as any).work.push({ name: "", position: "Ghost Dev", startDate: "2020-01" });
|
||||
expect(() => importer.parse(JSON.stringify(data))).toThrow();
|
||||
});
|
||||
|
||||
it("filters profiles without network name", () => {
|
||||
const data = makeMinimalJsonResume();
|
||||
(data as any).basics.profiles = [
|
||||
{ network: "GitHub", username: "jane" },
|
||||
{ username: "no-network" }, // no network
|
||||
];
|
||||
const result = importer.parse(JSON.stringify(data));
|
||||
expect(result.sections.profiles.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { flattenError, ZodError, z } from "zod";
|
||||
|
||||
import type { IconName } from "@/schema/icons";
|
||||
|
||||
import { defaultResumeData, type ResumeData, resumeDataSchema } from "@/schema/resume/data";
|
||||
import { formatPeriod, formatSingleDate } from "@/utils/date";
|
||||
import { arrayToHtmlList, toHtmlDescription } from "@/utils/html";
|
||||
import { parseLevel } from "@/utils/level";
|
||||
import { getNetworkIcon } from "@/utils/network-icons";
|
||||
import { generateId } from "@/utils/string";
|
||||
import { createUrl } from "@/utils/url";
|
||||
|
||||
// Custom ISO 8601 date pattern that allows partial dates (year only, year-month, or full date)
|
||||
const iso8601 = z
|
||||
@@ -153,138 +156,6 @@ const jsonResumeSchema = z.looseObject({
|
||||
|
||||
type JSONResume = z.infer<typeof jsonResumeSchema>;
|
||||
|
||||
// Helper function to format date period from start and end dates
|
||||
function formatPeriod(startDate?: string, endDate?: string): string {
|
||||
if (!startDate && !endDate) return "";
|
||||
if (!startDate) return endDate || "";
|
||||
if (!endDate) return `${startDate} - Present`;
|
||||
|
||||
// Format dates to be more readable
|
||||
const formatDate = (date: string): string => {
|
||||
// Handle YYYY-MM-DD, YYYY-MM, or YYYY formats
|
||||
const parts = date.split("-");
|
||||
|
||||
if (parts.length === 3) {
|
||||
// YYYY-MM-DD
|
||||
const [year, month] = parts;
|
||||
const monthNames = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
return `${monthNames[parseInt(month, 10) - 1]} ${year}`;
|
||||
}
|
||||
|
||||
if (parts.length === 2) {
|
||||
// YYYY-MM
|
||||
const [year, month] = parts;
|
||||
const monthNames = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
return `${monthNames[parseInt(month, 10) - 1]} ${year}`;
|
||||
}
|
||||
|
||||
// YYYY
|
||||
return date;
|
||||
};
|
||||
|
||||
return `${formatDate(startDate)} - ${formatDate(endDate)}`;
|
||||
}
|
||||
|
||||
// Helper function to format a single date
|
||||
function formatSingleDate(date?: string): string {
|
||||
if (!date) return "";
|
||||
|
||||
// Format dates to be more readable
|
||||
const parts = date.split("-");
|
||||
|
||||
if (parts.length === 3) {
|
||||
// YYYY-MM-DD
|
||||
const [year, month, day] = parts;
|
||||
const monthNames = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
return `${monthNames[parseInt(month, 10) - 1]} ${day}, ${year}`;
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
// YYYY-MM
|
||||
const [year, month] = parts;
|
||||
const monthNames = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
return `${monthNames[parseInt(month, 10) - 1]} ${year}`;
|
||||
}
|
||||
// YYYY
|
||||
return date;
|
||||
}
|
||||
|
||||
// Helper function to convert text and highlights to HTML
|
||||
function toHtmlDescription(summary?: string, highlights?: string[]): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (summary) {
|
||||
parts.push(`<p>${summary}</p>`);
|
||||
}
|
||||
|
||||
if (highlights && highlights.length > 0) {
|
||||
parts.push("<ul>");
|
||||
for (const highlight of highlights) {
|
||||
parts.push(`<li>${highlight}</li>`);
|
||||
}
|
||||
parts.push("</ul>");
|
||||
}
|
||||
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
// Helper function to convert array to HTML list
|
||||
function arrayToHtmlList(items?: string[]): string {
|
||||
if (!items || items.length === 0) return "";
|
||||
return `<ul>${items.map((item) => `<li>${item}</li>`).join("")}</ul>`;
|
||||
}
|
||||
|
||||
// Helper function to format location object to string
|
||||
function formatLocation(location?: {
|
||||
address?: string;
|
||||
@@ -303,62 +174,6 @@ function formatLocation(location?: {
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
// Helper function to map network name to icon
|
||||
function getNetworkIcon(network?: string): IconName {
|
||||
if (!network) return "star";
|
||||
|
||||
const networkLower = network.toLowerCase();
|
||||
if (networkLower.includes("github")) return "github-logo";
|
||||
if (networkLower.includes("linkedin")) return "linkedin-logo";
|
||||
if (networkLower.includes("twitter") || networkLower.includes("x.com")) return "twitter-logo";
|
||||
if (networkLower.includes("facebook")) return "facebook-logo";
|
||||
if (networkLower.includes("instagram")) return "instagram-logo";
|
||||
if (networkLower.includes("youtube")) return "youtube-logo";
|
||||
if (networkLower.includes("stackoverflow") || networkLower.includes("stack-overflow")) return "stack-overflow-logo";
|
||||
if (networkLower.includes("medium")) return "medium-logo";
|
||||
if (networkLower.includes("dev.to") || networkLower.includes("devto")) return "code";
|
||||
if (networkLower.includes("dribbble")) return "dribbble-logo";
|
||||
if (networkLower.includes("behance")) return "behance-logo";
|
||||
if (networkLower.includes("gitlab")) return "git-branch";
|
||||
if (networkLower.includes("bitbucket")) return "code";
|
||||
if (networkLower.includes("codepen")) return "code";
|
||||
|
||||
return "star";
|
||||
}
|
||||
|
||||
// Helper function to parse skill/language level to number (0-5)
|
||||
function parseLevel(level?: string): number {
|
||||
if (!level) return 0;
|
||||
|
||||
const levelLower = level.toLowerCase();
|
||||
// Try to parse numeric values
|
||||
const numeric = parseInt(levelLower, 10);
|
||||
if (!Number.isNaN(numeric) && numeric >= 0 && numeric <= 5) return numeric;
|
||||
|
||||
// Map text levels to numbers
|
||||
if (levelLower.includes("native") || levelLower.includes("expert") || levelLower.includes("master")) return 5;
|
||||
if (levelLower.includes("fluent") || levelLower.includes("advanced") || levelLower.includes("proficient")) return 4;
|
||||
if (levelLower.includes("intermediate") || levelLower.includes("conversational")) return 3;
|
||||
if (levelLower.includes("beginner") || levelLower.includes("basic") || levelLower.includes("elementary")) return 2;
|
||||
if (levelLower.includes("novice")) return 1;
|
||||
|
||||
// CEFR levels
|
||||
if (levelLower.includes("c2")) return 5;
|
||||
if (levelLower.includes("c1")) return 4;
|
||||
if (levelLower.includes("b2")) return 3;
|
||||
if (levelLower.includes("b1")) return 2;
|
||||
if (levelLower.includes("a2")) return 1;
|
||||
if (levelLower.includes("a1")) return 1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Helper function to create URL object
|
||||
function createUrl(url?: string, label?: string): { url: string; label: string } {
|
||||
if (!url) return { url: "", label: "" };
|
||||
return { url, label: label || url };
|
||||
}
|
||||
|
||||
export class JSONResumeImporter {
|
||||
convert(jsonResume: JSONResume): ResumeData {
|
||||
const result: ResumeData = {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { defaultResumeData, resumeDataSchema } from "@/schema/resume/data";
|
||||
|
||||
import { ReactiveResumeJSONImporter } from "./reactive-resume-json";
|
||||
|
||||
const importer = new ReactiveResumeJSONImporter();
|
||||
|
||||
describe("ReactiveResumeJSONImporter", () => {
|
||||
it("parses valid ResumeData JSON", () => {
|
||||
const result = importer.parse(JSON.stringify(defaultResumeData));
|
||||
expect(resumeDataSchema.safeParse(result).success).toBe(true);
|
||||
});
|
||||
|
||||
it("throws on invalid JSON", () => {
|
||||
expect(() => importer.parse("not json")).toThrow();
|
||||
});
|
||||
|
||||
it("throws on data that doesn't match schema", () => {
|
||||
expect(() => importer.parse(JSON.stringify({ invalid: true }))).toThrow();
|
||||
});
|
||||
|
||||
it("normalizes missing layout sections", () => {
|
||||
const data = {
|
||||
...defaultResumeData,
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
layout: {
|
||||
...defaultResumeData.metadata.layout,
|
||||
pages: [{ fullWidth: false, main: ["experience"], sidebar: [] }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = importer.parse(JSON.stringify(data));
|
||||
// Should add missing built-in section IDs to main
|
||||
const allSectionIds = result.metadata.layout.pages.flatMap((p) => [...p.main, ...p.sidebar]);
|
||||
expect(allSectionIds).toContain("experience");
|
||||
expect(allSectionIds).toContain("education");
|
||||
expect(allSectionIds).toContain("skills");
|
||||
});
|
||||
|
||||
it("handles empty layout pages by creating default page", () => {
|
||||
const data = {
|
||||
...defaultResumeData,
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
layout: {
|
||||
...defaultResumeData.metadata.layout,
|
||||
pages: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = importer.parse(JSON.stringify(data));
|
||||
expect(result.metadata.layout.pages).toHaveLength(1);
|
||||
expect(result.metadata.layout.pages[0].main.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("preserves complete layout without modification", () => {
|
||||
const result = importer.parse(JSON.stringify(defaultResumeData));
|
||||
expect(result.metadata.layout).toEqual(defaultResumeData.metadata.layout);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,808 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
import type { SectionItem } from "@/schema/resume/data";
|
||||
|
||||
import { resumeDataSchema } from "@/schema/resume/data";
|
||||
|
||||
import { ReactiveResumeV4JSONImporter } from "./reactive-resume-v4-json";
|
||||
|
||||
// Mock generateId for deterministic output
|
||||
let idCounter = 0;
|
||||
vi.mock("@/utils/string", () => ({
|
||||
generateId: () => `mock-id-${++idCounter}`,
|
||||
slugify: (str: string) => str.toLowerCase().replace(/\s+/g, "-"),
|
||||
getInitials: (str: string) =>
|
||||
str
|
||||
.split(" ")
|
||||
.map((s) => s[0])
|
||||
.join(""),
|
||||
toUsername: (str: string) => str.toLowerCase().replace(/\s+/g, ""),
|
||||
stripHtml: (str: string) => str.replace(/<[^>]*>/g, ""),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
idCounter = 0;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const importer = new ReactiveResumeV4JSONImporter();
|
||||
|
||||
function makeMinimalV4Resume(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
basics: {
|
||||
name: "Jane Doe",
|
||||
headline: "Developer",
|
||||
email: "jane@example.com",
|
||||
phone: "+1234567890",
|
||||
location: "New York, NY",
|
||||
url: { label: "Website", href: "https://jane.dev" },
|
||||
customFields: [],
|
||||
picture: {
|
||||
url: "https://example.com/photo.jpg",
|
||||
size: 80,
|
||||
aspectRatio: 1,
|
||||
borderRadius: 10,
|
||||
effects: { hidden: false, border: true, grayscale: false },
|
||||
},
|
||||
},
|
||||
sections: {
|
||||
summary: {
|
||||
name: "Summary",
|
||||
columns: 1,
|
||||
separateLinks: false,
|
||||
visible: true,
|
||||
id: "summary",
|
||||
content: "<p>A developer</p>",
|
||||
},
|
||||
awards: { name: "Awards", columns: 1, separateLinks: false, visible: true, id: "awards", items: [] },
|
||||
certifications: {
|
||||
name: "Certifications",
|
||||
columns: 1,
|
||||
separateLinks: false,
|
||||
visible: true,
|
||||
id: "certifications",
|
||||
items: [],
|
||||
},
|
||||
education: { name: "Education", columns: 1, separateLinks: false, visible: true, id: "education", items: [] },
|
||||
experience: { name: "Experience", columns: 1, separateLinks: false, visible: true, id: "experience", items: [] },
|
||||
volunteer: { name: "Volunteer", columns: 1, separateLinks: false, visible: true, id: "volunteer", items: [] },
|
||||
interests: { name: "Interests", columns: 1, separateLinks: false, visible: true, id: "interests", items: [] },
|
||||
languages: { name: "Languages", columns: 1, separateLinks: false, visible: true, id: "languages", items: [] },
|
||||
profiles: { name: "Profiles", columns: 1, separateLinks: false, visible: true, id: "profiles", items: [] },
|
||||
projects: { name: "Projects", columns: 1, separateLinks: false, visible: true, id: "projects", items: [] },
|
||||
publications: {
|
||||
name: "Publications",
|
||||
columns: 1,
|
||||
separateLinks: false,
|
||||
visible: true,
|
||||
id: "publications",
|
||||
items: [],
|
||||
},
|
||||
references: { name: "References", columns: 1, separateLinks: false, visible: true, id: "references", items: [] },
|
||||
skills: { name: "Skills", columns: 1, separateLinks: false, visible: true, id: "skills", items: [] },
|
||||
},
|
||||
metadata: {
|
||||
template: "onyx",
|
||||
layout: [
|
||||
[
|
||||
["experience", "education"],
|
||||
["skills", "languages"],
|
||||
],
|
||||
],
|
||||
css: { value: "", visible: false },
|
||||
page: { margin: 14, format: "a4" as const, options: { breakLine: true, pageNumbers: true } },
|
||||
theme: { background: "#ffffff", text: "#000000", primary: "#dc2626" },
|
||||
typography: {
|
||||
font: { family: "IBM Plex Serif", subset: "latin", variants: ["regular"], size: 14.67 },
|
||||
lineHeight: 1.5,
|
||||
hideIcons: false,
|
||||
underlineLinks: true,
|
||||
},
|
||||
notes: "",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Basic parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ReactiveResumeV4JSONImporter", () => {
|
||||
describe("basic parsing", () => {
|
||||
it("parses a minimal V4 resume and produces valid ResumeData", () => {
|
||||
const result = importer.parse(JSON.stringify(makeMinimalV4Resume()));
|
||||
expect(resumeDataSchema.safeParse(result).success).toBe(true);
|
||||
});
|
||||
|
||||
it("maps basics fields correctly", () => {
|
||||
const result = importer.parse(JSON.stringify(makeMinimalV4Resume()));
|
||||
expect(result.basics.name).toBe("Jane Doe");
|
||||
expect(result.basics.headline).toBe("Developer");
|
||||
expect(result.basics.email).toBe("jane@example.com");
|
||||
expect(result.basics.phone).toBe("+1234567890");
|
||||
expect(result.basics.location).toBe("New York, NY");
|
||||
expect(result.basics.website.url).toBe("https://jane.dev");
|
||||
expect(result.basics.website.label).toBe("Website");
|
||||
});
|
||||
|
||||
it("maps picture fields correctly", () => {
|
||||
const result = importer.parse(JSON.stringify(makeMinimalV4Resume()));
|
||||
expect(result.picture.url).toBe("https://example.com/photo.jpg");
|
||||
expect(result.picture.size).toBe(80);
|
||||
expect(result.picture.aspectRatio).toBe(1);
|
||||
expect(result.picture.borderRadius).toBe(10);
|
||||
expect(result.picture.hidden).toBe(false);
|
||||
expect(result.picture.borderWidth).toBe(1); // border effect was true
|
||||
});
|
||||
|
||||
it("maps summary correctly", () => {
|
||||
const result = importer.parse(JSON.stringify(makeMinimalV4Resume()));
|
||||
expect(result.summary.title).toBe("Summary");
|
||||
expect(result.summary.content).toBe("<p>A developer</p>");
|
||||
expect(result.summary.hidden).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section items
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("section items", () => {
|
||||
it("transforms experience items", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).experience.items = [
|
||||
{
|
||||
id: "exp-1",
|
||||
visible: true,
|
||||
company: "Acme Corp",
|
||||
position: "Engineer",
|
||||
location: "NYC",
|
||||
date: "2020 - 2022",
|
||||
summary: "<p>Built things</p>",
|
||||
url: { label: "Acme", href: "https://acme.com" },
|
||||
},
|
||||
];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.sections.experience.items).toHaveLength(1);
|
||||
const exp = result.sections.experience.items[0];
|
||||
expect(exp.company).toBe("Acme Corp");
|
||||
expect(exp.position).toBe("Engineer");
|
||||
expect(exp.location).toBe("NYC");
|
||||
expect(exp.period).toBe("2020 - 2022");
|
||||
expect(exp.description).toBe("<p>Built things</p>");
|
||||
expect(exp.website.url).toBe("https://acme.com");
|
||||
});
|
||||
|
||||
it("transforms education items", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).education.items = [
|
||||
{
|
||||
id: "edu-1",
|
||||
visible: true,
|
||||
institution: "MIT",
|
||||
studyType: "BSc",
|
||||
area: "Computer Science",
|
||||
score: "4.0",
|
||||
date: "2016 - 2020",
|
||||
summary: "<p>Graduated</p>",
|
||||
url: { label: "MIT", href: "https://mit.edu" },
|
||||
},
|
||||
];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
const edu = result.sections.education.items[0];
|
||||
expect(edu.school).toBe("MIT");
|
||||
expect(edu.degree).toBe("BSc");
|
||||
expect(edu.area).toBe("Computer Science");
|
||||
expect(edu.grade).toBe("4.0");
|
||||
expect(edu.period).toBe("2016 - 2020");
|
||||
});
|
||||
|
||||
it("transforms skill items with level clamping", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).skills.items = [
|
||||
{ id: "s1", visible: true, name: "TypeScript", description: "Advanced", level: 4, keywords: ["frontend"] },
|
||||
{ id: "s2", visible: true, name: "Rust", description: "Beginner", level: 10, keywords: [] }, // level > 5
|
||||
];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.sections.skills.items).toHaveLength(2);
|
||||
expect(result.sections.skills.items[0].name).toBe("TypeScript");
|
||||
expect(result.sections.skills.items[0].proficiency).toBe("Advanced");
|
||||
expect(result.sections.skills.items[0].level).toBe(4);
|
||||
expect(result.sections.skills.items[1].level).toBe(5); // clamped to 5
|
||||
});
|
||||
|
||||
it("transforms language items", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).languages.items = [
|
||||
{ id: "l1", visible: true, name: "English", description: "Native", level: 5 },
|
||||
];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.sections.languages.items[0].language).toBe("English");
|
||||
expect(result.sections.languages.items[0].fluency).toBe("Native");
|
||||
});
|
||||
|
||||
it("transforms profile items", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).profiles.items = [
|
||||
{
|
||||
id: "p1",
|
||||
visible: true,
|
||||
network: "GitHub",
|
||||
username: "janedoe",
|
||||
icon: "github",
|
||||
url: { label: "GitHub", href: "https://github.com/janedoe" },
|
||||
},
|
||||
];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.sections.profiles.items[0].network).toBe("GitHub");
|
||||
expect(result.sections.profiles.items[0].username).toBe("janedoe");
|
||||
expect(result.sections.profiles.items[0].icon).toBe("github");
|
||||
});
|
||||
|
||||
it("transforms award items", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).awards.items = [
|
||||
{
|
||||
id: "a1",
|
||||
visible: true,
|
||||
title: "Best Developer",
|
||||
awarder: "Company",
|
||||
date: "2023",
|
||||
summary: "<p>Won it</p>",
|
||||
},
|
||||
];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.sections.awards.items[0].title).toBe("Best Developer");
|
||||
expect(result.sections.awards.items[0].awarder).toBe("Company");
|
||||
});
|
||||
|
||||
it("transforms certification items", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).certifications.items = [
|
||||
{ id: "c1", visible: true, name: "AWS Certified", issuer: "AWS", date: "2023" },
|
||||
];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.sections.certifications.items[0].title).toBe("AWS Certified");
|
||||
expect(result.sections.certifications.items[0].issuer).toBe("AWS");
|
||||
});
|
||||
|
||||
it("transforms publication items", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).publications.items = [
|
||||
{ id: "pub1", visible: true, name: "My Paper", publisher: "IEEE", date: "2024", summary: "<p>Research</p>" },
|
||||
];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.sections.publications.items[0].title).toBe("My Paper");
|
||||
expect(result.sections.publications.items[0].publisher).toBe("IEEE");
|
||||
});
|
||||
|
||||
it("transforms volunteer items", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).volunteer.items = [
|
||||
{
|
||||
id: "v1",
|
||||
visible: true,
|
||||
organization: "Red Cross",
|
||||
position: "Helper",
|
||||
location: "NYC",
|
||||
date: "2023",
|
||||
summary: "<p>Helped</p>",
|
||||
},
|
||||
];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.sections.volunteer.items[0].organization).toBe("Red Cross");
|
||||
expect(result.sections.volunteer.items[0].location).toBe("NYC");
|
||||
});
|
||||
|
||||
it("transforms reference items", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).references.items = [
|
||||
{ id: "r1", visible: true, name: "John Manager", description: "CTO", summary: "<p>Great developer</p>" },
|
||||
];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.sections.references.items[0].name).toBe("John Manager");
|
||||
expect(result.sections.references.items[0].position).toBe("CTO");
|
||||
expect(result.sections.references.items[0].description).toBe("<p>Great developer</p>");
|
||||
});
|
||||
|
||||
it("transforms interest items", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).interests.items = [
|
||||
{ id: "i1", visible: true, name: "Gaming", keywords: ["RPG", "Strategy"] },
|
||||
];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.sections.interests.items[0].name).toBe("Gaming");
|
||||
expect(result.sections.interests.items[0].keywords).toEqual(["RPG", "Strategy"]);
|
||||
});
|
||||
|
||||
it("transforms project items using summary over description", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).projects.items = [
|
||||
{
|
||||
id: "proj1",
|
||||
visible: true,
|
||||
name: "My App",
|
||||
description: "desc",
|
||||
date: "2024",
|
||||
summary: "<p>summary</p>",
|
||||
url: { label: "", href: "" },
|
||||
},
|
||||
];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.sections.projects.items[0].description).toBe("<p>summary</p>");
|
||||
});
|
||||
|
||||
it("falls back to description when summary is missing for projects", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).projects.items = [
|
||||
{ id: "proj1", visible: true, name: "My App", description: "desc text", date: "2024" },
|
||||
];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.sections.projects.items[0].description).toBe("desc text");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filtering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("filtering", () => {
|
||||
it("filters out items with empty required fields", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).experience.items = [
|
||||
{ id: "e1", visible: true, company: "Acme", position: "Dev" },
|
||||
{ id: "e2", visible: true, company: "", position: "Dev" }, // empty company
|
||||
{ id: "e3", visible: true, position: "Dev" }, // missing company
|
||||
];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.sections.experience.items).toHaveLength(1);
|
||||
expect(result.sections.experience.items[0].company).toBe("Acme");
|
||||
});
|
||||
|
||||
it("filters out profiles without network name", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).profiles.items = [
|
||||
{ id: "p1", visible: true, network: "GitHub", username: "jane" },
|
||||
{ id: "p2", visible: true, network: "", username: "jane" },
|
||||
];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.sections.profiles.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("filters out skills without name", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).skills.items = [
|
||||
{ id: "s1", visible: true, name: "TypeScript" },
|
||||
{ id: "s2", visible: true, name: "" },
|
||||
];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.sections.skills.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visibility mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("visibility mapping", () => {
|
||||
it("maps visible: false to hidden: true", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).experience.items = [{ id: "e1", visible: false, company: "Acme", position: "Dev" }];
|
||||
(v4.sections as any).experience.visible = false;
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.sections.experience.hidden).toBe(true);
|
||||
expect(result.sections.experience.items[0].hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("maps visible: true to hidden: false", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).awards.visible = true;
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.sections.awards.hidden).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("metadata", () => {
|
||||
it("maps theme colors to rgba format", () => {
|
||||
const result = importer.parse(JSON.stringify(makeMinimalV4Resume()));
|
||||
expect(result.metadata.design.colors.primary).toMatch(/^rgba\(/);
|
||||
expect(result.metadata.design.colors.text).toMatch(/^rgba\(/);
|
||||
expect(result.metadata.design.colors.background).toMatch(/^rgba\(/);
|
||||
});
|
||||
|
||||
it("maps CSS settings", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
v4.metadata.css = { value: "body { color: red; }", visible: true };
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.metadata.css.enabled).toBe(true);
|
||||
expect(result.metadata.css.value).toBe("body { color: red; }");
|
||||
});
|
||||
|
||||
it("maps page format", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.metadata.page.format as "a4" | "letter") = "letter";
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.metadata.page.format).toBe("letter");
|
||||
});
|
||||
|
||||
it("maps font family and font size (px to pt conversion)", () => {
|
||||
const result = importer.parse(JSON.stringify(makeMinimalV4Resume()));
|
||||
expect(result.metadata.typography.body.fontFamily).toBe("IBM Plex Serif");
|
||||
// 14.67px * 0.75 = 11.0025pt
|
||||
expect(result.metadata.typography.body.fontSize).toBeCloseTo(11, 0);
|
||||
});
|
||||
|
||||
it("maps hideIcons setting", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
v4.metadata.typography.hideIcons = true;
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.metadata.page.hideIcons).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to onyx template for invalid template names", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
v4.metadata.template = "nonexistent-template";
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.metadata.template).toBe("onyx");
|
||||
});
|
||||
|
||||
it("preserves valid template names", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
v4.metadata.template = "pikachu";
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.metadata.template).toBe("pikachu");
|
||||
});
|
||||
|
||||
it("maps notes", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
v4.metadata.notes = "Some notes here";
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.metadata.notes).toBe("Some notes here");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("layout", () => {
|
||||
it("converts V4 layout pages to new format", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
v4.metadata.layout = [
|
||||
[["experience", "education"], ["skills"]],
|
||||
[["projects"], []],
|
||||
];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.metadata.layout.pages).toHaveLength(2);
|
||||
expect(result.metadata.layout.pages[0].main).toContain("experience");
|
||||
expect(result.metadata.layout.pages[0].sidebar).toContain("skills");
|
||||
expect(result.metadata.layout.pages[1].main).toContain("projects");
|
||||
expect(result.metadata.layout.pages[1].fullWidth).toBe(true); // empty sidebar
|
||||
});
|
||||
|
||||
it("strips 'custom.' prefix from layout section IDs", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
v4.metadata.layout = [[["experience", "custom.abc123"], ["skills"]]];
|
||||
(v4.sections as any).custom = {
|
||||
abc123: {
|
||||
name: "Custom Section",
|
||||
columns: 1,
|
||||
separateLinks: false,
|
||||
visible: true,
|
||||
id: "abc123",
|
||||
items: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.metadata.layout.pages[0].main).toContain("abc123");
|
||||
expect(result.metadata.layout.pages[0].main).not.toContain("custom.abc123");
|
||||
});
|
||||
|
||||
it("removes summary from layout columns (handled separately)", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
v4.metadata.layout = [[["summary", "experience"], ["skills"]]];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
// Summary should be re-added at the front when visible
|
||||
const mainSections = result.metadata.layout.pages[0].main;
|
||||
expect(mainSections[0]).toBe("summary");
|
||||
expect(mainSections.filter((s) => s === "summary")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("adds summary to front of first page when visible with content", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
v4.metadata.layout = [[["experience"], ["skills"]]];
|
||||
(v4.sections as any).summary.visible = true;
|
||||
(v4.sections as any).summary.content = "<p>Summary text</p>";
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.metadata.layout.pages[0].main[0]).toBe("summary");
|
||||
});
|
||||
|
||||
it("does NOT add summary to layout when it is not visible", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
v4.metadata.layout = [[["experience"], ["skills"]]];
|
||||
(v4.sections as any).summary.visible = false;
|
||||
(v4.sections as any).summary.content = "<p>Summary text</p>";
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.metadata.layout.pages[0].main).not.toContain("summary");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom sections
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("custom sections", () => {
|
||||
it("transforms custom sections to experience-typed custom sections", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).custom = {
|
||||
"custom-1": {
|
||||
name: "Freelance Work",
|
||||
columns: 2,
|
||||
separateLinks: false,
|
||||
visible: true,
|
||||
id: "custom-1",
|
||||
items: [
|
||||
{
|
||||
id: "ci1",
|
||||
visible: true,
|
||||
name: "Project A",
|
||||
description: "Lead Dev",
|
||||
date: "2023",
|
||||
location: "Remote",
|
||||
summary: "<p>Details</p>",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
const section = result.customSections[0];
|
||||
const item = section.items[0] as SectionItem<"experience">;
|
||||
|
||||
expect(result.customSections).toHaveLength(1);
|
||||
expect(section.title).toBe("Freelance Work");
|
||||
expect(section.type).toBe("experience");
|
||||
expect(section.columns).toBe(2);
|
||||
expect(section.items).toHaveLength(1);
|
||||
expect(item.company).toBe("Project A");
|
||||
expect(item.position).toBe("Lead Dev");
|
||||
});
|
||||
|
||||
it("handles custom section items without name by using index", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).custom = {
|
||||
"custom-1": {
|
||||
name: "Other",
|
||||
columns: 1,
|
||||
separateLinks: false,
|
||||
visible: true,
|
||||
id: "custom-1",
|
||||
items: [{ id: "ci1", visible: true, description: "Something" }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
const section = result.customSections[0];
|
||||
const item = section.items[0] as SectionItem<"experience">;
|
||||
|
||||
expect(item.company).toBe("#1");
|
||||
});
|
||||
|
||||
it("filters out invisible custom section items", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).custom = {
|
||||
"custom-1": {
|
||||
name: "Section",
|
||||
columns: 1,
|
||||
separateLinks: false,
|
||||
visible: true,
|
||||
id: "custom-1",
|
||||
items: [
|
||||
{ id: "ci1", visible: true, name: "Visible" },
|
||||
{ id: "ci2", visible: false, name: "Hidden" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.customSections[0].items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles empty custom sections object", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.sections as any).custom = {};
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.customSections).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles missing custom sections", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
delete (v4.sections as any).custom;
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.customSections).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("sanitizes invalid email addresses", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
v4.basics.email = "not-an-email";
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.basics.email).toBe("");
|
||||
});
|
||||
|
||||
it("preserves valid email addresses", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
v4.basics.email = "valid@example.com";
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.basics.email).toBe("valid@example.com");
|
||||
});
|
||||
|
||||
it("handles missing optional fields with defaults", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
v4.basics.phone = "";
|
||||
v4.basics.location = "";
|
||||
v4.basics.url = { label: "", href: "" };
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.basics.phone).toBe("");
|
||||
expect(result.basics.location).toBe("");
|
||||
expect(result.basics.website.url).toBe("");
|
||||
});
|
||||
|
||||
it("handles missing picture data", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.basics as any).picture = undefined;
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.picture.url).toBe("");
|
||||
expect(result.picture.size).toBe(80);
|
||||
});
|
||||
|
||||
it("clamps picture size to valid range", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
v4.basics.picture.size = 1000;
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.picture.size).toBe(512);
|
||||
});
|
||||
|
||||
it("clamps picture size minimum", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
v4.basics.picture.size = 5;
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.picture.size).toBe(32);
|
||||
});
|
||||
|
||||
it("clamps aspect ratio to valid range", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
v4.basics.picture.aspectRatio = 10;
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.picture.aspectRatio).toBe(2.5);
|
||||
});
|
||||
|
||||
it("converts font variants correctly", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
v4.metadata.typography.font.variants = ["regular", "bold", "700"];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.metadata.typography.body.fontWeights).toContain("400");
|
||||
expect(result.metadata.typography.body.fontWeights).toContain("700");
|
||||
});
|
||||
|
||||
it("heading font weights are >= 600", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
v4.metadata.typography.font.variants = ["regular", "300"];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
// All heading weights should be >= 600; defaults to ["600"] if none qualify
|
||||
for (const weight of result.metadata.typography.heading.fontWeights) {
|
||||
expect(Number.parseInt(weight, 10)).toBeGreaterThanOrEqual(600);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles custom fields in basics", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
(v4.basics.customFields as any) = [{ id: "cf1", icon: "star", text: "Custom field value" }];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.basics.customFields).toHaveLength(1);
|
||||
expect(result.basics.customFields[0].text).toBe("Custom field value");
|
||||
expect(result.basics.customFields[0].icon).toBe("star");
|
||||
});
|
||||
|
||||
it("throws on invalid JSON", () => {
|
||||
expect(() => importer.parse("not json")).toThrow();
|
||||
});
|
||||
|
||||
it("produces a result that validates against resumeDataSchema", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
// Add items to every section
|
||||
(v4.sections as any).experience.items = [{ id: "e1", visible: true, company: "Co", position: "Dev" }];
|
||||
(v4.sections as any).education.items = [{ id: "ed1", visible: true, institution: "Uni", studyType: "BSc" }];
|
||||
(v4.sections as any).skills.items = [{ id: "s1", visible: true, name: "JS", level: 3 }];
|
||||
(v4.sections as any).languages.items = [{ id: "l1", visible: true, name: "English", level: 5 }];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
const validation = resumeDataSchema.safeParse(result);
|
||||
expect(validation.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Font weight conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("font weight conversion", () => {
|
||||
it("maps 'bold' to 700", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
v4.metadata.typography.font.variants = ["bold"];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.metadata.typography.body.fontWeights).toContain("700");
|
||||
});
|
||||
|
||||
it("maps 'italic' to 400", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
v4.metadata.typography.font.variants = ["italic"];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.metadata.typography.body.fontWeights).toContain("400");
|
||||
});
|
||||
|
||||
it("defaults to 400 for empty variants", () => {
|
||||
const v4 = makeMinimalV4Resume();
|
||||
v4.metadata.typography.font.variants = [];
|
||||
|
||||
const result = importer.parse(JSON.stringify(v4));
|
||||
expect(result.metadata.typography.body.fontWeights).toEqual(["400"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { useJobsStore } from "./store";
|
||||
|
||||
afterEach(() => {
|
||||
useJobsStore.getState().reset();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Jobs Store — initial state", () => {
|
||||
it("starts with default values", () => {
|
||||
const state = useJobsStore.getState();
|
||||
expect(state.rapidApiKey).toBe("");
|
||||
expect(state.testStatus).toBe("unverified");
|
||||
expect(state.rapidApiQuota).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Jobs Store — set()", () => {
|
||||
it("updates rapidApiKey", () => {
|
||||
useJobsStore.getState().set((draft) => {
|
||||
draft.rapidApiKey = "new-api-key";
|
||||
});
|
||||
expect(useJobsStore.getState().rapidApiKey).toBe("new-api-key");
|
||||
});
|
||||
|
||||
it("resets testStatus to unverified when rapidApiKey changes", () => {
|
||||
useJobsStore.getState().set((draft) => {
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
useJobsStore.getState().set((draft) => {
|
||||
draft.rapidApiKey = "different-key";
|
||||
});
|
||||
expect(useJobsStore.getState().testStatus).toBe("unverified");
|
||||
});
|
||||
|
||||
it("does NOT reset testStatus when rapidApiKey stays the same", () => {
|
||||
useJobsStore.getState().set((draft) => {
|
||||
draft.rapidApiKey = "same-key";
|
||||
});
|
||||
useJobsStore.getState().set((draft) => {
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
// Change something else, keep the same key
|
||||
useJobsStore.getState().set((draft) => {
|
||||
draft.rapidApiQuota = { used: 10, limit: 100, remaining: 90 };
|
||||
});
|
||||
expect(useJobsStore.getState().testStatus).toBe("success");
|
||||
});
|
||||
|
||||
it("updates rapidApiQuota", () => {
|
||||
const quota = { used: 50, limit: 100, remaining: 50 };
|
||||
useJobsStore.getState().set((draft) => {
|
||||
draft.rapidApiQuota = quota;
|
||||
});
|
||||
expect(useJobsStore.getState().rapidApiQuota).toEqual(quota);
|
||||
});
|
||||
|
||||
it("updates testStatus directly", () => {
|
||||
useJobsStore.getState().set((draft) => {
|
||||
draft.testStatus = "failure";
|
||||
});
|
||||
expect(useJobsStore.getState().testStatus).toBe("failure");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reset()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Jobs Store — reset()", () => {
|
||||
it("restores all state to initial values", () => {
|
||||
useJobsStore.getState().set((draft) => {
|
||||
draft.rapidApiKey = "some-key";
|
||||
draft.testStatus = "success";
|
||||
draft.rapidApiQuota = { used: 10, limit: 100, remaining: 90 };
|
||||
});
|
||||
|
||||
useJobsStore.getState().reset();
|
||||
|
||||
const state = useJobsStore.getState();
|
||||
expect(state.rapidApiKey).toBe("");
|
||||
expect(state.testStatus).toBe("unverified");
|
||||
expect(state.rapidApiQuota).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,8 @@ async function getUserFromBearerToken(headers: Headers): Promise<User | null> {
|
||||
|
||||
const [userResult] = await db.select().from(user).where(eq(user.id, payload.sub)).limit(1);
|
||||
return userResult ?? null;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.warn("Bearer token verification failed:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -37,7 +38,8 @@ async function getUserFromHeaders(headers: Headers): Promise<User | null> {
|
||||
if (!result || !result.user) return null;
|
||||
|
||||
return result.user;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.warn("Session verification failed:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -51,7 +53,8 @@ async function getUserFromApiKey(apiKey: string): Promise<User | null> {
|
||||
if (!userResult) return null;
|
||||
|
||||
return userResult;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.warn("API key verification failed:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// We test the pure crypto helpers directly (signResumeAccessToken, safeEquals)
|
||||
// since they don't depend on cookies or env.
|
||||
// The actual module uses private functions, so we reimplement and test the algorithm.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function signResumeAccessToken(resumeId: string, passwordHash: string): string {
|
||||
return createHash("sha256").update(`${resumeId}:${passwordHash}`).digest("hex");
|
||||
}
|
||||
|
||||
function safeEquals(value: string, expected: string): boolean {
|
||||
const { timingSafeEqual } = require("node:crypto");
|
||||
const valueBuffer = Buffer.from(value);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
if (valueBuffer.length !== expectedBuffer.length) return false;
|
||||
return timingSafeEqual(valueBuffer, expectedBuffer);
|
||||
}
|
||||
|
||||
function getResumeAccessCookieName(resumeId: string): string {
|
||||
return `resume_access_${resumeId}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// signResumeAccessToken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("signResumeAccessToken", () => {
|
||||
it("produces a hex SHA256 hash", () => {
|
||||
const token = signResumeAccessToken("resume-123", "hashed-password");
|
||||
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("produces consistent results for same inputs", () => {
|
||||
const t1 = signResumeAccessToken("resume-1", "pw-hash");
|
||||
const t2 = signResumeAccessToken("resume-1", "pw-hash");
|
||||
expect(t1).toBe(t2);
|
||||
});
|
||||
|
||||
it("produces different tokens for different resume IDs", () => {
|
||||
const t1 = signResumeAccessToken("resume-1", "pw-hash");
|
||||
const t2 = signResumeAccessToken("resume-2", "pw-hash");
|
||||
expect(t1).not.toBe(t2);
|
||||
});
|
||||
|
||||
it("produces different tokens for different password hashes", () => {
|
||||
const t1 = signResumeAccessToken("resume-1", "hash-a");
|
||||
const t2 = signResumeAccessToken("resume-1", "hash-b");
|
||||
expect(t1).not.toBe(t2);
|
||||
});
|
||||
|
||||
it("handles empty inputs", () => {
|
||||
const token = signResumeAccessToken("", "");
|
||||
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// safeEquals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("safeEquals", () => {
|
||||
it("returns true for equal strings", () => {
|
||||
expect(safeEquals("abc123", "abc123")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for different strings of same length", () => {
|
||||
expect(safeEquals("abc123", "abc456")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for strings of different lengths", () => {
|
||||
expect(safeEquals("short", "much-longer-string")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for empty strings", () => {
|
||||
expect(safeEquals("", "")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for empty vs non-empty", () => {
|
||||
expect(safeEquals("", "notempty")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles long hex strings (like SHA256 hashes)", () => {
|
||||
const hash = signResumeAccessToken("id", "pw");
|
||||
expect(safeEquals(hash, hash)).toBe(true);
|
||||
expect(safeEquals(hash, hash.replace(/.$/, "0"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getResumeAccessCookieName
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getResumeAccessCookieName", () => {
|
||||
it("prefixes resume ID with resume_access_", () => {
|
||||
expect(getResumeAccessCookieName("abc-123")).toBe("resume_access_abc-123");
|
||||
});
|
||||
|
||||
it("handles various ID formats", () => {
|
||||
expect(getResumeAccessCookieName("01234567-89ab-cdef-0123-456789abcdef")).toBe(
|
||||
"resume_access_01234567-89ab-cdef-0123-456789abcdef",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ export const authRouter = {
|
||||
operationId: "listAuthProviders",
|
||||
summary: "List authentication providers",
|
||||
description:
|
||||
"Returns a list of all authentication providers enabled on this Reactive Resume instance, along with their display names. Possible providers include password-based credentials, Google, GitHub, and custom OAuth. No authentication required.",
|
||||
"Returns a list of all authentication providers enabled on this Reactive Resume instance, along with their display names. Possible providers include password-based credentials, Google, GitHub, LinkedIn, and custom OAuth. No authentication required.",
|
||||
successDescription: "A map of enabled authentication provider identifiers to their display names.",
|
||||
})
|
||||
.handler((): ProviderList => {
|
||||
|
||||
@@ -33,108 +33,10 @@ import {
|
||||
} from "@/integrations/ai/tools/patch-resume";
|
||||
import { defaultResumeData, resumeDataSchema } from "@/schema/resume/data";
|
||||
import { type TailorOutput, tailorOutputSchema } from "@/schema/tailor";
|
||||
import { buildAiExtractionTemplate } from "@/utils/ai-template";
|
||||
import { isObject } from "@/utils/sanitize";
|
||||
|
||||
const aiExtractionTemplate = {
|
||||
...defaultResumeData,
|
||||
basics: {
|
||||
...defaultResumeData.basics,
|
||||
customFields: [{ id: "", icon: "", text: "", link: "" }],
|
||||
},
|
||||
sections: {
|
||||
...defaultResumeData.sections,
|
||||
profiles: {
|
||||
...defaultResumeData.sections.profiles,
|
||||
items: [{ id: "", hidden: false, icon: "", network: "", username: "", website: { url: "", label: "" } }],
|
||||
},
|
||||
experience: {
|
||||
...defaultResumeData.sections.experience,
|
||||
items: [
|
||||
{
|
||||
id: "",
|
||||
hidden: false,
|
||||
company: "",
|
||||
position: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
education: {
|
||||
...defaultResumeData.sections.education,
|
||||
items: [
|
||||
{
|
||||
id: "",
|
||||
hidden: false,
|
||||
school: "",
|
||||
degree: "",
|
||||
area: "",
|
||||
grade: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
projects: {
|
||||
...defaultResumeData.sections.projects,
|
||||
items: [{ id: "", hidden: false, name: "", period: "", website: { url: "", label: "" }, description: "" }],
|
||||
},
|
||||
skills: {
|
||||
...defaultResumeData.sections.skills,
|
||||
items: [{ id: "", hidden: false, icon: "", name: "", proficiency: "", level: 0, keywords: [] }],
|
||||
},
|
||||
languages: {
|
||||
...defaultResumeData.sections.languages,
|
||||
items: [{ id: "", hidden: false, language: "", fluency: "", level: 0 }],
|
||||
},
|
||||
interests: {
|
||||
...defaultResumeData.sections.interests,
|
||||
items: [{ id: "", hidden: false, icon: "", name: "", keywords: [] }],
|
||||
},
|
||||
awards: {
|
||||
...defaultResumeData.sections.awards,
|
||||
items: [
|
||||
{ id: "", hidden: false, title: "", awarder: "", date: "", website: { url: "", label: "" }, description: "" },
|
||||
],
|
||||
},
|
||||
certifications: {
|
||||
...defaultResumeData.sections.certifications,
|
||||
items: [
|
||||
{ id: "", hidden: false, title: "", issuer: "", date: "", website: { url: "", label: "" }, description: "" },
|
||||
],
|
||||
},
|
||||
publications: {
|
||||
...defaultResumeData.sections.publications,
|
||||
items: [
|
||||
{ id: "", hidden: false, title: "", publisher: "", date: "", website: { url: "", label: "" }, description: "" },
|
||||
],
|
||||
},
|
||||
volunteer: {
|
||||
...defaultResumeData.sections.volunteer,
|
||||
items: [
|
||||
{
|
||||
id: "",
|
||||
hidden: false,
|
||||
organization: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
references: {
|
||||
...defaultResumeData.sections.references,
|
||||
items: [
|
||||
{ id: "", hidden: false, name: "", position: "", website: { url: "", label: "" }, phone: "", description: "" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const aiExtractionTemplate = buildAiExtractionTemplate();
|
||||
|
||||
/**
|
||||
* Merges two objects recursively, filling in missing properties in the target object
|
||||
|
||||
@@ -17,6 +17,7 @@ const providers = {
|
||||
|
||||
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) providers.google = "Google";
|
||||
if (env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET) providers.github = "GitHub";
|
||||
if (env.LINKEDIN_CLIENT_ID && env.LINKEDIN_CLIENT_SECRET) providers.linkedin = "LinkedIn";
|
||||
if (env.OAUTH_CLIENT_ID && env.OAUTH_CLIENT_SECRET) providers.custom = env.OAUTH_PROVIDER_NAME ?? "Custom OAuth";
|
||||
|
||||
return providers;
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { InferSelectModel } from "drizzle-orm";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import dns from "node:dns/promises";
|
||||
import { isIP } from "node:net";
|
||||
import puppeteer, { type Browser, type ConnectOptions, type Page } from "puppeteer-core";
|
||||
import puppeteer, { type Browser, type BrowserContext, type ConnectOptions, type Page } from "puppeteer-core";
|
||||
|
||||
import type { schema } from "@/integrations/drizzle";
|
||||
|
||||
@@ -16,6 +16,10 @@ import { getStorageService, uploadFile } from "./storage";
|
||||
|
||||
const SCREENSHOT_TTL = 1000 * 60 * 60 * 6; // 6 hours
|
||||
|
||||
// Deduplicate concurrent requests for the same resume
|
||||
const activePrintJobs = new Map<string, Promise<string>>();
|
||||
const activeScreenshotJobs = new Map<string, Promise<string>>();
|
||||
|
||||
// Singleton browser instance for connection reuse
|
||||
let browserInstance: Browser | null = null;
|
||||
|
||||
@@ -59,15 +63,312 @@ async function closeBrowser(): Promise<void> {
|
||||
}
|
||||
|
||||
// Close browser on process termination
|
||||
process.on("SIGINT", async () => {
|
||||
process.on("exit", async () => {
|
||||
await closeBrowser();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
await closeBrowser();
|
||||
process.exit(0);
|
||||
});
|
||||
/**
|
||||
* Generates a PDF from a resume and uploads it to storage.
|
||||
*
|
||||
* The process:
|
||||
* 1. Clean up any existing PDF for this resume
|
||||
* 2. Navigate to the printer route which renders the resume
|
||||
* 3. Calculate PDF margins (some templates require margins to be applied via PDF)
|
||||
* 4. Adjust CSS variables so content fits within printable area (accounting for margins)
|
||||
* 5. Add page break CSS to ensure each visual resume page becomes a PDF page
|
||||
* 6. Generate the PDF with proper dimensions and margins
|
||||
* 7. Upload to storage and return the URL
|
||||
*/
|
||||
async function doPrintResumeAsPDF(
|
||||
input: Pick<InferSelectModel<typeof schema.resume>, "id" | "data" | "userId">,
|
||||
): Promise<string> {
|
||||
const { id, data, userId } = input;
|
||||
|
||||
// Step 1: Delete any existing PDF for this resume to ensure fresh generation
|
||||
const storageService = getStorageService();
|
||||
const pdfPrefix = `uploads/${userId}/pdfs/${id}`;
|
||||
await storageService.delete(pdfPrefix);
|
||||
|
||||
// Step 2: Prepare the URL and authentication for the printer route
|
||||
// The printer route renders the resume in a format optimized for PDF generation
|
||||
const baseUrl = env.PRINTER_APP_URL ?? env.APP_URL;
|
||||
const domain = new URL(baseUrl).hostname;
|
||||
|
||||
const format = data.metadata.page.format;
|
||||
const locale = data.metadata.page.locale;
|
||||
const template = data.metadata.template;
|
||||
|
||||
// Generate a secure token to authenticate the printer request
|
||||
const token = generatePrinterToken(id);
|
||||
const url = `${baseUrl}/printer/${id}?token=${token}`;
|
||||
|
||||
// Step 3: Calculate print paddings for templates that disable CSS padding in print mode.
|
||||
// We render these margins inside the page (not via Puppeteer's PDF margins), so the margin
|
||||
// area matches the resume background color instead of staying white.
|
||||
let pagePaddingX = 0;
|
||||
let pagePaddingY = 0;
|
||||
|
||||
if (printMarginTemplates.includes(template)) {
|
||||
pagePaddingX = data.metadata.page.marginX;
|
||||
pagePaddingY = data.metadata.page.marginY;
|
||||
}
|
||||
|
||||
let context: BrowserContext | null = null;
|
||||
let page: Page | null = null;
|
||||
|
||||
try {
|
||||
// Step 4: Connect to the browser and navigate to the printer route
|
||||
// Use an isolated browser context so concurrent requests with different locales don't interfere
|
||||
const browser = await getBrowser();
|
||||
context = await browser.createBrowserContext();
|
||||
|
||||
await context.setCookie({ name: "locale", value: locale, domain });
|
||||
|
||||
page = await context.newPage();
|
||||
|
||||
// Wait for the page to fully load (network idle + custom loaded attribute)
|
||||
await page.emulateMediaType("print");
|
||||
await page.setViewport(pageDimensionsAsPixels[format]);
|
||||
await page.goto(url, { waitUntil: "networkidle0" });
|
||||
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
|
||||
|
||||
// Step 5a: Prepare the DOM for PDF rendering (background colors, reset margins, print padding)
|
||||
await page.evaluate(
|
||||
(pagePaddingX: number, pagePaddingY: number, backgroundColor: string) => {
|
||||
const root = document.documentElement;
|
||||
const body = document.body;
|
||||
const pageElements = document.querySelectorAll("[data-page-index]");
|
||||
const pageContentElements = document.querySelectorAll(".page-content");
|
||||
|
||||
// Ensure PDF margins inherit the resume background color instead of defaulting to white.
|
||||
root.style.backgroundColor = backgroundColor;
|
||||
body.style.backgroundColor = backgroundColor;
|
||||
root.style.margin = "0";
|
||||
body.style.margin = "0";
|
||||
root.style.padding = "0";
|
||||
body.style.padding = "0";
|
||||
|
||||
for (const el of pageElements) {
|
||||
const pageWrapper = el as HTMLElement;
|
||||
const pageSurface = pageWrapper.querySelector(".page") as HTMLElement | null;
|
||||
|
||||
pageWrapper.style.backgroundColor = backgroundColor;
|
||||
pageWrapper.style.breakInside = "auto";
|
||||
|
||||
if (pageSurface) pageSurface.style.backgroundColor = backgroundColor;
|
||||
}
|
||||
|
||||
// Apply print-only margins as padding inside each page's content surface.
|
||||
if (pagePaddingX > 0 || pagePaddingY > 0) {
|
||||
for (const el of pageContentElements) {
|
||||
const pageContent = el as HTMLElement;
|
||||
|
||||
pageContent.style.boxSizing = "border-box";
|
||||
// Ensure padding is repeated on every printed fragment when content
|
||||
// flows across physical PDF pages (not just the first fragment).
|
||||
pageContent.style.boxDecorationBreak = "clone";
|
||||
pageContent.style.setProperty("-webkit-box-decoration-break", "clone");
|
||||
if (pagePaddingX > 0) {
|
||||
pageContent.style.paddingLeft = `${pagePaddingX}pt`;
|
||||
pageContent.style.paddingRight = `${pagePaddingX}pt`;
|
||||
}
|
||||
if (pagePaddingY > 0) {
|
||||
pageContent.style.paddingTop = `${pagePaddingY}pt`;
|
||||
pageContent.style.paddingBottom = `${pagePaddingY}pt`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
pagePaddingX,
|
||||
pagePaddingY,
|
||||
data.metadata.design.colors.background,
|
||||
);
|
||||
|
||||
// Step 5b: Format-specific layout adjustments
|
||||
const isFreeForm = format === "free-form";
|
||||
let contentHeight: number | null = null;
|
||||
|
||||
if (isFreeForm) {
|
||||
// Free-form: measure actual content height after adding inter-page margins
|
||||
contentHeight = await page.evaluate(
|
||||
(pagePaddingY: number, minPageHeight: number) => {
|
||||
const pageElements = document.querySelectorAll("[data-page-index]");
|
||||
const numberOfPages = pageElements.length;
|
||||
|
||||
// Add margin between pages (except the last one)
|
||||
for (let i = 0; i < numberOfPages - 1; i++) {
|
||||
const pageEl = pageElements[i] as HTMLElement;
|
||||
if (pagePaddingY > 0) pageEl.style.marginBottom = `${pagePaddingY}pt`;
|
||||
}
|
||||
|
||||
// Measure the total height (margins are now part of the DOM)
|
||||
let totalHeight = 0;
|
||||
|
||||
for (const el of pageElements) {
|
||||
const pageEl = el as HTMLElement;
|
||||
const style = getComputedStyle(pageEl);
|
||||
const marginBottom = Number.parseFloat(style.marginBottom) || 0;
|
||||
totalHeight += pageEl.offsetHeight + marginBottom;
|
||||
}
|
||||
|
||||
return Math.max(totalHeight, minPageHeight);
|
||||
},
|
||||
pagePaddingY,
|
||||
pageDimensionsAsPixels[format].height,
|
||||
);
|
||||
} else {
|
||||
// A4/Letter: set fixed page height and add page breaks between pages
|
||||
await page.evaluate((pageHeight: number) => {
|
||||
const root = document.documentElement;
|
||||
const pageElements = document.querySelectorAll("[data-page-index]");
|
||||
const container = document.querySelector(".resume-preview-container") as HTMLElement | null;
|
||||
|
||||
const newHeight = `${pageHeight}px`;
|
||||
if (container) container.style.setProperty("--page-height", newHeight);
|
||||
root.style.setProperty("--page-height", newHeight);
|
||||
|
||||
for (const el of pageElements) {
|
||||
const element = el as HTMLElement;
|
||||
const index = Number.parseInt(element.getAttribute("data-page-index") ?? "0", 10);
|
||||
|
||||
// Force a page break before each page except the first
|
||||
if (index > 0) {
|
||||
element.style.breakBefore = "page";
|
||||
element.style.pageBreakBefore = "always";
|
||||
}
|
||||
|
||||
// Allow content within a page to break naturally if it overflows
|
||||
element.style.breakInside = "auto";
|
||||
}
|
||||
}, pageDimensionsAsPixels[format].height);
|
||||
}
|
||||
|
||||
// Step 6: Generate the PDF with the specified dimensions and margins
|
||||
// For free-form: use measured content height (with minimum constraint)
|
||||
// For A4/Letter: use fixed dimensions from pageDimensionsAsPixels
|
||||
const pdfHeight = isFreeForm && contentHeight ? contentHeight : pageDimensionsAsPixels[format].height;
|
||||
|
||||
const pdfBuffer = await page.pdf({
|
||||
width: `${pageDimensionsAsPixels[format].width}px`,
|
||||
height: `${pdfHeight}px`,
|
||||
tagged: true, // Adds accessibility tags to the PDF
|
||||
waitForFonts: true, // Ensures all fonts are loaded before rendering
|
||||
printBackground: true, // Includes background colors and images
|
||||
margin: {
|
||||
bottom: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Step 7: Upload the generated PDF to storage
|
||||
const result = await uploadFile({
|
||||
userId,
|
||||
resumeId: id,
|
||||
data: new Uint8Array(pdfBuffer),
|
||||
contentType: "application/pdf",
|
||||
type: "pdf",
|
||||
});
|
||||
|
||||
return result.url;
|
||||
} catch (error) {
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to generate PDF", cause: error });
|
||||
} finally {
|
||||
if (page) await page.close().catch(() => null);
|
||||
if (context) await context.close().catch(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures a screenshot of the first page of a resume as WebP.
|
||||
*
|
||||
* Uses a timestamp-based cache (6-hour TTL) to avoid regenerating screenshots
|
||||
* for resumes that haven't changed. Old screenshots are cleaned up on regeneration.
|
||||
*/
|
||||
async function doGetResumeScreenshot(
|
||||
input: Pick<InferSelectModel<typeof schema.resume>, "userId" | "id" | "data" | "updatedAt">,
|
||||
): Promise<string> {
|
||||
const { id, userId, data, updatedAt } = input;
|
||||
|
||||
const storageService = getStorageService();
|
||||
const screenshotPrefix = `uploads/${userId}/screenshots/${id}`;
|
||||
|
||||
const existingScreenshots = await storageService.list(screenshotPrefix);
|
||||
const now = Date.now();
|
||||
const resumeUpdatedAt = updatedAt.getTime();
|
||||
|
||||
if (existingScreenshots.length > 0) {
|
||||
const sortedFiles = existingScreenshots
|
||||
.map((path) => {
|
||||
const filename = path.split("/").pop();
|
||||
const match = filename?.match(/^(\d+)\.webp$/);
|
||||
return match ? { path, timestamp: Number(match[1]) } : null;
|
||||
})
|
||||
.filter((item): item is { path: string; timestamp: number } => item !== null)
|
||||
.sort((a, b) => b.timestamp - a.timestamp);
|
||||
|
||||
if (sortedFiles.length > 0) {
|
||||
const latest = sortedFiles[0];
|
||||
const age = now - latest.timestamp;
|
||||
|
||||
// Return existing screenshot if it's still fresh (within TTL)
|
||||
if (age < SCREENSHOT_TTL) return new URL(latest.path, env.APP_URL).toString();
|
||||
|
||||
// Screenshot is stale (past TTL), but only regenerate if the resume
|
||||
// was updated after the screenshot was taken. If the resume hasn't
|
||||
// changed, keep using the existing screenshot to avoid unnecessary work.
|
||||
if (resumeUpdatedAt <= latest.timestamp) {
|
||||
return new URL(latest.path, env.APP_URL).toString();
|
||||
}
|
||||
|
||||
// Resume was updated after the screenshot - delete old ones and regenerate
|
||||
await Promise.all(sortedFiles.map((file) => storageService.delete(file.path)));
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = env.PRINTER_APP_URL ?? env.APP_URL;
|
||||
const domain = new URL(baseUrl).hostname;
|
||||
|
||||
const locale = data.metadata.page.locale;
|
||||
|
||||
const token = generatePrinterToken(id);
|
||||
const url = `${baseUrl}/printer/${id}?token=${token}`;
|
||||
|
||||
let context: BrowserContext | null = null;
|
||||
let page: Page | null = null;
|
||||
|
||||
try {
|
||||
const browser = await getBrowser();
|
||||
context = await browser.createBrowserContext();
|
||||
|
||||
await context.setCookie({ name: "locale", value: locale, domain });
|
||||
|
||||
page = await context.newPage();
|
||||
|
||||
await page.setViewport(pageDimensionsAsPixels.a4);
|
||||
await page.goto(url, { waitUntil: "networkidle0" });
|
||||
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
|
||||
|
||||
const screenshotBuffer = await page.screenshot({ type: "webp", quality: 80 });
|
||||
|
||||
const result = await uploadFile({
|
||||
userId,
|
||||
resumeId: id,
|
||||
data: new Uint8Array(screenshotBuffer),
|
||||
contentType: "image/webp",
|
||||
type: "screenshot",
|
||||
});
|
||||
|
||||
return result.url;
|
||||
} catch (error) {
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to capture screenshot", cause: error });
|
||||
} finally {
|
||||
if (page) await page.close().catch(() => null);
|
||||
if (context) await context.close().catch(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
export const printerService = {
|
||||
healthcheck: async (): Promise<object> => {
|
||||
@@ -83,300 +384,39 @@ export const printerService = {
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generates a PDF from a resume and uploads it to storage.
|
||||
*
|
||||
* The process:
|
||||
* 1. Clean up any existing PDF for this resume
|
||||
* 2. Navigate to the printer route which renders the resume
|
||||
* 3. Calculate PDF margins (some templates require margins to be applied via PDF)
|
||||
* 4. Adjust CSS variables so content fits within printable area (accounting for margins)
|
||||
* 5. Add page break CSS to ensure each visual resume page becomes a PDF page
|
||||
* 6. Generate the PDF with proper dimensions and margins
|
||||
* 7. Upload to storage and return the URL
|
||||
*/
|
||||
/** Generates a PDF, deduplicating concurrent requests for the same resume. */
|
||||
printResumeAsPDF: async (
|
||||
input: Pick<InferSelectModel<typeof schema.resume>, "id" | "data" | "userId">,
|
||||
): Promise<string> => {
|
||||
const { id, data, userId } = input;
|
||||
const { id } = input;
|
||||
|
||||
// Step 1: Delete any existing PDF for this resume to ensure fresh generation
|
||||
const storageService = getStorageService();
|
||||
const pdfPrefix = `uploads/${userId}/pdfs/${id}`;
|
||||
await storageService.delete(pdfPrefix);
|
||||
// Deduplicate concurrent requests for the same resume
|
||||
const existing = activePrintJobs.get(id);
|
||||
if (existing) return existing;
|
||||
|
||||
// Step 2: Prepare the URL and authentication for the printer route
|
||||
// The printer route renders the resume in a format optimized for PDF generation
|
||||
const baseUrl = env.PRINTER_APP_URL ?? env.APP_URL;
|
||||
const domain = new URL(baseUrl).hostname;
|
||||
const job = doPrintResumeAsPDF(input).finally(() => {
|
||||
activePrintJobs.delete(id);
|
||||
});
|
||||
|
||||
const format = data.metadata.page.format;
|
||||
const locale = data.metadata.page.locale;
|
||||
const template = data.metadata.template;
|
||||
|
||||
// Generate a secure token to authenticate the printer request
|
||||
const token = generatePrinterToken(id);
|
||||
const url = `${baseUrl}/printer/${id}?token=${token}`;
|
||||
|
||||
// Step 3: Calculate print paddings for templates that disable CSS padding in print mode.
|
||||
// We render these margins inside the page (not via Puppeteer's PDF margins), so the margin
|
||||
// area matches the resume background color instead of staying white.
|
||||
let pagePaddingX = 0;
|
||||
let pagePaddingY = 0;
|
||||
|
||||
if (printMarginTemplates.includes(template)) {
|
||||
pagePaddingX = data.metadata.page.marginX;
|
||||
pagePaddingY = data.metadata.page.marginY;
|
||||
}
|
||||
|
||||
let browser: Browser | null = null;
|
||||
let page: Page | null = null;
|
||||
|
||||
try {
|
||||
// Step 4: Connect to the browser and navigate to the printer route
|
||||
browser = await getBrowser();
|
||||
|
||||
// Set locale cookie so the resume renders in the correct language
|
||||
await browser.setCookie({ name: "locale", value: locale, domain });
|
||||
|
||||
page = await browser.newPage();
|
||||
|
||||
// Wait for the page to fully load (network idle + custom loaded attribute)
|
||||
await page.emulateMediaType("print");
|
||||
await page.setViewport(pageDimensionsAsPixels[format]);
|
||||
await page.goto(url, { waitUntil: "networkidle0" });
|
||||
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
|
||||
|
||||
// Step 5: Adjust the DOM for proper PDF pagination
|
||||
// This runs in the browser context to modify CSS before PDF generation
|
||||
// For free-form: measure actual content height, don't add page breaks
|
||||
// For A4/Letter: adjust page height for margins, add page breaks
|
||||
const isFreeForm = format === "free-form";
|
||||
|
||||
const contentHeight = await page.evaluate(
|
||||
(
|
||||
pagePaddingX: number,
|
||||
pagePaddingY: number,
|
||||
isFreeForm: boolean,
|
||||
minPageHeight: number,
|
||||
backgroundColor: string,
|
||||
) => {
|
||||
const root = document.documentElement;
|
||||
const body = document.body;
|
||||
const pageElements = document.querySelectorAll("[data-page-index]");
|
||||
const pageContentElements = document.querySelectorAll(".page-content");
|
||||
const container = document.querySelector(".resume-preview-container") as HTMLElement | null;
|
||||
|
||||
// Ensure PDF margins inherit the resume background color instead of defaulting to white.
|
||||
root.style.backgroundColor = backgroundColor;
|
||||
body.style.backgroundColor = backgroundColor;
|
||||
root.style.margin = "0";
|
||||
body.style.margin = "0";
|
||||
root.style.padding = "0";
|
||||
body.style.padding = "0";
|
||||
|
||||
for (const el of pageElements) {
|
||||
const pageWrapper = el as HTMLElement;
|
||||
const pageSurface = pageWrapper.querySelector(".page") as HTMLElement | null;
|
||||
|
||||
pageWrapper.style.backgroundColor = backgroundColor;
|
||||
pageWrapper.style.breakInside = "auto";
|
||||
|
||||
if (pageSurface) pageSurface.style.backgroundColor = backgroundColor;
|
||||
}
|
||||
|
||||
// Apply print-only margins as padding inside each page's content surface.
|
||||
if (pagePaddingX > 0 || pagePaddingY > 0) {
|
||||
for (const el of pageContentElements) {
|
||||
const pageContent = el as HTMLElement;
|
||||
|
||||
pageContent.style.boxSizing = "border-box";
|
||||
// Ensure padding is repeated on every printed fragment when content
|
||||
// flows across physical PDF pages (not just the first fragment).
|
||||
pageContent.style.boxDecorationBreak = "clone";
|
||||
pageContent.style.setProperty("-webkit-box-decoration-break", "clone");
|
||||
if (pagePaddingX > 0) {
|
||||
pageContent.style.paddingLeft = `${pagePaddingX}pt`;
|
||||
pageContent.style.paddingRight = `${pagePaddingX}pt`;
|
||||
}
|
||||
if (pagePaddingY > 0) {
|
||||
pageContent.style.paddingTop = `${pagePaddingY}pt`;
|
||||
pageContent.style.paddingBottom = `${pagePaddingY}pt`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isFreeForm) {
|
||||
const numberOfPages = pageElements.length;
|
||||
|
||||
// Add margin between pages (except the last one)
|
||||
for (let i = 0; i < numberOfPages - 1; i++) {
|
||||
const pageEl = pageElements[i] as HTMLElement;
|
||||
if (pagePaddingY > 0) pageEl.style.marginBottom = `${pagePaddingY}pt`;
|
||||
}
|
||||
|
||||
// Now measure the total height (margins are now part of the DOM)
|
||||
let totalHeight = 0;
|
||||
|
||||
for (const el of pageElements) {
|
||||
const pageEl = el as HTMLElement;
|
||||
// offsetHeight includes padding and border, but not margin
|
||||
const style = getComputedStyle(pageEl);
|
||||
const marginBottom = Number.parseFloat(style.marginBottom) || 0;
|
||||
totalHeight += pageEl.offsetHeight + marginBottom;
|
||||
}
|
||||
|
||||
return Math.max(totalHeight, minPageHeight);
|
||||
}
|
||||
|
||||
// For A4/Letter
|
||||
const heightValue = minPageHeight;
|
||||
|
||||
// Keep page height fixed and let in-page padding (if any) define content bounds.
|
||||
const newHeight = `${heightValue}px`;
|
||||
if (container) container.style.setProperty("--page-height", newHeight);
|
||||
root.style.setProperty("--page-height", newHeight);
|
||||
|
||||
// Add page break CSS to each resume page element (identified by data-page-index attribute)
|
||||
// This ensures each visual resume page starts a new PDF page
|
||||
for (const el of pageElements) {
|
||||
const element = el as HTMLElement;
|
||||
const index = Number.parseInt(element.getAttribute("data-page-index") ?? "0", 10);
|
||||
|
||||
// Force a page break before each page except the first
|
||||
if (index > 0) {
|
||||
element.style.breakBefore = "page";
|
||||
element.style.pageBreakBefore = "always";
|
||||
}
|
||||
|
||||
// Allow content within a page to break naturally if it overflows
|
||||
// (e.g., if a single page has more content than fits on one PDF page)
|
||||
element.style.breakInside = "auto";
|
||||
}
|
||||
|
||||
return null; // Fixed height from pageDimensionsAsPixels for A4/Letter
|
||||
},
|
||||
pagePaddingX,
|
||||
pagePaddingY,
|
||||
isFreeForm,
|
||||
pageDimensionsAsPixels[format].height,
|
||||
data.metadata.design.colors.background,
|
||||
);
|
||||
|
||||
// Step 6: Generate the PDF with the specified dimensions and margins
|
||||
// For free-form: use measured content height (with minimum constraint)
|
||||
// For A4/Letter: use fixed dimensions from pageDimensionsAsPixels
|
||||
const pdfHeight = isFreeForm && contentHeight ? contentHeight : pageDimensionsAsPixels[format].height;
|
||||
|
||||
const pdfBuffer = await page.pdf({
|
||||
width: `${pageDimensionsAsPixels[format].width}px`,
|
||||
height: `${pdfHeight}px`,
|
||||
tagged: true, // Adds accessibility tags to the PDF
|
||||
waitForFonts: true, // Ensures all fonts are loaded before rendering
|
||||
printBackground: true, // Includes background colors and images
|
||||
margin: {
|
||||
bottom: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Step 7: Upload the generated PDF to storage
|
||||
const result = await uploadFile({
|
||||
userId,
|
||||
resumeId: id,
|
||||
data: new Uint8Array(pdfBuffer),
|
||||
contentType: "application/pdf",
|
||||
type: "pdf",
|
||||
});
|
||||
|
||||
return result.url;
|
||||
} catch (error) {
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR", error as Error);
|
||||
} finally {
|
||||
if (page) await page.close().catch(() => null);
|
||||
}
|
||||
activePrintJobs.set(id, job);
|
||||
return job;
|
||||
},
|
||||
|
||||
/** Captures a resume screenshot, deduplicating concurrent requests for the same resume. */
|
||||
getResumeScreenshot: async (
|
||||
input: Pick<InferSelectModel<typeof schema.resume>, "userId" | "id" | "data" | "updatedAt">,
|
||||
): Promise<string> => {
|
||||
const { id, userId, data, updatedAt } = input;
|
||||
const { id } = input;
|
||||
|
||||
const storageService = getStorageService();
|
||||
const screenshotPrefix = `uploads/${userId}/screenshots/${id}`;
|
||||
// Deduplicate concurrent requests for the same resume
|
||||
const existing = activeScreenshotJobs.get(id);
|
||||
if (existing) return existing;
|
||||
|
||||
const existingScreenshots = await storageService.list(screenshotPrefix);
|
||||
const now = Date.now();
|
||||
const resumeUpdatedAt = updatedAt.getTime();
|
||||
const job = doGetResumeScreenshot(input).finally(() => {
|
||||
activeScreenshotJobs.delete(id);
|
||||
});
|
||||
|
||||
if (existingScreenshots.length > 0) {
|
||||
const sortedFiles = existingScreenshots
|
||||
.map((path) => {
|
||||
const filename = path.split("/").pop();
|
||||
const match = filename?.match(/^(\d+)\.webp$/);
|
||||
return match ? { path, timestamp: Number(match[1]) } : null;
|
||||
})
|
||||
.filter((item): item is { path: string; timestamp: number } => item !== null)
|
||||
.sort((a, b) => b.timestamp - a.timestamp);
|
||||
|
||||
if (sortedFiles.length > 0) {
|
||||
const latest = sortedFiles[0];
|
||||
const age = now - latest.timestamp;
|
||||
|
||||
// Return existing screenshot if it's still fresh (within TTL)
|
||||
if (age < SCREENSHOT_TTL) return new URL(latest.path, env.APP_URL).toString();
|
||||
|
||||
// Screenshot is stale (past TTL), but only regenerate if the resume
|
||||
// was updated after the screenshot was taken. If the resume hasn't
|
||||
// changed, keep using the existing screenshot to avoid unnecessary work.
|
||||
if (resumeUpdatedAt <= latest.timestamp) {
|
||||
return new URL(latest.path, env.APP_URL).toString();
|
||||
}
|
||||
|
||||
// Resume was updated after the screenshot - delete old ones and regenerate
|
||||
await Promise.all(sortedFiles.map((file) => storageService.delete(file.path)));
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = env.PRINTER_APP_URL ?? env.APP_URL;
|
||||
const domain = new URL(baseUrl).hostname;
|
||||
|
||||
const locale = data.metadata.page.locale;
|
||||
|
||||
const token = generatePrinterToken(id);
|
||||
const url = `${baseUrl}/printer/${id}?token=${token}`;
|
||||
|
||||
let browser: Browser | null = null;
|
||||
let page: Page | null = null;
|
||||
|
||||
try {
|
||||
browser = await getBrowser();
|
||||
|
||||
await browser.setCookie({ name: "locale", value: locale, domain });
|
||||
|
||||
page = await browser.newPage();
|
||||
|
||||
await page.setViewport(pageDimensionsAsPixels.a4);
|
||||
await page.goto(url, { waitUntil: "networkidle0" });
|
||||
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
|
||||
|
||||
const screenshotBuffer = await page.screenshot({ type: "webp", quality: 80 });
|
||||
|
||||
const result = await uploadFile({
|
||||
userId,
|
||||
resumeId: id,
|
||||
data: new Uint8Array(screenshotBuffer),
|
||||
contentType: "image/webp",
|
||||
type: "screenshot",
|
||||
});
|
||||
|
||||
return result.url;
|
||||
} catch (error) {
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR", error as Error);
|
||||
} finally {
|
||||
if (page) await page.close().catch(() => null);
|
||||
}
|
||||
activeScreenshotJobs.set(id, job);
|
||||
return job;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -269,7 +269,8 @@ export const resumeService = {
|
||||
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
|
||||
}
|
||||
|
||||
throw error;
|
||||
console.error("Failed to create resume:", error);
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to create resume" });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -325,7 +326,8 @@ export const resumeService = {
|
||||
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
|
||||
}
|
||||
|
||||
throw error;
|
||||
console.error("Failed to update resume:", error);
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to update resume" });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -426,26 +428,23 @@ export const resumeService = {
|
||||
},
|
||||
|
||||
delete: async (input: { id: string; userId: string }) => {
|
||||
const [resume] = await db
|
||||
.select({ isLocked: schema.resume.isLocked })
|
||||
.from(schema.resume)
|
||||
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
|
||||
await db.transaction(async (tx) => {
|
||||
const [resume] = await tx
|
||||
.select({ isLocked: schema.resume.isLocked })
|
||||
.from(schema.resume)
|
||||
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
|
||||
|
||||
if (!resume) throw new ORPCError("NOT_FOUND");
|
||||
if (resume.isLocked) throw new ORPCError("RESUME_LOCKED");
|
||||
if (!resume) throw new ORPCError("NOT_FOUND");
|
||||
if (resume.isLocked) throw new ORPCError("RESUME_LOCKED");
|
||||
|
||||
await tx.delete(schema.resume).where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
|
||||
});
|
||||
|
||||
// Clean up storage files after the DB transaction succeeds
|
||||
const storageService = getStorageService();
|
||||
|
||||
const deleteResumePromise = db
|
||||
.delete(schema.resume)
|
||||
.where(
|
||||
and(eq(schema.resume.id, input.id), eq(schema.resume.isLocked, false), eq(schema.resume.userId, input.userId)),
|
||||
);
|
||||
|
||||
// Delete screenshots and PDFs using the new key format
|
||||
const deleteScreenshotsPromise = storageService.delete(`uploads/${input.userId}/screenshots/${input.id}`);
|
||||
const deletePdfsPromise = storageService.delete(`uploads/${input.userId}/pdfs/${input.id}`);
|
||||
|
||||
await Promise.allSettled([deleteResumePromise, deleteScreenshotsPromise, deletePdfsPromise]);
|
||||
await Promise.allSettled([
|
||||
storageService.delete(`uploads/${input.userId}/screenshots/${input.id}`),
|
||||
storageService.delete(`uploads/${input.userId}/pdfs/${input.id}`),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -70,14 +70,13 @@ export function FAQ() {
|
||||
>
|
||||
<motion.h2
|
||||
className={cn(
|
||||
"flex-1 text-2xl font-semibold tracking-tight md:text-4xl xl:text-5xl",
|
||||
"flex-1 text-2xl font-semibold tracking-tight will-change-[transform,opacity] md:text-4xl xl:text-5xl",
|
||||
"flex shrink-0 flex-wrap items-center gap-x-1.5 lg:flex-col lg:items-start",
|
||||
)}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.45 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Trans context="Every word needs to be wrapped in a tag">
|
||||
<span>Frequently</span>
|
||||
@@ -87,12 +86,11 @@ export function FAQ() {
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
className="max-w-2xl flex-2 lg:ml-auto 2xl:max-w-3xl"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.45, delay: 0.08 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="max-w-2xl flex-2 will-change-[transform,opacity] lg:ml-auto 2xl:max-w-3xl"
|
||||
>
|
||||
<Accordion multiple>
|
||||
{faqItems.map((item, index) => (
|
||||
@@ -112,12 +110,11 @@ type FAQItemComponentProps = {
|
||||
function FAQItemComponent({ item, index }: FAQItemComponentProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className="last:border-b"
|
||||
className="will-change-[transform,opacity] last:border-b"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.24, delay: Math.min(0.16, index * 0.03) }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<AccordionItem value={item.question} className="group border-t">
|
||||
<AccordionTrigger className="py-5">{item.question}</AccordionTrigger>
|
||||
|
||||
@@ -136,7 +136,7 @@ function FeatureCard({ icon: Icon, title, description }: FeatureCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"group relative flex min-h-48 flex-col gap-4 overflow-hidden border-b bg-background p-6 transition-[background-color] duration-300",
|
||||
"group relative flex min-h-48 flex-col gap-4 overflow-hidden border-b bg-background p-6 transition-[background-color] duration-300 will-change-[transform,opacity]",
|
||||
"not-nth-[2n]:border-r xl:not-nth-[4n]:border-r",
|
||||
"hover:bg-secondary/30",
|
||||
)}
|
||||
@@ -146,7 +146,6 @@ function FeatureCard({ icon: Icon, title, description }: FeatureCardProps) {
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
whileHover={{ y: -3, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.995 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
{/* Hover gradient overlay */}
|
||||
<div
|
||||
@@ -177,12 +176,11 @@ export function Features() {
|
||||
<section id="features">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
className="space-y-4 p-4 md:p-8 xl:py-16"
|
||||
className="space-y-4 p-4 will-change-[transform,opacity] md:p-8 xl:py-16"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.45 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<h2 className="text-2xl font-semibold tracking-tight md:text-4xl xl:text-5xl">
|
||||
<Trans>Features</Trans>
|
||||
|
||||
@@ -50,12 +50,11 @@ export function Footer() {
|
||||
return (
|
||||
<motion.footer
|
||||
id="footer"
|
||||
className="p-4 pb-8 md:p-8 md:pb-12"
|
||||
className="p-4 pb-8 will-change-[opacity] md:p-8 md:pb-12"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.45 }}
|
||||
style={{ willChange: "opacity" }}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Brand Column */}
|
||||
@@ -143,8 +142,7 @@ function FooterLink({ url, label }: FooterLinkItem) {
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={isHovered ? { width: "100%", opacity: 1 } : { width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="pointer-events-none absolute inset-s-0 -bottom-0.5 h-px rounded-md bg-primary"
|
||||
style={{ willChange: "width, opacity" }}
|
||||
className="pointer-events-none absolute inset-s-0 -bottom-0.5 h-px rounded-md bg-primary will-change-[width,opacity]"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -18,10 +18,10 @@ export function Hero() {
|
||||
<Spotlight />
|
||||
|
||||
<motion.div
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: 100 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1.1, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<CometCard glareOpacity={0} className="3xl:max-w-7xl relative -mb-12 max-w-4xl px-8 md:-mb-24 md:px-12 lg:px-0">
|
||||
<video
|
||||
@@ -46,12 +46,12 @@ export function Hero() {
|
||||
<div className="xs:px-0 relative z-10 flex max-w-2xl flex-col items-center gap-y-6 px-4 text-center">
|
||||
{/* Badge */}
|
||||
<motion.a
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.45, delay: 0.55 }}
|
||||
whileHover={{ y: -2, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.985 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.rxresu.me/getting-started"
|
||||
@@ -64,10 +64,10 @@ export function Hero() {
|
||||
|
||||
{/* Headline */}
|
||||
<motion.div
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.45, delay: 0.7 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Trans>
|
||||
<p className="font-medium tracking-tight text-muted-foreground md:text-lg">Finally,</p>
|
||||
@@ -79,11 +79,10 @@ export function Hero() {
|
||||
|
||||
{/* Description */}
|
||||
<motion.p
|
||||
className="max-w-xl text-base leading-relaxed text-muted-foreground md:text-lg"
|
||||
className="max-w-xl text-base leading-relaxed text-muted-foreground will-change-[transform,opacity] md:text-lg"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.45, delay: 0.82 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Trans>
|
||||
Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating,
|
||||
@@ -93,11 +92,10 @@ export function Hero() {
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<motion.div
|
||||
className="flex flex-col items-center gap-3 sm:flex-row sm:gap-4"
|
||||
className="flex flex-col items-center gap-3 will-change-[transform,opacity] sm:flex-row sm:gap-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.45, delay: 0.95 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
@@ -144,10 +142,9 @@ export function Hero() {
|
||||
transition={{ delay: 1.25, duration: 0.7 }}
|
||||
>
|
||||
<motion.div
|
||||
className="flex h-8 w-5 items-start justify-center rounded-full border border-muted-foreground/30 p-1.5"
|
||||
className="flex h-8 w-5 items-start justify-center rounded-full border border-muted-foreground/30 p-1.5 will-change-transform"
|
||||
animate={{ y: [0, 5, 0] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<motion.div className="h-1.5 w-1 rounded-full bg-muted-foreground/50" />
|
||||
</motion.div>
|
||||
|
||||
@@ -16,12 +16,11 @@ export function Prefooter() {
|
||||
<TextMaskEffect aria-hidden="true" text="Reactive Resume" className="hidden md:block" />
|
||||
|
||||
<motion.div
|
||||
className="mx-auto max-w-3xl space-y-8 px-6 text-center md:px-8 xl:px-0"
|
||||
className="mx-auto max-w-3xl space-y-8 px-6 text-center will-change-[transform,opacity] md:px-8 xl:px-0"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.45 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<h2 className="text-2xl font-bold tracking-tight md:text-4xl">
|
||||
<Trans>By the community, for the community.</Trans>
|
||||
|
||||
@@ -13,12 +13,11 @@ type TemplateItemProps = {
|
||||
function TemplateItem({ metadata }: TemplateItemProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className="group relative shrink-0"
|
||||
className="group relative shrink-0 will-change-transform"
|
||||
initial={{ scale: 1, zIndex: 10 }}
|
||||
whileHover={{ scale: 1.06, zIndex: 20 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 26 }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<div className="relative aspect-page w-48 overflow-hidden rounded-md border bg-card shadow-lg transition-all duration-300 group-hover:shadow-2xl sm:w-56 md:w-64 lg:w-72">
|
||||
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
|
||||
@@ -86,12 +85,11 @@ export function Templates() {
|
||||
return (
|
||||
<section id="templates" className="overflow-hidden border-t-0! p-4 md:p-8 xl:py-16">
|
||||
<motion.div
|
||||
className="space-y-4"
|
||||
className="space-y-4 will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.35 }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<h2 className="text-2xl font-semibold tracking-tight md:text-4xl xl:text-5xl">
|
||||
<Trans>Templates</Trans>
|
||||
|
||||
@@ -35,12 +35,11 @@ type TestimonialCardProps = {
|
||||
function TestimonialCard({ testimonial }: TestimonialCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className="group relative w-[320px] shrink-0 sm:w-[360px] md:w-[400px]"
|
||||
className="group relative w-[320px] shrink-0 will-change-transform sm:w-[360px] md:w-[400px]"
|
||||
initial={{ scale: 1 }}
|
||||
whileHover={{ y: -3, scale: 1.02 }}
|
||||
whileTap={{ scale: 0.995 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 24 }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<div className="relative flex h-full flex-col rounded-md border bg-card p-5 shadow-sm transition-shadow duration-300 group-hover:shadow-xl">
|
||||
<p className="flex-1 leading-relaxed text-muted-foreground">"{testimonial}"</p>
|
||||
|
||||
@@ -1,18 +1,55 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { GithubLogoIcon, GoogleLogoIcon, VaultIcon } from "@phosphor-icons/react";
|
||||
import { GithubLogoIcon, GoogleLogoIcon, LinkedinLogoIcon, VaultIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { RouterOutput } from "@/integrations/orpc/client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
export function SocialAuth() {
|
||||
const { data: providers = {}, isLoading } = useQuery(orpc.auth.providers.list.queryOptions());
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<hr className="flex-1" />
|
||||
<span className="text-xs font-medium tracking-wide">
|
||||
<Trans context="Choose to authenticate with a social provider (Google, GitHub, etc.) instead of email and password">
|
||||
or continue with
|
||||
</Trans>
|
||||
</span>
|
||||
<hr className="flex-1" />
|
||||
</div>
|
||||
|
||||
{isLoading ? <SocialAuthSkeleton /> : <SocialAuthButtons providers={providers} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SocialAuthSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SocialAuthButtonsProps = {
|
||||
providers: RouterOutput["auth"]["providers"]["list"];
|
||||
};
|
||||
|
||||
function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
const router = useRouter();
|
||||
const { data: authProviders = {} } = useQuery(orpc.auth.providers.list.queryOptions());
|
||||
|
||||
const handleSocialLogin = async (provider: string) => {
|
||||
const toastId = toast.loading(t`Signing in...`);
|
||||
@@ -49,51 +86,48 @@ export function SocialAuth() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<hr className="flex-1" />
|
||||
<span className="text-xs font-medium tracking-wide">
|
||||
<Trans context="Choose to authenticate with a social provider (Google, GitHub, etc.) instead of email and password">
|
||||
or continue with
|
||||
</Trans>
|
||||
</span>
|
||||
<hr className="flex-1" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleOAuthLogin}
|
||||
className={cn("hidden", "custom" in providers && "inline-flex")}
|
||||
>
|
||||
<VaultIcon />
|
||||
{providers.custom}
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleOAuthLogin}
|
||||
className={cn("hidden", "custom" in authProviders && "inline-flex")}
|
||||
>
|
||||
<VaultIcon />
|
||||
{authProviders.custom}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleSocialLogin("google")}
|
||||
className={cn(
|
||||
"hidden flex-1 bg-[#4285F4] text-white hover:bg-[#4285F4]/80",
|
||||
"google" in providers && "inline-flex",
|
||||
)}
|
||||
>
|
||||
<GoogleLogoIcon />
|
||||
Google
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleSocialLogin("google")}
|
||||
className={cn(
|
||||
"hidden flex-1 bg-[#4285F4] text-white hover:bg-[#4285F4]/80",
|
||||
"google" in authProviders && "inline-flex",
|
||||
)}
|
||||
>
|
||||
<GoogleLogoIcon />
|
||||
Google
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleSocialLogin("github")}
|
||||
className={cn(
|
||||
"hidden flex-1 bg-[#2b3137] text-white hover:bg-[#2b3137]/80",
|
||||
"github" in providers && "inline-flex",
|
||||
)}
|
||||
>
|
||||
<GithubLogoIcon />
|
||||
GitHub
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleSocialLogin("github")}
|
||||
className={cn(
|
||||
"hidden flex-1 bg-[#2b3137] text-white hover:bg-[#2b3137]/80",
|
||||
"github" in authProviders && "inline-flex",
|
||||
)}
|
||||
>
|
||||
<GithubLogoIcon />
|
||||
GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<Button
|
||||
onClick={() => handleSocialLogin("linkedin")}
|
||||
className={cn(
|
||||
"hidden flex-1 bg-[#0A66C2] text-white hover:bg-[#0A66C2]/80",
|
||||
"linkedin" in providers && "inline-flex",
|
||||
)}
|
||||
>
|
||||
<LinkedinLogoIcon />
|
||||
LinkedIn
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,8 +111,7 @@ export function BuilderDock() {
|
||||
animate={{ opacity: 0.6, y: 0 }}
|
||||
whileHover={{ opacity: 1, y: -2, scale: 1.01 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="flex items-center rounded-l-full rounded-r-full bg-popover px-2 shadow-xl"
|
||||
className="flex items-center rounded-l-full rounded-r-full bg-popover px-2 shadow-xl will-change-[transform,opacity]"
|
||||
>
|
||||
<DockIcon
|
||||
disabled={!canUndo}
|
||||
@@ -166,10 +165,10 @@ function DockIcon({ icon: Icon, title, disabled, onClick, iconClassName }: DockI
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<motion.div
|
||||
className="will-change-transform"
|
||||
whileHover={disabled ? undefined : { y: -1, scale: 1.04 }}
|
||||
whileTap={disabled ? undefined : { scale: 0.97 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button size="icon" variant="ghost" disabled={disabled} onClick={onClick}>
|
||||
<Icon className={cn("size-4", iconClassName)} />
|
||||
|
||||
@@ -245,8 +245,7 @@ export function SectionItem<T extends CustomSectionItem | SectionItemType>({
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="group relative flex h-18 border-b select-none"
|
||||
className="group relative flex h-18 border-b will-change-[transform,opacity] select-none"
|
||||
>
|
||||
<div
|
||||
className="flex cursor-ns-resize touch-none items-center px-1.5 opacity-40 transition-[background-color,opacity] group-hover:opacity-100 hover:bg-secondary/40"
|
||||
|
||||
@@ -192,8 +192,7 @@ function QuickColorCircle({ color, active, onSelect, className, ...props }: Quic
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
className="absolute inset-0 flex size-8 items-center justify-center"
|
||||
className="absolute inset-0 flex size-8 items-center justify-center will-change-transform"
|
||||
>
|
||||
<div className="size-4 rounded-md bg-foreground" />
|
||||
</motion.div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { usePanelRef } from "react-resizable-panels";
|
||||
import type { Layout, usePanelRef } from "react-resizable-panels";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useWindowSize } from "usehooks-ts";
|
||||
@@ -8,12 +8,61 @@ import { useIsMobile } from "@/hooks/use-mobile";
|
||||
|
||||
type PanelImperativeHandle = ReturnType<typeof usePanelRef>;
|
||||
|
||||
export const BUILDER_LAYOUT_COOKIE_NAME = "builder_layout";
|
||||
|
||||
export type BuilderLayout = {
|
||||
left: number;
|
||||
artboard: number;
|
||||
right: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_BUILDER_LAYOUT: BuilderLayout = {
|
||||
left: 22,
|
||||
artboard: 56,
|
||||
right: 22,
|
||||
};
|
||||
|
||||
export const mapPanelLayoutToBuilderLayout = (layout: Layout): BuilderLayout => {
|
||||
const left = layout.left;
|
||||
const artboard = layout.artboard;
|
||||
const right = layout.right;
|
||||
|
||||
if (typeof left !== "number" || typeof artboard !== "number" || typeof right !== "number")
|
||||
return DEFAULT_BUILDER_LAYOUT;
|
||||
|
||||
return { left, artboard, right };
|
||||
};
|
||||
|
||||
export const parseBuilderLayoutCookie = (value?: string | null): BuilderLayout => {
|
||||
if (!value) return DEFAULT_BUILDER_LAYOUT;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
|
||||
if (Array.isArray(parsed)) return DEFAULT_BUILDER_LAYOUT;
|
||||
if (typeof parsed !== "object" || parsed === null) return DEFAULT_BUILDER_LAYOUT;
|
||||
|
||||
const left = (parsed as { left?: unknown }).left;
|
||||
const artboard = (parsed as { artboard?: unknown }).artboard;
|
||||
const right = (parsed as { right?: unknown }).right;
|
||||
|
||||
if (typeof left !== "number" || typeof artboard !== "number" || typeof right !== "number")
|
||||
return DEFAULT_BUILDER_LAYOUT;
|
||||
|
||||
return { left, artboard, right };
|
||||
} catch {
|
||||
return DEFAULT_BUILDER_LAYOUT;
|
||||
}
|
||||
};
|
||||
|
||||
interface BuilderSidebarState {
|
||||
layout: BuilderLayout;
|
||||
leftSidebar: PanelImperativeHandle | null;
|
||||
rightSidebar: PanelImperativeHandle | null;
|
||||
}
|
||||
|
||||
interface BuilderSidebarActions {
|
||||
setLayout: (layout: BuilderLayout) => void;
|
||||
setLeftSidebar: (ref: PanelImperativeHandle | null) => void;
|
||||
setRightSidebar: (ref: PanelImperativeHandle | null) => void;
|
||||
}
|
||||
@@ -21,9 +70,10 @@ interface BuilderSidebarActions {
|
||||
type BuilderSidebar = BuilderSidebarState & BuilderSidebarActions;
|
||||
|
||||
export const useBuilderSidebarStore = create<BuilderSidebar>((set) => ({
|
||||
isDragging: false,
|
||||
layout: DEFAULT_BUILDER_LAYOUT,
|
||||
leftSidebar: null,
|
||||
rightSidebar: null,
|
||||
setLayout: (layout) => set({ layout }),
|
||||
setLeftSidebar: (ref) => set({ leftSidebar: ref }),
|
||||
setRightSidebar: (ref) => set({ rightSidebar: ref }),
|
||||
}));
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type React from "react";
|
||||
import type { Layout } from "react-resizable-panels";
|
||||
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { getCookie, setCookie } from "@tanstack/react-start/server";
|
||||
import { useEffect } from "react";
|
||||
import { type Layout, usePanelRef } from "react-resizable-panels";
|
||||
import { useDebounceCallback } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePanelRef } from "react-resizable-panels";
|
||||
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
import { useCSSVariables } from "@/components/resume/hooks/use-css-variables";
|
||||
@@ -19,7 +18,15 @@ import { orpc } from "@/integrations/orpc/client";
|
||||
import { BuilderHeader } from "./-components/header";
|
||||
import { BuilderSidebarLeft } from "./-sidebar/left";
|
||||
import { BuilderSidebarRight } from "./-sidebar/right";
|
||||
import { useBuilderSidebar, useBuilderSidebarStore } from "./-store/sidebar";
|
||||
import {
|
||||
BUILDER_LAYOUT_COOKIE_NAME,
|
||||
DEFAULT_BUILDER_LAYOUT,
|
||||
mapPanelLayoutToBuilderLayout,
|
||||
parseBuilderLayoutCookie,
|
||||
useBuilderSidebar,
|
||||
useBuilderSidebarStore,
|
||||
type BuilderLayout,
|
||||
} from "./-store/sidebar";
|
||||
|
||||
export const Route = createFileRoute("/builder/$resumeId")({
|
||||
component: RouteComponent,
|
||||
@@ -61,26 +68,36 @@ function RouteComponent() {
|
||||
}
|
||||
|
||||
type BuilderLayoutProps = React.ComponentProps<"div"> & {
|
||||
initialLayout: Layout;
|
||||
initialLayout: BuilderLayout;
|
||||
};
|
||||
|
||||
function BuilderLayout({ initialLayout, ...props }: BuilderLayoutProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const canPersistLayoutRef = useRef(false);
|
||||
|
||||
const leftSidebarRef = usePanelRef();
|
||||
const rightSidebarRef = usePanelRef();
|
||||
|
||||
const setLeftSidebar = useBuilderSidebarStore((state) => state.setLeftSidebar);
|
||||
const setRightSidebar = useBuilderSidebarStore((state) => state.setRightSidebar);
|
||||
const setLayout = useBuilderSidebarStore((state) => state.setLayout);
|
||||
|
||||
const { maxSidebarSize, collapsedSidebarSize } = useBuilderSidebar((state) => ({
|
||||
maxSidebarSize: state.maxSidebarSize,
|
||||
collapsedSidebarSize: state.collapsedSidebarSize,
|
||||
}));
|
||||
|
||||
const onLayoutChange = useDebounceCallback((layout: Layout) => {
|
||||
void setBuilderLayoutServerFn({ data: layout });
|
||||
}, 200);
|
||||
useEffect(() => {
|
||||
setLayout(initialLayout);
|
||||
canPersistLayoutRef.current = true;
|
||||
}, [initialLayout, setLayout]);
|
||||
|
||||
const onLayoutChanged = (layout: Layout) => {
|
||||
const nextLayout = mapPanelLayoutToBuilderLayout(layout);
|
||||
if (!canPersistLayoutRef.current) return;
|
||||
setLayout(nextLayout);
|
||||
void setBuilderLayoutServerFn({ data: nextLayout });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!leftSidebarRef || !rightSidebarRef) return;
|
||||
@@ -89,22 +106,24 @@ function BuilderLayout({ initialLayout, ...props }: BuilderLayoutProps) {
|
||||
setRightSidebar(rightSidebarRef);
|
||||
}, [leftSidebarRef, rightSidebarRef, setLeftSidebar, setRightSidebar]);
|
||||
|
||||
const leftSidebarSize = isMobile ? 0 : initialLayout.left;
|
||||
const rightSidebarSize = isMobile ? 0 : initialLayout.right;
|
||||
const artboardSize = isMobile ? 100 : initialLayout.artboard;
|
||||
const sidebarMinSize = isMobile ? "0%" : `${collapsedSidebarSize * 2}px`;
|
||||
const sidebarCollapsedSize = isMobile ? "0%" : `${collapsedSidebarSize}px`;
|
||||
const leftSidebarSize = isMobile ? "0%" : `${initialLayout.left}%`;
|
||||
const rightSidebarSize = isMobile ? "0%" : `${initialLayout.right}%`;
|
||||
const artboardSize = isMobile ? "100%" : `${initialLayout.artboard}%`;
|
||||
|
||||
return (
|
||||
<div className="flex h-svh flex-col" {...props}>
|
||||
<BuilderHeader />
|
||||
|
||||
<ResizableGroup orientation="horizontal" className="mt-14 flex-1" onLayoutChange={onLayoutChange}>
|
||||
<ResizableGroup orientation="horizontal" className="mt-14 flex-1" onLayoutChanged={onLayoutChanged}>
|
||||
<ResizablePanel
|
||||
collapsible
|
||||
id="left"
|
||||
panelRef={leftSidebarRef}
|
||||
maxSize={maxSidebarSize}
|
||||
minSize={collapsedSidebarSize * 2}
|
||||
collapsedSize={collapsedSidebarSize}
|
||||
minSize={sidebarMinSize}
|
||||
collapsedSize={sidebarCollapsedSize}
|
||||
defaultSize={leftSidebarSize}
|
||||
className="z-20 h-[calc(100svh-3.5rem)]"
|
||||
>
|
||||
@@ -120,8 +139,8 @@ function BuilderLayout({ initialLayout, ...props }: BuilderLayoutProps) {
|
||||
id="right"
|
||||
panelRef={rightSidebarRef}
|
||||
maxSize={maxSidebarSize}
|
||||
minSize={collapsedSidebarSize * 2}
|
||||
collapsedSize={collapsedSidebarSize}
|
||||
minSize={sidebarMinSize}
|
||||
collapsedSize={sidebarCollapsedSize}
|
||||
defaultSize={rightSidebarSize}
|
||||
className="z-20 h-[calc(100svh-3.5rem)]"
|
||||
>
|
||||
@@ -132,19 +151,14 @@ function BuilderLayout({ initialLayout, ...props }: BuilderLayoutProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const defaultLayout = { left: 30, artboard: 40, right: 30 };
|
||||
const BUILDER_LAYOUT_COOKIE_NAME = "builder_layout";
|
||||
|
||||
const layoutSchema = z.record(z.string(), z.number()).catch(defaultLayout);
|
||||
|
||||
const setBuilderLayoutServerFn = createServerFn({ method: "POST" })
|
||||
.inputValidator(layoutSchema)
|
||||
.inputValidator((data): BuilderLayout => parseBuilderLayoutCookie(JSON.stringify(data)))
|
||||
.handler(async ({ data }) => {
|
||||
setCookie(BUILDER_LAYOUT_COOKIE_NAME, JSON.stringify(data));
|
||||
setCookie(BUILDER_LAYOUT_COOKIE_NAME, JSON.stringify(data), { path: "/" });
|
||||
});
|
||||
|
||||
const getBuilderLayoutServerFn = createServerFn({ method: "GET" }).handler(async () => {
|
||||
const getBuilderLayoutServerFn = createServerFn({ method: "GET" }).handler(async (): Promise<BuilderLayout> => {
|
||||
const layout = getCookie(BUILDER_LAYOUT_COOKIE_NAME);
|
||||
if (!layout) return defaultLayout;
|
||||
return layoutSchema.parse(JSON.parse(layout));
|
||||
if (!layout) return DEFAULT_BUILDER_LAYOUT;
|
||||
return parseBuilderLayoutCookie(layout);
|
||||
});
|
||||
|
||||
@@ -194,11 +194,11 @@ export function DashboardSidebar() {
|
||||
{state === "expanded" && (
|
||||
<motion.div
|
||||
key="copyright"
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ y: 12, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 12, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Copyright className="shrink-0 p-2 wrap-break-word whitespace-normal" />
|
||||
</motion.div>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { BriefcaseIcon, BuildingsIcon, ClockIcon, GlobeIcon, MapPinIcon, MoneyIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import type { JobResult } from "@/schema/jobs";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
import { formatPostedDate, formatSalary } from "./job-utils";
|
||||
|
||||
type Props = {
|
||||
job: JobResult;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function JobCard({ job, onClick }: Props) {
|
||||
const salary = formatSalary(job.job_min_salary, job.job_max_salary, job.job_salary_currency, job.job_salary_period);
|
||||
const posted = formatPostedDate(job.job_posted_at_timestamp);
|
||||
const location = [job.job_city, job.job_state, job.job_country].filter(Boolean).join(", ");
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer flex-col gap-y-3 rounded-md border bg-card p-4 text-start transition-colors hover:bg-accent/50"
|
||||
onClick={onClick}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
>
|
||||
<div className="flex items-start gap-x-3">
|
||||
{job.employer_logo ? (
|
||||
<img src={job.employer_logo} alt={job.employer_name} className="size-10 shrink-0 rounded-md object-contain" />
|
||||
) : (
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
<BuildingsIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate font-medium">{job.job_title}</h3>
|
||||
<p className="truncate text-sm text-muted-foreground">{job.employer_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{location && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<MapPinIcon className="size-3" />
|
||||
{location}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{job.job_is_remote && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<GlobeIcon className="size-3" />
|
||||
<Trans>Remote</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{job.job_employment_type && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<BriefcaseIcon className="size-3" />
|
||||
{job.job_employment_type.replaceAll("_", " ")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{salary && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<MoneyIcon className="size-3" />
|
||||
{salary}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{posted && (
|
||||
<Badge variant="outline" className="gap-x-1">
|
||||
<ClockIcon className="size-3" />
|
||||
{posted}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { useAIStore } from "@/integrations/ai/store";
|
||||
import { sanitizeHtml } from "@/utils/sanitize";
|
||||
|
||||
import { formatSalary, isValidExternalUrl } from "./job-utils";
|
||||
import { TailorDialog } from "./tailor-dialog";
|
||||
@@ -31,7 +31,6 @@ type Props = {
|
||||
};
|
||||
|
||||
export function JobDetailSheet({ job, open, onOpenChange }: Props) {
|
||||
const isAIEnabled = useAIStore((s) => s.enabled);
|
||||
const [tailorOpen, setTailorOpen] = useState(false);
|
||||
|
||||
if (!job) return null;
|
||||
@@ -119,12 +118,7 @@ export function JobDetailSheet({ job, open, onOpenChange }: Props) {
|
||||
<Trans>Apply</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={!isAIEnabled}
|
||||
onClick={() => setTailorOpen(true)}
|
||||
>
|
||||
<Button variant="outline" className="flex-1" onClick={() => setTailorOpen(true)}>
|
||||
<StarIcon />
|
||||
<Trans>Tailor Resume</Trans>
|
||||
</Button>
|
||||
@@ -137,9 +131,10 @@ export function JobDetailSheet({ job, open, onOpenChange }: Props) {
|
||||
<h4 className="font-medium">
|
||||
<Trans>Description</Trans>
|
||||
</h4>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground">
|
||||
{job.job_description}
|
||||
</p>
|
||||
<div
|
||||
className="text-sm leading-relaxed text-muted-foreground [&_a]:text-primary [&_a]:underline [&_h1]:text-base [&_h1]:font-semibold [&_h1]:text-foreground [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:text-foreground [&_h3]:text-sm [&_h3]:font-medium [&_h3]:text-foreground [&_li]:ml-4 [&_ol]:list-decimal [&_p]:mb-2 [&_strong]:font-semibold [&_strong]:text-foreground [&_ul]:list-disc"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHtml(job.job_description) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -197,7 +192,7 @@ export function JobDetailSheet({ job, open, onOpenChange }: Props) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{job.apply_options.length > 0 && (
|
||||
{job.apply_options.some((option) => isValidExternalUrl(option.apply_link)) && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-y-2">
|
||||
@@ -205,23 +200,25 @@ export function JobDetailSheet({ job, open, onOpenChange }: Props) {
|
||||
<Trans>Apply Via</Trans>
|
||||
</h4>
|
||||
<div className="flex flex-col gap-y-1.5">
|
||||
{job.apply_options.map((option, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={option.apply_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-x-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
<ArrowSquareOutIcon className="size-3.5 shrink-0" />
|
||||
{option.publisher || t`Apply Link`}
|
||||
{option.is_direct && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
<Trans>Direct</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
{job.apply_options
|
||||
.filter((option) => isValidExternalUrl(option.apply_link))
|
||||
.map((option, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={option.apply_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-x-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
<ArrowSquareOutIcon className="size-3.5 shrink-0" />
|
||||
{option.publisher || t`Apply Link`}
|
||||
{option.is_direct && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
<Trans>Direct</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ReadCvLogoIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -40,7 +39,6 @@ type DialogPhase =
|
||||
| { step: "skill-sync"; newResumeId: string; newSkills: NewSkillInfo[]; sourceResumeId: string };
|
||||
|
||||
export function TailorDialog({ job, open, onOpenChange }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const { data: resumes, isLoading } = useQuery(orpc.resume.list.queryOptions());
|
||||
|
||||
const [phase, setPhase] = useState<DialogPhase>({ step: "select" });
|
||||
@@ -65,11 +63,7 @@ export function TailorDialog({ job, open, onOpenChange }: Props) {
|
||||
};
|
||||
|
||||
const navigateToBuilder = (resumeId: string) => {
|
||||
if (job.job_apply_link) {
|
||||
window.open(job.job_apply_link, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
handleOpenChange(false);
|
||||
void navigate({ to: "/builder/$resumeId", params: { resumeId } });
|
||||
window.open(`/builder/${resumeId}`, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const handleSelectResume = async (resumeId: string, resumeName: string) => {
|
||||
|
||||
@@ -2,14 +2,9 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
BriefcaseIcon,
|
||||
BuildingsIcon,
|
||||
CaretLeftIcon,
|
||||
CaretRightIcon,
|
||||
ClockIcon,
|
||||
GlobeIcon,
|
||||
MagnifyingGlassIcon,
|
||||
MapPinIcon,
|
||||
MoneyIcon,
|
||||
WarningCircleIcon,
|
||||
XIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
@@ -17,19 +12,19 @@ import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { JobResult } from "@/schema/jobs";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
import { JobCard } from "./-components/job-card";
|
||||
import { JobDetailSheet } from "./-components/job-detail";
|
||||
import { formatPostedDate, formatSalary, getQuotaStatus } from "./-components/job-utils";
|
||||
import { getQuotaStatus } from "./-components/job-utils";
|
||||
import { hasActiveFilters, initialFilterState, SearchFilters } from "./-components/search-filters";
|
||||
import { useJobSearch } from "./-components/use-job-search";
|
||||
|
||||
@@ -37,75 +32,6 @@ export const Route = createFileRoute("/dashboard/job-search/")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function JobCard({ job, onClick }: { job: JobResult; onClick: () => void }) {
|
||||
const salary = formatSalary(job.job_min_salary, job.job_max_salary, job.job_salary_currency, job.job_salary_period);
|
||||
const posted = formatPostedDate(job.job_posted_at_timestamp);
|
||||
const location = [job.job_city, job.job_state, job.job_country].filter(Boolean).join(", ");
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer flex-col gap-y-3 rounded-md border bg-card p-4 text-start transition-colors hover:bg-accent/50"
|
||||
onClick={onClick}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
>
|
||||
<div className="flex items-start gap-x-3">
|
||||
{job.employer_logo ? (
|
||||
<img src={job.employer_logo} alt={job.employer_name} className="size-10 shrink-0 rounded-md object-contain" />
|
||||
) : (
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
<BuildingsIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate font-medium">{job.job_title}</h3>
|
||||
<p className="truncate text-sm text-muted-foreground">{job.employer_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{location && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<MapPinIcon className="size-3" />
|
||||
{location}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{job.job_is_remote && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<GlobeIcon className="size-3" />
|
||||
<Trans>Remote</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{job.job_employment_type && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<BriefcaseIcon className="size-3" />
|
||||
{job.job_employment_type.replaceAll("_", " ")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{salary && (
|
||||
<Badge variant="secondary" className="gap-x-1">
|
||||
<MoneyIcon className="size-3" />
|
||||
{salary}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{posted && (
|
||||
<Badge variant="outline" className="gap-x-1">
|
||||
<ClockIcon className="size-3" />
|
||||
{posted}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const {
|
||||
activeFilterChips,
|
||||
@@ -144,7 +70,7 @@ function RouteComponent() {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex max-w-xl flex-col items-center gap-y-4 py-12 text-center"
|
||||
className="mx-auto flex max-w-xl flex-col items-center gap-y-4 py-12 text-center"
|
||||
>
|
||||
<MagnifyingGlassIcon className="size-12 text-muted-foreground" weight="light" />
|
||||
<h2 className="text-lg font-medium">
|
||||
@@ -230,10 +156,19 @@ function RouteComponent() {
|
||||
{isPending && jobs.length === 0 && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="flex animate-pulse flex-col gap-y-3 rounded-md border bg-card p-4">
|
||||
<div className="h-4 w-3/4 rounded bg-muted" />
|
||||
<div className="h-3 w-1/2 rounded bg-muted" />
|
||||
<div className="h-3 w-5/6 rounded bg-muted" />
|
||||
<div key={index} className="flex flex-col gap-y-3 rounded-md border bg-card p-4">
|
||||
<div className="flex items-start gap-x-3">
|
||||
<Skeleton className="size-10 shrink-0 rounded-md" />
|
||||
<div className="flex flex-1 flex-col gap-y-1.5">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="h-5 w-24 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -32,10 +32,10 @@ export function ResumeCard({ resume }: ResumeCardProps) {
|
||||
<ResumeContextMenu resume={resume}>
|
||||
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }} className="cursor-default">
|
||||
<motion.div
|
||||
className="will-change-transform"
|
||||
whileHover={{ y: -2, scale: 1.005 }}
|
||||
whileTap={{ scale: 0.998 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 28 }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<BaseCard title={resume.name} description={t`Last updated on ${updatedAt}`} tags={resume.tags}>
|
||||
{match({ isLoading, imageSrc: screenshotData?.url })
|
||||
@@ -71,8 +71,7 @@ function ResumeLockOverlay({ isLocked }: { isLocked: boolean }) {
|
||||
animate={{ opacity: 0.6 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
style={{ willChange: "opacity" }}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
className="absolute inset-0 flex items-center justify-center will-change-[opacity]"
|
||||
>
|
||||
<div className="flex items-center justify-center rounded-full bg-popover p-6">
|
||||
<LockSimpleIcon weight="thin" className="size-12 opacity-60" />
|
||||
|
||||
@@ -20,7 +20,7 @@ export function GridView({ resumes }: Props) {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="will-change-[transform,opacity]"
|
||||
>
|
||||
<CreateResumeCard />
|
||||
</motion.div>
|
||||
@@ -30,7 +30,7 @@ export function GridView({ resumes }: Props) {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: 0.03, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="will-change-[transform,opacity]"
|
||||
>
|
||||
<ImportResumeCard />
|
||||
</motion.div>
|
||||
@@ -48,7 +48,7 @@ export function GridView({ resumes }: Props) {
|
||||
filter: "blur(8px)",
|
||||
}}
|
||||
transition={{ duration: 0.2, delay: Math.min(0.12, (index + 2) * 0.02), ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="will-change-[transform,opacity]"
|
||||
>
|
||||
<ResumeCard resume={resume} />
|
||||
</motion.div>
|
||||
|
||||
@@ -32,11 +32,11 @@ export function ListView({ resumes }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<motion.div
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
@@ -56,11 +56,11 @@ export function ListView({ resumes }: Props) {
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: 0.03, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
@@ -85,11 +85,11 @@ export function ListView({ resumes }: Props) {
|
||||
<motion.div
|
||||
layout
|
||||
key={resume.id}
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.18, delay: Math.min(0.12, (index + 2) * 0.02), ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<ResumeListItem resume={resume} />
|
||||
</motion.div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useMemo } from "react";
|
||||
import z from "zod";
|
||||
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
@@ -74,26 +75,35 @@ function RouteComponent() {
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Combobox
|
||||
value={sort}
|
||||
options={sortOptions}
|
||||
placeholder={t`Sort by`}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
void navigate({ search: { tags, sort: value as SortOption } });
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Label>
|
||||
<Trans>Sort by</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
value={sort}
|
||||
options={sortOptions}
|
||||
placeholder={t`Sort by`}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
void navigate({ search: { tags, sort: value as SortOption } });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Combobox
|
||||
multiple
|
||||
value={tags}
|
||||
options={tagOptions}
|
||||
placeholder={t`Filter by`}
|
||||
className={cn({ hidden: tagOptions.length === 0 })}
|
||||
onValueChange={(value) => {
|
||||
void navigate({ search: { tags: value ?? [], sort } });
|
||||
}}
|
||||
/>
|
||||
<div className={cn("flex gap-2", { hidden: tagOptions.length === 0 })}>
|
||||
<Label>
|
||||
<Trans>Filter by</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
multiple
|
||||
value={tags}
|
||||
options={tagOptions}
|
||||
placeholder={t`Filter by`}
|
||||
onValueChange={(value) => {
|
||||
void navigate({ search: { tags: value ?? [], sort } });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs className="ltr:ms-auto rtl:me-auto" value={view} onValueChange={onViewChange}>
|
||||
<TabsList>
|
||||
|
||||
@@ -222,8 +222,7 @@ function RouteComponent() {
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="grid max-w-xl gap-6"
|
||||
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
|
||||
>
|
||||
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
|
||||
<div className="rounded-md bg-primary/10 p-2.5">
|
||||
|
||||
@@ -67,8 +67,7 @@ function RouteComponent() {
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="grid max-w-xl gap-6"
|
||||
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
|
||||
>
|
||||
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
|
||||
<div className="rounded-md bg-primary/10 p-2.5">
|
||||
@@ -116,12 +115,11 @@ function RouteComponent() {
|
||||
{apiKeys.map((key, index) => (
|
||||
<motion.div
|
||||
key={key.id}
|
||||
className="flex items-center gap-x-4 py-4"
|
||||
className="flex items-center gap-x-4 py-4 will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: -16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -16 }}
|
||||
transition={{ duration: 0.16, delay: Math.min(0.12, index * 0.04) }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<KeyIcon />
|
||||
|
||||
@@ -133,10 +131,10 @@ function RouteComponent() {
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="will-change-transform"
|
||||
whileHover={{ y: -1, scale: 1.03 }}
|
||||
whileTap={{ scale: 0.96 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button size="icon" variant="ghost" onClick={() => onDelete(key.id)}>
|
||||
<TrashSimpleIcon />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { GithubLogoIcon, GoogleLogoIcon, PasswordIcon, VaultIcon } from "@phosphor-icons/react";
|
||||
import { GithubLogoIcon, GoogleLogoIcon, LinkedinLogoIcon, PasswordIcon, VaultIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -20,6 +20,7 @@ export function getProviderName(providerId: AuthProvider): string {
|
||||
.with("credential", () => "Password")
|
||||
.with("google", () => "Google")
|
||||
.with("github", () => "GitHub")
|
||||
.with("linkedin", () => "LinkedIn")
|
||||
.with("custom", () => "Custom OAuth")
|
||||
.exhaustive();
|
||||
}
|
||||
@@ -32,6 +33,7 @@ export function getProviderIcon(providerId: AuthProvider): ReactNode {
|
||||
.with("credential", () => <PasswordIcon />)
|
||||
.with("google", () => <GoogleLogoIcon />)
|
||||
.with("github", () => <GithubLogoIcon />)
|
||||
.with("linkedin", () => <LinkedinLogoIcon />)
|
||||
.with("custom", () => <VaultIcon />)
|
||||
.exhaustive();
|
||||
}
|
||||
@@ -100,7 +102,7 @@ export function useAuthProviderActions() {
|
||||
|
||||
/**
|
||||
* Hook to get enabled social providers for the current user
|
||||
* Possible values: "credential", "google", "github", "custom"
|
||||
* Possible values: "credential", "google", "github", "linkedin", "custom"
|
||||
*/
|
||||
export function useEnabledProviders() {
|
||||
const { data: enabledProviders = [] } = useQuery(orpc.auth.providers.list.queryOptions());
|
||||
|
||||
@@ -30,8 +30,7 @@ export function PasswordSection() {
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: 0.1, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="flex items-center justify-between gap-x-4"
|
||||
className="flex items-center justify-between gap-x-4 will-change-[transform,opacity]"
|
||||
>
|
||||
<h2 className="flex items-center gap-x-3 text-base font-medium">
|
||||
<PasswordIcon />
|
||||
@@ -41,10 +40,10 @@ export function PasswordSection() {
|
||||
{match(hasPassword)
|
||||
.with(true, () => (
|
||||
<motion.div
|
||||
className="will-change-transform"
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button variant="outline" onClick={handleUpdatePassword}>
|
||||
<PencilSimpleLineIcon />
|
||||
@@ -54,10 +53,10 @@ export function PasswordSection() {
|
||||
))
|
||||
.with(false, () => (
|
||||
<motion.div
|
||||
className="will-change-transform"
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -38,10 +38,10 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: animationDelay, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Separator />
|
||||
|
||||
@@ -54,10 +54,10 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
|
||||
{match(isConnected)
|
||||
.with(true, () => (
|
||||
<motion.div
|
||||
className="will-change-transform"
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button variant="outline" onClick={handleUnlink}>
|
||||
<LinkBreakIcon />
|
||||
@@ -67,10 +67,10 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
|
||||
))
|
||||
.with(false, () => (
|
||||
<motion.div
|
||||
className="will-change-transform"
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button variant="outline" onClick={handleLink}>
|
||||
<LinkIcon />
|
||||
|
||||
@@ -31,10 +31,10 @@ export function TwoFactorSection() {
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: 0.2, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
>
|
||||
<Separator />
|
||||
|
||||
@@ -47,10 +47,10 @@ export function TwoFactorSection() {
|
||||
{match(hasTwoFactor)
|
||||
.with(true, () => (
|
||||
<motion.div
|
||||
className="will-change-transform"
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button variant="outline" onClick={handleTwoFactorAction}>
|
||||
<ToggleLeftIcon />
|
||||
@@ -60,10 +60,10 @@ export function TwoFactorSection() {
|
||||
))
|
||||
.with(false, () => (
|
||||
<motion.div
|
||||
className="will-change-transform"
|
||||
whileHover={{ y: -1, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button variant="outline" onClick={handleTwoFactorAction}>
|
||||
<ToggleRightIcon />
|
||||
|
||||
@@ -28,8 +28,7 @@ function RouteComponent() {
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="grid max-w-xl gap-4"
|
||||
className="grid max-w-xl gap-4 will-change-[transform,opacity]"
|
||||
>
|
||||
<PasswordSection />
|
||||
|
||||
@@ -46,8 +45,10 @@ function RouteComponent() {
|
||||
|
||||
{"github" in enabledProviders && <SocialProviderSection provider="github" animationDelay={0.5} />}
|
||||
|
||||
{"linkedin" in enabledProviders && <SocialProviderSection provider="linkedin" animationDelay={0.6} />}
|
||||
|
||||
{"custom" in enabledProviders && (
|
||||
<SocialProviderSection provider="custom" animationDelay={0.6} name={enabledProviders.custom} />
|
||||
<SocialProviderSection provider="custom" animationDelay={0.7} name={enabledProviders.custom} />
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -63,8 +63,7 @@ function RouteComponent() {
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="grid max-w-xl gap-6"
|
||||
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
|
||||
>
|
||||
<p className="leading-relaxed">
|
||||
<Trans>To delete your account, you need to enter the confirmation text and click the button below.</Trans>
|
||||
@@ -78,11 +77,10 @@ function RouteComponent() {
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="justify-self-end"
|
||||
className="justify-self-end will-change-transform"
|
||||
whileHover={!isConfirmationValid ? undefined : { y: -1, scale: 1.01 }}
|
||||
whileTap={!isConfirmationValid ? undefined : { scale: 0.98 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
<Button variant="destructive" onClick={handleDeleteAccount} disabled={!isConfirmationValid}>
|
||||
<TrashSimpleIcon />
|
||||
|
||||
@@ -27,8 +27,7 @@ function RouteComponent() {
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="grid max-w-xl gap-6"
|
||||
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="mb-0.5">
|
||||
|
||||
@@ -124,8 +124,7 @@ function RouteComponent() {
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="grid max-w-xl gap-6"
|
||||
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
@@ -222,8 +221,7 @@ function RouteComponent() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="flex items-center gap-x-4 justify-self-end"
|
||||
className="flex items-center gap-x-4 justify-self-end will-change-[transform,opacity]"
|
||||
>
|
||||
<Button type="reset" variant="ghost" onClick={onCancel}>
|
||||
<Trans>Cancel</Trans>
|
||||
|
||||
@@ -51,6 +51,8 @@ export const tailorOutputSchema = z.object({
|
||||
),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.catch([])
|
||||
.describe(
|
||||
"Tailored role-level updates. Use an empty array when the experience does not have role progression.",
|
||||
),
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { defaultResumeData } from "@/schema/resume/data";
|
||||
|
||||
import { buildAiExtractionTemplate } from "./ai-template";
|
||||
|
||||
describe("buildAiExtractionTemplate", () => {
|
||||
const template = buildAiExtractionTemplate();
|
||||
|
||||
it("produces a valid ResumeData structure", () => {
|
||||
// The template should pass schema validation (items have empty strings which satisfy .min(1) checks,
|
||||
// so we validate the overall shape matches)
|
||||
expect(template).toBeDefined();
|
||||
expect(template.basics).toBeDefined();
|
||||
expect(template.sections).toBeDefined();
|
||||
expect(template.metadata).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes a sample custom field in basics", () => {
|
||||
expect(template.basics.customFields).toHaveLength(1);
|
||||
expect(template.basics.customFields[0]).toEqual({ id: "", icon: "", text: "", link: "" });
|
||||
});
|
||||
|
||||
it("populates each section with exactly one example item", () => {
|
||||
const sectionKeys = [
|
||||
"profiles",
|
||||
"experience",
|
||||
"education",
|
||||
"projects",
|
||||
"skills",
|
||||
"languages",
|
||||
"interests",
|
||||
"awards",
|
||||
"certifications",
|
||||
"publications",
|
||||
"volunteer",
|
||||
"references",
|
||||
] as const;
|
||||
|
||||
for (const key of sectionKeys) {
|
||||
const section = template.sections[key];
|
||||
expect(section.items).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves section metadata from defaultResumeData", () => {
|
||||
const sectionKeys = Object.keys(defaultResumeData.sections) as Array<keyof typeof defaultResumeData.sections>;
|
||||
|
||||
for (const key of sectionKeys) {
|
||||
expect(template.sections[key].title).toBe(defaultResumeData.sections[key].title);
|
||||
expect(template.sections[key].columns).toBe(defaultResumeData.sections[key].columns);
|
||||
expect(template.sections[key].hidden).toBe(defaultResumeData.sections[key].hidden);
|
||||
}
|
||||
});
|
||||
|
||||
it("has empty string values in example items (not default data)", () => {
|
||||
const expItem = template.sections.experience.items[0];
|
||||
expect(expItem.company).toBe("");
|
||||
expect(expItem.position).toBe("");
|
||||
expect(expItem.id).toBe("");
|
||||
});
|
||||
|
||||
it("has zeroed numeric values in example items", () => {
|
||||
const skillItem = template.sections.skills.items[0];
|
||||
expect(skillItem.level).toBe(0);
|
||||
});
|
||||
|
||||
it("has nested objects with empty values", () => {
|
||||
const expItem = template.sections.experience.items[0];
|
||||
expect(expItem.website).toEqual({ url: "", label: "" });
|
||||
});
|
||||
|
||||
it("covers all sections present in defaultResumeData", () => {
|
||||
const defaultKeys = Object.keys(defaultResumeData.sections).sort();
|
||||
const templateKeys = Object.keys(template.sections).sort();
|
||||
expect(templateKeys).toEqual(defaultKeys);
|
||||
});
|
||||
|
||||
it("preserves metadata and picture from defaultResumeData", () => {
|
||||
expect(template.metadata).toEqual(defaultResumeData.metadata);
|
||||
expect(template.picture).toEqual(defaultResumeData.picture);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { defaultResumeData } from "@/schema/resume/data";
|
||||
|
||||
/**
|
||||
* Generates an AI extraction template item for a section by creating an object
|
||||
* with the same keys as the section's default items but with zeroed/empty values.
|
||||
* This template tells the AI what fields to extract for each section type.
|
||||
*/
|
||||
function makeEmptyItem(shape: Record<string, unknown>): Record<string, unknown> {
|
||||
const item: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(shape)) {
|
||||
if (typeof value === "string") item[key] = "";
|
||||
else if (typeof value === "number") item[key] = 0;
|
||||
else if (typeof value === "boolean") item[key] = false;
|
||||
else if (Array.isArray(value)) item[key] = [];
|
||||
else if (value !== null && typeof value === "object") {
|
||||
item[key] = makeEmptyItem(value as Record<string, unknown>);
|
||||
} else {
|
||||
item[key] = "";
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section item shape definitions. Each describes the fields the AI should extract.
|
||||
* These are the canonical shapes — if a schema field is added, add it here too.
|
||||
*/
|
||||
const SECTION_ITEM_SHAPES: Record<string, Record<string, unknown>> = {
|
||||
profiles: { id: "", hidden: false, icon: "", network: "", username: "", website: { url: "", label: "" } },
|
||||
experience: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
company: "",
|
||||
position: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
education: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
school: "",
|
||||
degree: "",
|
||||
area: "",
|
||||
grade: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
projects: { id: "", hidden: false, name: "", period: "", website: { url: "", label: "" }, description: "" },
|
||||
skills: { id: "", hidden: false, icon: "", name: "", proficiency: "", level: 0, keywords: [] },
|
||||
languages: { id: "", hidden: false, language: "", fluency: "", level: 0 },
|
||||
interests: { id: "", hidden: false, icon: "", name: "", keywords: [] },
|
||||
awards: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
title: "",
|
||||
awarder: "",
|
||||
date: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
certifications: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
title: "",
|
||||
issuer: "",
|
||||
date: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
publications: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
title: "",
|
||||
publisher: "",
|
||||
date: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
volunteer: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
organization: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
references: {
|
||||
id: "",
|
||||
hidden: false,
|
||||
name: "",
|
||||
position: "",
|
||||
website: { url: "", label: "" },
|
||||
phone: "",
|
||||
description: "",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the AI extraction template programmatically from defaultResumeData.
|
||||
* This eliminates manual duplication and ensures the template stays in sync with the schema.
|
||||
*/
|
||||
export function buildAiExtractionTemplate() {
|
||||
const sections: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, shape] of Object.entries(SECTION_ITEM_SHAPES)) {
|
||||
const sectionKey = key as keyof typeof defaultResumeData.sections;
|
||||
sections[key] = {
|
||||
...defaultResumeData.sections[sectionKey],
|
||||
items: [makeEmptyItem(shape)],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...defaultResumeData,
|
||||
basics: {
|
||||
...defaultResumeData.basics,
|
||||
customFields: [{ id: "", icon: "", text: "", link: "" }],
|
||||
},
|
||||
sections: {
|
||||
...defaultResumeData.sections,
|
||||
...sections,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -38,4 +38,55 @@ describe("parseColorString", () => {
|
||||
it("should return null for incomplete hex", () => {
|
||||
expect(parseColorString("#ff")).toBeNull();
|
||||
});
|
||||
|
||||
// --- Additional branch coverage ---
|
||||
|
||||
it("should parse rgba() without alpha (defaults to 1)", () => {
|
||||
expect(parseColorString("rgba(100, 200, 50)")).toEqual({ r: 100, g: 200, b: 50, a: 1 });
|
||||
});
|
||||
|
||||
it("should parse rgb() with max values", () => {
|
||||
expect(parseColorString("rgb(255, 255, 255)")).toEqual({ r: 255, g: 255, b: 255, a: 1 });
|
||||
});
|
||||
|
||||
it("should parse rgba() with alpha of 0", () => {
|
||||
expect(parseColorString("rgba(0, 0, 0, 0)")).toEqual({ r: 0, g: 0, b: 0, a: 0 });
|
||||
});
|
||||
|
||||
it("should parse rgba() with alpha of 1", () => {
|
||||
expect(parseColorString("rgba(0, 0, 0, 1)")).toEqual({ r: 0, g: 0, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("should parse 3-digit hex #000", () => {
|
||||
expect(parseColorString("#000")).toEqual({ r: 0, g: 0, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("should parse 3-digit hex #fff", () => {
|
||||
expect(parseColorString("#fff")).toEqual({ r: 255, g: 255, b: 255, a: 1 });
|
||||
});
|
||||
|
||||
it("should return null for 4-digit hex", () => {
|
||||
expect(parseColorString("#ffff")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for 8-digit hex (with alpha)", () => {
|
||||
expect(parseColorString("#ff880080")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for non-hex characters after #", () => {
|
||||
expect(parseColorString("#gggggg")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for named colors", () => {
|
||||
expect(parseColorString("red")).toBeNull();
|
||||
expect(parseColorString("blue")).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle rgb with extra spaces", () => {
|
||||
expect(parseColorString("rgb( 10 , 20 , 30 )")).toEqual({ r: 10, g: 20, b: 30, a: 1 });
|
||||
});
|
||||
|
||||
it("should parse rgba with decimal alpha", () => {
|
||||
expect(parseColorString("rgba(255, 0, 0, 0.75)")).toEqual({ r: 255, g: 0, b: 0, a: 0.75 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { formatDate, formatPeriod, formatSingleDate } from "./date";
|
||||
|
||||
describe("formatDate", () => {
|
||||
it("formats YYYY-MM-DD to month year by default", () => {
|
||||
expect(formatDate("2024-01-15")).toBe("January 2024");
|
||||
});
|
||||
|
||||
it("formats YYYY-MM to month year", () => {
|
||||
expect(formatDate("2024-06")).toBe("June 2024");
|
||||
});
|
||||
|
||||
it("returns year only for YYYY", () => {
|
||||
expect(formatDate("2024")).toBe("2024");
|
||||
});
|
||||
|
||||
it("includes day when includeDay is true", () => {
|
||||
expect(formatDate("2024-01-15", true)).toBe("January 15, 2024");
|
||||
});
|
||||
|
||||
it("formats YYYY-MM with includeDay (no day available)", () => {
|
||||
expect(formatDate("2024-06", true)).toBe("June 2024");
|
||||
});
|
||||
|
||||
it("handles December correctly", () => {
|
||||
expect(formatDate("2023-12-25")).toBe("December 2023");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPeriod", () => {
|
||||
it("returns empty string when both dates are missing", () => {
|
||||
expect(formatPeriod()).toBe("");
|
||||
expect(formatPeriod(undefined, undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("returns end date when start is missing", () => {
|
||||
expect(formatPeriod(undefined, "2024-06")).toBe("2024-06");
|
||||
});
|
||||
|
||||
it("returns 'Start - Present' when end is missing", () => {
|
||||
expect(formatPeriod("2024-01")).toBe("January 2024 - Present");
|
||||
});
|
||||
|
||||
it("formats both dates", () => {
|
||||
expect(formatPeriod("2020-01", "2024-06")).toBe("January 2020 - June 2024");
|
||||
});
|
||||
|
||||
it("formats full dates to month-year", () => {
|
||||
expect(formatPeriod("2020-01-15", "2024-06-30")).toBe("January 2020 - June 2024");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSingleDate", () => {
|
||||
it("returns empty string for undefined", () => {
|
||||
expect(formatSingleDate()).toBe("");
|
||||
expect(formatSingleDate(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("formats YYYY-MM-DD with day included", () => {
|
||||
expect(formatSingleDate("2024-03-15")).toBe("March 15, 2024");
|
||||
});
|
||||
|
||||
it("formats YYYY-MM without day", () => {
|
||||
expect(formatSingleDate("2024-03")).toBe("March 2024");
|
||||
});
|
||||
|
||||
it("returns year only for YYYY", () => {
|
||||
expect(formatSingleDate("2024")).toBe("2024");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
const MONTH_NAMES = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
/**
|
||||
* Formats a partial ISO 8601 date string (YYYY, YYYY-MM, or YYYY-MM-DD)
|
||||
* into a human-readable format like "January 2024" or "January 15, 2024".
|
||||
*/
|
||||
export function formatDate(date: string, includeDay = false): string {
|
||||
const parts = date.split("-");
|
||||
|
||||
if (parts.length >= 2) {
|
||||
const [year, month] = parts;
|
||||
const monthName = MONTH_NAMES[parseInt(month, 10) - 1];
|
||||
|
||||
if (parts.length === 3 && includeDay) {
|
||||
return `${monthName} ${parts[2]}, ${year}`;
|
||||
}
|
||||
|
||||
return `${monthName} ${year}`;
|
||||
}
|
||||
|
||||
// YYYY only
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date range from start and end dates.
|
||||
* Returns "Start - End", "Start - Present" if no end, or just the end date if no start.
|
||||
*/
|
||||
export function formatPeriod(startDate?: string, endDate?: string): string {
|
||||
if (!startDate && !endDate) return "";
|
||||
if (!startDate) return endDate || "";
|
||||
if (!endDate) return `${formatDate(startDate)} - Present`;
|
||||
|
||||
return `${formatDate(startDate)} - ${formatDate(endDate)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a single date with day included (e.g., "January 15, 2024").
|
||||
* Falls back to month-year or year-only for partial dates.
|
||||
*/
|
||||
export function formatSingleDate(date?: string): string {
|
||||
if (!date) return "";
|
||||
return formatDate(date, true);
|
||||
}
|
||||
@@ -32,6 +32,10 @@ export const env = createEnv({
|
||||
GITHUB_CLIENT_ID: z.string().min(1).optional(),
|
||||
GITHUB_CLIENT_SECRET: z.string().min(1).optional(),
|
||||
|
||||
// Social Auth (LinkedIn)
|
||||
LINKEDIN_CLIENT_ID: z.string().min(1).optional(),
|
||||
LINKEDIN_CLIENT_SECRET: z.string().min(1).optional(),
|
||||
|
||||
// Custom OAuth Provider
|
||||
OAUTH_PROVIDER_NAME: z.string().min(1).optional(),
|
||||
OAUTH_CLIENT_ID: z.string().min(1).optional(),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { arrayToHtmlList, toHtmlDescription } from "./html";
|
||||
|
||||
describe("toHtmlDescription", () => {
|
||||
it("returns empty string when both are missing", () => {
|
||||
expect(toHtmlDescription()).toBe("");
|
||||
expect(toHtmlDescription(undefined, undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("wraps summary in <p>", () => {
|
||||
expect(toHtmlDescription("Hello")).toBe("<p>Hello</p>");
|
||||
});
|
||||
|
||||
it("creates <ul> from highlights", () => {
|
||||
expect(toHtmlDescription(undefined, ["a", "b"])).toBe("<ul><li>a</li><li>b</li></ul>");
|
||||
});
|
||||
|
||||
it("combines summary and highlights", () => {
|
||||
expect(toHtmlDescription("Summary", ["a", "b"])).toBe("<p>Summary</p><ul><li>a</li><li>b</li></ul>");
|
||||
});
|
||||
|
||||
it("ignores empty highlights array", () => {
|
||||
expect(toHtmlDescription("Summary", [])).toBe("<p>Summary</p>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("arrayToHtmlList", () => {
|
||||
it("returns empty string for empty array", () => {
|
||||
expect(arrayToHtmlList([])).toBe("");
|
||||
});
|
||||
|
||||
it("creates <ul> list from items", () => {
|
||||
expect(arrayToHtmlList(["a", "b", "c"])).toBe("<ul><li>a</li><li>b</li><li>c</li></ul>");
|
||||
});
|
||||
|
||||
it("handles single item", () => {
|
||||
expect(arrayToHtmlList(["only"])).toBe("<ul><li>only</li></ul>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Converts a summary string and optional highlights array into an HTML description.
|
||||
* Summary becomes a <p> tag, highlights become a <ul> list.
|
||||
*/
|
||||
export function toHtmlDescription(summary?: string, highlights?: string[]): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (summary) {
|
||||
parts.push(`<p>${summary}</p>`);
|
||||
}
|
||||
|
||||
if (highlights && highlights.length > 0) {
|
||||
parts.push("<ul>");
|
||||
|
||||
for (const highlight of highlights) {
|
||||
parts.push(`<li>${highlight}</li>`);
|
||||
}
|
||||
|
||||
parts.push("</ul>");
|
||||
}
|
||||
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an array of strings into an HTML unordered list.
|
||||
*/
|
||||
export function arrayToHtmlList(items: string[]): string {
|
||||
if (items.length === 0) return "";
|
||||
return `<ul>${items.map((item) => `<li>${item}</li>`).join("")}</ul>`;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { parseLevel } from "./level";
|
||||
|
||||
describe("parseLevel", () => {
|
||||
it("returns 0 for undefined/empty", () => {
|
||||
expect(parseLevel()).toBe(0);
|
||||
expect(parseLevel(undefined)).toBe(0);
|
||||
expect(parseLevel("")).toBe(0);
|
||||
});
|
||||
|
||||
it("parses numeric values within range", () => {
|
||||
expect(parseLevel("0")).toBe(0);
|
||||
expect(parseLevel("3")).toBe(3);
|
||||
expect(parseLevel("5")).toBe(5);
|
||||
});
|
||||
|
||||
it("returns 0 for numeric values out of range", () => {
|
||||
expect(parseLevel("6")).toBe(0);
|
||||
expect(parseLevel("-1")).toBe(0);
|
||||
});
|
||||
|
||||
it("maps expert-level text to 5", () => {
|
||||
expect(parseLevel("Native")).toBe(5);
|
||||
expect(parseLevel("Expert")).toBe(5);
|
||||
expect(parseLevel("Master")).toBe(5);
|
||||
});
|
||||
|
||||
it("maps advanced-level text to 4", () => {
|
||||
expect(parseLevel("Fluent")).toBe(4);
|
||||
expect(parseLevel("Advanced")).toBe(4);
|
||||
expect(parseLevel("Proficient")).toBe(4);
|
||||
});
|
||||
|
||||
it("maps intermediate-level text to 3", () => {
|
||||
expect(parseLevel("Intermediate")).toBe(3);
|
||||
expect(parseLevel("Conversational")).toBe(3);
|
||||
});
|
||||
|
||||
it("maps beginner-level text to 2", () => {
|
||||
expect(parseLevel("Beginner")).toBe(2);
|
||||
expect(parseLevel("Basic")).toBe(2);
|
||||
expect(parseLevel("Elementary")).toBe(2);
|
||||
});
|
||||
|
||||
it("maps novice to 1", () => {
|
||||
expect(parseLevel("Novice")).toBe(1);
|
||||
});
|
||||
|
||||
it("maps CEFR levels correctly", () => {
|
||||
expect(parseLevel("C2")).toBe(5);
|
||||
expect(parseLevel("C1")).toBe(4);
|
||||
expect(parseLevel("B2")).toBe(3);
|
||||
expect(parseLevel("B1")).toBe(2);
|
||||
expect(parseLevel("A2")).toBe(1);
|
||||
expect(parseLevel("A1")).toBe(1);
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(parseLevel("NATIVE")).toBe(5);
|
||||
expect(parseLevel("beginner")).toBe(2);
|
||||
expect(parseLevel("c2")).toBe(5);
|
||||
});
|
||||
|
||||
it("matches partial strings", () => {
|
||||
expect(parseLevel("Native speaker")).toBe(5);
|
||||
expect(parseLevel("Advanced level")).toBe(4);
|
||||
});
|
||||
|
||||
it("returns 0 for unrecognized text", () => {
|
||||
expect(parseLevel("unknown")).toBe(0);
|
||||
expect(parseLevel("xyz")).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Parses a skill/language level string to a numeric value (0-5).
|
||||
* Supports numeric values, text levels (beginner/intermediate/advanced/expert),
|
||||
* and CEFR levels (A1-C2).
|
||||
*/
|
||||
export function parseLevel(level?: string): number {
|
||||
if (!level) return 0;
|
||||
|
||||
const levelLower = level.toLowerCase();
|
||||
|
||||
// Try to parse numeric values
|
||||
const numeric = parseInt(levelLower, 10);
|
||||
if (!Number.isNaN(numeric) && numeric >= 0 && numeric <= 5) return numeric;
|
||||
|
||||
// Map text levels to numbers
|
||||
if (levelLower.includes("native") || levelLower.includes("expert") || levelLower.includes("master")) return 5;
|
||||
if (levelLower.includes("fluent") || levelLower.includes("advanced") || levelLower.includes("proficient")) return 4;
|
||||
if (levelLower.includes("intermediate") || levelLower.includes("conversational")) return 3;
|
||||
if (levelLower.includes("beginner") || levelLower.includes("basic") || levelLower.includes("elementary")) return 2;
|
||||
if (levelLower.includes("novice")) return 1;
|
||||
|
||||
// CEFR levels
|
||||
if (levelLower.includes("c2")) return 5;
|
||||
if (levelLower.includes("c1")) return 4;
|
||||
if (levelLower.includes("b2")) return 3;
|
||||
if (levelLower.includes("b1")) return 2;
|
||||
if (levelLower.includes("a2")) return 1;
|
||||
if (levelLower.includes("a1")) return 1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { getNetworkIcon } from "./network-icons";
|
||||
|
||||
describe("getNetworkIcon", () => {
|
||||
it("returns 'star' for undefined/empty", () => {
|
||||
expect(getNetworkIcon()).toBe("star");
|
||||
expect(getNetworkIcon(undefined)).toBe("star");
|
||||
});
|
||||
|
||||
it("maps GitHub correctly", () => {
|
||||
expect(getNetworkIcon("GitHub")).toBe("github-logo");
|
||||
expect(getNetworkIcon("github")).toBe("github-logo");
|
||||
});
|
||||
|
||||
it("maps LinkedIn correctly", () => {
|
||||
expect(getNetworkIcon("LinkedIn")).toBe("linkedin-logo");
|
||||
});
|
||||
|
||||
it("maps Twitter and X.com", () => {
|
||||
expect(getNetworkIcon("Twitter")).toBe("twitter-logo");
|
||||
expect(getNetworkIcon("x.com")).toBe("twitter-logo");
|
||||
});
|
||||
|
||||
it("maps other social networks", () => {
|
||||
expect(getNetworkIcon("Facebook")).toBe("facebook-logo");
|
||||
expect(getNetworkIcon("Instagram")).toBe("instagram-logo");
|
||||
expect(getNetworkIcon("YouTube")).toBe("youtube-logo");
|
||||
expect(getNetworkIcon("Medium")).toBe("medium-logo");
|
||||
expect(getNetworkIcon("Dribbble")).toBe("dribbble-logo");
|
||||
expect(getNetworkIcon("Behance")).toBe("behance-logo");
|
||||
});
|
||||
|
||||
it("maps dev platforms", () => {
|
||||
expect(getNetworkIcon("StackOverflow")).toBe("stack-overflow-logo");
|
||||
expect(getNetworkIcon("Stack-Overflow")).toBe("stack-overflow-logo");
|
||||
expect(getNetworkIcon("dev.to")).toBe("code");
|
||||
expect(getNetworkIcon("devto")).toBe("code");
|
||||
expect(getNetworkIcon("GitLab")).toBe("git-branch");
|
||||
expect(getNetworkIcon("Bitbucket")).toBe("code");
|
||||
expect(getNetworkIcon("CodePen")).toBe("code");
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(getNetworkIcon("GITHUB")).toBe("github-logo");
|
||||
expect(getNetworkIcon("linkedin")).toBe("linkedin-logo");
|
||||
});
|
||||
|
||||
it("returns 'star' for unknown networks", () => {
|
||||
expect(getNetworkIcon("MySpace")).toBe("star");
|
||||
expect(getNetworkIcon("Unknown")).toBe("star");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { IconName } from "@/schema/icons";
|
||||
|
||||
const NETWORK_ICON_MAP: Array<{ match: string[]; icon: IconName }> = [
|
||||
{ match: ["github"], icon: "github-logo" },
|
||||
{ match: ["linkedin"], icon: "linkedin-logo" },
|
||||
{ match: ["twitter", "x", "x.com"], icon: "twitter-logo" },
|
||||
{ match: ["facebook"], icon: "facebook-logo" },
|
||||
{ match: ["instagram"], icon: "instagram-logo" },
|
||||
{ match: ["youtube"], icon: "youtube-logo" },
|
||||
{ match: ["stackoverflow", "stack-overflow"], icon: "stack-overflow-logo" },
|
||||
{ match: ["medium"], icon: "medium-logo" },
|
||||
{ match: ["dev.to", "devto"], icon: "code" },
|
||||
{ match: ["dribbble"], icon: "dribbble-logo" },
|
||||
{ match: ["behance"], icon: "behance-logo" },
|
||||
{ match: ["gitlab"], icon: "git-branch" },
|
||||
{ match: ["bitbucket", "codepen"], icon: "code" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Maps a social network name to a Phosphor icon name.
|
||||
* Returns "star" as default if no match is found.
|
||||
*/
|
||||
export function getNetworkIcon(network?: string): IconName {
|
||||
if (!network) return "star";
|
||||
|
||||
const networkLower = network.toLowerCase();
|
||||
|
||||
for (const entry of NETWORK_ICON_MAP) {
|
||||
if (entry.match.some((keyword) => networkLower.includes(keyword))) {
|
||||
return entry.icon;
|
||||
}
|
||||
}
|
||||
|
||||
return "star";
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { hashPassword, verifyPassword } from "./password";
|
||||
|
||||
describe("hashPassword", () => {
|
||||
it("returns a bcrypt hash string", async () => {
|
||||
const hash = await hashPassword("my-password");
|
||||
expect(hash).toMatch(/^\$2[aby]?\$\d+\$/);
|
||||
});
|
||||
|
||||
it("produces different hashes for the same password (salted)", async () => {
|
||||
const hash1 = await hashPassword("same-password");
|
||||
const hash2 = await hashPassword("same-password");
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it("produces a hash of expected length (~60 chars)", async () => {
|
||||
const hash = await hashPassword("test");
|
||||
expect(hash.length).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyPassword", () => {
|
||||
it("returns true for correct password", async () => {
|
||||
const hash = await hashPassword("correct-password");
|
||||
const result = await verifyPassword("correct-password", hash);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for incorrect password", async () => {
|
||||
const hash = await hashPassword("correct-password");
|
||||
const result = await verifyPassword("wrong-password", hash);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("handles empty passwords", async () => {
|
||||
const hash = await hashPassword("");
|
||||
expect(await verifyPassword("", hash)).toBe(true);
|
||||
expect(await verifyPassword("notempty", hash)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles special characters", async () => {
|
||||
const password = "p@$$w0rd!#%&*(){}[]|\\/<>?";
|
||||
const hash = await hashPassword(password);
|
||||
expect(await verifyPassword(password, hash)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles unicode characters", async () => {
|
||||
const password = "パスワード123";
|
||||
const hash = await hashPassword(password);
|
||||
expect(await verifyPassword(password, hash)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// We can't easily mock createIsomorphicFn, so we reimplement the core logic
|
||||
// directly in the test to verify the token generation/verification algorithm.
|
||||
// This tests the same code paths without depending on the TanStack wrapper.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TEST_SECRET = "test-secret-key-for-hmac";
|
||||
const PRINTER_TOKEN_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Reimplementation of generatePrinterToken for testing */
|
||||
function generateToken(resumeId: string): string {
|
||||
const timestamp = Date.now();
|
||||
const payload = `${resumeId}:${timestamp}`;
|
||||
const payloadBase64 = Buffer.from(payload).toString("base64url");
|
||||
const signature = createHmac("sha256", TEST_SECRET).update(payloadBase64).digest("hex");
|
||||
return `${payloadBase64}.${signature}`;
|
||||
}
|
||||
|
||||
/** Reimplementation of verifyPrinterToken for testing */
|
||||
function verifyToken(token: string): string {
|
||||
const { timingSafeEqual } = require("node:crypto");
|
||||
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 2) throw new Error("Invalid token format");
|
||||
|
||||
const [payloadBase64, signature] = parts;
|
||||
|
||||
const expectedSignature = createHmac("sha256", TEST_SECRET).update(payloadBase64).digest("hex");
|
||||
const signatureBuffer = Buffer.from(signature);
|
||||
const expectedBuffer = Buffer.from(expectedSignature);
|
||||
|
||||
if (signatureBuffer.length !== expectedBuffer.length || !timingSafeEqual(signatureBuffer, expectedBuffer)) {
|
||||
throw new Error("Invalid token signature");
|
||||
}
|
||||
|
||||
const payload = Buffer.from(payloadBase64, "base64url").toString("utf-8");
|
||||
const [resumeId, timestampStr] = payload.split(":");
|
||||
if (!resumeId || !timestampStr) throw new Error("Invalid token payload");
|
||||
|
||||
const timestamp = Number.parseInt(timestampStr, 10);
|
||||
if (Number.isNaN(timestamp)) throw new Error("Invalid timestamp");
|
||||
|
||||
const age = Date.now() - timestamp;
|
||||
if (age < 0 || age > PRINTER_TOKEN_TTL_MS) throw new Error("Token expired");
|
||||
|
||||
return resumeId;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createValidToken(resumeId: string, timestamp: number): string {
|
||||
const payload = `${resumeId}:${timestamp}`;
|
||||
const payloadBase64 = Buffer.from(payload).toString("base64url");
|
||||
const signature = createHmac("sha256", TEST_SECRET).update(payloadBase64).digest("hex");
|
||||
return `${payloadBase64}.${signature}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — these validate the algorithm used by printer-token.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("generatePrinterToken (algorithm)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns a token with format payload.signature", () => {
|
||||
const token = generateToken("resume-123");
|
||||
const parts = token.split(".");
|
||||
expect(parts).toHaveLength(2);
|
||||
expect(parts[0].length).toBeGreaterThan(0);
|
||||
expect(parts[1].length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("encodes the resume ID and timestamp in the payload", () => {
|
||||
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
|
||||
const token = generateToken("my-resume-id");
|
||||
const [payloadBase64] = token.split(".");
|
||||
const decoded = Buffer.from(payloadBase64, "base64url").toString("utf-8");
|
||||
|
||||
expect(decoded).toContain("my-resume-id");
|
||||
expect(decoded).toContain(String(Date.now()));
|
||||
});
|
||||
|
||||
it("produces a valid HMAC-SHA256 signature", () => {
|
||||
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
|
||||
const token = generateToken("resume-abc");
|
||||
const [payloadBase64, signature] = token.split(".");
|
||||
|
||||
const expectedSignature = createHmac("sha256", TEST_SECRET).update(payloadBase64).digest("hex");
|
||||
expect(signature).toBe(expectedSignature);
|
||||
});
|
||||
|
||||
it("generates different tokens for different resume IDs", () => {
|
||||
const token1 = generateToken("resume-1");
|
||||
const token2 = generateToken("resume-2");
|
||||
expect(token1).not.toBe(token2);
|
||||
});
|
||||
|
||||
it("generates different tokens at different timestamps", () => {
|
||||
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
|
||||
const token1 = generateToken("same-resume");
|
||||
|
||||
vi.setSystemTime(new Date("2025-01-01T00:01:00Z"));
|
||||
const token2 = generateToken("same-resume");
|
||||
|
||||
expect(token1).not.toBe(token2);
|
||||
});
|
||||
|
||||
it("uses base64url encoding (no +, /, or = characters in payload)", () => {
|
||||
const token = generateToken("resume/with+special=chars");
|
||||
const [payloadBase64] = token.split(".");
|
||||
|
||||
expect(payloadBase64).not.toMatch(/[+/=]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyPrinterToken (algorithm)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns the resume ID for a valid, non-expired token", () => {
|
||||
const now = new Date("2025-06-15T12:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const token = createValidToken("resume-123", now.getTime());
|
||||
const resumeId = verifyToken(token);
|
||||
|
||||
expect(resumeId).toBe("resume-123");
|
||||
});
|
||||
|
||||
it("accepts a token that is just under 5 minutes old", () => {
|
||||
const createdAt = new Date("2025-06-15T12:00:00Z");
|
||||
vi.setSystemTime(new Date("2025-06-15T12:04:59Z"));
|
||||
|
||||
const token = createValidToken("resume-456", createdAt.getTime());
|
||||
const resumeId = verifyToken(token);
|
||||
|
||||
expect(resumeId).toBe("resume-456");
|
||||
});
|
||||
|
||||
it("rejects a token exactly at the 5-minute boundary", () => {
|
||||
const createdAt = new Date("2025-06-15T12:00:00Z");
|
||||
// 5 minutes + 1ms after creation
|
||||
vi.setSystemTime(new Date(createdAt.getTime() + PRINTER_TOKEN_TTL_MS + 1));
|
||||
|
||||
const token = createValidToken("resume-789", createdAt.getTime());
|
||||
expect(() => verifyToken(token)).toThrow("Token expired");
|
||||
});
|
||||
|
||||
it("throws for a token older than 5 minutes", () => {
|
||||
const createdAt = new Date("2025-06-15T12:00:00Z");
|
||||
vi.setSystemTime(new Date("2025-06-15T12:06:00Z"));
|
||||
|
||||
const token = createValidToken("resume-789", createdAt.getTime());
|
||||
expect(() => verifyToken(token)).toThrow("Token expired");
|
||||
});
|
||||
|
||||
it("throws for a token with a future timestamp (negative age)", () => {
|
||||
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
|
||||
const futureTime = new Date("2025-06-15T13:00:00Z").getTime();
|
||||
|
||||
const token = createValidToken("resume-future", futureTime);
|
||||
expect(() => verifyToken(token)).toThrow("Token expired");
|
||||
});
|
||||
|
||||
it("throws for invalid token format (no dot separator)", () => {
|
||||
expect(() => verifyToken("nodothere")).toThrow("Invalid token format");
|
||||
});
|
||||
|
||||
it("throws for invalid token format (too many dots)", () => {
|
||||
expect(() => verifyToken("a.b.c")).toThrow("Invalid token format");
|
||||
});
|
||||
|
||||
it("throws for empty token", () => {
|
||||
expect(() => verifyToken("")).toThrow("Invalid token format");
|
||||
});
|
||||
|
||||
it("throws for a token with tampered signature", () => {
|
||||
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
|
||||
const token = createValidToken("resume-123", Date.now());
|
||||
const [payload] = token.split(".");
|
||||
|
||||
const tamperedToken = `${payload}.deadbeef0000000000000000000000000000000000000000000000000000dead`;
|
||||
expect(() => verifyToken(tamperedToken)).toThrow("Invalid token signature");
|
||||
});
|
||||
|
||||
it("throws for a token with tampered payload", () => {
|
||||
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
|
||||
const token = createValidToken("resume-123", Date.now());
|
||||
const [, signature] = token.split(".");
|
||||
|
||||
const tamperedPayload = Buffer.from("hacked-resume:0").toString("base64url");
|
||||
expect(() => verifyToken(`${tamperedPayload}.${signature}`)).toThrow("Invalid token signature");
|
||||
});
|
||||
|
||||
it("throws for a token with invalid base64 payload (no colon separator)", () => {
|
||||
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
|
||||
const payloadBase64 = Buffer.from("nocolon").toString("base64url");
|
||||
const signature = createHmac("sha256", TEST_SECRET).update(payloadBase64).digest("hex");
|
||||
|
||||
// "nocolon".split(":") => ["nocolon"], so timestampStr is undefined
|
||||
expect(() => verifyToken(`${payloadBase64}.${signature}`)).toThrow("Invalid token payload");
|
||||
});
|
||||
|
||||
it("throws for payload with non-numeric timestamp", () => {
|
||||
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
|
||||
const payloadBase64 = Buffer.from("resume-id:not-a-number").toString("base64url");
|
||||
const signature = createHmac("sha256", TEST_SECRET).update(payloadBase64).digest("hex");
|
||||
|
||||
expect(() => verifyToken(`${payloadBase64}.${signature}`)).toThrow("Invalid timestamp");
|
||||
});
|
||||
|
||||
it("round-trips: generate then verify returns original resume ID", () => {
|
||||
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
|
||||
|
||||
const token = generateToken("my-resume-uuid-v7");
|
||||
const result = verifyToken(token);
|
||||
|
||||
expect(result).toBe("my-resume-uuid-v7");
|
||||
});
|
||||
|
||||
it("handles resume IDs with dashes and underscores", () => {
|
||||
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
|
||||
|
||||
const id = "resume-with-dashes_and_underscores";
|
||||
const token = generateToken(id);
|
||||
const result = verifyToken(token);
|
||||
|
||||
expect(result).toBe(id);
|
||||
});
|
||||
|
||||
it("resume IDs with colons cause verification to fail", () => {
|
||||
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
|
||||
|
||||
// Documents a known limitation: IDs with ":" break the payload format
|
||||
// because verifyToken splits on ":" to separate ID from timestamp,
|
||||
// so the second segment becomes part of the ID, not the timestamp.
|
||||
const token = generateToken("resume:with:colons");
|
||||
expect(() => verifyToken(token)).toThrow("Invalid timestamp");
|
||||
});
|
||||
|
||||
it("accepts a token at exactly age 0 (just created)", () => {
|
||||
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
|
||||
const token = createValidToken("fresh", Date.now());
|
||||
expect(verifyToken(token)).toBe("fresh");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createIsomorphicFn } from "@tanstack/react-start";
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
@@ -15,7 +15,7 @@ export const generatePrinterToken = createIsomorphicFn().server((resumeId: strin
|
||||
const payloadBase64 = Buffer.from(payload).toString("base64url");
|
||||
|
||||
// Create HMAC signature using AUTH_SECRET
|
||||
const signature = createHash("sha256").update(`${payloadBase64}.${env.AUTH_SECRET}`).digest("hex");
|
||||
const signature = createHmac("sha256", env.AUTH_SECRET).update(payloadBase64).digest("hex");
|
||||
|
||||
return `${payloadBase64}.${signature}`;
|
||||
});
|
||||
@@ -31,7 +31,7 @@ export const verifyPrinterToken = createIsomorphicFn().server((token: string) =>
|
||||
const [payloadBase64, signature] = parts;
|
||||
|
||||
// Verify signature
|
||||
const expectedSignature = createHash("sha256").update(`${payloadBase64}.${env.AUTH_SECRET}`).digest("hex");
|
||||
const expectedSignature = createHmac("sha256", env.AUTH_SECRET).update(payloadBase64).digest("hex");
|
||||
const signatureBuffer = Buffer.from(signature);
|
||||
const expectedBuffer = Buffer.from(expectedSignature);
|
||||
|
||||
|
||||
@@ -288,6 +288,150 @@ describe("buildDocument", () => {
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
// --- Template configs ---
|
||||
|
||||
it("renders with chikorita template (sidebar-only header, solid sidebar bg)", () => {
|
||||
const data = makeResumeData({
|
||||
basics: { ...defaultResumeData.basics, name: "Jane", headline: "Dev", email: "j@e.com" },
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
template: "chikorita",
|
||||
layout: {
|
||||
sidebarWidth: 35,
|
||||
pages: [{ fullWidth: false, main: ["experience"], sidebar: ["skills"] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("renders with ditgar template (sidebar-only header, tint sidebar bg)", () => {
|
||||
const data = makeResumeData({
|
||||
basics: { ...defaultResumeData.basics, name: "Alice", headline: "PM" },
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
template: "ditgar",
|
||||
layout: {
|
||||
sidebarWidth: 30,
|
||||
pages: [{ fullWidth: false, main: ["experience"], sidebar: ["skills"] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("renders with pikachu template (main-only header)", () => {
|
||||
const data = makeResumeData({
|
||||
basics: { ...defaultResumeData.basics, name: "Bob" },
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
template: "pikachu",
|
||||
layout: {
|
||||
sidebarWidth: 35,
|
||||
pages: [{ fullWidth: false, main: ["experience"], sidebar: ["skills"] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("renders with unknown template (falls back to default config)", () => {
|
||||
const data = makeResumeData({
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
template: "unknown-template" as any,
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
// --- Header edge cases ---
|
||||
|
||||
it("renders header with custom fields (with links)", () => {
|
||||
const data = makeResumeData({
|
||||
basics: {
|
||||
...defaultResumeData.basics,
|
||||
name: "Jane",
|
||||
customFields: [
|
||||
{ id: "cf1", icon: "star", text: "Portfolio", link: "https://portfolio.dev" },
|
||||
{ id: "cf2", icon: "", text: "Plain text field", link: "" },
|
||||
{ id: "cf3", icon: "", text: "", link: "" }, // empty text — skipped
|
||||
{ id: "cf4", icon: "", text: "Bad link", link: "javascript:alert(1)" }, // unsafe link — rendered as text
|
||||
],
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("renders header without name (no title paragraph)", () => {
|
||||
const data = makeResumeData({
|
||||
basics: { ...defaultResumeData.basics, name: "" },
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("renders header without headline", () => {
|
||||
const data = makeResumeData({
|
||||
basics: { ...defaultResumeData.basics, name: "Jane", headline: "" },
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
// --- Multi-page layout ---
|
||||
|
||||
it("renders multi-page layout", () => {
|
||||
const data = makeResumeData({
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
layout: {
|
||||
sidebarWidth: 35,
|
||||
pages: [
|
||||
{ fullWidth: false, main: ["experience"], sidebar: ["skills"] },
|
||||
{ fullWidth: true, main: ["education"], sidebar: [] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
// --- Section rendering with unknown section ID ---
|
||||
|
||||
it("handles unknown section IDs in layout gracefully", () => {
|
||||
const data = makeResumeData({
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
layout: {
|
||||
sidebarWidth: 35,
|
||||
pages: [{ fullWidth: false, main: ["nonexistent-section"], sidebar: [] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
// --- Free-form page format ---
|
||||
|
||||
it("handles free-form page format", () => {
|
||||
const data = makeResumeData({
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
page: { ...defaultResumeData.metadata.page, format: "free-form" },
|
||||
},
|
||||
});
|
||||
const doc = buildDocument(data);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it("produces a valid DOCX blob via Packer", async () => {
|
||||
const { Packer } = await import("docx");
|
||||
const data = makeResumeData({
|
||||
|
||||
@@ -140,4 +140,148 @@ describe("htmlToParagraphs", () => {
|
||||
const result = htmlToParagraphs("<p><u>under</u></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
// --- Additional coverage: block elements ---
|
||||
|
||||
it("parses blockquote with italic styling", () => {
|
||||
const result = htmlToParagraphs("<blockquote>Quote text</blockquote>");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBeInstanceOf(Paragraph);
|
||||
});
|
||||
|
||||
it("parses pre/code block with monospace font", () => {
|
||||
const result = htmlToParagraphs("<pre>const x = 42;</pre>");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBeInstanceOf(Paragraph);
|
||||
});
|
||||
|
||||
it("parses hr as empty paragraph", () => {
|
||||
const result = htmlToParagraphs("<hr>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses heading elements (h1-h6)", () => {
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const result = htmlToParagraphs(`<h${i}>Heading ${i}</h${i}>`);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBeInstanceOf(Paragraph);
|
||||
}
|
||||
});
|
||||
|
||||
it("parses div as block element", () => {
|
||||
const result = htmlToParagraphs("<div>Content in div</div>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses inline code with Courier New font", () => {
|
||||
const result = htmlToParagraphs("<p><code>inline code</code></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses mark/highlight element", () => {
|
||||
const result = htmlToParagraphs("<p><mark>highlighted</mark></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses b tag (same as strong)", () => {
|
||||
const result = htmlToParagraphs("<p><b>bold</b></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses i tag (same as em)", () => {
|
||||
const result = htmlToParagraphs("<p><i>italic</i></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses strike tag", () => {
|
||||
const result = htmlToParagraphs("<p><strike>struck</strike></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
// --- Nested lists ---
|
||||
|
||||
it("parses nested unordered list", () => {
|
||||
const html = "<ul><li>Top<ul><li>Nested</li></ul></li></ul>";
|
||||
const result = htmlToParagraphs(html);
|
||||
expect(result.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("parses nested ordered list", () => {
|
||||
const html = "<ol><li>First<ol><li>Sub</li></ol></li></ol>";
|
||||
const result = htmlToParagraphs(html);
|
||||
expect(result.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("parses list with paragraph inside li", () => {
|
||||
const html = "<ul><li><p>Paragraph in list</p></li></ul>";
|
||||
const result = htmlToParagraphs(html);
|
||||
expect(result.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// --- Style config ---
|
||||
|
||||
it("applies styleConfig font and size to text runs", () => {
|
||||
const result = htmlToParagraphs("<p>Styled</p>", {
|
||||
font: "Georgia",
|
||||
size: 24,
|
||||
color: "333333",
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("applies linkColor from styleConfig", () => {
|
||||
const result = htmlToParagraphs('<p><a href="https://example.com">link</a></p>', {
|
||||
linkColor: "FF0000",
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
const children = getChildren(result[0] as Paragraph);
|
||||
const hyperlinks = children.filter((c) => c instanceof ExternalHyperlink);
|
||||
expect(hyperlinks).toHaveLength(1);
|
||||
});
|
||||
|
||||
// --- Top-level text nodes ---
|
||||
|
||||
it("handles plain text without wrapper element", () => {
|
||||
const result = htmlToParagraphs("Just plain text");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles link without href falling back to inline text", () => {
|
||||
const result = htmlToParagraphs("<p><a>No href link</a></p>");
|
||||
expect(result).toHaveLength(1);
|
||||
const children = getChildren(result[0] as Paragraph);
|
||||
// Should have TextRun, not ExternalHyperlink (no href)
|
||||
const hyperlinks = children.filter((c) => c instanceof ExternalHyperlink);
|
||||
expect(hyperlinks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles empty blockquote", () => {
|
||||
const result = htmlToParagraphs("<blockquote></blockquote>");
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles empty pre", () => {
|
||||
const result = htmlToParagraphs("<pre></pre>");
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles empty heading", () => {
|
||||
const result = htmlToParagraphs("<h1></h1>");
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
// --- Multiple block elements ---
|
||||
|
||||
it("handles complex HTML with mixed elements", () => {
|
||||
const html = `
|
||||
<h2>Title</h2>
|
||||
<p>Introduction paragraph</p>
|
||||
<ul><li>Item 1</li><li>Item 2</li></ul>
|
||||
<blockquote>A quote</blockquote>
|
||||
<pre>code block</pre>
|
||||
`;
|
||||
const result = htmlToParagraphs(html);
|
||||
// h2 + p + 2 li + blockquote + pre = 6
|
||||
expect(result.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { toSafeDocxLink } from "./link-utils";
|
||||
|
||||
describe("toSafeDocxLink", () => {
|
||||
describe("valid URLs", () => {
|
||||
it("returns http URLs as-is", () => {
|
||||
expect(toSafeDocxLink("http://example.com")).toBe("http://example.com/");
|
||||
});
|
||||
|
||||
it("returns https URLs as-is", () => {
|
||||
expect(toSafeDocxLink("https://example.com")).toBe("https://example.com/");
|
||||
});
|
||||
|
||||
it("returns https URLs with paths", () => {
|
||||
expect(toSafeDocxLink("https://example.com/path/to/page")).toBe("https://example.com/path/to/page");
|
||||
});
|
||||
|
||||
it("returns https URLs with query parameters", () => {
|
||||
expect(toSafeDocxLink("https://example.com?foo=bar")).toBe("https://example.com/?foo=bar");
|
||||
});
|
||||
|
||||
it("returns mailto links", () => {
|
||||
expect(toSafeDocxLink("mailto:user@example.com")).toBe("mailto:user@example.com");
|
||||
});
|
||||
|
||||
it("trims whitespace before processing", () => {
|
||||
expect(toSafeDocxLink(" https://example.com ")).toBe("https://example.com/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid/unsafe URLs", () => {
|
||||
it("returns null for empty string", () => {
|
||||
expect(toSafeDocxLink("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for whitespace-only string", () => {
|
||||
expect(toSafeDocxLink(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for javascript: URLs", () => {
|
||||
expect(toSafeDocxLink("javascript:alert(1)")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for data: URLs", () => {
|
||||
expect(toSafeDocxLink("data:text/html,<h1>hi</h1>")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for file: URLs", () => {
|
||||
expect(toSafeDocxLink("file:///etc/passwd")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for ftp: URLs", () => {
|
||||
expect(toSafeDocxLink("ftp://example.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid URLs", () => {
|
||||
expect(toSafeDocxLink("not a url at all")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for mailto: without email", () => {
|
||||
expect(toSafeDocxLink("mailto:")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for mailto: with only whitespace", () => {
|
||||
expect(toSafeDocxLink("mailto: ")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,654 @@
|
||||
import { Paragraph } from "docx";
|
||||
import { beforeEach, describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import type { CustomSection, ResumeData } from "@/schema/resume/data";
|
||||
|
||||
import { defaultResumeData } from "@/schema/resume/data";
|
||||
|
||||
import { renderBuiltInSection, renderCustomSection, renderSummary, setRenderConfig } from "./section-renderers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const COLOR_HEX = "#DC2626";
|
||||
|
||||
function freshSections(): ResumeData["sections"] {
|
||||
return structuredClone(defaultResumeData.sections);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setRenderConfig({
|
||||
headingFont: "Arial",
|
||||
headingSizeHalfPt: 28,
|
||||
bodyFont: "Calibri",
|
||||
bodySizeHalfPt: 22,
|
||||
textColorHex: "#000000",
|
||||
primaryColorHex: "#DC2626",
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderSummary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("renderSummary", () => {
|
||||
it("renders summary with title and content", () => {
|
||||
const summary = { title: "Summary", columns: 1, hidden: false, content: "<p>A great developer</p>" };
|
||||
const result = renderSummary(summary, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
// First paragraph should be the heading
|
||||
expect(result[0]).toBeInstanceOf(Paragraph);
|
||||
});
|
||||
|
||||
it("returns empty array when summary is hidden", () => {
|
||||
const summary = { title: "Summary", columns: 1, hidden: true, content: "<p>Hidden</p>" };
|
||||
expect(renderSummary(summary, COLOR_HEX)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns empty array when content is empty", () => {
|
||||
const summary = { title: "Summary", columns: 1, hidden: false, content: "" };
|
||||
expect(renderSummary(summary, COLOR_HEX)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips heading when title is empty", () => {
|
||||
const summary = { title: "", columns: 1, hidden: false, content: "<p>Content only</p>" };
|
||||
const result = renderSummary(summary, COLOR_HEX);
|
||||
// Should have content paragraphs but no heading
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderBuiltInSection — experience
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("renderBuiltInSection — experience", () => {
|
||||
it("renders experience items with company and position", () => {
|
||||
const sections = freshSections();
|
||||
sections.experience.title = "Experience";
|
||||
sections.experience.items = [
|
||||
{
|
||||
id: "e1",
|
||||
hidden: false,
|
||||
company: "Acme Corp",
|
||||
position: "Engineer",
|
||||
location: "NYC",
|
||||
period: "2020 - 2022",
|
||||
website: { url: "https://acme.com", label: "Acme" },
|
||||
description: "<p>Built things</p>",
|
||||
roles: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("experience", sections.experience, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1); // heading + item paragraphs
|
||||
expect(result[0]).toBeInstanceOf(Paragraph);
|
||||
});
|
||||
|
||||
it("returns empty for hidden section", () => {
|
||||
const sections = freshSections();
|
||||
sections.experience.hidden = true;
|
||||
sections.experience.items = [
|
||||
{
|
||||
id: "e1",
|
||||
hidden: false,
|
||||
company: "Co",
|
||||
position: "Dev",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("experience", sections.experience, COLOR_HEX);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns empty when all items are hidden", () => {
|
||||
const sections = freshSections();
|
||||
sections.experience.items = [
|
||||
{
|
||||
id: "e1",
|
||||
hidden: true,
|
||||
company: "Co",
|
||||
position: "Dev",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("experience", sections.experience, COLOR_HEX);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders experience with roles", () => {
|
||||
const sections = freshSections();
|
||||
sections.experience.title = "Experience";
|
||||
sections.experience.items = [
|
||||
{
|
||||
id: "e1",
|
||||
hidden: false,
|
||||
company: "BigCo",
|
||||
position: "",
|
||||
location: "SF",
|
||||
period: "2018 - 2024",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [
|
||||
{ id: "r1", position: "Senior Dev", period: "2022 - 2024", description: "<p>Led team</p>" },
|
||||
{ id: "r2", position: "Junior Dev", period: "2018 - 2022", description: "<p>Learned things</p>" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("experience", sections.experience, COLOR_HEX);
|
||||
// Should have heading + company title + location + role1 + role1 desc + role2 + role2 desc
|
||||
expect(result.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
it("filters out hidden items", () => {
|
||||
const sections = freshSections();
|
||||
sections.experience.title = "Experience";
|
||||
sections.experience.items = [
|
||||
{
|
||||
id: "e1",
|
||||
hidden: false,
|
||||
company: "Visible",
|
||||
position: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: "e2",
|
||||
hidden: true,
|
||||
company: "Hidden",
|
||||
position: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("experience", sections.experience, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderBuiltInSection — skills
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("renderBuiltInSection — skills", () => {
|
||||
it("renders skills with name, proficiency, and keywords", () => {
|
||||
const sections = freshSections();
|
||||
sections.skills.title = "Skills";
|
||||
sections.skills.items = [
|
||||
{
|
||||
id: "s1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "TypeScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
keywords: ["frontend", "backend"],
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("skills", sections.skills, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1); // heading + skill
|
||||
});
|
||||
|
||||
it("renders skills without proficiency or keywords", () => {
|
||||
const sections = freshSections();
|
||||
sections.skills.title = "Skills";
|
||||
sections.skills.items = [
|
||||
{ id: "s1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] },
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("skills", sections.skills, COLOR_HEX);
|
||||
expect(result.length).toBe(2); // heading + skill name only
|
||||
});
|
||||
|
||||
it("returns empty for empty items", () => {
|
||||
const sections = freshSections();
|
||||
const result = renderBuiltInSection("skills", sections.skills, COLOR_HEX);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderBuiltInSection — education
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("renderBuiltInSection — education", () => {
|
||||
it("renders education with degree, area, and grade", () => {
|
||||
const sections = freshSections();
|
||||
sections.education.title = "Education";
|
||||
sections.education.items = [
|
||||
{
|
||||
id: "ed1",
|
||||
hidden: false,
|
||||
school: "MIT",
|
||||
degree: "BSc",
|
||||
area: "CS",
|
||||
grade: "4.0",
|
||||
location: "Cambridge",
|
||||
period: "2016 - 2020",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("education", sections.education, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("skips grade paragraph when grade is empty", () => {
|
||||
const sections = freshSections();
|
||||
sections.education.title = "Education";
|
||||
sections.education.items = [
|
||||
{
|
||||
id: "ed1",
|
||||
hidden: false,
|
||||
school: "MIT",
|
||||
degree: "BSc",
|
||||
area: "",
|
||||
grade: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("education", sections.education, COLOR_HEX);
|
||||
// heading + title only (no grade, no location, no description, no website)
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderBuiltInSection — languages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("renderBuiltInSection — languages", () => {
|
||||
it("renders language with fluency", () => {
|
||||
const sections = freshSections();
|
||||
sections.languages.title = "Languages";
|
||||
sections.languages.items = [{ id: "l1", hidden: false, language: "English", fluency: "Native", level: 5 }];
|
||||
|
||||
const result = renderBuiltInSection("languages", sections.languages, COLOR_HEX);
|
||||
expect(result.length).toBe(2); // heading + language
|
||||
});
|
||||
|
||||
it("renders language without fluency", () => {
|
||||
const sections = freshSections();
|
||||
sections.languages.title = "Languages";
|
||||
sections.languages.items = [{ id: "l1", hidden: false, language: "Spanish", fluency: "", level: 0 }];
|
||||
|
||||
const result = renderBuiltInSection("languages", sections.languages, COLOR_HEX);
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderBuiltInSection — other sections
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("renderBuiltInSection — other sections", () => {
|
||||
it("renders profiles with network and username", () => {
|
||||
const sections = freshSections();
|
||||
sections.profiles.title = "Profiles";
|
||||
sections.profiles.items = [
|
||||
{
|
||||
id: "p1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
network: "GitHub",
|
||||
username: "janedoe",
|
||||
website: { url: "https://github.com/janedoe", label: "GitHub" },
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("profiles", sections.profiles, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("renders awards with title and awarder", () => {
|
||||
const sections = freshSections();
|
||||
sections.awards.title = "Awards";
|
||||
sections.awards.items = [
|
||||
{
|
||||
id: "a1",
|
||||
hidden: false,
|
||||
title: "Best Dev",
|
||||
awarder: "Company",
|
||||
date: "2024",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("awards", sections.awards, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("renders certifications", () => {
|
||||
const sections = freshSections();
|
||||
sections.certifications.title = "Certifications";
|
||||
sections.certifications.items = [
|
||||
{
|
||||
id: "c1",
|
||||
hidden: false,
|
||||
title: "AWS",
|
||||
issuer: "Amazon",
|
||||
date: "2023",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("certifications", sections.certifications, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("renders publications", () => {
|
||||
const sections = freshSections();
|
||||
sections.publications.title = "Publications";
|
||||
sections.publications.items = [
|
||||
{
|
||||
id: "pub1",
|
||||
hidden: false,
|
||||
title: "My Paper",
|
||||
publisher: "IEEE",
|
||||
date: "2024",
|
||||
website: { url: "", label: "" },
|
||||
description: "<p>Research</p>",
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("publications", sections.publications, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("renders volunteer section", () => {
|
||||
const sections = freshSections();
|
||||
sections.volunteer.title = "Volunteer";
|
||||
sections.volunteer.items = [
|
||||
{
|
||||
id: "v1",
|
||||
hidden: false,
|
||||
organization: "Red Cross",
|
||||
location: "NYC",
|
||||
period: "2023",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("volunteer", sections.volunteer, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("renders references with position and phone", () => {
|
||||
const sections = freshSections();
|
||||
sections.references.title = "References";
|
||||
sections.references.items = [
|
||||
{
|
||||
id: "r1",
|
||||
hidden: false,
|
||||
name: "John",
|
||||
position: "CTO",
|
||||
phone: "+1234567890",
|
||||
website: { url: "", label: "" },
|
||||
description: "<p>Great dev</p>",
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("references", sections.references, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("renders interests with keywords", () => {
|
||||
const sections = freshSections();
|
||||
sections.interests.title = "Interests";
|
||||
sections.interests.items = [{ id: "i1", hidden: false, icon: "", name: "Gaming", keywords: ["RPG", "Strategy"] }];
|
||||
|
||||
const result = renderBuiltInSection("interests", sections.interests, COLOR_HEX);
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
it("renders projects with period and website", () => {
|
||||
const sections = freshSections();
|
||||
sections.projects.title = "Projects";
|
||||
sections.projects.items = [
|
||||
{
|
||||
id: "proj1",
|
||||
hidden: false,
|
||||
name: "My App",
|
||||
period: "2024",
|
||||
website: { url: "https://app.com", label: "App" },
|
||||
description: "<p>A cool app</p>",
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("projects", sections.projects, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(2); // heading + title + description + website
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderCustomSection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("renderCustomSection", () => {
|
||||
it("renders custom experience-typed section", () => {
|
||||
const custom: CustomSection = {
|
||||
id: "custom-1",
|
||||
type: "experience",
|
||||
title: "Freelance",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [
|
||||
{
|
||||
id: "ci1",
|
||||
hidden: false,
|
||||
company: "Client A",
|
||||
position: "Contractor",
|
||||
location: "Remote",
|
||||
period: "2023",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = renderCustomSection(custom, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns empty for hidden custom section", () => {
|
||||
const custom: CustomSection = {
|
||||
id: "custom-1",
|
||||
type: "skills",
|
||||
title: "Hidden",
|
||||
columns: 1,
|
||||
hidden: true,
|
||||
items: [{ id: "ci1", hidden: false, icon: "", name: "Skill", proficiency: "", level: 0, keywords: [] }],
|
||||
};
|
||||
|
||||
expect(renderCustomSection(custom, COLOR_HEX)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns empty when all items are hidden", () => {
|
||||
const custom: CustomSection = {
|
||||
id: "custom-1",
|
||||
type: "skills",
|
||||
title: "Skills",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [{ id: "ci1", hidden: true, icon: "", name: "Skill", proficiency: "", level: 0, keywords: [] }],
|
||||
};
|
||||
|
||||
expect(renderCustomSection(custom, COLOR_HEX)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders summary-type custom section", () => {
|
||||
const custom: CustomSection = {
|
||||
id: "custom-1",
|
||||
type: "summary",
|
||||
title: "Cover Note",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [{ id: "ci1", hidden: false, content: "<p>Hello there</p>" }],
|
||||
};
|
||||
|
||||
const result = renderCustomSection(custom, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders cover-letter-type custom section", () => {
|
||||
const custom: CustomSection = {
|
||||
id: "custom-1",
|
||||
type: "cover-letter",
|
||||
title: "Cover Letter",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [
|
||||
{
|
||||
id: "ci1",
|
||||
hidden: false,
|
||||
recipient: "<p>Dear Hiring Manager</p>",
|
||||
content: "<p>I am writing to apply...</p>",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = renderCustomSection(custom, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1); // heading + recipient + content
|
||||
});
|
||||
|
||||
it("renders skills-typed custom section", () => {
|
||||
const custom: CustomSection = {
|
||||
id: "custom-1",
|
||||
type: "skills",
|
||||
title: "Soft Skills",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [
|
||||
{ id: "ci1", hidden: false, icon: "", name: "Leadership", proficiency: "Expert", level: 5, keywords: [] },
|
||||
],
|
||||
};
|
||||
|
||||
const result = renderCustomSection(custom, COLOR_HEX);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setRenderConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("setRenderConfig", () => {
|
||||
it("configures typography that affects all renderers", () => {
|
||||
setRenderConfig({
|
||||
headingFont: "Georgia",
|
||||
headingSizeHalfPt: 32,
|
||||
bodyFont: "Times New Roman",
|
||||
bodySizeHalfPt: 24,
|
||||
textColorHex: "#333333",
|
||||
primaryColorHex: "#0000FF",
|
||||
});
|
||||
|
||||
const sections = freshSections();
|
||||
sections.skills.title = "Skills";
|
||||
sections.skills.items = [
|
||||
{ id: "s1", hidden: false, icon: "", name: "Test", proficiency: "", level: 0, keywords: [] },
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("skills", sections.skills, "#0000FF");
|
||||
// Should not crash and should produce paragraphs
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles website with unsafe URL (no website paragraph added)", () => {
|
||||
const sections = freshSections();
|
||||
sections.experience.title = "Experience";
|
||||
sections.experience.items = [
|
||||
{
|
||||
id: "e1",
|
||||
hidden: false,
|
||||
company: "Co",
|
||||
position: "Dev",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "javascript:alert(1)", label: "Evil" },
|
||||
description: "",
|
||||
roles: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("experience", sections.experience, COLOR_HEX);
|
||||
// Should still render without the unsafe website
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("handles items with only minimal data", () => {
|
||||
const sections = freshSections();
|
||||
sections.awards.title = "Awards";
|
||||
sections.awards.items = [
|
||||
{
|
||||
id: "a1",
|
||||
hidden: false,
|
||||
title: "Award",
|
||||
awarder: "",
|
||||
date: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("awards", sections.awards, COLOR_HEX);
|
||||
expect(result).toHaveLength(2); // heading + title only
|
||||
});
|
||||
|
||||
it("handles experience with empty description and no location", () => {
|
||||
const sections = freshSections();
|
||||
sections.experience.title = "Experience";
|
||||
sections.experience.items = [
|
||||
{
|
||||
id: "e1",
|
||||
hidden: false,
|
||||
company: "Co",
|
||||
position: "Dev",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = renderBuiltInSection("experience", sections.experience, COLOR_HEX);
|
||||
// heading + title/subtitle only
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,468 @@
|
||||
import { produce } from "immer";
|
||||
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
import { type ResumeData, defaultResumeData } from "@/schema/resume/data";
|
||||
|
||||
import {
|
||||
addItemToSection,
|
||||
createCustomSectionWithItem,
|
||||
createPageWithSection,
|
||||
getCompatibleMoveTargets,
|
||||
getSourceSectionTitle,
|
||||
removeItemFromSource,
|
||||
} from "./move-item";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Mock getSectionTitle — it uses @lingui/core/macro `t` which isn't available in tests
|
||||
vi.mock("./section", () => ({
|
||||
getSectionTitle: (type: string) => type.charAt(0).toUpperCase() + type.slice(1),
|
||||
}));
|
||||
|
||||
// Mock generateId to return deterministic values
|
||||
let idCounter = 0;
|
||||
vi.mock("@/utils/string", () => ({
|
||||
generateId: () => `mock-id-${++idCounter}`,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function freshResume(): ResumeData {
|
||||
return structuredClone(defaultResumeData);
|
||||
}
|
||||
|
||||
function resumeWithSkills(): ResumeData {
|
||||
const data = freshResume();
|
||||
data.sections.skills.items = [
|
||||
{ id: "s1", hidden: false, icon: "", name: "JS", proficiency: "Advanced", level: 4, keywords: ["frontend"] },
|
||||
{ id: "s2", hidden: false, icon: "", name: "TS", proficiency: "Intermediate", level: 3, keywords: [] },
|
||||
{ id: "s3", hidden: false, icon: "", name: "Rust", proficiency: "Beginner", level: 1, keywords: [] },
|
||||
];
|
||||
return data;
|
||||
}
|
||||
|
||||
function resumeWithCustomSections(): ResumeData {
|
||||
const data = freshResume();
|
||||
data.customSections = [
|
||||
{
|
||||
id: "custom-exp-1",
|
||||
type: "experience",
|
||||
title: "Freelance Work",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [
|
||||
{
|
||||
id: "item-1",
|
||||
hidden: false,
|
||||
company: "Acme",
|
||||
position: "Dev",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "custom-skills-1",
|
||||
type: "skills",
|
||||
title: "Soft Skills",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [{ id: "cs1", hidden: false, icon: "", name: "Leadership", proficiency: "", level: 0, keywords: [] }],
|
||||
},
|
||||
];
|
||||
// Add custom sections to the layout
|
||||
data.metadata.layout.pages[0].main.push("custom-exp-1");
|
||||
data.metadata.layout.pages[0].sidebar.push("custom-skills-1");
|
||||
return data;
|
||||
}
|
||||
|
||||
function resumeWithMultiplePages(): ResumeData {
|
||||
const data = resumeWithCustomSections();
|
||||
data.metadata.layout.pages.push({
|
||||
fullWidth: false,
|
||||
main: ["experience"],
|
||||
sidebar: ["skills"],
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
idCounter = 0;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getSourceSectionTitle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getSourceSectionTitle", () => {
|
||||
it("returns the default section title for a standard section", () => {
|
||||
const data = freshResume();
|
||||
const title = getSourceSectionTitle(data, "experience");
|
||||
expect(title).toBe("Experience");
|
||||
});
|
||||
|
||||
it("returns the custom section title when customSectionId is provided", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
const title = getSourceSectionTitle(data, "experience", "custom-exp-1");
|
||||
expect(title).toBe("Freelance Work");
|
||||
});
|
||||
|
||||
it("falls back to default title when customSectionId doesn't match", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
const title = getSourceSectionTitle(data, "experience", "nonexistent-id");
|
||||
expect(title).toBe("Experience");
|
||||
});
|
||||
|
||||
it("returns the default title for various section types", () => {
|
||||
const data = freshResume();
|
||||
expect(getSourceSectionTitle(data, "skills")).toBe("Skills");
|
||||
expect(getSourceSectionTitle(data, "education")).toBe("Education");
|
||||
expect(getSourceSectionTitle(data, "projects")).toBe("Projects");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getCompatibleMoveTargets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getCompatibleMoveTargets", () => {
|
||||
it("returns empty sections for each page when no compatible targets exist", () => {
|
||||
const data = freshResume();
|
||||
const targets = getCompatibleMoveTargets(data, "skills", undefined);
|
||||
|
||||
// The source section (skills) itself should be excluded
|
||||
// All other sections have different types, so no compatible targets
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0].sections).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("finds custom sections with matching type as move targets", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
// Source is the standard "skills" section
|
||||
const targets = getCompatibleMoveTargets(data, "skills", undefined);
|
||||
|
||||
const allSections = targets.flatMap((p) => p.sections);
|
||||
expect(allSections.some((s) => s.sectionId === "custom-skills-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes the source section from targets", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
// Source is the custom skills section
|
||||
const targets = getCompatibleMoveTargets(data, "skills", "custom-skills-1");
|
||||
|
||||
const allSections = targets.flatMap((p) => p.sections);
|
||||
expect(allSections.some((s) => s.sectionId === "custom-skills-1")).toBe(false);
|
||||
// But standard "skills" should be included
|
||||
expect(allSections.some((s) => s.sectionId === "skills")).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes the standard source section when sourceSectionId is undefined", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
const targets = getCompatibleMoveTargets(data, "experience", undefined);
|
||||
|
||||
const allSections = targets.flatMap((p) => p.sections);
|
||||
// Standard "experience" should be excluded (it's the source)
|
||||
expect(allSections.some((s) => s.sectionId === "experience")).toBe(false);
|
||||
// Custom experience section should be included
|
||||
expect(allSections.some((s) => s.sectionId === "custom-exp-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns targets across multiple pages", () => {
|
||||
const data = resumeWithMultiplePages();
|
||||
const targets = getCompatibleMoveTargets(data, "skills", "custom-skills-1");
|
||||
|
||||
expect(targets).toHaveLength(2);
|
||||
// Page 0 should have standard skills in sidebar
|
||||
const page0Sections = targets[0].sections;
|
||||
expect(page0Sections.some((s) => s.sectionId === "skills")).toBe(true);
|
||||
// Page 1 should also have skills in sidebar
|
||||
const page1Sections = targets[1].sections;
|
||||
expect(page1Sections.some((s) => s.sectionId === "skills")).toBe(true);
|
||||
});
|
||||
|
||||
it("correctly labels standard vs custom sections", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
const targets = getCompatibleMoveTargets(data, "skills", "custom-skills-1");
|
||||
|
||||
const allSections = targets.flatMap((p) => p.sections);
|
||||
const standardSection = allSections.find((s) => s.sectionId === "skills");
|
||||
expect(standardSection?.isStandard).toBe(true);
|
||||
});
|
||||
|
||||
it("correctly labels custom sections", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
const targets = getCompatibleMoveTargets(data, "skills", undefined);
|
||||
|
||||
const allSections = targets.flatMap((p) => p.sections);
|
||||
const customSection = allSections.find((s) => s.sectionId === "custom-skills-1");
|
||||
expect(customSection?.isStandard).toBe(false);
|
||||
expect(customSection?.sectionTitle).toBe("Soft Skills");
|
||||
});
|
||||
|
||||
it("includes page index in results", () => {
|
||||
const data = resumeWithMultiplePages();
|
||||
const targets = getCompatibleMoveTargets(data, "experience", undefined);
|
||||
|
||||
expect(targets[0].pageIndex).toBe(0);
|
||||
expect(targets[1].pageIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("returns no targets for a section type not present elsewhere", () => {
|
||||
const data = freshResume();
|
||||
const targets = getCompatibleMoveTargets(data, "awards", undefined);
|
||||
|
||||
// awards only appears once in the layout
|
||||
const allSections = targets.flatMap((p) => p.sections);
|
||||
expect(allSections).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// removeItemFromSource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("removeItemFromSource", () => {
|
||||
it("removes an item from a standard section and returns it", () => {
|
||||
const data = resumeWithSkills();
|
||||
const result = produce(data, (draft) => {
|
||||
const removed = removeItemFromSource(draft, "s2", "skills");
|
||||
expect(removed).not.toBeNull();
|
||||
expect(removed!.id).toBe("s2");
|
||||
});
|
||||
|
||||
expect(result.sections.skills.items).toHaveLength(2);
|
||||
expect(result.sections.skills.items.every((i) => i.id !== "s2")).toBe(true);
|
||||
});
|
||||
|
||||
it("removes the first item from a standard section", () => {
|
||||
const data = resumeWithSkills();
|
||||
const result = produce(data, (draft) => {
|
||||
const removed = removeItemFromSource(draft, "s1", "skills");
|
||||
expect(removed!.id).toBe("s1");
|
||||
});
|
||||
|
||||
expect(result.sections.skills.items).toHaveLength(2);
|
||||
expect(result.sections.skills.items[0].id).toBe("s2");
|
||||
});
|
||||
|
||||
it("removes the last item from a standard section", () => {
|
||||
const data = resumeWithSkills();
|
||||
const result = produce(data, (draft) => {
|
||||
const removed = removeItemFromSource(draft, "s3", "skills");
|
||||
expect(removed!.id).toBe("s3");
|
||||
});
|
||||
|
||||
expect(result.sections.skills.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns null when item is not found in standard section", () => {
|
||||
const data = resumeWithSkills();
|
||||
produce(data, (draft) => {
|
||||
const removed = removeItemFromSource(draft, "nonexistent", "skills");
|
||||
expect(removed).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("removes an item from a custom section", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
const result = produce(data, (draft) => {
|
||||
const removed = removeItemFromSource(draft, "item-1", "experience", "custom-exp-1");
|
||||
expect(removed).not.toBeNull();
|
||||
expect(removed!.id).toBe("item-1");
|
||||
});
|
||||
|
||||
const customSection = result.customSections.find((s) => s.id === "custom-exp-1")!;
|
||||
expect(customSection.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns null when custom section is not found", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
produce(data, (draft) => {
|
||||
const removed = removeItemFromSource(draft, "item-1", "experience", "nonexistent");
|
||||
expect(removed).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when item is not in the custom section", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
produce(data, (draft) => {
|
||||
const removed = removeItemFromSource(draft, "nonexistent", "experience", "custom-exp-1");
|
||||
expect(removed).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// addItemToSection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("addItemToSection", () => {
|
||||
it("adds an item to a standard section", () => {
|
||||
const data = freshResume();
|
||||
const newItem = { id: "new-1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
addItemToSection(draft, newItem, "skills", "skills");
|
||||
});
|
||||
|
||||
expect(result.sections.skills.items).toHaveLength(1);
|
||||
expect(result.sections.skills.items[0].id).toBe("new-1");
|
||||
});
|
||||
|
||||
it("adds an item to a custom section", () => {
|
||||
const data = resumeWithCustomSections();
|
||||
const newItem = { id: "new-2", hidden: false, icon: "", name: "Teamwork", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
addItemToSection(draft, newItem, "custom-skills-1", "skills");
|
||||
});
|
||||
|
||||
const customSection = result.customSections.find((s) => s.id === "custom-skills-1")!;
|
||||
expect(customSection.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does nothing when target custom section doesn't exist", () => {
|
||||
const data = freshResume();
|
||||
const newItem = { id: "new-3", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
addItemToSection(draft, newItem, "nonexistent-section", "skills");
|
||||
});
|
||||
|
||||
// Nothing should change
|
||||
expect(result.sections.skills.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("appends to the end of an existing items array", () => {
|
||||
const data = resumeWithSkills();
|
||||
const newItem = { id: "s4", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
addItemToSection(draft, newItem, "skills", "skills");
|
||||
});
|
||||
|
||||
expect(result.sections.skills.items).toHaveLength(4);
|
||||
expect(result.sections.skills.items[3].id).toBe("s4");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createCustomSectionWithItem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createCustomSectionWithItem", () => {
|
||||
it("creates a new custom section with the given item", () => {
|
||||
const data = freshResume();
|
||||
const item = { id: "item-1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
const sectionId = createCustomSectionWithItem(draft, item, "skills", "Tech Skills", 0);
|
||||
expect(sectionId).toBe("mock-id-1");
|
||||
});
|
||||
|
||||
expect(result.customSections).toHaveLength(1);
|
||||
expect(result.customSections[0].title).toBe("Tech Skills");
|
||||
expect(result.customSections[0].type).toBe("skills");
|
||||
expect(result.customSections[0].items).toHaveLength(1);
|
||||
expect(result.customSections[0].columns).toBe(1);
|
||||
expect(result.customSections[0].hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("adds the new section to the target page's main column", () => {
|
||||
const data = freshResume();
|
||||
const item = { id: "item-1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
const mainLengthBefore = data.metadata.layout.pages[0].main.length;
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createCustomSectionWithItem(draft, item, "skills", "Skills 2", 0);
|
||||
});
|
||||
|
||||
expect(result.metadata.layout.pages[0].main).toHaveLength(mainLengthBefore + 1);
|
||||
expect(result.metadata.layout.pages[0].main).toContain("mock-id-1");
|
||||
});
|
||||
|
||||
it("handles targeting a specific page index", () => {
|
||||
const data = resumeWithMultiplePages();
|
||||
const item = { id: "item-2", hidden: false, icon: "", name: "Python", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createCustomSectionWithItem(draft, item, "skills", "More Skills", 1);
|
||||
});
|
||||
|
||||
expect(result.metadata.layout.pages[1].main).toContain("mock-id-1");
|
||||
// Page 0 should be unchanged
|
||||
expect(result.metadata.layout.pages[0].main).not.toContain("mock-id-1");
|
||||
});
|
||||
|
||||
it("handles out-of-bounds page index gracefully (no crash)", () => {
|
||||
const data = freshResume();
|
||||
const item = { id: "item-3", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
// Should not throw — the page just doesn't exist so nothing is pushed
|
||||
const result = produce(data, (draft) => {
|
||||
createCustomSectionWithItem(draft, item, "skills", "Skills", 99);
|
||||
});
|
||||
|
||||
// Custom section still gets created
|
||||
expect(result.customSections).toHaveLength(1);
|
||||
// But no page was modified
|
||||
expect(result.metadata.layout.pages[0].main).not.toContain("mock-id-1");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createPageWithSection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createPageWithSection", () => {
|
||||
it("creates a new page with a custom section", () => {
|
||||
const data = freshResume();
|
||||
const item = { id: "item-1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
const pagesBefore = data.metadata.layout.pages.length;
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createPageWithSection(draft, item, "skills", "New Skills Page");
|
||||
});
|
||||
|
||||
expect(result.metadata.layout.pages).toHaveLength(pagesBefore + 1);
|
||||
const newPage = result.metadata.layout.pages[result.metadata.layout.pages.length - 1];
|
||||
expect(newPage.fullWidth).toBe(false);
|
||||
expect(newPage.main).toContain("mock-id-1");
|
||||
expect(newPage.sidebar).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("creates the custom section with correct properties", () => {
|
||||
const data = freshResume();
|
||||
const item = { id: "item-1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createPageWithSection(draft, item, "skills", "Page 2 Skills");
|
||||
});
|
||||
|
||||
expect(result.customSections).toHaveLength(1);
|
||||
expect(result.customSections[0].title).toBe("Page 2 Skills");
|
||||
expect(result.customSections[0].type).toBe("skills");
|
||||
expect(result.customSections[0].items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("can create multiple pages in sequence", () => {
|
||||
const data = freshResume();
|
||||
const item1 = { id: "i1", hidden: false, icon: "", name: "A", proficiency: "", level: 0, keywords: [] };
|
||||
const item2 = { id: "i2", hidden: false, icon: "", name: "B", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createPageWithSection(draft, item1, "skills", "Page 2");
|
||||
createPageWithSection(draft, item2, "skills", "Page 3");
|
||||
});
|
||||
|
||||
expect(result.metadata.layout.pages).toHaveLength(3);
|
||||
expect(result.customSections).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,469 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { type ResumeData, defaultResumeData, resumeDataSchema } from "@/schema/resume/data";
|
||||
|
||||
import { ResumePatchError, applyResumePatches, jsonPatchOperationSchema } from "./patch";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns a deep clone of defaultResumeData to avoid cross-test mutation. */
|
||||
function freshResume(): ResumeData {
|
||||
return structuredClone(defaultResumeData);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// jsonPatchOperationSchema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("jsonPatchOperationSchema", () => {
|
||||
it("accepts a valid 'add' operation", () => {
|
||||
const op = { op: "add", path: "/basics/name", value: "Jane" };
|
||||
expect(jsonPatchOperationSchema.parse(op)).toEqual(op);
|
||||
});
|
||||
|
||||
it("accepts a valid 'remove' operation", () => {
|
||||
const op = { op: "remove", path: "/basics/name" };
|
||||
expect(jsonPatchOperationSchema.parse(op)).toEqual(op);
|
||||
});
|
||||
|
||||
it("accepts a valid 'replace' operation", () => {
|
||||
const op = { op: "replace", path: "/basics/name", value: "Jane" };
|
||||
expect(jsonPatchOperationSchema.parse(op)).toEqual(op);
|
||||
});
|
||||
|
||||
it("accepts a valid 'move' operation", () => {
|
||||
const op = { op: "move", path: "/basics/name", from: "/basics/headline" };
|
||||
expect(jsonPatchOperationSchema.parse(op)).toEqual(op);
|
||||
});
|
||||
|
||||
it("accepts a valid 'copy' operation", () => {
|
||||
const op = { op: "copy", path: "/basics/headline", from: "/basics/name" };
|
||||
expect(jsonPatchOperationSchema.parse(op)).toEqual(op);
|
||||
});
|
||||
|
||||
it("accepts a valid 'test' operation", () => {
|
||||
const op = { op: "test", path: "/basics/name", value: "" };
|
||||
expect(jsonPatchOperationSchema.parse(op)).toEqual(op);
|
||||
});
|
||||
|
||||
it("rejects an unknown op type", () => {
|
||||
expect(() => jsonPatchOperationSchema.parse({ op: "patch", path: "/foo" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects 'move' without from", () => {
|
||||
expect(() => jsonPatchOperationSchema.parse({ op: "move", path: "/foo" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects 'copy' without from", () => {
|
||||
expect(() => jsonPatchOperationSchema.parse({ op: "copy", path: "/foo" })).toThrow();
|
||||
});
|
||||
|
||||
it("accepts 'add' with undefined value (z.any() allows it)", () => {
|
||||
// z.any() accepts undefined — this is valid at the schema level
|
||||
const result = jsonPatchOperationSchema.parse({ op: "add", path: "/foo" });
|
||||
expect(result.op).toBe("add");
|
||||
});
|
||||
|
||||
it("accepts 'replace' with explicit value of null", () => {
|
||||
const result = jsonPatchOperationSchema.parse({ op: "replace", path: "/foo", value: null });
|
||||
expect(result.op).toBe("replace");
|
||||
});
|
||||
|
||||
it("accepts 'test' with value of 0", () => {
|
||||
const result = jsonPatchOperationSchema.parse({ op: "test", path: "/foo", value: 0 });
|
||||
expect(result.op).toBe("test");
|
||||
});
|
||||
|
||||
it("rejects operations without a path", () => {
|
||||
expect(() => jsonPatchOperationSchema.parse({ op: "remove" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ResumePatchError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ResumePatchError", () => {
|
||||
it("stores code, message, index, and operation", () => {
|
||||
const op = { op: "replace" as const, path: "/basics/name", value: "Jane" };
|
||||
const err = new ResumePatchError("TEST_OPERATION_FAILED", "test failed", 2, op);
|
||||
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.name).toBe("ResumePatchError");
|
||||
expect(err.code).toBe("TEST_OPERATION_FAILED");
|
||||
expect(err.message).toBe("test failed");
|
||||
expect(err.index).toBe(2);
|
||||
expect(err.operation).toEqual(op);
|
||||
});
|
||||
|
||||
it("has a proper prototype chain", () => {
|
||||
const err = new ResumePatchError("CODE", "msg", 0, { op: "remove", path: "/x" });
|
||||
expect(err instanceof ResumePatchError).toBe(true);
|
||||
expect(err instanceof Error).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyResumePatches — success cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("applyResumePatches", () => {
|
||||
describe("basic operations", () => {
|
||||
it("replaces a top-level string field", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [{ op: "replace", path: "/basics/name", value: "Alice" }]);
|
||||
|
||||
expect(result.basics.name).toBe("Alice");
|
||||
});
|
||||
|
||||
it("replaces a nested field", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [
|
||||
{ op: "replace", path: "/basics/website/url", value: "https://example.com" },
|
||||
]);
|
||||
|
||||
expect(result.basics.website.url).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("adds an item to a section's items array", () => {
|
||||
const data = freshResume();
|
||||
const newSkill = {
|
||||
id: "skill-1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "TypeScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
keywords: ["frontend"],
|
||||
};
|
||||
const result = applyResumePatches(data, [{ op: "add", path: "/sections/skills/items/-", value: newSkill }]);
|
||||
|
||||
expect(result.sections.skills.items).toHaveLength(1);
|
||||
expect(result.sections.skills.items[0].name).toBe("TypeScript");
|
||||
});
|
||||
|
||||
it("removes an item from a section's items array", () => {
|
||||
const data = freshResume();
|
||||
data.sections.skills.items = [
|
||||
{ id: "s1", hidden: false, icon: "", name: "JS", proficiency: "", level: 0, keywords: [] },
|
||||
{ id: "s2", hidden: false, icon: "", name: "TS", proficiency: "", level: 0, keywords: [] },
|
||||
];
|
||||
|
||||
const result = applyResumePatches(data, [{ op: "remove", path: "/sections/skills/items/0" }]);
|
||||
expect(result.sections.skills.items).toHaveLength(1);
|
||||
expect(result.sections.skills.items[0].name).toBe("TS");
|
||||
});
|
||||
|
||||
it("applies multiple operations in sequence", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [
|
||||
{ op: "replace", path: "/basics/name", value: "Bob" },
|
||||
{ op: "replace", path: "/basics/email", value: "bob@test.com" },
|
||||
{ op: "replace", path: "/basics/headline", value: "Developer" },
|
||||
]);
|
||||
|
||||
expect(result.basics.name).toBe("Bob");
|
||||
expect(result.basics.email).toBe("bob@test.com");
|
||||
expect(result.basics.headline).toBe("Developer");
|
||||
});
|
||||
|
||||
it("does not mutate the original data", () => {
|
||||
const data = freshResume();
|
||||
const originalName = data.basics.name;
|
||||
applyResumePatches(data, [{ op: "replace", path: "/basics/name", value: "Mutated?" }]);
|
||||
|
||||
expect(data.basics.name).toBe(originalName);
|
||||
});
|
||||
});
|
||||
|
||||
describe("test operation", () => {
|
||||
it("succeeds when test value matches", () => {
|
||||
const data = freshResume();
|
||||
data.basics.name = "Alice";
|
||||
|
||||
const result = applyResumePatches(data, [
|
||||
{ op: "test", path: "/basics/name", value: "Alice" },
|
||||
{ op: "replace", path: "/basics/name", value: "Bob" },
|
||||
]);
|
||||
|
||||
expect(result.basics.name).toBe("Bob");
|
||||
});
|
||||
|
||||
it("throws ResumePatchError when test value does not match", () => {
|
||||
const data = freshResume();
|
||||
data.basics.name = "Alice";
|
||||
|
||||
expect(() => applyResumePatches(data, [{ op: "test", path: "/basics/name", value: "Wrong" }])).toThrow(
|
||||
ResumePatchError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("copy and move operations", () => {
|
||||
it("copies a value from one path to another", () => {
|
||||
const data = freshResume();
|
||||
data.basics.name = "Alice";
|
||||
|
||||
const result = applyResumePatches(data, [{ op: "copy", path: "/basics/headline", from: "/basics/name" }]);
|
||||
|
||||
expect(result.basics.headline).toBe("Alice");
|
||||
expect(result.basics.name).toBe("Alice");
|
||||
});
|
||||
|
||||
it("moves a value between compatible paths", () => {
|
||||
const data = freshResume();
|
||||
data.basics.name = "Alice";
|
||||
|
||||
// Move name to headline — both are strings, but move deletes the source.
|
||||
// Since the schema requires name to be a string, we replace it back after moving.
|
||||
const result = applyResumePatches(data, [
|
||||
{ op: "move", path: "/basics/headline", from: "/basics/name" },
|
||||
{ op: "add", path: "/basics/name", value: "" },
|
||||
]);
|
||||
|
||||
expect(result.basics.headline).toBe("Alice");
|
||||
expect(result.basics.name).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("metadata operations", () => {
|
||||
it("replaces the template", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [{ op: "replace", path: "/metadata/template", value: "pikachu" }]);
|
||||
expect(result.metadata.template).toBe("pikachu");
|
||||
});
|
||||
|
||||
it("replaces design colors", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [
|
||||
{ op: "replace", path: "/metadata/design/colors/primary", value: "rgba(0, 0, 255, 1)" },
|
||||
]);
|
||||
expect(result.metadata.design.colors.primary).toBe("rgba(0, 0, 255, 1)");
|
||||
});
|
||||
|
||||
it("toggles section visibility", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [{ op: "replace", path: "/sections/skills/hidden", value: true }]);
|
||||
expect(result.sections.skills.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("replaces page margins", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [{ op: "replace", path: "/metadata/page/marginX", value: 20 }]);
|
||||
expect(result.metadata.page.marginX).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom sections", () => {
|
||||
it("adds a custom section", () => {
|
||||
const data = freshResume();
|
||||
const customSection = {
|
||||
id: "custom-1",
|
||||
type: "experience",
|
||||
title: "Freelance",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [],
|
||||
};
|
||||
|
||||
const result = applyResumePatches(data, [{ op: "add", path: "/customSections/-", value: customSection }]);
|
||||
|
||||
expect(result.customSections).toHaveLength(1);
|
||||
expect(result.customSections[0].title).toBe("Freelance");
|
||||
});
|
||||
|
||||
it("removes a custom section by index", () => {
|
||||
const data = freshResume();
|
||||
data.customSections = [
|
||||
{ id: "c1", type: "experience", title: "Freelance", columns: 1, hidden: false, items: [] },
|
||||
{ id: "c2", type: "projects", title: "Side Projects", columns: 1, hidden: false, items: [] },
|
||||
];
|
||||
|
||||
const result = applyResumePatches(data, [{ op: "remove", path: "/customSections/0" }]);
|
||||
expect(result.customSections).toHaveLength(1);
|
||||
expect(result.customSections[0].id).toBe("c2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("picture operations", () => {
|
||||
it("replaces picture URL", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [
|
||||
{ op: "replace", path: "/picture/url", value: "https://example.com/photo.jpg" },
|
||||
]);
|
||||
expect(result.picture.url).toBe("https://example.com/photo.jpg");
|
||||
});
|
||||
|
||||
it("toggles picture visibility", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [{ op: "replace", path: "/picture/hidden", value: true }]);
|
||||
expect(result.picture.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("updates picture size within valid range", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [{ op: "replace", path: "/picture/size", value: 120 }]);
|
||||
expect(result.picture.size).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyResumePatches — error / edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("error handling", () => {
|
||||
it("throws ResumePatchError for invalid path", () => {
|
||||
const data = freshResume();
|
||||
|
||||
expect(() => applyResumePatches(data, [{ op: "replace", path: "/nonexistent/path", value: "x" }])).toThrow(
|
||||
ResumePatchError,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws ResumePatchError for unresolvable 'from' in move", () => {
|
||||
const data = freshResume();
|
||||
|
||||
expect(() =>
|
||||
applyResumePatches(data, [{ op: "move", path: "/basics/name", from: "/nonexistent/field" }]),
|
||||
).toThrow(ResumePatchError);
|
||||
});
|
||||
|
||||
it("throws ResumePatchError for out-of-bounds array index", () => {
|
||||
const data = freshResume();
|
||||
|
||||
expect(() => applyResumePatches(data, [{ op: "remove", path: "/sections/skills/items/999" }])).toThrow(
|
||||
ResumePatchError,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when patch produces invalid resume data (e.g. wrong type)", () => {
|
||||
const data = freshResume();
|
||||
|
||||
// Setting a boolean field to a string should fail schema validation
|
||||
expect(() =>
|
||||
applyResumePatches(data, [{ op: "replace", path: "/picture/hidden", value: "not-a-boolean" }]),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("throws when patch produces invalid resume data (picture size out of range)", () => {
|
||||
const data = freshResume();
|
||||
|
||||
// picture.size must be 32-512
|
||||
expect(() => applyResumePatches(data, [{ op: "replace", path: "/picture/size", value: 9999 }])).toThrow();
|
||||
});
|
||||
|
||||
it("handles empty operations array without error", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, []);
|
||||
|
||||
// Should return equivalent data
|
||||
expect(result.basics.name).toBe(data.basics.name);
|
||||
});
|
||||
|
||||
it("returns a valid ResumeData after patching", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [{ op: "replace", path: "/basics/name", value: "Valid" }]);
|
||||
|
||||
expect(resumeDataSchema.safeParse(result).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout operations", () => {
|
||||
it("replaces sidebar width", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [{ op: "replace", path: "/metadata/layout/sidebarWidth", value: 40 }]);
|
||||
expect(result.metadata.layout.sidebarWidth).toBe(40);
|
||||
});
|
||||
|
||||
it("adds a section to a page layout", () => {
|
||||
const data = freshResume();
|
||||
const result = applyResumePatches(data, [
|
||||
{ op: "add", path: "/metadata/layout/pages/0/sidebar/-", value: "custom-section-id" },
|
||||
]);
|
||||
expect(result.metadata.layout.pages[0].sidebar).toContain("custom-section-id");
|
||||
});
|
||||
|
||||
it("adds a new page to layout", () => {
|
||||
const data = freshResume();
|
||||
const newPage = { fullWidth: false, main: [], sidebar: [] };
|
||||
const result = applyResumePatches(data, [{ op: "add", path: "/metadata/layout/pages/-", value: newPage }]);
|
||||
expect(result.metadata.layout.pages).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional branch/edge case coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("test operation failures (applyPatch catch block)", () => {
|
||||
it("throws ResumePatchError when test op value mismatches (triggers catch)", () => {
|
||||
const data = freshResume();
|
||||
data.basics.name = "Real Name";
|
||||
|
||||
try {
|
||||
applyResumePatches(data, [{ op: "test", path: "/basics/name", value: "Wrong Name" }]);
|
||||
expect.unreachable("Should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ResumePatchError);
|
||||
const patchErr = error as ResumePatchError;
|
||||
expect(patchErr.code).toBeTruthy();
|
||||
expect(patchErr.index).toBeDefined();
|
||||
expect(patchErr.operation).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("ResumePatchError has correct operation in error", () => {
|
||||
const data = freshResume();
|
||||
data.basics.name = "Alice";
|
||||
|
||||
const testOp = { op: "test" as const, path: "/basics/name", value: "Bob" };
|
||||
try {
|
||||
applyResumePatches(data, [testOp]);
|
||||
expect.unreachable("Should have thrown");
|
||||
} catch (error) {
|
||||
const patchErr = error as ResumePatchError;
|
||||
expect(patchErr.operation.path).toBe("/basics/name");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema validation after patch", () => {
|
||||
it("throws on negative picture size (below min)", () => {
|
||||
const data = freshResume();
|
||||
expect(() => applyResumePatches(data, [{ op: "replace", path: "/picture/size", value: -1 }])).toThrow();
|
||||
});
|
||||
|
||||
it("falls back to default for invalid page format (schema uses .catch)", () => {
|
||||
const data = freshResume();
|
||||
// format has .catch("a4"), so invalid values fall back rather than throwing
|
||||
const result = applyResumePatches(data, [{ op: "replace", path: "/metadata/page/format", value: "tabloid" }]);
|
||||
expect(result.metadata.page.format).toBe("a4");
|
||||
});
|
||||
|
||||
it("allows setting customSections to empty array", () => {
|
||||
const data = freshResume();
|
||||
data.customSections = [{ id: "c1", type: "experience", title: "X", columns: 1, hidden: false, items: [] }];
|
||||
|
||||
const result = applyResumePatches(data, [{ op: "replace", path: "/customSections", value: [] }]);
|
||||
expect(result.customSections).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replace at array index", () => {
|
||||
it("replaces a specific item in an array", () => {
|
||||
const data = freshResume();
|
||||
data.sections.skills.items = [
|
||||
{ id: "s1", hidden: false, icon: "", name: "JS", proficiency: "", level: 0, keywords: [] },
|
||||
{ id: "s2", hidden: false, icon: "", name: "TS", proficiency: "", level: 0, keywords: [] },
|
||||
];
|
||||
|
||||
const result = applyResumePatches(data, [
|
||||
{ op: "replace", path: "/sections/skills/items/0/name", value: "JavaScript" },
|
||||
]);
|
||||
expect(result.sections.skills.items[0].name).toBe("JavaScript");
|
||||
expect(result.sections.skills.items[1].name).toBe("TS");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
import { produce } from "immer";
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import type { SectionItem } from "@/schema/resume/data";
|
||||
|
||||
import { type ResumeData, defaultResumeData } from "@/schema/resume/data";
|
||||
|
||||
import { createSectionItem, updateSectionItem } from "./section-actions";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function freshResume(): ResumeData {
|
||||
return structuredClone(defaultResumeData);
|
||||
}
|
||||
|
||||
function resumeWithExperience(): ResumeData {
|
||||
const data = freshResume();
|
||||
data.sections.experience.items = [
|
||||
{
|
||||
id: "exp-1",
|
||||
hidden: false,
|
||||
company: "Acme Corp",
|
||||
position: "Engineer",
|
||||
location: "NYC",
|
||||
period: "2020-2022",
|
||||
website: { url: "", label: "" },
|
||||
description: "<p>Built things</p>",
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: "exp-2",
|
||||
hidden: false,
|
||||
company: "Beta Inc",
|
||||
position: "Senior Dev",
|
||||
location: "SF",
|
||||
period: "2022-2024",
|
||||
website: { url: "", label: "" },
|
||||
description: "<p>Led things</p>",
|
||||
roles: [],
|
||||
},
|
||||
];
|
||||
return data;
|
||||
}
|
||||
|
||||
function resumeWithCustomSection(): ResumeData {
|
||||
const data = freshResume();
|
||||
data.customSections = [
|
||||
{
|
||||
id: "custom-1",
|
||||
type: "skills",
|
||||
title: "Soft Skills",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [{ id: "cs-1", hidden: false, icon: "", name: "Leadership", proficiency: "", level: 0, keywords: [] }],
|
||||
},
|
||||
];
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createSectionItem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createSectionItem", () => {
|
||||
it("adds an item to an empty standard section", () => {
|
||||
const data = freshResume();
|
||||
const newItem = {
|
||||
id: "new-1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "TypeScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
keywords: [],
|
||||
};
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createSectionItem(draft, "skills", newItem);
|
||||
});
|
||||
|
||||
expect(result.sections.skills.items).toHaveLength(1);
|
||||
expect(result.sections.skills.items[0]).toEqual(newItem);
|
||||
});
|
||||
|
||||
it("appends an item to a non-empty standard section", () => {
|
||||
const data = resumeWithExperience();
|
||||
const newExp = {
|
||||
id: "exp-3",
|
||||
hidden: false,
|
||||
company: "Gamma LLC",
|
||||
position: "CTO",
|
||||
location: "",
|
||||
period: "2024-",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
};
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createSectionItem(draft, "experience", newExp);
|
||||
});
|
||||
|
||||
expect(result.sections.experience.items).toHaveLength(3);
|
||||
expect(result.sections.experience.items[2].id).toBe("exp-3");
|
||||
});
|
||||
|
||||
it("adds an item to a custom section", () => {
|
||||
const data = resumeWithCustomSection();
|
||||
const newItem = { id: "cs-2", hidden: false, icon: "", name: "Teamwork", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createSectionItem(draft, "skills", newItem, "custom-1");
|
||||
});
|
||||
|
||||
const custom = result.customSections.find((s) => s.id === "custom-1")!;
|
||||
expect(custom.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does nothing when custom section id doesn't exist", () => {
|
||||
const data = resumeWithCustomSection();
|
||||
const newItem = { id: "cs-3", hidden: false, icon: "", name: "X", proficiency: "", level: 0, keywords: [] };
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createSectionItem(draft, "skills", newItem, "nonexistent");
|
||||
});
|
||||
|
||||
// Nothing should have changed
|
||||
expect(result.customSections[0].items).toHaveLength(1);
|
||||
expect(result.sections.skills.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("can add multiple items in sequence", () => {
|
||||
const data = freshResume();
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createSectionItem(draft, "skills", {
|
||||
id: "a",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "A",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
});
|
||||
createSectionItem(draft, "skills", {
|
||||
id: "b",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "B",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
});
|
||||
createSectionItem(draft, "skills", {
|
||||
id: "c",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "C",
|
||||
proficiency: "",
|
||||
level: 0,
|
||||
keywords: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.sections.skills.items).toHaveLength(3);
|
||||
expect(result.sections.skills.items.map((i) => i.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("adds items to different section types", () => {
|
||||
const data = freshResume();
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
createSectionItem(draft, "awards", {
|
||||
id: "a1",
|
||||
hidden: false,
|
||||
title: "Best Dev",
|
||||
awarder: "Company",
|
||||
date: "2024",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
});
|
||||
createSectionItem(draft, "education", {
|
||||
id: "e1",
|
||||
hidden: false,
|
||||
school: "MIT",
|
||||
degree: "BS",
|
||||
area: "CS",
|
||||
grade: "",
|
||||
location: "",
|
||||
period: "2016-2020",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.sections.awards.items).toHaveLength(1);
|
||||
expect(result.sections.education.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateSectionItem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("updateSectionItem", () => {
|
||||
it("updates an existing item in a standard section", () => {
|
||||
const data = resumeWithExperience();
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
updateSectionItem(draft, "experience", {
|
||||
id: "exp-1",
|
||||
hidden: false,
|
||||
company: "Acme Corp Updated",
|
||||
position: "Senior Engineer",
|
||||
location: "NYC",
|
||||
period: "2020-2023",
|
||||
website: { url: "", label: "" },
|
||||
description: "<p>Built more things</p>",
|
||||
roles: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.sections.experience.items[0].company).toBe("Acme Corp Updated");
|
||||
expect(result.sections.experience.items[0].position).toBe("Senior Engineer");
|
||||
// Second item should be unchanged
|
||||
expect(result.sections.experience.items[1].company).toBe("Beta Inc");
|
||||
});
|
||||
|
||||
it("updates the last item in a section", () => {
|
||||
const data = resumeWithExperience();
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
updateSectionItem(draft, "experience", {
|
||||
id: "exp-2",
|
||||
hidden: true,
|
||||
company: "Beta Inc",
|
||||
position: "VP",
|
||||
location: "Remote",
|
||||
period: "2022-2024",
|
||||
website: { url: "https://beta.com", label: "Beta" },
|
||||
description: "",
|
||||
roles: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.sections.experience.items[1].hidden).toBe(true);
|
||||
expect(result.sections.experience.items[1].position).toBe("VP");
|
||||
});
|
||||
|
||||
it("does nothing when item id doesn't match any item in standard section", () => {
|
||||
const data = resumeWithExperience();
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
updateSectionItem(draft, "experience", {
|
||||
id: "nonexistent",
|
||||
hidden: false,
|
||||
company: "Ghost",
|
||||
position: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
});
|
||||
});
|
||||
|
||||
// Nothing should change
|
||||
expect(result.sections.experience.items).toHaveLength(2);
|
||||
expect(result.sections.experience.items[0].company).toBe("Acme Corp");
|
||||
});
|
||||
|
||||
it("updates an item in a custom section", () => {
|
||||
const data = resumeWithCustomSection();
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
updateSectionItem(
|
||||
draft,
|
||||
"skills",
|
||||
{
|
||||
id: "cs-1",
|
||||
hidden: false,
|
||||
icon: "star",
|
||||
name: "Communication",
|
||||
proficiency: "Expert",
|
||||
level: 5,
|
||||
keywords: ["soft"],
|
||||
},
|
||||
"custom-1",
|
||||
);
|
||||
});
|
||||
|
||||
const section = result.customSections.find((s) => s.id === "custom-1")!;
|
||||
const item = section.items[0] as SectionItem<"skills">;
|
||||
|
||||
expect(item.name).toBe("Communication");
|
||||
});
|
||||
|
||||
it("does nothing when custom section id doesn't exist", () => {
|
||||
const data = resumeWithCustomSection();
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
updateSectionItem(
|
||||
draft,
|
||||
"skills",
|
||||
{ id: "cs-1", hidden: false, icon: "", name: "Updated", proficiency: "", level: 0, keywords: [] },
|
||||
"nonexistent",
|
||||
);
|
||||
});
|
||||
|
||||
const section = result.customSections.find((s) => s.id === "custom-1")!;
|
||||
const item = section.items[0] as SectionItem<"skills">;
|
||||
|
||||
expect(item.name).toBe("Leadership");
|
||||
});
|
||||
|
||||
it("does nothing when item id doesn't match in custom section", () => {
|
||||
const data = resumeWithCustomSection();
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
updateSectionItem(
|
||||
draft,
|
||||
"skills",
|
||||
{ id: "nonexistent", hidden: false, icon: "", name: "X", proficiency: "", level: 0, keywords: [] },
|
||||
"custom-1",
|
||||
);
|
||||
});
|
||||
|
||||
const section = result.customSections.find((s) => s.id === "custom-1")!;
|
||||
const item = section.items[0] as SectionItem<"skills">;
|
||||
|
||||
expect(section.items).toHaveLength(1);
|
||||
expect(item.name).toBe("Leadership");
|
||||
});
|
||||
|
||||
it("replaces the entire item object (not a merge)", () => {
|
||||
const data = resumeWithExperience();
|
||||
|
||||
const result = produce(data, (draft) => {
|
||||
updateSectionItem(draft, "experience", {
|
||||
id: "exp-1",
|
||||
hidden: false,
|
||||
company: "New Company",
|
||||
position: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "" },
|
||||
description: "",
|
||||
roles: [],
|
||||
});
|
||||
});
|
||||
|
||||
// position should be empty now — it's a full replace, not merge
|
||||
expect(result.sections.experience.items[0].position).toBe("");
|
||||
expect(result.sections.experience.items[0].company).toBe("New Company");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { WritableDraft } from "immer";
|
||||
|
||||
import type { ResumeData, SectionType } from "@/schema/resume/data";
|
||||
|
||||
/**
|
||||
* Pushes a new item into a section's items array.
|
||||
* Handles both built-in sections and custom sections.
|
||||
*/
|
||||
export function createSectionItem(
|
||||
draft: WritableDraft<ResumeData>,
|
||||
sectionKey: SectionType,
|
||||
formData: Record<string, unknown>,
|
||||
customSectionId?: string,
|
||||
) {
|
||||
if (customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === customSectionId);
|
||||
if (section) section.items.push(formData as never);
|
||||
} else {
|
||||
(draft.sections[sectionKey].items as unknown[]).push(formData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and replaces an existing item in a section's items array by id.
|
||||
* Handles both built-in sections and custom sections.
|
||||
*/
|
||||
export function updateSectionItem(
|
||||
draft: WritableDraft<ResumeData>,
|
||||
sectionKey: SectionType,
|
||||
formData: { id: string } & Record<string, unknown>,
|
||||
customSectionId?: string,
|
||||
) {
|
||||
if (customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData as never;
|
||||
} else {
|
||||
const items = draft.sections[sectionKey].items as Array<{ id: string }>;
|
||||
const index = items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) (items[index] as unknown as Record<string, unknown>) = formData;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user