mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-27 10:24:48 +10:00
test: add unit and component tests across the monorepo
Adds ~1000 tests to bring the previously-untested packages and apps under coverage: - packages/utils — string, color, date, file, level, locale, sanitize, field, html, network-icons, rate-limit, url, url-security, monorepo, resume/patch, resume/docx/link-utils, style helpers (~97% on the testable utility files) - packages/ui — 28 component test files plus the use-controlled-state, use-mobile, use-confirm, use-prompt hooks (95% statements) - packages/pdf — shared template helpers (filtering, columns, picture, metrics, section-links, page-size, rich-text-html, section-title) - packages/schema — resumeDataSchema, page, templates, default - packages/fonts — expanded coverage on font helpers - packages/ai — resume sanitize and extraction template - packages/api — resume-access-policy - apps/web — error-message, locale, theme, pwa, dialogs/store, and the resume/move-item / section-actions / make-section-item helpers Adds jsdom polyfills (ResizeObserver, IntersectionObserver, scrollIntoView, matchMedia) and an explicit React Testing Library cleanup hook to vitest.setup.ts so portal- and overlay-based components work without per-test setup.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ConfirmDialogProvider, useConfirm } from "./use-confirm";
|
||||
|
||||
const ConfirmRunner = ({
|
||||
onResult,
|
||||
title,
|
||||
options,
|
||||
}: {
|
||||
onResult: (v: boolean) => void;
|
||||
title: string;
|
||||
options?: Parameters<ReturnType<typeof useConfirm>>[1];
|
||||
}) => {
|
||||
const confirm = useConfirm();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const result = await confirm(title, options);
|
||||
onResult(result);
|
||||
}}
|
||||
>
|
||||
Trigger
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
describe("useConfirm", () => {
|
||||
it("throws when used outside ConfirmDialogProvider", () => {
|
||||
// React 19 logs the error rather than throwing — we use renderHook+ErrorBoundary alt:
|
||||
// Use the hook in a component and check that React surfaces the throw.
|
||||
expect(() =>
|
||||
render(
|
||||
<ConfirmRunner
|
||||
title="x"
|
||||
onResult={() => {
|
||||
/* noop */
|
||||
}}
|
||||
/>,
|
||||
),
|
||||
).toThrow(/useConfirm must be used within/);
|
||||
});
|
||||
|
||||
it("renders children inside provider", () => {
|
||||
render(
|
||||
<ConfirmDialogProvider>
|
||||
<div data-testid="child">child</div>
|
||||
</ConfirmDialogProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens an alert dialog when confirm is called", async () => {
|
||||
const results: boolean[] = [];
|
||||
render(
|
||||
<ConfirmDialogProvider>
|
||||
<ConfirmRunner title="Delete it?" onResult={(v) => results.push(v)} />
|
||||
</ConfirmDialogProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Trigger"));
|
||||
expect(await screen.findByText("Delete it?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resolves true when confirm is clicked", async () => {
|
||||
const results: boolean[] = [];
|
||||
render(
|
||||
<ConfirmDialogProvider>
|
||||
<ConfirmRunner title="Sure?" onResult={(v) => results.push(v)} />
|
||||
</ConfirmDialogProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Trigger"));
|
||||
await screen.findByText("Sure?");
|
||||
await userEvent.click(screen.getByText("Confirm"));
|
||||
|
||||
// Wait for promise to resolve
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
expect(results).toEqual([true]);
|
||||
});
|
||||
|
||||
it("resolves false when cancel is clicked", async () => {
|
||||
const results: boolean[] = [];
|
||||
render(
|
||||
<ConfirmDialogProvider>
|
||||
<ConfirmRunner title="Cancel?" onResult={(v) => results.push(v)} />
|
||||
</ConfirmDialogProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Trigger"));
|
||||
await screen.findByText("Cancel?");
|
||||
await userEvent.click(screen.getByText("Cancel"));
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
expect(results).toEqual([false]);
|
||||
});
|
||||
|
||||
it("renders custom confirmText and cancelText", async () => {
|
||||
render(
|
||||
<ConfirmDialogProvider>
|
||||
<ConfirmRunner
|
||||
title="x"
|
||||
options={{ confirmText: "Yes!", cancelText: "Nope" }}
|
||||
onResult={() => {
|
||||
/* noop */
|
||||
}}
|
||||
/>
|
||||
</ConfirmDialogProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Trigger"));
|
||||
expect(await screen.findByText("Yes!")).toBeInTheDocument();
|
||||
expect(screen.getByText("Nope")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders description when provided", async () => {
|
||||
render(
|
||||
<ConfirmDialogProvider>
|
||||
<ConfirmRunner
|
||||
title="x"
|
||||
options={{ description: "This will permanently delete the record." }}
|
||||
onResult={() => {
|
||||
/* noop */
|
||||
}}
|
||||
/>
|
||||
</ConfirmDialogProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Trigger"));
|
||||
expect(await screen.findByText("This will permanently delete the record.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to 'Confirm' / 'Cancel' default labels", async () => {
|
||||
render(
|
||||
<ConfirmDialogProvider>
|
||||
<ConfirmRunner
|
||||
title="x"
|
||||
onResult={() => {
|
||||
/* noop */
|
||||
}}
|
||||
/>
|
||||
</ConfirmDialogProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Trigger"));
|
||||
expect(await screen.findByText("Confirm")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useControlledState } from "./use-controlled-state";
|
||||
|
||||
describe("useControlledState", () => {
|
||||
it("returns the defaultValue when uncontrolled", () => {
|
||||
const { result } = renderHook(() => useControlledState({ defaultValue: 0 }));
|
||||
expect(result.current[0]).toBe(0);
|
||||
});
|
||||
|
||||
it("returns the value when controlled", () => {
|
||||
const { result } = renderHook(() => useControlledState({ value: 42, defaultValue: 0 }));
|
||||
expect(result.current[0]).toBe(42);
|
||||
});
|
||||
|
||||
it("updates internal state when value prop changes (controlled)", () => {
|
||||
const { result, rerender } = renderHook(({ value }: { value: number }) => useControlledState({ value }), {
|
||||
initialProps: { value: 1 },
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(1);
|
||||
rerender({ value: 2 });
|
||||
expect(result.current[0]).toBe(2);
|
||||
});
|
||||
|
||||
it("updates state via setter when uncontrolled", () => {
|
||||
const { result } = renderHook(() => useControlledState<number>({ defaultValue: 0 }));
|
||||
act(() => {
|
||||
result.current[1](10);
|
||||
});
|
||||
expect(result.current[0]).toBe(10);
|
||||
});
|
||||
|
||||
it("calls onChange when state is updated", () => {
|
||||
const onChange = vi.fn();
|
||||
const { result } = renderHook(() => useControlledState<string>({ defaultValue: "a", onChange }));
|
||||
|
||||
act(() => {
|
||||
result.current[1]("b");
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("b");
|
||||
});
|
||||
|
||||
it("forwards extra args to onChange", () => {
|
||||
const onChange = vi.fn();
|
||||
const { result } = renderHook(() => useControlledState<string, [number, boolean]>({ defaultValue: "a", onChange }));
|
||||
|
||||
act(() => {
|
||||
result.current[1]("b", 42, true);
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("b", 42, true);
|
||||
});
|
||||
|
||||
it("does not call onChange when value prop changes from outside", () => {
|
||||
const onChange = vi.fn();
|
||||
const { rerender } = renderHook(({ value }: { value: number }) => useControlledState<number>({ value, onChange }), {
|
||||
initialProps: { value: 1 },
|
||||
});
|
||||
|
||||
rerender({ value: 2 });
|
||||
// onChange is only called via setState, not the controlled-value sync effect.
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the same setter reference when onChange is stable", () => {
|
||||
const onChange = vi.fn();
|
||||
const { result, rerender } = renderHook(
|
||||
({ value }: { value: number }) => useControlledState<number>({ value, onChange }),
|
||||
{ initialProps: { value: 1 } },
|
||||
);
|
||||
|
||||
const initialSetter = result.current[1];
|
||||
rerender({ value: 2 });
|
||||
expect(result.current[1]).toBe(initialSetter);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useIsMobile } from "./use-mobile";
|
||||
|
||||
type ChangeListener = (e: { matches: boolean }) => void;
|
||||
|
||||
describe("useIsMobile", () => {
|
||||
let listeners: ChangeListener[];
|
||||
let matches: boolean;
|
||||
let originalMatchMedia: typeof window.matchMedia;
|
||||
|
||||
beforeEach(() => {
|
||||
listeners = [];
|
||||
matches = false;
|
||||
originalMatchMedia = window.matchMedia;
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn((_event: string, listener: ChangeListener) => {
|
||||
listeners.push(listener);
|
||||
}),
|
||||
removeEventListener: vi.fn((_event: string, listener: ChangeListener) => {
|
||||
const idx = listeners.indexOf(listener);
|
||||
if (idx >= 0) listeners.splice(idx, 1);
|
||||
}),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
});
|
||||
|
||||
it("returns false when matchMedia.matches is false", () => {
|
||||
matches = false;
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when matchMedia.matches is true", () => {
|
||||
matches = true;
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the documented mobile breakpoint of 768px", () => {
|
||||
renderHook(() => useIsMobile());
|
||||
expect(window.matchMedia).toHaveBeenCalledWith("(max-width: 767px)");
|
||||
});
|
||||
|
||||
it("updates when the media query changes", () => {
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
for (const listener of listeners) listener({ matches: true });
|
||||
});
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
for (const listener of listeners) listener({ matches: false });
|
||||
});
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("removes the change listener on unmount", () => {
|
||||
const { unmount } = renderHook(() => useIsMobile());
|
||||
expect(listeners).toHaveLength(1);
|
||||
unmount();
|
||||
expect(listeners).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PromptDialogProvider, usePrompt } from "./use-prompt";
|
||||
|
||||
const PromptRunner = ({
|
||||
onResult,
|
||||
title,
|
||||
options,
|
||||
}: {
|
||||
onResult: (v: string | null) => void;
|
||||
title: string;
|
||||
options?: Parameters<ReturnType<typeof usePrompt>>[1];
|
||||
}) => {
|
||||
const prompt = usePrompt();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const result = await prompt(title, options);
|
||||
onResult(result);
|
||||
}}
|
||||
>
|
||||
Trigger
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
describe("usePrompt", () => {
|
||||
it("throws when used outside PromptDialogProvider", () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<PromptRunner
|
||||
title="x"
|
||||
onResult={() => {
|
||||
/* noop */
|
||||
}}
|
||||
/>,
|
||||
),
|
||||
).toThrow(/usePrompt must be used within/);
|
||||
});
|
||||
|
||||
it("renders children inside provider", () => {
|
||||
render(
|
||||
<PromptDialogProvider>
|
||||
<div data-testid="child">child</div>
|
||||
</PromptDialogProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens an alert dialog when prompt is called", async () => {
|
||||
render(
|
||||
<PromptDialogProvider>
|
||||
<PromptRunner
|
||||
title="Enter name"
|
||||
onResult={() => {
|
||||
/* noop */
|
||||
}}
|
||||
/>
|
||||
</PromptDialogProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Trigger"));
|
||||
expect(await screen.findByText("Enter name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resolves with input value when confirm clicked", async () => {
|
||||
const results: (string | null)[] = [];
|
||||
render(
|
||||
<PromptDialogProvider>
|
||||
<PromptRunner title="Name?" onResult={(v) => results.push(v)} />
|
||||
</PromptDialogProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Trigger"));
|
||||
await screen.findByText("Name?");
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
await userEvent.type(input, "Alice");
|
||||
await userEvent.click(screen.getByText("Confirm"));
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
expect(results).toEqual(["Alice"]);
|
||||
});
|
||||
|
||||
it("resolves null when cancel is clicked", async () => {
|
||||
const results: (string | null)[] = [];
|
||||
render(
|
||||
<PromptDialogProvider>
|
||||
<PromptRunner title="x" onResult={(v) => results.push(v)} />
|
||||
</PromptDialogProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Trigger"));
|
||||
await screen.findByText("x");
|
||||
await userEvent.click(screen.getByText("Cancel"));
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
expect(results).toEqual([null]);
|
||||
});
|
||||
|
||||
it("uses defaultValue as initial input value", async () => {
|
||||
render(
|
||||
<PromptDialogProvider>
|
||||
<PromptRunner
|
||||
title="x"
|
||||
options={{ defaultValue: "default-name" }}
|
||||
onResult={() => {
|
||||
/* noop */
|
||||
}}
|
||||
/>
|
||||
</PromptDialogProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Trigger"));
|
||||
const input = await screen.findByRole("textbox");
|
||||
expect(input).toHaveValue("default-name");
|
||||
});
|
||||
|
||||
it("submits on Enter key", async () => {
|
||||
const results: (string | null)[] = [];
|
||||
render(
|
||||
<PromptDialogProvider>
|
||||
<PromptRunner title="x" onResult={(v) => results.push(v)} />
|
||||
</PromptDialogProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Trigger"));
|
||||
const input = await screen.findByRole("textbox");
|
||||
await userEvent.type(input, "via-enter{Enter}");
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
expect(results).toEqual(["via-enter"]);
|
||||
});
|
||||
|
||||
it("renders custom confirmText and cancelText", async () => {
|
||||
render(
|
||||
<PromptDialogProvider>
|
||||
<PromptRunner
|
||||
title="x"
|
||||
options={{ confirmText: "Save", cancelText: "Discard" }}
|
||||
onResult={() => {
|
||||
/* noop */
|
||||
}}
|
||||
/>
|
||||
</PromptDialogProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Trigger"));
|
||||
expect(await screen.findByText("Save")).toBeInTheDocument();
|
||||
expect(screen.getByText("Discard")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders description when provided", async () => {
|
||||
render(
|
||||
<PromptDialogProvider>
|
||||
<PromptRunner
|
||||
title="x"
|
||||
options={{ description: "Explain why" }}
|
||||
onResult={() => {
|
||||
/* noop */
|
||||
}}
|
||||
/>
|
||||
</PromptDialogProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Trigger"));
|
||||
expect(await screen.findByText("Explain why")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user