mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 10:13:47 +10:00
test: add unit and component tests across the monorepo
Adds ~1000 tests to bring the previously-untested packages and apps under coverage: - packages/utils — string, color, date, file, level, locale, sanitize, field, html, network-icons, rate-limit, url, url-security, monorepo, resume/patch, resume/docx/link-utils, style helpers (~97% on the testable utility files) - packages/ui — 28 component test files plus the use-controlled-state, use-mobile, use-confirm, use-prompt hooks (95% statements) - packages/pdf — shared template helpers (filtering, columns, picture, metrics, section-links, page-size, rich-text-html, section-title) - packages/schema — resumeDataSchema, page, templates, default - packages/fonts — expanded coverage on font helpers - packages/ai — resume sanitize and extraction template - packages/api — resume-access-policy - apps/web — error-message, locale, theme, pwa, dialogs/store, and the resume/move-item / section-actions / make-section-item helpers Adds jsdom polyfills (ResizeObserver, IntersectionObserver, scrollIntoView, matchMedia) and an explicit React Testing Library cleanup hook to vitest.setup.ts so portal- and overlay-based components work without per-test setup.
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDialogStore } from "./store";
|
||||
|
||||
describe("useDialogStore", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// Reset store state between tests
|
||||
useDialogStore.setState({ open: false, activeDialog: null, onBeforeClose: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("openDialog", () => {
|
||||
it("opens a dialog and sets activeDialog", () => {
|
||||
useDialogStore.getState().openDialog("api-key.create", undefined);
|
||||
|
||||
const state = useDialogStore.getState();
|
||||
expect(state.open).toBe(true);
|
||||
expect(state.activeDialog?.type).toBe("api-key.create");
|
||||
});
|
||||
|
||||
it("clears any existing onBeforeClose handler", () => {
|
||||
useDialogStore.setState({ onBeforeClose: () => true });
|
||||
useDialogStore.getState().openDialog("resume.create", undefined);
|
||||
|
||||
expect(useDialogStore.getState().onBeforeClose).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves dialog data for typed payloads", () => {
|
||||
const data = { id: "r1", name: "My Resume", slug: "my-resume", tags: [] };
|
||||
useDialogStore.getState().openDialog("resume.update", data);
|
||||
|
||||
const active = useDialogStore.getState().activeDialog;
|
||||
expect(active?.type).toBe("resume.update");
|
||||
if (active?.type === "resume.update") {
|
||||
expect(active.data).toEqual(data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeDialog", () => {
|
||||
it("immediately sets open to false", () => {
|
||||
useDialogStore.setState({
|
||||
open: true,
|
||||
activeDialog: { type: "api-key.create", data: undefined },
|
||||
});
|
||||
|
||||
useDialogStore.getState().closeDialog();
|
||||
expect(useDialogStore.getState().open).toBe(false);
|
||||
});
|
||||
|
||||
it("clears activeDialog after 300ms (animation finished)", () => {
|
||||
useDialogStore.setState({
|
||||
open: true,
|
||||
activeDialog: { type: "api-key.create", data: undefined },
|
||||
});
|
||||
|
||||
useDialogStore.getState().closeDialog();
|
||||
expect(useDialogStore.getState().activeDialog).not.toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(useDialogStore.getState().activeDialog).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("onOpenChange", () => {
|
||||
it("opens via set without invoking onBeforeClose", () => {
|
||||
const onBefore = vi.fn();
|
||||
useDialogStore.setState({ onBeforeClose: onBefore });
|
||||
|
||||
useDialogStore.getState().onOpenChange(true);
|
||||
expect(useDialogStore.getState().open).toBe(true);
|
||||
expect(onBefore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes immediately when no onBeforeClose handler", () => {
|
||||
useDialogStore.setState({ open: true, activeDialog: { type: "api-key.create", data: undefined } });
|
||||
useDialogStore.getState().onOpenChange(false);
|
||||
|
||||
expect(useDialogStore.getState().open).toBe(false);
|
||||
});
|
||||
|
||||
it("invokes onBeforeClose when closing", async () => {
|
||||
const handler = vi.fn().mockResolvedValue(true);
|
||||
useDialogStore.setState({
|
||||
open: true,
|
||||
activeDialog: { type: "api-key.create", data: undefined },
|
||||
onBeforeClose: handler,
|
||||
});
|
||||
|
||||
useDialogStore.getState().onOpenChange(false);
|
||||
await vi.waitFor(() => expect(handler).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("calls cancel when onBeforeClose returns false", async () => {
|
||||
const handler = vi.fn().mockResolvedValue(false);
|
||||
const cancel = vi.fn();
|
||||
useDialogStore.setState({
|
||||
open: true,
|
||||
activeDialog: { type: "api-key.create", data: undefined },
|
||||
onBeforeClose: handler,
|
||||
});
|
||||
|
||||
useDialogStore.getState().onOpenChange(false, { cancel });
|
||||
await vi.waitFor(() => expect(cancel).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
describe("setOnBeforeClose", () => {
|
||||
it("sets and clears the handler", () => {
|
||||
const handler = () => true;
|
||||
useDialogStore.getState().setOnBeforeClose(handler);
|
||||
expect(useDialogStore.getState().onBeforeClose).toBe(handler);
|
||||
|
||||
useDialogStore.getState().setOnBeforeClose(null);
|
||||
expect(useDialogStore.getState().onBeforeClose).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { getOrpcErrorMessage, getReadableErrorMessage, getResumeErrorMessage } from "./error-message";
|
||||
|
||||
describe("getReadableErrorMessage", () => {
|
||||
it("returns the string error directly", () => {
|
||||
expect(getReadableErrorMessage("explicit error", "fallback")).toBe("explicit error");
|
||||
});
|
||||
|
||||
it("returns Error.message", () => {
|
||||
expect(getReadableErrorMessage(new Error("boom"), "fallback")).toBe("boom");
|
||||
});
|
||||
|
||||
it("returns fallback for unknown shapes", () => {
|
||||
expect(getReadableErrorMessage({ random: "object" }, "fallback")).toBe("fallback");
|
||||
expect(getReadableErrorMessage(null, "fallback")).toBe("fallback");
|
||||
expect(getReadableErrorMessage(undefined, "fallback")).toBe("fallback");
|
||||
expect(getReadableErrorMessage(42, "fallback")).toBe("fallback");
|
||||
});
|
||||
|
||||
it("returns fallback for empty string error (falsy)", () => {
|
||||
expect(getReadableErrorMessage("", "fallback")).toBe("fallback");
|
||||
});
|
||||
|
||||
it("returns fallback for Error with empty message", () => {
|
||||
expect(getReadableErrorMessage(new Error(""), "fallback")).toBe("fallback");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrpcErrorMessage", () => {
|
||||
it("delegates to getReadableErrorMessage for non-ORPCErrors", () => {
|
||||
expect(getOrpcErrorMessage(new Error("boom"), { fallback: "fallback" })).toBe("boom");
|
||||
expect(getOrpcErrorMessage("string error", { fallback: "fallback" })).toBe("string error");
|
||||
});
|
||||
|
||||
it("uses byCode mapping when present", () => {
|
||||
const error = new ORPCError("RESUME_LOCKED");
|
||||
expect(
|
||||
getOrpcErrorMessage(error, {
|
||||
fallback: "fallback",
|
||||
byCode: { RESUME_LOCKED: "It is locked." },
|
||||
}),
|
||||
).toBe("It is locked.");
|
||||
});
|
||||
|
||||
it("returns server message when allowServerMessage and message is set", () => {
|
||||
const error = new ORPCError("OTHER", { message: "Server-provided message" });
|
||||
expect(
|
||||
getOrpcErrorMessage(error, {
|
||||
fallback: "fallback",
|
||||
allowServerMessage: true,
|
||||
}),
|
||||
).toBe("Server-provided message");
|
||||
});
|
||||
|
||||
it("falls back when allowServerMessage is false even if message set", () => {
|
||||
const error = new ORPCError("OTHER", { message: "Server-provided message" });
|
||||
expect(getOrpcErrorMessage(error, { fallback: "fallback" })).toBe("fallback");
|
||||
});
|
||||
|
||||
it("byCode takes precedence over allowServerMessage", () => {
|
||||
const error = new ORPCError("RESUME_LOCKED", { message: "Server msg" });
|
||||
expect(
|
||||
getOrpcErrorMessage(error, {
|
||||
fallback: "fallback",
|
||||
byCode: { RESUME_LOCKED: "It is locked." },
|
||||
allowServerMessage: true,
|
||||
}),
|
||||
).toBe("It is locked.");
|
||||
});
|
||||
|
||||
it("returns fallback when no mapping or server message", () => {
|
||||
const error = new ORPCError("UNKNOWN_CODE");
|
||||
expect(getOrpcErrorMessage(error, { fallback: "fallback" })).toBe("fallback");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResumeErrorMessage", () => {
|
||||
it("returns mapped message for RESUME_SLUG_ALREADY_EXISTS", () => {
|
||||
const error = new ORPCError("RESUME_SLUG_ALREADY_EXISTS");
|
||||
expect(getResumeErrorMessage(error)).toBe("A resume with this slug already exists.");
|
||||
});
|
||||
|
||||
it("returns mapped message for RESUME_LOCKED", () => {
|
||||
const error = new ORPCError("RESUME_LOCKED");
|
||||
expect(getResumeErrorMessage(error)).toBe("This resume is locked. Unlock it first to make changes.");
|
||||
});
|
||||
|
||||
it("returns generic fallback for unknown codes", () => {
|
||||
const error = new ORPCError("UNKNOWN");
|
||||
expect(getResumeErrorMessage(error)).toBe("Something went wrong. Please try again.");
|
||||
});
|
||||
|
||||
it("returns fallback for plain Error (delegates to getOrpcErrorMessage)", () => {
|
||||
// Plain Error gets readable message
|
||||
expect(getResumeErrorMessage(new Error("boom"))).toBe("boom");
|
||||
});
|
||||
|
||||
it("returns fallback for unknown shape", () => {
|
||||
expect(getResumeErrorMessage(null)).toBe("Something went wrong. Please try again.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isLocale, isRTL, resolveLocale } from "./locale";
|
||||
|
||||
describe("isLocale", () => {
|
||||
it("returns true for known locale en-US", () => {
|
||||
expect(isLocale("en-US")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for de-DE", () => {
|
||||
expect(isLocale("de-DE")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for zh-CN", () => {
|
||||
expect(isLocale("zh-CN")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unknown locale", () => {
|
||||
expect(isLocale("xx-YY")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty string", () => {
|
||||
expect(isLocale("")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for malformed locale", () => {
|
||||
expect(isLocale("not a locale")).toBe(false);
|
||||
});
|
||||
|
||||
it("is case-sensitive", () => {
|
||||
expect(isLocale("en-us")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLocale", () => {
|
||||
it("returns the locale unchanged when valid", () => {
|
||||
expect(resolveLocale("fr-FR")).toBe("fr-FR");
|
||||
});
|
||||
|
||||
it("returns en-US default for invalid locale", () => {
|
||||
expect(resolveLocale("xx-YY")).toBe("en-US");
|
||||
});
|
||||
|
||||
it("returns en-US default for empty string", () => {
|
||||
expect(resolveLocale("")).toBe("en-US");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRTL", () => {
|
||||
it("returns true for Arabic", () => {
|
||||
expect(isRTL("ar-SA")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for Hebrew", () => {
|
||||
expect(isRTL("he-IL")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for Persian/Farsi", () => {
|
||||
expect(isRTL("fa-IR")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for Urdu", () => {
|
||||
expect(isRTL("ur-PK")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for English", () => {
|
||||
expect(isRTL("en-US")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for German", () => {
|
||||
expect(isRTL("de-DE")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for Chinese", () => {
|
||||
expect(isRTL("zh-CN")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for unknown locale", () => {
|
||||
expect(isRTL("xyz-XX")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches prefix case-insensitively (lowercase prefix)", () => {
|
||||
expect(isRTL("AR-SA")).toBe(true);
|
||||
});
|
||||
|
||||
it("works with locale-only string (no region)", () => {
|
||||
expect(isRTL("ar")).toBe(true);
|
||||
expect(isRTL("en")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pwaHeadMetaTags, pwaManifest, pwaServiceWorkerRegistrationScript } from "./pwa";
|
||||
|
||||
describe("pwaManifest", () => {
|
||||
it("uses the same theme color and background color (dark zinc)", () => {
|
||||
expect(pwaManifest.theme_color).toBe(pwaManifest.background_color);
|
||||
});
|
||||
|
||||
it("declares standalone display and portrait orientation", () => {
|
||||
expect(pwaManifest.display).toBe("standalone");
|
||||
expect(pwaManifest.orientation).toBe("portrait");
|
||||
});
|
||||
|
||||
it("scopes to root '/'", () => {
|
||||
expect(pwaManifest.scope).toBe("/");
|
||||
expect(pwaManifest.start_url).toContain("/");
|
||||
});
|
||||
|
||||
it("declares all icon sizes (64, 192, 512, maskable, favicon)", () => {
|
||||
const sizes = pwaManifest.icons?.map((i) => i.sizes) ?? [];
|
||||
expect(sizes).toContain("64x64");
|
||||
expect(sizes).toContain("192x192");
|
||||
expect(sizes).toContain("512x512");
|
||||
});
|
||||
|
||||
it("declares at least one maskable icon", () => {
|
||||
const maskable = pwaManifest.icons?.find((i) => i.purpose === "maskable");
|
||||
expect(maskable).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes both wide and narrow screenshots for proper installation UI", () => {
|
||||
const wide = pwaManifest.screenshots?.filter((s) => s.form_factor === "wide");
|
||||
const narrow = pwaManifest.screenshots?.filter((s) => s.form_factor === "narrow");
|
||||
expect(wide?.length).toBeGreaterThan(0);
|
||||
expect(narrow?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("includes 'resume' in categories for app stores", () => {
|
||||
expect(pwaManifest.categories).toContain("resume");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pwaHeadMetaTags", () => {
|
||||
it("includes theme-color meta tag", () => {
|
||||
expect(pwaHeadMetaTags).toContainEqual(expect.objectContaining({ name: "theme-color" }));
|
||||
});
|
||||
|
||||
it("declares apple-mobile-web-app capable", () => {
|
||||
const tag = pwaHeadMetaTags.find((t) => t.name === "apple-mobile-web-app-capable");
|
||||
expect(tag?.content).toBe("yes");
|
||||
});
|
||||
|
||||
it("uses 'black-translucent' status bar style for iOS", () => {
|
||||
const tag = pwaHeadMetaTags.find((t) => t.name === "apple-mobile-web-app-status-bar-style");
|
||||
expect(tag?.content).toBe("black-translucent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pwaServiceWorkerRegistrationScript", () => {
|
||||
it("registers the service worker only when supported", () => {
|
||||
expect(pwaServiceWorkerRegistrationScript).toContain('"serviceWorker" in navigator');
|
||||
});
|
||||
|
||||
it("registers /sw.js with root scope", () => {
|
||||
expect(pwaServiceWorkerRegistrationScript).toContain("/sw.js");
|
||||
expect(pwaServiceWorkerRegistrationScript).toContain('scope: "/"');
|
||||
});
|
||||
|
||||
it("registers on window load to avoid blocking initial paint", () => {
|
||||
expect(pwaServiceWorkerRegistrationScript).toContain('addEventListener("load"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { makeSectionItem } from "./make-section-item";
|
||||
|
||||
describe("makeSectionItem", () => {
|
||||
it("clones the provided item but generates a fresh id", () => {
|
||||
const original = { id: "original-id", name: "Skill" };
|
||||
const result = makeSectionItem({ id: "default", name: "" }, original);
|
||||
|
||||
expect(result.name).toBe("Skill");
|
||||
expect(result.id).not.toBe("original-id");
|
||||
expect(result.id.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("uses defaultItem when no item is provided", () => {
|
||||
const defaultItem = { id: "default", name: "", level: 0 };
|
||||
const result = makeSectionItem(defaultItem);
|
||||
|
||||
expect(result.name).toBe("");
|
||||
expect(result.level).toBe(0);
|
||||
expect(result.id).not.toBe("default");
|
||||
});
|
||||
|
||||
it("does not mutate the input item when duplicating", () => {
|
||||
const original = { id: "original-id", value: "test" };
|
||||
const before = JSON.stringify(original);
|
||||
makeSectionItem({ id: "default", value: "" }, original);
|
||||
expect(JSON.stringify(original)).toBe(before);
|
||||
});
|
||||
|
||||
it("does not mutate the defaultItem", () => {
|
||||
const defaultItem = { id: "default", value: "x" };
|
||||
const before = JSON.stringify(defaultItem);
|
||||
makeSectionItem(defaultItem);
|
||||
expect(JSON.stringify(defaultItem)).toBe(before);
|
||||
});
|
||||
|
||||
it("generates a unique id per call", () => {
|
||||
const a = makeSectionItem({ id: "default", name: "" });
|
||||
const b = makeSectionItem({ id: "default", name: "" });
|
||||
expect(a.id).not.toBe(b.id);
|
||||
});
|
||||
|
||||
it("preserves all other fields when duplicating", () => {
|
||||
const item = { id: "x", a: 1, b: { c: 2 }, d: [3] };
|
||||
const result = makeSectionItem({ id: "default", a: 0, b: { c: 0 }, d: [] }, item);
|
||||
expect(result.a).toBe(1);
|
||||
expect(result.b).toEqual({ c: 2 });
|
||||
expect(result.d).toEqual([3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { produce } from "immer";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import {
|
||||
addItemToSection,
|
||||
createCustomSectionWithItem,
|
||||
createPageWithSection,
|
||||
getCompatibleMoveTargets,
|
||||
getSourceSectionTitle,
|
||||
removeItemFromSource,
|
||||
} from "./move-item";
|
||||
|
||||
beforeAll(() => {
|
||||
// Lingui requires an active locale before t`...` macros can render.
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
describe("getSourceSectionTitle", () => {
|
||||
it("returns custom section title for custom sections", () => {
|
||||
const data = produce(defaultResumeData, (draft) => {
|
||||
draft.customSections.push({
|
||||
id: "ext-1",
|
||||
type: "cover-letter",
|
||||
title: "My Custom Title",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [],
|
||||
});
|
||||
});
|
||||
expect(getSourceSectionTitle(data, "cover-letter", "ext-1")).toBe("My Custom Title");
|
||||
});
|
||||
|
||||
it("returns localized default title when customSectionId is undefined", () => {
|
||||
// Default section title comes from t`...` macro — we just check it's a non-empty string
|
||||
expect(getSourceSectionTitle(defaultResumeData, "experience").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("falls back to default title when custom section is not found", () => {
|
||||
expect(getSourceSectionTitle(defaultResumeData, "experience", "non-existent").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCompatibleMoveTargets", () => {
|
||||
it("excludes the source section itself", () => {
|
||||
// Default has experience in main; trying to move from experience to other targets
|
||||
const targets = getCompatibleMoveTargets(defaultResumeData, "experience", undefined);
|
||||
expect(targets[0]?.sections.find((s) => s.sectionId === "experience")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns only custom sections of the same type as source", () => {
|
||||
const data = produce(defaultResumeData, (draft) => {
|
||||
draft.customSections.push({
|
||||
id: "ext-1",
|
||||
type: "cover-letter",
|
||||
title: "Cover Letter",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [],
|
||||
});
|
||||
draft.metadata.layout.pages[0].main.push("ext-1");
|
||||
});
|
||||
|
||||
// Source is also a cover-letter custom section, but with different id
|
||||
const targets = getCompatibleMoveTargets(data, "cover-letter", "different-id");
|
||||
expect(targets[0]?.sections.find((s) => s.sectionId === "ext-1")).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns empty per page when no compatible targets", () => {
|
||||
const targets = getCompatibleMoveTargets(defaultResumeData, "cover-letter", undefined);
|
||||
// No custom sections of cover-letter type in default
|
||||
for (const page of targets) {
|
||||
expect(page.sections).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns one entry per page", () => {
|
||||
const targets = getCompatibleMoveTargets(defaultResumeData, "experience", undefined);
|
||||
expect(targets).toHaveLength(defaultResumeData.metadata.layout.pages.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeItemFromSource", () => {
|
||||
it("removes an item from a built-in section and returns its id", () => {
|
||||
const initial = produce(defaultResumeData, (draft) => {
|
||||
draft.sections.skills.items.push({
|
||||
id: "skill-1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Go",
|
||||
proficiency: "",
|
||||
level: 4,
|
||||
keywords: [],
|
||||
});
|
||||
});
|
||||
|
||||
// Capture the id before produce revokes the draft proxy.
|
||||
let removedId: string | undefined;
|
||||
const result = produce(initial, (draft) => {
|
||||
const removed = removeItemFromSource(draft, "skill-1", "skills");
|
||||
removedId = (removed as { id: string } | null)?.id;
|
||||
});
|
||||
|
||||
expect(result.sections.skills.items).toHaveLength(0);
|
||||
expect(removedId).toBe("skill-1");
|
||||
});
|
||||
|
||||
it("returns null when item id is not found in built-in section", () => {
|
||||
let removed: unknown;
|
||||
produce(defaultResumeData, (draft) => {
|
||||
removed = removeItemFromSource(draft, "non-existent", "skills");
|
||||
});
|
||||
expect(removed).toBeNull();
|
||||
});
|
||||
|
||||
it("removes an item from a custom section and returns its id", () => {
|
||||
const initial = produce(defaultResumeData, (draft) => {
|
||||
draft.customSections.push({
|
||||
id: "ext-1",
|
||||
type: "cover-letter",
|
||||
title: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [{ id: "i1" } as never],
|
||||
});
|
||||
});
|
||||
|
||||
let removedId: string | undefined;
|
||||
const result = produce(initial, (draft) => {
|
||||
const removed = removeItemFromSource(draft, "i1", "cover-letter", "ext-1");
|
||||
removedId = (removed as { id?: string } | null)?.id;
|
||||
});
|
||||
|
||||
expect(result.customSections[0]?.items).toHaveLength(0);
|
||||
expect(removedId).toBe("i1");
|
||||
});
|
||||
|
||||
it("returns null when custom section does not exist", () => {
|
||||
let removed: unknown;
|
||||
produce(defaultResumeData, (draft) => {
|
||||
removed = removeItemFromSource(draft, "i1", "cover-letter", "non-existent");
|
||||
});
|
||||
expect(removed).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("addItemToSection", () => {
|
||||
it("adds to a matching built-in section", () => {
|
||||
const item = {
|
||||
id: "skill-x",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Rust",
|
||||
proficiency: "",
|
||||
level: 4,
|
||||
keywords: [],
|
||||
};
|
||||
|
||||
const result = produce(defaultResumeData, (draft) => {
|
||||
addItemToSection(draft, item, "skills", "skills");
|
||||
});
|
||||
|
||||
expect(result.sections.skills.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("adds to a custom section by id", () => {
|
||||
const initial = produce(defaultResumeData, (draft) => {
|
||||
draft.customSections.push({
|
||||
id: "ext-1",
|
||||
type: "cover-letter",
|
||||
title: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [],
|
||||
});
|
||||
});
|
||||
|
||||
const result = produce(initial, (draft) => {
|
||||
addItemToSection(draft, { id: "i1" } as never, "ext-1", "cover-letter");
|
||||
});
|
||||
|
||||
expect(result.customSections[0]?.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not add when target id does not match anything", () => {
|
||||
const result = produce(defaultResumeData, (draft) => {
|
||||
addItemToSection(draft, { id: "x" } as never, "non-existent", "skills");
|
||||
});
|
||||
expect(result.sections.skills.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCustomSectionWithItem", () => {
|
||||
it("creates a new custom section and adds it to the target page main", () => {
|
||||
let newSectionId = "";
|
||||
const result = produce(defaultResumeData, (draft) => {
|
||||
newSectionId = createCustomSectionWithItem(draft, { id: "i1" } as never, "cover-letter", "My Section", 0);
|
||||
});
|
||||
|
||||
expect(result.customSections).toHaveLength(1);
|
||||
expect(result.customSections[0]?.title).toBe("My Section");
|
||||
expect(result.customSections[0]?.items).toHaveLength(1);
|
||||
expect(result.metadata.layout.pages[0]?.main).toContain(newSectionId);
|
||||
});
|
||||
|
||||
it("returns the generated section id", () => {
|
||||
let newSectionId = "";
|
||||
produce(defaultResumeData, (draft) => {
|
||||
newSectionId = createCustomSectionWithItem(draft, { id: "i1" } as never, "cover-letter", "X", 0);
|
||||
});
|
||||
expect(newSectionId.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not crash on out-of-range page index (no main column to push to)", () => {
|
||||
// targetPageIndex=99 — page does not exist; section should still be created
|
||||
const result = produce(defaultResumeData, (draft) => {
|
||||
createCustomSectionWithItem(draft, { id: "i1" } as never, "cover-letter", "X", 99);
|
||||
});
|
||||
expect(result.customSections).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPageWithSection", () => {
|
||||
it("creates a new page with the new custom section in main", () => {
|
||||
const initialPageCount = defaultResumeData.metadata.layout.pages.length;
|
||||
const result = produce(defaultResumeData, (draft) => {
|
||||
createPageWithSection(draft, { id: "i1" } as never, "cover-letter", "My New Page");
|
||||
});
|
||||
expect(result.metadata.layout.pages).toHaveLength(initialPageCount + 1);
|
||||
|
||||
const newPage = result.metadata.layout.pages.at(-1);
|
||||
expect(newPage?.main).toHaveLength(1);
|
||||
expect(newPage?.sidebar).toEqual([]);
|
||||
expect(newPage?.fullWidth).toBe(false);
|
||||
});
|
||||
|
||||
it("adds the custom section to customSections array", () => {
|
||||
const result = produce(defaultResumeData, (draft) => {
|
||||
createPageWithSection(draft, { id: "i1" } as never, "cover-letter", "My Section");
|
||||
});
|
||||
expect(result.customSections).toHaveLength(1);
|
||||
expect(result.customSections[0]?.title).toBe("My Section");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { produce } from "immer";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { createSectionItem, updateSectionItem } from "./section-actions";
|
||||
|
||||
describe("createSectionItem", () => {
|
||||
it("appends to a built-in section's items array", () => {
|
||||
const result = produce(defaultResumeData, (draft) => {
|
||||
createSectionItem(draft, "skills", { id: "1", name: "Go" });
|
||||
});
|
||||
expect(result.sections.skills.items).toHaveLength(1);
|
||||
expect((result.sections.skills.items[0] as { name?: string })?.name).toBe("Go");
|
||||
});
|
||||
|
||||
it("appends to a custom section by id", () => {
|
||||
const initial = produce(defaultResumeData, (draft) => {
|
||||
draft.customSections.push({
|
||||
id: "custom-1",
|
||||
type: "cover-letter",
|
||||
title: "Cover Letter",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [],
|
||||
});
|
||||
});
|
||||
|
||||
const result = produce(initial, (draft) => {
|
||||
createSectionItem(draft, "skills", { id: "1", text: "Custom item" }, "custom-1");
|
||||
});
|
||||
|
||||
const customSection = result.customSections.find((s) => s.id === "custom-1");
|
||||
expect(customSection?.items).toHaveLength(1);
|
||||
// Built-in section is untouched
|
||||
expect(result.sections.skills.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does nothing when customSectionId does not match", () => {
|
||||
const result = produce(defaultResumeData, (draft) => {
|
||||
createSectionItem(draft, "skills", { id: "1", name: "x" }, "non-existent");
|
||||
});
|
||||
// Skills not touched, no custom section to modify
|
||||
expect(result.sections.skills.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSectionItem", () => {
|
||||
it("replaces the matching item in a built-in section", () => {
|
||||
const initial = produce(defaultResumeData, (draft) => {
|
||||
createSectionItem(draft, "skills", { id: "abc", name: "Go", level: 4 });
|
||||
});
|
||||
|
||||
const result = produce(initial, (draft) => {
|
||||
updateSectionItem(draft, "skills", { id: "abc", name: "Go", level: 5 });
|
||||
});
|
||||
|
||||
const item = result.sections.skills.items[0] as { id?: string; name?: string; level?: number };
|
||||
expect(item?.level).toBe(5);
|
||||
});
|
||||
|
||||
it("does nothing when item id does not exist", () => {
|
||||
const initial = produce(defaultResumeData, (draft) => {
|
||||
createSectionItem(draft, "skills", { id: "abc", name: "Go" });
|
||||
});
|
||||
|
||||
const before = JSON.stringify(initial.sections.skills);
|
||||
const result = produce(initial, (draft) => {
|
||||
updateSectionItem(draft, "skills", { id: "non-existent", name: "X" });
|
||||
});
|
||||
expect(JSON.stringify(result.sections.skills)).toBe(before);
|
||||
});
|
||||
|
||||
it("replaces the matching item in a custom section", () => {
|
||||
const initial = produce(defaultResumeData, (draft) => {
|
||||
draft.customSections.push({
|
||||
id: "custom-1",
|
||||
type: "cover-letter",
|
||||
title: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [{ id: "x", value: "old" } as never],
|
||||
});
|
||||
});
|
||||
|
||||
const result = produce(initial, (draft) => {
|
||||
updateSectionItem(draft, "skills", { id: "x", value: "new" }, "custom-1");
|
||||
});
|
||||
|
||||
const customSection = result.customSections.find((s) => s.id === "custom-1");
|
||||
expect((customSection?.items[0] as { value?: string }).value).toBe("new");
|
||||
});
|
||||
|
||||
it("does nothing when custom section is not found", () => {
|
||||
const before = JSON.stringify(defaultResumeData);
|
||||
const result = produce(defaultResumeData, (draft) => {
|
||||
updateSectionItem(draft, "skills", { id: "x", value: "new" }, "non-existent");
|
||||
});
|
||||
expect(JSON.stringify(result)).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isTheme, themeMap } from "./theme";
|
||||
|
||||
describe("isTheme", () => {
|
||||
it("returns true for 'light'", () => {
|
||||
expect(isTheme("light")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for 'dark'", () => {
|
||||
expect(isTheme("dark")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unknown theme", () => {
|
||||
expect(isTheme("auto")).toBe(false);
|
||||
expect(isTheme("system")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty string", () => {
|
||||
expect(isTheme("")).toBe(false);
|
||||
});
|
||||
|
||||
it("is case-sensitive", () => {
|
||||
expect(isTheme("Light")).toBe(false);
|
||||
expect(isTheme("DARK")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("themeMap", () => {
|
||||
it("includes a descriptor for each theme", () => {
|
||||
expect(themeMap.light).toBeDefined();
|
||||
expect(themeMap.dark).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { buildAiExtractionTemplate } from "./extraction-template";
|
||||
|
||||
describe("buildAiExtractionTemplate", () => {
|
||||
it("returns a copy of defaultResumeData with one item per section", () => {
|
||||
const template = buildAiExtractionTemplate();
|
||||
|
||||
for (const [, section] of Object.entries(template.sections)) {
|
||||
expect(Array.isArray(section.items)).toBe(true);
|
||||
expect(section.items).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("populates the customFields with a single empty entry", () => {
|
||||
const template = buildAiExtractionTemplate();
|
||||
expect(template.basics.customFields).toEqual([{ id: "", icon: "", text: "", link: "" }]);
|
||||
});
|
||||
|
||||
it("does not mutate defaultResumeData", () => {
|
||||
const before = JSON.stringify(defaultResumeData);
|
||||
buildAiExtractionTemplate();
|
||||
expect(JSON.stringify(defaultResumeData)).toBe(before);
|
||||
});
|
||||
|
||||
it("creates skill items with empty fields and zero level", () => {
|
||||
const template = buildAiExtractionTemplate();
|
||||
const item = template.sections.skills.items[0] as Record<string, unknown>;
|
||||
expect(item.id).toBe("");
|
||||
expect(item.hidden).toBe(false);
|
||||
expect(item.name).toBe("");
|
||||
expect(item.level).toBe(0);
|
||||
expect(item.keywords).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates experience items with nested website object", () => {
|
||||
const template = buildAiExtractionTemplate();
|
||||
const item = template.sections.experience.items[0] as Record<string, unknown>;
|
||||
expect(item.website).toEqual({ url: "", label: "", inlineLink: false });
|
||||
expect(item.company).toBe("");
|
||||
expect(item.period).toBe("");
|
||||
});
|
||||
|
||||
it("preserves unmodified sections from default (e.g., title)", () => {
|
||||
const template = buildAiExtractionTemplate();
|
||||
expect(template.sections.skills.title).toBe(defaultResumeData.sections.skills.title);
|
||||
expect(template.sections.skills.hidden).toBe(defaultResumeData.sections.skills.hidden);
|
||||
expect(template.sections.skills.columns).toBe(defaultResumeData.sections.skills.columns);
|
||||
});
|
||||
|
||||
it("creates languages items with empty language and zero level", () => {
|
||||
const template = buildAiExtractionTemplate();
|
||||
const item = template.sections.languages.items[0] as Record<string, unknown>;
|
||||
expect(item.language).toBe("");
|
||||
expect(item.level).toBe(0);
|
||||
});
|
||||
|
||||
it("creates references items with phone field", () => {
|
||||
const template = buildAiExtractionTemplate();
|
||||
const item = template.sections.references.items[0] as Record<string, unknown>;
|
||||
expect(item.phone).toBe("");
|
||||
expect(item.name).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeAndParseResumeJson } from "./sanitize";
|
||||
|
||||
describe("sanitizeAndParseResumeJson", () => {
|
||||
it("parses a minimal but well-formed resume JSON", () => {
|
||||
const json = JSON.stringify({
|
||||
basics: { name: "Alice" },
|
||||
});
|
||||
|
||||
const result = sanitizeAndParseResumeJson(json);
|
||||
|
||||
expect(result.data.basics.name).toBe("Alice");
|
||||
expect(result.diagnostics.coercions).toEqual([]);
|
||||
});
|
||||
|
||||
it("repairs unquoted keys and trailing commas via jsonrepair", () => {
|
||||
const broken = `{ basics: { name: "Bob", }, }`;
|
||||
const result = sanitizeAndParseResumeJson(broken);
|
||||
expect(result.data.basics.name).toBe("Bob");
|
||||
});
|
||||
|
||||
it("strips text before and after the JSON block", () => {
|
||||
const messy = `Some preamble text\n\n{ "basics": { "name": "Carol" } }\n\nSome trailing text`;
|
||||
const result = sanitizeAndParseResumeJson(messy);
|
||||
expect(result.data.basics.name).toBe("Carol");
|
||||
});
|
||||
|
||||
it("coerces 'true'/'false' strings to booleans", () => {
|
||||
const json = JSON.stringify({
|
||||
basics: { name: "Test" },
|
||||
summary: { hidden: "true", title: "", columns: 1, content: "" },
|
||||
});
|
||||
|
||||
const result = sanitizeAndParseResumeJson(json);
|
||||
expect(result.data.summary.hidden).toBe(true);
|
||||
expect(result.diagnostics.coercions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("coerces numeric strings to numbers", () => {
|
||||
const json = JSON.stringify({
|
||||
basics: { name: "Test" },
|
||||
summary: { hidden: false, title: "", columns: "2", content: "" },
|
||||
});
|
||||
|
||||
const result = sanitizeAndParseResumeJson(json);
|
||||
expect(result.data.summary.columns).toBe(2);
|
||||
});
|
||||
|
||||
it("drops items missing required fields with diagnostics", () => {
|
||||
const json = JSON.stringify({
|
||||
basics: { name: "Test" },
|
||||
sections: {
|
||||
skills: {
|
||||
items: [
|
||||
{ name: "TypeScript", level: 4 },
|
||||
{ name: "" }, // dropped: missing name
|
||||
{}, // dropped: missing name
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = sanitizeAndParseResumeJson(json);
|
||||
expect(result.data.sections.skills.items).toHaveLength(1);
|
||||
expect(result.diagnostics.droppedSectionItems.length).toBeGreaterThan(0);
|
||||
expect(result.diagnostics.salvageApplied).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-generates ids for items lacking them", () => {
|
||||
const json = JSON.stringify({
|
||||
basics: { name: "Test" },
|
||||
sections: {
|
||||
skills: { items: [{ name: "Go" }] },
|
||||
},
|
||||
});
|
||||
|
||||
const result = sanitizeAndParseResumeJson(json);
|
||||
const item = result.data.sections.skills.items[0];
|
||||
expect(item).toBeDefined();
|
||||
expect(item?.id?.length).toBeGreaterThan(0);
|
||||
expect(result.diagnostics.salvageApplied).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults hidden=false on items missing the field", () => {
|
||||
const json = JSON.stringify({
|
||||
basics: { name: "Test" },
|
||||
sections: {
|
||||
skills: { items: [{ name: "Go" }] },
|
||||
},
|
||||
});
|
||||
|
||||
const result = sanitizeAndParseResumeJson(json);
|
||||
expect(result.data.sections.skills.items[0]?.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("uses defaultResumeData picture and metadata regardless of input", () => {
|
||||
const json = JSON.stringify({
|
||||
basics: { name: "Test" },
|
||||
picture: { url: "https://attacker.com/pic.png", size: 1024 }, // overridden
|
||||
metadata: { template: "unknown" }, // overridden
|
||||
});
|
||||
|
||||
const result = sanitizeAndParseResumeJson(json);
|
||||
expect(result.data.metadata.template).toBe("onyx");
|
||||
expect(result.data.picture.url).toBe("");
|
||||
});
|
||||
|
||||
it("preserves valid items alongside invalid ones in the same section", () => {
|
||||
const json = JSON.stringify({
|
||||
basics: { name: "Test" },
|
||||
sections: {
|
||||
experience: {
|
||||
items: [
|
||||
{ company: "Acme", position: "Engineer" },
|
||||
{ position: "Designer" }, // dropped: missing company
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = sanitizeAndParseResumeJson(json);
|
||||
expect(result.data.sections.experience.items).toHaveLength(1);
|
||||
expect(result.data.sections.experience.items[0]?.company).toBe("Acme");
|
||||
});
|
||||
|
||||
it("clears customSections to empty array regardless of input", () => {
|
||||
const json = JSON.stringify({
|
||||
basics: { name: "Test" },
|
||||
customSections: [{ key: "hack" }],
|
||||
});
|
||||
|
||||
const result = sanitizeAndParseResumeJson(json);
|
||||
expect(result.data.customSections).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws when input is non-JSON garbage", () => {
|
||||
expect(() => sanitizeAndParseResumeJson("not even close to json")).toThrow();
|
||||
});
|
||||
|
||||
it("preserves coercion path entries in diagnostics", () => {
|
||||
const json = JSON.stringify({
|
||||
basics: { name: "Test" },
|
||||
summary: { hidden: "true", title: "", columns: 1, content: "" },
|
||||
});
|
||||
|
||||
const result = sanitizeAndParseResumeJson(json);
|
||||
const hiddenCoercion = result.diagnostics.coercions.find((c) => c.path === "summary.hidden");
|
||||
expect(hiddenCoercion).toEqual({ path: "summary.hidden", fromType: "string", toType: "boolean" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { assertCanView, isOwner, redactResumeForViewer, shouldCountForStatistics } from "./resume-access-policy";
|
||||
|
||||
describe("isOwner", () => {
|
||||
it("returns true when viewer.id matches resume.userId", () => {
|
||||
expect(isOwner({ userId: "u1", isPublic: false }, { id: "u1" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when viewer.id differs", () => {
|
||||
expect(isOwner({ userId: "u1", isPublic: false }, { id: "u2" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when viewer is null (anonymous)", () => {
|
||||
expect(isOwner({ userId: "u1", isPublic: false }, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertCanView", () => {
|
||||
it("does not throw when viewer is owner of private resume", () => {
|
||||
expect(() => assertCanView({ userId: "u1", isPublic: false }, { id: "u1" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not throw when resume is public regardless of viewer", () => {
|
||||
expect(() => assertCanView({ userId: "u1", isPublic: true }, { id: "u2" })).not.toThrow();
|
||||
expect(() => assertCanView({ userId: "u1", isPublic: true }, null)).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND for private resume viewed by non-owner", () => {
|
||||
expect(() => assertCanView({ userId: "u1", isPublic: false }, { id: "u2" })).toThrow();
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND for private resume viewed anonymously", () => {
|
||||
expect(() => assertCanView({ userId: "u1", isPublic: false }, null)).toThrow();
|
||||
});
|
||||
|
||||
it("error code is NOT_FOUND (not FORBIDDEN) to prevent existence disclosure", () => {
|
||||
try {
|
||||
assertCanView({ userId: "u1", isPublic: false }, null);
|
||||
expect.unreachable();
|
||||
} catch (error: unknown) {
|
||||
expect((error as { code?: string }).code).toBe("NOT_FOUND");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactResumeForViewer", () => {
|
||||
it("returns the resume unchanged for owner", () => {
|
||||
const resume = {
|
||||
name: "My Dashboard Title",
|
||||
data: { ...defaultResumeData, metadata: { ...defaultResumeData.metadata, notes: "Private" } },
|
||||
};
|
||||
expect(redactResumeForViewer(resume, true)).toBe(resume);
|
||||
});
|
||||
|
||||
it("strips name to empty for non-owner", () => {
|
||||
const resume = {
|
||||
name: "Senior Eng @ Foo — final draft",
|
||||
data: defaultResumeData,
|
||||
};
|
||||
const result = redactResumeForViewer(resume, false);
|
||||
expect(result.name).toBe("");
|
||||
});
|
||||
|
||||
it("strips metadata.notes for non-owner", () => {
|
||||
const resume = {
|
||||
name: "Title",
|
||||
data: { ...defaultResumeData, metadata: { ...defaultResumeData.metadata, notes: "Private notes" } },
|
||||
};
|
||||
const result = redactResumeForViewer(resume, false);
|
||||
expect(result.data.metadata.notes).toBe("");
|
||||
});
|
||||
|
||||
it("preserves resume.data.basics.name (the person's name) for non-owner", () => {
|
||||
const resume = {
|
||||
name: "Dashboard title",
|
||||
data: {
|
||||
...defaultResumeData,
|
||||
basics: { ...defaultResumeData.basics, name: "Alice Smith" },
|
||||
},
|
||||
};
|
||||
const result = redactResumeForViewer(resume, false);
|
||||
expect(result.data.basics.name).toBe("Alice Smith");
|
||||
});
|
||||
|
||||
it("does not mutate the input", () => {
|
||||
const resume = {
|
||||
name: "Title",
|
||||
data: { ...defaultResumeData, metadata: { ...defaultResumeData.metadata, notes: "Notes" } },
|
||||
};
|
||||
const before = JSON.stringify(resume);
|
||||
redactResumeForViewer(resume, false);
|
||||
expect(JSON.stringify(resume)).toBe(before);
|
||||
});
|
||||
|
||||
it("preserves additional resume fields", () => {
|
||||
const resume = {
|
||||
name: "Title",
|
||||
data: defaultResumeData,
|
||||
extraField: "extra",
|
||||
};
|
||||
const result = redactResumeForViewer(resume, false);
|
||||
expect((result as typeof resume).extraField).toBe("extra");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldCountForStatistics", () => {
|
||||
it("returns false when viewer is the owner", () => {
|
||||
expect(shouldCountForStatistics({ userId: "u1", isPublic: true }, { id: "u1" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for anonymous viewers", () => {
|
||||
expect(shouldCountForStatistics({ userId: "u1", isPublic: true }, null)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for non-owner viewers", () => {
|
||||
expect(shouldCountForStatistics({ userId: "u1", isPublic: true }, { id: "u2" })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fontList, getPdfCjkFallbackFontFamily, getWebFontSource } from "./index";
|
||||
import {
|
||||
buildResumeFontFamily,
|
||||
fontList,
|
||||
getFallbackWebFontFamilies,
|
||||
getFont,
|
||||
getFontDisplayName,
|
||||
getFontSearchKeywords,
|
||||
getLoadableWebFontWeights,
|
||||
getPdfCjkFallbackFontFamily,
|
||||
getWebFont,
|
||||
getWebFontSource,
|
||||
isStandardPdfFontFamily,
|
||||
standardFontList,
|
||||
webFontList,
|
||||
webFontMap,
|
||||
} from "./index";
|
||||
|
||||
const sortFontFamilies = (families: string[]) => {
|
||||
return [...families].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
|
||||
@@ -8,18 +23,237 @@ const sortFontFamilies = (families: string[]) => {
|
||||
describe("fontList", () => {
|
||||
it("is ordered by font family name instead of localized display name", () => {
|
||||
const families = fontList.map((font) => font.family);
|
||||
|
||||
expect(families).toEqual(sortFontFamilies(families));
|
||||
});
|
||||
|
||||
it("contains standard PDF fonts", () => {
|
||||
const families = fontList.map((font) => font.family);
|
||||
expect(families).toContain("Helvetica");
|
||||
expect(families).toContain("Courier");
|
||||
expect(families).toContain("Times-Roman");
|
||||
});
|
||||
|
||||
it("merges standardFontList and webFontList without duplicates", () => {
|
||||
const expectedSize = standardFontList.length + webFontList.length;
|
||||
expect(fontList).toHaveLength(expectedSize);
|
||||
});
|
||||
});
|
||||
|
||||
describe("font source helpers", () => {
|
||||
describe("standardFontList", () => {
|
||||
it("contains only fonts not present in webFontMap", () => {
|
||||
for (const font of standardFontList) {
|
||||
expect(webFontMap.has(font.family)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("marks all standard fonts with type='standard'", () => {
|
||||
for (const font of standardFontList) {
|
||||
expect(font.type).toBe("standard");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFont", () => {
|
||||
it("returns a font record by family", () => {
|
||||
expect(getFont("Helvetica")).toBeDefined();
|
||||
expect(getFont("Helvetica")?.family).toBe("Helvetica");
|
||||
});
|
||||
|
||||
it("returns undefined for unknown families", () => {
|
||||
expect(getFont("DefinitelyNotAFont")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFontDisplayName", () => {
|
||||
it("returns localized name when available", () => {
|
||||
expect(getFontDisplayName("Noto Sans SC")).toBe("思源黑体");
|
||||
expect(getFontDisplayName("PingFang SC")).toBe("苹方");
|
||||
});
|
||||
|
||||
it("returns the family name when no localized name exists", () => {
|
||||
expect(getFontDisplayName("Helvetica")).toBe("Helvetica");
|
||||
expect(getFontDisplayName("Roboto")).toBe("Roboto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFontSearchKeywords", () => {
|
||||
it("returns family for non-CJK fonts", () => {
|
||||
const keywords = getFontSearchKeywords("Helvetica");
|
||||
expect(keywords).toContain("Helvetica");
|
||||
expect(keywords).not.toContain("中文");
|
||||
});
|
||||
|
||||
it("includes localized display name and 中文 marker for CJK fonts", () => {
|
||||
const keywords = getFontSearchKeywords("Noto Sans SC");
|
||||
expect(keywords).toContain("Noto Sans SC");
|
||||
expect(keywords).toContain("思源黑体");
|
||||
expect(keywords).toContain("中文");
|
||||
});
|
||||
|
||||
it("deduplicates entries", () => {
|
||||
const keywords = getFontSearchKeywords("Helvetica");
|
||||
expect(new Set(keywords).size).toBe(keywords.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isStandardPdfFontFamily", () => {
|
||||
it("returns true for Helvetica, Courier, Times-Roman", () => {
|
||||
expect(isStandardPdfFontFamily("Helvetica")).toBe(true);
|
||||
expect(isStandardPdfFontFamily("Courier")).toBe(true);
|
||||
expect(isStandardPdfFontFamily("Times-Roman")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unknown families", () => {
|
||||
expect(isStandardPdfFontFamily("Not-A-Font")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for non-standard web fonts", () => {
|
||||
// Roboto is a web font, not a standard PDF font
|
||||
const roboto = getWebFont("Roboto");
|
||||
if (roboto) expect(isStandardPdfFontFamily("Roboto")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWebFont", () => {
|
||||
it("returns webFont record for known web font family", () => {
|
||||
// Spot-check a font that should be in the list
|
||||
const font = getWebFont("Roboto");
|
||||
if (font) {
|
||||
expect(font.type).toBe("web");
|
||||
expect(font.family).toBe("Roboto");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns undefined for non-web families", () => {
|
||||
expect(getWebFont("Helvetica")).toBeUndefined();
|
||||
expect(getWebFont("definitely-not-a-font")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWebFontSource", () => {
|
||||
it("uses the full normal font source when an italic variant is unavailable", () => {
|
||||
expect(getWebFontSource("Noto Serif SC", "400", true)).toBe(getWebFontSource("Noto Serif SC", "400", false));
|
||||
});
|
||||
|
||||
it("returns CJK PDF fallbacks for standard PDF fonts", () => {
|
||||
it("returns null for unknown fonts", () => {
|
||||
expect(getWebFontSource("definitely-not-a-font", "400")).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults to weight 400 when not specified", () => {
|
||||
const roboto = getWebFont("Roboto");
|
||||
if (roboto) {
|
||||
const source = getWebFontSource("Roboto");
|
||||
expect(source).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPdfCjkFallbackFontFamily", () => {
|
||||
it("returns Noto Sans SC for sans-serif/standard PDF fonts", () => {
|
||||
expect(getPdfCjkFallbackFontFamily("Helvetica")).toBe("Noto Sans SC");
|
||||
});
|
||||
|
||||
it("returns Noto Serif SC for serif fonts", () => {
|
||||
expect(getPdfCjkFallbackFontFamily("Times-Roman")).toBe("Noto Serif SC");
|
||||
});
|
||||
|
||||
it("returns null when family already is the CJK fallback", () => {
|
||||
expect(getPdfCjkFallbackFontFamily("Noto Sans SC")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFallbackWebFontFamilies", () => {
|
||||
it("returns empty array for standard PDF fonts", () => {
|
||||
expect(getFallbackWebFontFamilies("Helvetica")).toEqual([]);
|
||||
expect(getFallbackWebFontFamilies("Courier")).toEqual([]);
|
||||
expect(getFallbackWebFontFamilies("Times-Roman")).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns the primary CJK web font for non-standard, non-CJK families", () => {
|
||||
// e.g. Roboto (sans-serif) → Noto Sans SC fallback
|
||||
const roboto = getWebFont("Roboto");
|
||||
if (roboto) {
|
||||
expect(getFallbackWebFontFamilies("Roboto")).toEqual(["Noto Sans SC"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns empty when family is already its primary CJK fallback", () => {
|
||||
expect(getFallbackWebFontFamilies("Noto Sans SC")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLoadableWebFontWeights", () => {
|
||||
it("returns empty array for unknown fonts", () => {
|
||||
expect(getLoadableWebFontWeights("definitely-not-a-font", ["400"])).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns matching weights when preferred weights are available", () => {
|
||||
const roboto = getWebFont("Roboto");
|
||||
if (roboto) {
|
||||
const result = getLoadableWebFontWeights("Roboto", ["400", "700"]);
|
||||
for (const weight of result) {
|
||||
expect(roboto.weights).toContain(weight);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to default weights when no preferences match", () => {
|
||||
const roboto = getWebFont("Roboto");
|
||||
if (roboto) {
|
||||
const result = getLoadableWebFontWeights("Roboto", ["999"]);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("deduplicates preferred weights", () => {
|
||||
const roboto = getWebFont("Roboto");
|
||||
if (roboto?.weights.includes("400")) {
|
||||
const result = getLoadableWebFontWeights("Roboto", ["400", "400"]);
|
||||
expect(result).toEqual(["400"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildResumeFontFamily", () => {
|
||||
it("wraps the primary family in single quotes", () => {
|
||||
const result = buildResumeFontFamily("Roboto");
|
||||
expect(result.startsWith("'Roboto',")).toBe(true);
|
||||
});
|
||||
|
||||
it("includes generic sans-serif fallback by default", () => {
|
||||
const result = buildResumeFontFamily("Roboto");
|
||||
expect(result.endsWith("sans-serif")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses serif fallback for serif-category fonts", () => {
|
||||
expect(buildResumeFontFamily("Times-Roman").endsWith("serif")).toBe(true);
|
||||
});
|
||||
|
||||
it("includes system-ui and Segoe UI fallbacks", () => {
|
||||
const result = buildResumeFontFamily("Roboto");
|
||||
expect(result).toContain("system-ui");
|
||||
expect(result).toContain("Segoe UI");
|
||||
});
|
||||
|
||||
it("includes CJK fallbacks for sans-serif fonts", () => {
|
||||
const result = buildResumeFontFamily("Roboto");
|
||||
expect(result).toContain("Noto Sans SC");
|
||||
});
|
||||
|
||||
it("includes CJK serif fallbacks for serif fonts", () => {
|
||||
const result = buildResumeFontFamily("Times-Roman");
|
||||
expect(result).toContain("Noto Serif SC");
|
||||
});
|
||||
|
||||
it("does not duplicate primary family in fallback list", () => {
|
||||
const result = buildResumeFontFamily("Noto Sans SC");
|
||||
// Family should appear once
|
||||
const occurrences = result.split("Noto Sans SC").length - 1;
|
||||
expect(occurrences).toBe(1);
|
||||
});
|
||||
|
||||
it("escapes single quotes in family names", () => {
|
||||
const result = buildResumeFontFamily("Bob's Font");
|
||||
expect(result).toContain("Bob\\'s Font");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { getResumeSectionTitle, resolveSectionTitle } from "./section-title";
|
||||
|
||||
describe("resolveSectionTitle", () => {
|
||||
it("returns the trimmed user-provided title when non-empty", () => {
|
||||
const result = resolveSectionTitle("My Title", {
|
||||
sectionId: "experience",
|
||||
locale: "en-US",
|
||||
sectionKind: "builtin",
|
||||
});
|
||||
expect(result).toBe("My Title");
|
||||
});
|
||||
|
||||
it("falls through to resolver when title is empty", () => {
|
||||
const resolver = vi.fn().mockReturnValue("Resolved");
|
||||
const result = resolveSectionTitle(
|
||||
"",
|
||||
{
|
||||
sectionId: "experience",
|
||||
locale: "en-US",
|
||||
sectionKind: "builtin",
|
||||
},
|
||||
resolver,
|
||||
);
|
||||
expect(resolver).toHaveBeenCalled();
|
||||
expect(result).toBe("Resolved");
|
||||
});
|
||||
|
||||
it("falls through to legacyFallback when title and resolver return blank", () => {
|
||||
const resolver = vi.fn().mockReturnValue("");
|
||||
const result = resolveSectionTitle(
|
||||
"",
|
||||
{ sectionId: "experience", locale: "en-US", sectionKind: "builtin" },
|
||||
resolver,
|
||||
"Legacy Title",
|
||||
);
|
||||
expect(result).toBe("Legacy Title");
|
||||
});
|
||||
|
||||
it("falls through to defaultEnglishTitle when no other source available", () => {
|
||||
const result = resolveSectionTitle("", {
|
||||
sectionId: "experience",
|
||||
locale: "en-US",
|
||||
sectionKind: "builtin",
|
||||
defaultEnglishTitle: "Experience",
|
||||
});
|
||||
expect(result).toBe("Experience");
|
||||
});
|
||||
|
||||
it("falls through to sectionId as last resort", () => {
|
||||
const result = resolveSectionTitle("", {
|
||||
sectionId: "experience",
|
||||
locale: "en-US",
|
||||
sectionKind: "builtin",
|
||||
});
|
||||
expect(result).toBe("experience");
|
||||
});
|
||||
|
||||
it("treats whitespace-only title as empty", () => {
|
||||
const result = resolveSectionTitle(" ", {
|
||||
sectionId: "x",
|
||||
locale: "en-US",
|
||||
sectionKind: "builtin",
|
||||
defaultEnglishTitle: "Default",
|
||||
});
|
||||
expect(result).toBe("Default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResumeSectionTitle", () => {
|
||||
it("uses summary's user-provided title when set", () => {
|
||||
const data = {
|
||||
...defaultResumeData,
|
||||
summary: { ...defaultResumeData.summary, title: "Profile Summary" },
|
||||
};
|
||||
expect(getResumeSectionTitle(data, "summary")).toBe("Profile Summary");
|
||||
});
|
||||
|
||||
it("uses default English title for summary when title is empty", () => {
|
||||
expect(getResumeSectionTitle(defaultResumeData, "summary")).toBe("Summary");
|
||||
});
|
||||
|
||||
it("uses default English title for built-in sections", () => {
|
||||
expect(getResumeSectionTitle(defaultResumeData, "experience")).toBe("Experience");
|
||||
expect(getResumeSectionTitle(defaultResumeData, "skills")).toBe("Skills");
|
||||
});
|
||||
|
||||
it("falls back to legacyFallback for unknown section ids", () => {
|
||||
expect(getResumeSectionTitle(defaultResumeData, "unknown-id", "Custom Legacy")).toBe("Custom Legacy");
|
||||
});
|
||||
|
||||
it("falls back to sectionId for unknown sections without legacyFallback", () => {
|
||||
expect(getResumeSectionTitle(defaultResumeData, "unknown-section")).toBe("unknown-section");
|
||||
});
|
||||
|
||||
it("calls user's resolveSectionTitle when present", () => {
|
||||
const resolver = vi.fn().mockReturnValue("Translated");
|
||||
const data = { ...defaultResumeData, resolveSectionTitle: resolver };
|
||||
expect(getResumeSectionTitle(data, "experience")).toBe("Translated");
|
||||
expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ sectionId: "experience", sectionKind: "builtin" }));
|
||||
});
|
||||
|
||||
it("uses custom section's title when defined", () => {
|
||||
const data = {
|
||||
...defaultResumeData,
|
||||
customSections: [
|
||||
{
|
||||
id: "ext-1",
|
||||
type: "cover-letter",
|
||||
title: "My Cover Letter",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(getResumeSectionTitle(data, "ext-1")).toBe("My Cover Letter");
|
||||
});
|
||||
|
||||
it("uses default English title 'Cover Letter' for cover-letter custom sections", () => {
|
||||
const data = {
|
||||
...defaultResumeData,
|
||||
customSections: [
|
||||
{
|
||||
id: "ext-1",
|
||||
type: "cover-letter",
|
||||
title: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(getResumeSectionTitle(data, "ext-1")).toBe("Cover Letter");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getSectionItemRows, getSectionItemsLayout, shouldUseSectionTimeline } from "./columns";
|
||||
|
||||
describe("getSectionItemsLayout", () => {
|
||||
it("returns single-column layout for columns=1", () => {
|
||||
const layout = getSectionItemsLayout({ columns: 1, rowGap: 4, columnGap: 6 });
|
||||
expect(layout.columns).toBe(1);
|
||||
expect(layout.isGrid).toBe(false);
|
||||
expect(layout.rowStyle).toBeUndefined();
|
||||
expect(layout.itemStyle).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns grid layout for columns=2", () => {
|
||||
const layout = getSectionItemsLayout({ columns: 2, rowGap: 4, columnGap: 6 });
|
||||
expect(layout.columns).toBe(2);
|
||||
expect(layout.isGrid).toBe(true);
|
||||
expect(layout.rowStyle).toEqual({ flexDirection: "row", columnGap: 6 });
|
||||
expect(layout.itemStyle).toMatchObject({
|
||||
flexBasis: 0,
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
maxWidth: "100%",
|
||||
overflow: "hidden",
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps columns above 6 to 6", () => {
|
||||
const layout = getSectionItemsLayout({ columns: 99, rowGap: 4, columnGap: 6 });
|
||||
expect(layout.columns).toBe(6);
|
||||
});
|
||||
|
||||
it("clamps columns below 1 to 1", () => {
|
||||
const layout = getSectionItemsLayout({ columns: 0, rowGap: 4, columnGap: 6 });
|
||||
expect(layout.columns).toBe(1);
|
||||
});
|
||||
|
||||
it("treats non-integer columns as 1", () => {
|
||||
const layout = getSectionItemsLayout({ columns: 2.5, rowGap: 4, columnGap: 6 });
|
||||
expect(layout.columns).toBe(1);
|
||||
});
|
||||
|
||||
it("treats non-numeric columns as 1", () => {
|
||||
const layout = getSectionItemsLayout({ columns: "2", rowGap: 4, columnGap: 6 });
|
||||
expect(layout.columns).toBe(1);
|
||||
});
|
||||
|
||||
it("treats Infinity as 1", () => {
|
||||
const layout = getSectionItemsLayout({ columns: Number.POSITIVE_INFINITY, rowGap: 4, columnGap: 6 });
|
||||
expect(layout.columns).toBe(1);
|
||||
});
|
||||
|
||||
it("includes containerStyle.rowGap", () => {
|
||||
const layout = getSectionItemsLayout({ columns: 1, rowGap: 8, columnGap: 6 });
|
||||
expect(layout.containerStyle).toEqual({ rowGap: 8 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSectionItemRows", () => {
|
||||
it("groups items into rows of N columns", () => {
|
||||
expect(getSectionItemRows([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
|
||||
});
|
||||
|
||||
it("returns single-item rows for columns=1", () => {
|
||||
expect(getSectionItemRows([1, 2, 3], 1)).toEqual([[1], [2], [3]]);
|
||||
});
|
||||
|
||||
it("returns one row when items fit in one row", () => {
|
||||
expect(getSectionItemRows([1, 2], 3)).toEqual([[1, 2]]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty input", () => {
|
||||
expect(getSectionItemRows([], 2)).toEqual([]);
|
||||
});
|
||||
|
||||
it("clamps invalid columns to 1", () => {
|
||||
expect(getSectionItemRows([1, 2], 0)).toEqual([[1], [2]]);
|
||||
expect(getSectionItemRows([1, 2], "not-a-number")).toEqual([[1], [2]]);
|
||||
});
|
||||
|
||||
it("clamps columns above 6 to 6", () => {
|
||||
expect(getSectionItemRows([1, 2, 3, 4, 5, 6, 7, 8], 99)).toEqual([
|
||||
[1, 2, 3, 4, 5, 6],
|
||||
[7, 8],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldUseSectionTimeline", () => {
|
||||
it("returns true only for sectionTimeline=true, placement='main', columns=1", () => {
|
||||
expect(shouldUseSectionTimeline({ sectionTimeline: true, placement: "main", columns: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when placement is not 'main'", () => {
|
||||
expect(shouldUseSectionTimeline({ sectionTimeline: true, placement: "sidebar", columns: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when columns > 1 (after normalization)", () => {
|
||||
expect(shouldUseSectionTimeline({ sectionTimeline: true, placement: "main", columns: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when sectionTimeline=false", () => {
|
||||
expect(shouldUseSectionTimeline({ sectionTimeline: false, placement: "main", columns: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when columns is invalid (normalized to 1)", () => {
|
||||
expect(shouldUseSectionTimeline({ sectionTimeline: true, placement: "main", columns: 0 })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterItems, filterSections, hasVisibleItems, isSectionVisible, isVisibleSummary } from "./filtering";
|
||||
|
||||
describe("filterItems", () => {
|
||||
it("returns only items where hidden is false", () => {
|
||||
const items = [
|
||||
{ id: 1, hidden: false },
|
||||
{ id: 2, hidden: true },
|
||||
{ id: 3, hidden: false },
|
||||
];
|
||||
expect(filterItems(items)).toEqual([
|
||||
{ id: 1, hidden: false },
|
||||
{ id: 3, hidden: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty array when all items are hidden", () => {
|
||||
const items = [{ hidden: true }, { hidden: true }];
|
||||
expect(filterItems(items)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty input", () => {
|
||||
expect(filterItems([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves additional properties on items", () => {
|
||||
const items = [{ hidden: false, name: "Alice", level: 4 }];
|
||||
expect(filterItems(items)).toEqual([{ hidden: false, name: "Alice", level: 4 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasVisibleItems", () => {
|
||||
it("returns false when section.hidden is true", () => {
|
||||
expect(hasVisibleItems({ hidden: true, items: [{ hidden: false }] })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when no items are visible", () => {
|
||||
expect(hasVisibleItems({ hidden: false, items: [{ hidden: true }] })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when at least one item is visible and section not hidden", () => {
|
||||
expect(hasVisibleItems({ hidden: false, items: [{ hidden: false }] })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for empty items", () => {
|
||||
expect(hasVisibleItems({ hidden: false, items: [] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isVisibleSummary", () => {
|
||||
it("returns true when not hidden and content is non-empty", () => {
|
||||
expect(isVisibleSummary({ hidden: false, content: "<p>Some text</p>" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when summary is hidden", () => {
|
||||
expect(isVisibleSummary({ hidden: true, content: "<p>Text</p>" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when content is empty after trimming", () => {
|
||||
expect(isVisibleSummary({ hidden: false, content: " \n " })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when content is empty", () => {
|
||||
expect(isVisibleSummary({ hidden: false, content: "" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSectionVisible", () => {
|
||||
const data = {
|
||||
summary: { hidden: false, content: "<p>Hi</p>" },
|
||||
sections: {
|
||||
experience: { hidden: false, items: [{ hidden: false }] },
|
||||
skills: { hidden: false, items: [] },
|
||||
education: { hidden: true, items: [{ hidden: false }] },
|
||||
},
|
||||
customSections: [{ id: "ext-1", hidden: false, items: [{ hidden: false }] }],
|
||||
};
|
||||
|
||||
it("returns true for visible summary", () => {
|
||||
expect(isSectionVisible("summary", data)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for hidden summary", () => {
|
||||
expect(isSectionVisible("summary", { ...data, summary: { hidden: true, content: "<p>x</p>" } })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for built-in section with visible items", () => {
|
||||
expect(isSectionVisible("experience", data)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for built-in section with no items", () => {
|
||||
expect(isSectionVisible("skills", data)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for built-in section with hidden flag", () => {
|
||||
expect(isSectionVisible("education", data)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for matching custom section by id", () => {
|
||||
expect(isSectionVisible("ext-1", data)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unknown section id", () => {
|
||||
expect(isSectionVisible("does-not-exist", data)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterSections", () => {
|
||||
const data = {
|
||||
summary: { hidden: false, content: "<p>Hi</p>" },
|
||||
sections: {
|
||||
experience: { hidden: false, items: [{ hidden: false }] },
|
||||
skills: { hidden: false, items: [] },
|
||||
},
|
||||
customSections: [],
|
||||
};
|
||||
|
||||
it("returns only visible section ids in input order", () => {
|
||||
expect(filterSections(["summary", "experience", "skills"], data)).toEqual(["summary", "experience"]);
|
||||
});
|
||||
|
||||
it("returns empty array when no sections are visible", () => {
|
||||
const empty = {
|
||||
summary: { hidden: false, content: "" },
|
||||
sections: { skills: { hidden: false, items: [] } },
|
||||
customSections: [],
|
||||
};
|
||||
expect(filterSections(["summary", "skills"], empty)).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves order of input section ids", () => {
|
||||
expect(filterSections(["experience", "summary"], data)).toEqual(["experience", "summary"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getTemplateMetrics } from "./metrics";
|
||||
|
||||
describe("getTemplateMetrics", () => {
|
||||
it("maps margins to page paddings", () => {
|
||||
const metrics = getTemplateMetrics({ gapX: 4, gapY: 6, marginX: 14, marginY: 12 });
|
||||
expect(metrics.page.paddingHorizontal).toBe(14);
|
||||
expect(metrics.page.paddingVertical).toBe(12);
|
||||
});
|
||||
|
||||
it("uses marginY as headerGap and sectionGap", () => {
|
||||
const metrics = getTemplateMetrics({ gapX: 4, gapY: 6, marginX: 14, marginY: 20 });
|
||||
expect(metrics.headerGap).toBe(20);
|
||||
expect(metrics.sectionGap).toBe(20);
|
||||
});
|
||||
|
||||
it("uses marginX as columnGap", () => {
|
||||
const metrics = getTemplateMetrics({ gapX: 4, gapY: 6, marginX: 30, marginY: 12 });
|
||||
expect(metrics.columnGap).toBe(30);
|
||||
});
|
||||
|
||||
it("preserves itemGapX and itemGapY directly from gapX/gapY", () => {
|
||||
const metrics = getTemplateMetrics({ gapX: 7, gapY: 9, marginX: 14, marginY: 12 });
|
||||
expect(metrics.itemGapX).toBe(7);
|
||||
expect(metrics.itemGapY).toBe(9);
|
||||
});
|
||||
|
||||
it("provides gapX/gapY scaling functions", () => {
|
||||
const metrics = getTemplateMetrics({ gapX: 4, gapY: 6, marginX: 14, marginY: 12 });
|
||||
expect(metrics.gapX(2)).toBe(8);
|
||||
expect(metrics.gapY(3)).toBe(18);
|
||||
});
|
||||
|
||||
it("scaling by 0.5 returns half-gap", () => {
|
||||
const metrics = getTemplateMetrics({ gapX: 10, gapY: 20, marginX: 14, marginY: 12 });
|
||||
expect(metrics.gapX(0.5)).toBe(5);
|
||||
expect(metrics.gapY(0.5)).toBe(10);
|
||||
});
|
||||
|
||||
it("scaling by 0 returns 0", () => {
|
||||
const metrics = getTemplateMetrics({ gapX: 4, gapY: 6, marginX: 14, marginY: 12 });
|
||||
expect(metrics.gapX(0)).toBe(0);
|
||||
expect(metrics.gapY(0)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "./page-size";
|
||||
|
||||
describe("getTemplatePageSize", () => {
|
||||
it("returns 'A4' for a4 format", () => {
|
||||
expect(getTemplatePageSize("a4")).toBe("A4");
|
||||
});
|
||||
|
||||
it("returns 'LETTER' for letter format", () => {
|
||||
expect(getTemplatePageSize("letter")).toBe("LETTER");
|
||||
});
|
||||
|
||||
it("returns custom width for free-form format", () => {
|
||||
const result = getTemplatePageSize("free-form");
|
||||
expect(typeof result).toBe("object");
|
||||
if (typeof result === "object") {
|
||||
expect(result.width).toBeCloseTo(595.28, 2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTemplatePageMinHeightStyle", () => {
|
||||
it("returns undefined for non-free-form formats", () => {
|
||||
expect(getTemplatePageMinHeightStyle("a4")).toBeUndefined();
|
||||
expect(getTemplatePageMinHeightStyle("letter")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns A4-style minHeight for free-form", () => {
|
||||
expect(getTemplatePageMinHeightStyle("free-form")).toEqual({ minHeight: 841.89 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasTemplatePicture } from "./picture";
|
||||
|
||||
const basePicture = {
|
||||
hidden: false,
|
||||
url: "",
|
||||
size: 80,
|
||||
rotation: 0,
|
||||
aspectRatio: 1,
|
||||
borderRadius: 0,
|
||||
borderColor: "rgba(0, 0, 0, 0.5)",
|
||||
borderWidth: 0,
|
||||
shadowColor: "rgba(0, 0, 0, 0.5)",
|
||||
shadowWidth: 0,
|
||||
} as const;
|
||||
|
||||
describe("hasTemplatePicture", () => {
|
||||
it("returns true when not hidden and url is non-empty", () => {
|
||||
expect(hasTemplatePicture({ ...basePicture, url: "/uploads/me.png" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when hidden", () => {
|
||||
expect(hasTemplatePicture({ ...basePicture, url: "/uploads/me.png", hidden: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when url is empty", () => {
|
||||
expect(hasTemplatePicture(basePicture)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when url is whitespace only", () => {
|
||||
expect(hasTemplatePicture({ ...basePicture, url: " " })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeRichTextHtml } from "./rich-text-html";
|
||||
|
||||
describe("normalizeRichTextHtml", () => {
|
||||
it("wraps loose inline content in a <p>", () => {
|
||||
expect(normalizeRichTextHtml("hello world")).toBe("<p>hello world</p>");
|
||||
});
|
||||
|
||||
it("wraps inline tags in a <p>", () => {
|
||||
expect(normalizeRichTextHtml("<strong>bold</strong> text")).toBe("<p><strong>bold</strong> text</p>");
|
||||
});
|
||||
|
||||
it("preserves block-level <p> as-is", () => {
|
||||
expect(normalizeRichTextHtml("<p>Already wrapped</p>")).toBe("<p>Already wrapped</p>");
|
||||
});
|
||||
|
||||
it("preserves block-level <ul>", () => {
|
||||
const html = "<ul><li>a</li><li>b</li></ul>";
|
||||
expect(normalizeRichTextHtml(html)).toBe(html);
|
||||
});
|
||||
|
||||
it("flushes accumulated inlines before block-level tags", () => {
|
||||
expect(normalizeRichTextHtml("loose<ul><li>a</li></ul>")).toBe("<p>loose</p><ul><li>a</li></ul>");
|
||||
});
|
||||
|
||||
it("flushes accumulated inlines after block-level tags", () => {
|
||||
expect(normalizeRichTextHtml("<ul><li>a</li></ul>after")).toBe("<ul><li>a</li></ul><p>after</p>");
|
||||
});
|
||||
|
||||
it("treats <span> as inline", () => {
|
||||
expect(normalizeRichTextHtml("<span>x</span>")).toBe("<p><span>x</span></p>");
|
||||
});
|
||||
|
||||
it("trims input whitespace", () => {
|
||||
expect(normalizeRichTextHtml(" text ")).toBe("<p>text</p>");
|
||||
});
|
||||
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(normalizeRichTextHtml("")).toBe("");
|
||||
});
|
||||
|
||||
it("treats <a> as inline (no need to wrap by itself)", () => {
|
||||
expect(normalizeRichTextHtml('<a href="x">link</a>')).toBe('<p><a href="x">link</a></p>');
|
||||
});
|
||||
|
||||
it("does not double-wrap inline tags inside block elements", () => {
|
||||
expect(normalizeRichTextHtml("<p><strong>x</strong></p>")).toBe("<p><strong>x</strong></p>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getInlineItemWebsiteUrl, shouldRenderSeparateItemWebsite } from "./section-links";
|
||||
|
||||
describe("getInlineItemWebsiteUrl", () => {
|
||||
it("returns the url when url is set and inlineLink is true", () => {
|
||||
expect(getInlineItemWebsiteUrl({ url: "https://example.com", label: "", inlineLink: true })).toBe(
|
||||
"https://example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined when inlineLink is false", () => {
|
||||
expect(getInlineItemWebsiteUrl({ url: "https://example.com", label: "", inlineLink: false })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when url is empty", () => {
|
||||
expect(getInlineItemWebsiteUrl({ url: "", label: "", inlineLink: true })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when inlineLink is undefined", () => {
|
||||
expect(getInlineItemWebsiteUrl({ url: "https://example.com", label: "" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRenderSeparateItemWebsite", () => {
|
||||
it("returns true when url is set and inlineLink is false", () => {
|
||||
expect(shouldRenderSeparateItemWebsite({ url: "https://example.com", label: "", inlineLink: false })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when url is empty", () => {
|
||||
expect(shouldRenderSeparateItemWebsite({ url: "", label: "", inlineLink: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when inlineLink is true", () => {
|
||||
expect(shouldRenderSeparateItemWebsite({ url: "https://example.com", label: "", inlineLink: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when inlineLink is undefined", () => {
|
||||
expect(shouldRenderSeparateItemWebsite({ url: "https://example.com", label: "" })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pageDimensionsAsMillimeters, pageDimensionsAsPixels } from "./page";
|
||||
|
||||
describe("pageDimensionsAsPixels", () => {
|
||||
it("defines a4 dimensions matching ISO 216 at ~96 DPI", () => {
|
||||
expect(pageDimensionsAsPixels.a4).toEqual({ width: 794, height: 1123 });
|
||||
});
|
||||
|
||||
it("defines US letter dimensions", () => {
|
||||
expect(pageDimensionsAsPixels.letter).toEqual({ width: 816, height: 1056 });
|
||||
});
|
||||
|
||||
it("defines free-form dimensions same as a4", () => {
|
||||
expect(pageDimensionsAsPixels["free-form"]).toEqual({ width: 794, height: 1123 });
|
||||
});
|
||||
|
||||
it("uses portrait orientation (height > width) for all formats", () => {
|
||||
expect(pageDimensionsAsPixels.a4.height).toBeGreaterThan(pageDimensionsAsPixels.a4.width);
|
||||
expect(pageDimensionsAsPixels.letter.height).toBeGreaterThan(pageDimensionsAsPixels.letter.width);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pageDimensionsAsMillimeters", () => {
|
||||
it("defines a4 dimensions in millimeters per ISO 216", () => {
|
||||
expect(pageDimensionsAsMillimeters.a4).toEqual({ width: "210mm", height: "297mm" });
|
||||
});
|
||||
|
||||
it("defines letter dimensions in millimeters", () => {
|
||||
expect(pageDimensionsAsMillimeters.letter).toEqual({ width: "216mm", height: "279mm" });
|
||||
});
|
||||
|
||||
it("appends 'mm' unit suffix to all values", () => {
|
||||
for (const [, dims] of Object.entries(pageDimensionsAsMillimeters)) {
|
||||
expect(dims.width).toMatch(/mm$/);
|
||||
expect(dims.height).toMatch(/mm$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("provides all three formats (a4, letter, free-form)", () => {
|
||||
expect(Object.keys(pageDimensionsAsMillimeters).sort()).toEqual(["a4", "free-form", "letter"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
basicsSchema,
|
||||
customFieldSchema,
|
||||
experienceItemSchema,
|
||||
layoutSchema,
|
||||
pageSchema,
|
||||
pictureSchema,
|
||||
resumeDataSchema,
|
||||
skillItemSchema,
|
||||
websiteSchema,
|
||||
} from "./data";
|
||||
import { defaultResumeData } from "./default";
|
||||
|
||||
describe("resumeDataSchema", () => {
|
||||
it("validates the default resume", () => {
|
||||
expect(resumeDataSchema.safeParse(defaultResumeData).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty object", () => {
|
||||
expect(resumeDataSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects missing top-level keys", () => {
|
||||
const partial = { ...defaultResumeData, basics: undefined };
|
||||
expect(resumeDataSchema.safeParse(partial).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("websiteSchema", () => {
|
||||
it("requires url and label fields", () => {
|
||||
expect(websiteSchema.safeParse({ url: "https://example.com", label: "Example" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing url", () => {
|
||||
expect(websiteSchema.safeParse({ label: "Example" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects missing label", () => {
|
||||
expect(websiteSchema.safeParse({ url: "https://example.com" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("allows empty strings (caller decides display)", () => {
|
||||
expect(websiteSchema.safeParse({ url: "", label: "" }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pictureSchema", () => {
|
||||
it("accepts the default picture config", () => {
|
||||
expect(pictureSchema.safeParse(defaultResumeData.picture).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects size below 32", () => {
|
||||
const invalid = { ...defaultResumeData.picture, size: 16 };
|
||||
expect(pictureSchema.safeParse(invalid).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects size above 512", () => {
|
||||
const invalid = { ...defaultResumeData.picture, size: 1024 };
|
||||
expect(pictureSchema.safeParse(invalid).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects rotation below 0", () => {
|
||||
const invalid = { ...defaultResumeData.picture, rotation: -1 };
|
||||
expect(pictureSchema.safeParse(invalid).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects rotation above 360", () => {
|
||||
const invalid = { ...defaultResumeData.picture, rotation: 361 };
|
||||
expect(pictureSchema.safeParse(invalid).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("basicsSchema", () => {
|
||||
it("validates default basics", () => {
|
||||
expect(basicsSchema.safeParse(defaultResumeData.basics).success).toBe(true);
|
||||
});
|
||||
|
||||
it("requires email to be a string but does not enforce email shape (catches at app level)", () => {
|
||||
const result = basicsSchema.safeParse({
|
||||
...defaultResumeData.basics,
|
||||
email: "not-an-email",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("supports customFields array", () => {
|
||||
const result = basicsSchema.safeParse({
|
||||
...defaultResumeData.basics,
|
||||
customFields: [{ id: "1", icon: "phone", text: "555-0000", link: "" }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("customFieldSchema", () => {
|
||||
it("requires id, icon, and text", () => {
|
||||
const result = customFieldSchema.safeParse({
|
||||
id: "1",
|
||||
icon: "phone",
|
||||
text: "555-0000",
|
||||
link: "",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing id", () => {
|
||||
expect(customFieldSchema.safeParse({ icon: "phone", text: "555-0000", link: "" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to empty link via .catch when missing", () => {
|
||||
const result = customFieldSchema.safeParse({ id: "1", icon: "phone", text: "x" });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data.link).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("experienceItemSchema", () => {
|
||||
it("requires company name (min 1)", () => {
|
||||
const result = experienceItemSchema.safeParse({
|
||||
id: "abcdef0123456789",
|
||||
hidden: false,
|
||||
company: "",
|
||||
position: "Engineer",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
description: "",
|
||||
roles: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("validates a complete experience item", () => {
|
||||
const result = experienceItemSchema.safeParse({
|
||||
id: "abcdef0123456789",
|
||||
hidden: false,
|
||||
company: "Acme",
|
||||
position: "Engineer",
|
||||
location: "NYC",
|
||||
period: "2020 - 2023",
|
||||
website: { url: "https://acme.com", label: "Acme", inlineLink: false },
|
||||
description: "<p>Did things</p>",
|
||||
roles: [],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults roles to [] via .catch when missing", () => {
|
||||
const result = experienceItemSchema.safeParse({
|
||||
id: "x",
|
||||
hidden: false,
|
||||
company: "Acme",
|
||||
position: "",
|
||||
location: "",
|
||||
period: "",
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
description: "",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data.roles).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skillItemSchema", () => {
|
||||
it("requires name (min 1)", () => {
|
||||
const invalid = {
|
||||
id: "x",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "",
|
||||
proficiency: "",
|
||||
level: 4,
|
||||
keywords: [],
|
||||
};
|
||||
expect(skillItemSchema.safeParse(invalid).success).toBe(false);
|
||||
});
|
||||
|
||||
it("clamps invalid level via .catch(0)", () => {
|
||||
const item = {
|
||||
id: "x",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "TS",
|
||||
proficiency: "",
|
||||
level: 99,
|
||||
keywords: [],
|
||||
};
|
||||
const result = skillItemSchema.safeParse(item);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data.level).toBe(0);
|
||||
});
|
||||
|
||||
it("allows level between 0 and 5", () => {
|
||||
for (const level of [0, 1, 2, 3, 4, 5]) {
|
||||
const item = {
|
||||
id: "x",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "X",
|
||||
proficiency: "",
|
||||
level,
|
||||
keywords: [],
|
||||
};
|
||||
expect(skillItemSchema.safeParse(item).success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to empty array when keywords is invalid via .catch", () => {
|
||||
const item = {
|
||||
id: "x",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "X",
|
||||
proficiency: "",
|
||||
level: 3,
|
||||
keywords: "not-an-array",
|
||||
};
|
||||
const result = skillItemSchema.safeParse(item);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data.keywords).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layoutSchema", () => {
|
||||
it("validates default layout", () => {
|
||||
expect(layoutSchema.safeParse(defaultResumeData.metadata.layout).success).toBe(true);
|
||||
});
|
||||
|
||||
it("clamps sidebarWidth out-of-range to 35", () => {
|
||||
const result = layoutSchema.safeParse({
|
||||
...defaultResumeData.metadata.layout,
|
||||
sidebarWidth: 999,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data.sidebarWidth).toBe(35);
|
||||
});
|
||||
|
||||
it("clamps sidebarWidth below 10 to 35", () => {
|
||||
const result = layoutSchema.safeParse({
|
||||
...defaultResumeData.metadata.layout,
|
||||
sidebarWidth: 5,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data.sidebarWidth).toBe(35);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pageSchema", () => {
|
||||
it("rejects negative gap or margin values", () => {
|
||||
const invalid = { ...defaultResumeData.metadata.page, gapX: -1 };
|
||||
expect(pageSchema.safeParse(invalid).success).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to 'a4' for unknown format via .catch", () => {
|
||||
const invalid = { ...defaultResumeData.metadata.page, format: "huge" };
|
||||
const result = pageSchema.safeParse(invalid);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data.format).toBe("a4");
|
||||
});
|
||||
|
||||
it("accepts each known format", () => {
|
||||
for (const format of ["a4", "letter", "free-form"] as const) {
|
||||
const valid = { ...defaultResumeData.metadata.page, format };
|
||||
expect(pageSchema.safeParse(valid).success).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resumeDataSchema } from "./data";
|
||||
import { defaultResumeData } from "./default";
|
||||
|
||||
describe("defaultResumeData", () => {
|
||||
it("conforms to resumeDataSchema", () => {
|
||||
const result = resumeDataSchema.safeParse(defaultResumeData);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the 'onyx' template", () => {
|
||||
expect(defaultResumeData.metadata.template).toBe("onyx");
|
||||
});
|
||||
|
||||
it("uses en-US locale", () => {
|
||||
expect(defaultResumeData.metadata.page.locale).toBe("en-US");
|
||||
});
|
||||
|
||||
it("uses A4 format by default", () => {
|
||||
expect(defaultResumeData.metadata.page.format).toBe("a4");
|
||||
});
|
||||
|
||||
it("starts with no resume content", () => {
|
||||
expect(defaultResumeData.basics.name).toBe("");
|
||||
expect(defaultResumeData.summary.content).toBe("");
|
||||
for (const section of Object.values(defaultResumeData.sections)) {
|
||||
expect(section.items).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("has exactly one default page layout", () => {
|
||||
expect(defaultResumeData.metadata.layout.pages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("default page has standard main and sidebar columns", () => {
|
||||
const page = defaultResumeData.metadata.layout.pages[0];
|
||||
expect(page).toBeDefined();
|
||||
expect(page?.main).toContain("experience");
|
||||
expect(page?.main).toContain("education");
|
||||
expect(page?.sidebar).toContain("skills");
|
||||
});
|
||||
|
||||
it("uses serif font family for body", () => {
|
||||
expect(defaultResumeData.metadata.typography.body.fontFamily).toBe("IBM Plex Serif");
|
||||
});
|
||||
|
||||
it("default sidebar width is 35%", () => {
|
||||
expect(defaultResumeData.metadata.layout.sidebarWidth).toBe(35);
|
||||
});
|
||||
|
||||
it("custom sections are empty", () => {
|
||||
expect(defaultResumeData.customSections).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { templateSchema } from "./templates";
|
||||
|
||||
describe("templateSchema", () => {
|
||||
it("accepts known template names", () => {
|
||||
const validTemplates = [
|
||||
"azurill",
|
||||
"bronzor",
|
||||
"chikorita",
|
||||
"ditgar",
|
||||
"ditto",
|
||||
"gengar",
|
||||
"glalie",
|
||||
"kakuna",
|
||||
"lapras",
|
||||
"leafish",
|
||||
"meowth",
|
||||
"onyx",
|
||||
"pikachu",
|
||||
"rhyhorn",
|
||||
];
|
||||
for (const t of validTemplates) {
|
||||
expect(templateSchema.safeParse(t).success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown template names", () => {
|
||||
expect(templateSchema.safeParse("unknown").success).toBe(false);
|
||||
expect(templateSchema.safeParse("").success).toBe(false);
|
||||
expect(templateSchema.safeParse("ONYX").success).toBe(false); // case-sensitive
|
||||
});
|
||||
|
||||
it("rejects non-string values", () => {
|
||||
expect(templateSchema.safeParse(null).success).toBe(false);
|
||||
expect(templateSchema.safeParse(undefined).success).toBe(false);
|
||||
expect(templateSchema.safeParse(42).success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns the exact value for a valid template", () => {
|
||||
const result = templateSchema.safeParse("onyx");
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toBe("onyx");
|
||||
}
|
||||
});
|
||||
|
||||
it("includes 14 templates", () => {
|
||||
const validTemplates = templateSchema.options;
|
||||
expect(validTemplates).toHaveLength(14);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./accordion";
|
||||
|
||||
describe("Accordion", () => {
|
||||
it("renders with data-slot='accordion'", () => {
|
||||
render(
|
||||
<Accordion data-testid="acc">
|
||||
<AccordionItem value="1">
|
||||
<AccordionTrigger>Q1</AccordionTrigger>
|
||||
<AccordionContent>A1</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>,
|
||||
);
|
||||
expect(screen.getByTestId("acc")).toHaveAttribute("data-slot", "accordion");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(
|
||||
<Accordion data-testid="acc" className="my-class">
|
||||
<AccordionItem value="1">
|
||||
<AccordionTrigger>Q</AccordionTrigger>
|
||||
<AccordionContent>A</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>,
|
||||
);
|
||||
expect(screen.getByTestId("acc")).toHaveClass("my-class");
|
||||
});
|
||||
|
||||
it("expands and collapses on trigger click", async () => {
|
||||
render(
|
||||
<Accordion>
|
||||
<AccordionItem value="1">
|
||||
<AccordionTrigger>Question 1</AccordionTrigger>
|
||||
<AccordionContent>Answer 1</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByText("Question 1");
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
await userEvent.click(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
await userEvent.click(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("renders multiple items", () => {
|
||||
render(
|
||||
<Accordion>
|
||||
<AccordionItem value="1">
|
||||
<AccordionTrigger>Q1</AccordionTrigger>
|
||||
<AccordionContent>A1</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="2">
|
||||
<AccordionTrigger>Q2</AccordionTrigger>
|
||||
<AccordionContent>A2</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>,
|
||||
);
|
||||
expect(screen.getByText("Q1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Q2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccordionItem", () => {
|
||||
it("uses data-slot='accordion-item'", () => {
|
||||
render(
|
||||
<Accordion>
|
||||
<AccordionItem value="1" data-testid="item">
|
||||
<AccordionTrigger>Q</AccordionTrigger>
|
||||
<AccordionContent>A</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>,
|
||||
);
|
||||
expect(screen.getByTestId("item")).toHaveAttribute("data-slot", "accordion-item");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccordionTrigger", () => {
|
||||
it("uses data-slot='accordion-trigger'", () => {
|
||||
render(
|
||||
<Accordion>
|
||||
<AccordionItem value="1">
|
||||
<AccordionTrigger data-testid="trigger">Q</AccordionTrigger>
|
||||
<AccordionContent>A</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>,
|
||||
);
|
||||
expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "accordion-trigger");
|
||||
});
|
||||
|
||||
it("renders down/up caret icons", () => {
|
||||
const { container } = render(
|
||||
<Accordion>
|
||||
<AccordionItem value="1">
|
||||
<AccordionTrigger>Q</AccordionTrigger>
|
||||
<AccordionContent>A</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>,
|
||||
);
|
||||
expect(container.querySelectorAll("[data-slot=accordion-trigger-icon]")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "./alert-dialog";
|
||||
|
||||
describe("AlertDialog", () => {
|
||||
it("does not render content by default", () => {
|
||||
render(
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger>Open</AlertDialogTrigger>
|
||||
<AlertDialogContent>Hidden body</AlertDialogContent>
|
||||
</AlertDialog>,
|
||||
);
|
||||
expect(screen.queryByText("Hidden body")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens via defaultOpen", () => {
|
||||
render(
|
||||
<AlertDialog defaultOpen>
|
||||
<AlertDialogTrigger>Open</AlertDialogTrigger>
|
||||
<AlertDialogContent>Visible body</AlertDialogContent>
|
||||
</AlertDialog>,
|
||||
);
|
||||
expect(screen.getByText("Visible body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("trigger has data-slot='alert-dialog-trigger'", () => {
|
||||
render(
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger data-testid="trigger">Open</AlertDialogTrigger>
|
||||
<AlertDialogContent>x</AlertDialogContent>
|
||||
</AlertDialog>,
|
||||
);
|
||||
expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "alert-dialog-trigger");
|
||||
});
|
||||
|
||||
it("content uses size 'default' by default", () => {
|
||||
render(
|
||||
<AlertDialog open>
|
||||
<AlertDialogTrigger>x</AlertDialogTrigger>
|
||||
<AlertDialogContent>visible</AlertDialogContent>
|
||||
</AlertDialog>,
|
||||
);
|
||||
expect(screen.getByText("visible")).toHaveAttribute("data-size", "default");
|
||||
});
|
||||
|
||||
it("content supports size='sm'", () => {
|
||||
render(
|
||||
<AlertDialog open>
|
||||
<AlertDialogTrigger>x</AlertDialogTrigger>
|
||||
<AlertDialogContent size="sm">visible</AlertDialogContent>
|
||||
</AlertDialog>,
|
||||
);
|
||||
expect(screen.getByText("visible")).toHaveAttribute("data-size", "sm");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AlertDialog subcomponents", () => {
|
||||
it("AlertDialogHeader uses data-slot='alert-dialog-header'", () => {
|
||||
render(<AlertDialogHeader data-testid="h">x</AlertDialogHeader>);
|
||||
expect(screen.getByTestId("h")).toHaveAttribute("data-slot", "alert-dialog-header");
|
||||
});
|
||||
|
||||
it("AlertDialogFooter uses data-slot='alert-dialog-footer'", () => {
|
||||
render(<AlertDialogFooter data-testid="f">x</AlertDialogFooter>);
|
||||
expect(screen.getByTestId("f")).toHaveAttribute("data-slot", "alert-dialog-footer");
|
||||
});
|
||||
|
||||
it("AlertDialogMedia uses data-slot='alert-dialog-media'", () => {
|
||||
render(<AlertDialogMedia data-testid="m">x</AlertDialogMedia>);
|
||||
expect(screen.getByTestId("m")).toHaveAttribute("data-slot", "alert-dialog-media");
|
||||
});
|
||||
|
||||
it("AlertDialogTitle uses data-slot='alert-dialog-title'", () => {
|
||||
render(
|
||||
<AlertDialog open>
|
||||
<AlertDialogTrigger>x</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>,
|
||||
);
|
||||
expect(screen.getByText("Are you sure?")).toHaveAttribute("data-slot", "alert-dialog-title");
|
||||
});
|
||||
|
||||
it("AlertDialogDescription uses data-slot='alert-dialog-description'", () => {
|
||||
render(
|
||||
<AlertDialog open>
|
||||
<AlertDialogTrigger>x</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogDescription>This is permanent</AlertDialogDescription>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>,
|
||||
);
|
||||
expect(screen.getByText("This is permanent")).toHaveAttribute("data-slot", "alert-dialog-description");
|
||||
});
|
||||
|
||||
it("AlertDialogAction renders a Button with data-slot='alert-dialog-action'", () => {
|
||||
render(<AlertDialogAction>Confirm</AlertDialogAction>);
|
||||
const button = screen.getByText("Confirm");
|
||||
expect(button).toHaveAttribute("data-slot", "alert-dialog-action");
|
||||
});
|
||||
|
||||
it("AlertDialogCancel uses data-slot='alert-dialog-cancel'", () => {
|
||||
render(
|
||||
<AlertDialog open>
|
||||
<AlertDialogTrigger>x</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>,
|
||||
);
|
||||
const button = screen.getByText("Cancel");
|
||||
expect(button).toHaveAttribute("data-slot", "alert-dialog-cancel");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Alert, AlertAction, AlertDescription, AlertTitle } from "./alert";
|
||||
|
||||
describe("Alert", () => {
|
||||
it("renders children with role='alert'", () => {
|
||||
render(<Alert>Heads up!</Alert>);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Heads up!");
|
||||
});
|
||||
|
||||
it("applies data-slot='alert' for slotting", () => {
|
||||
render(<Alert>x</Alert>);
|
||||
expect(screen.getByRole("alert")).toHaveAttribute("data-slot", "alert");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Alert className="my-class">x</Alert>);
|
||||
expect(screen.getByRole("alert")).toHaveClass("my-class");
|
||||
});
|
||||
|
||||
it("supports destructive variant", () => {
|
||||
render(<Alert variant="destructive">x</Alert>);
|
||||
// We just verify the component renders without error
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AlertTitle", () => {
|
||||
it("renders children", () => {
|
||||
render(<AlertTitle>Title</AlertTitle>);
|
||||
expect(screen.getByText("Title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies data-slot='alert-title'", () => {
|
||||
render(<AlertTitle>x</AlertTitle>);
|
||||
expect(screen.getByText("x")).toHaveAttribute("data-slot", "alert-title");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AlertDescription", () => {
|
||||
it("renders children", () => {
|
||||
render(<AlertDescription>Description text</AlertDescription>);
|
||||
expect(screen.getByText("Description text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies data-slot='alert-description'", () => {
|
||||
render(<AlertDescription>x</AlertDescription>);
|
||||
expect(screen.getByText("x")).toHaveAttribute("data-slot", "alert-description");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AlertAction", () => {
|
||||
it("renders children", () => {
|
||||
render(<AlertAction>Action</AlertAction>);
|
||||
expect(screen.getByText("Action")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies data-slot='alert-action'", () => {
|
||||
render(<AlertAction>x</AlertAction>);
|
||||
expect(screen.getByText("x")).toHaveAttribute("data-slot", "alert-action");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Alert composition", () => {
|
||||
it("composes all subcomponents", () => {
|
||||
render(
|
||||
<Alert>
|
||||
<AlertTitle>Title</AlertTitle>
|
||||
<AlertDescription>Body</AlertDescription>
|
||||
<AlertAction>OK</AlertAction>
|
||||
</Alert>,
|
||||
);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(/TitleBodyOK/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Avatar, AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage } from "./avatar";
|
||||
|
||||
describe("Avatar", () => {
|
||||
it("renders with data-slot='avatar'", () => {
|
||||
render(<Avatar data-testid="av" />);
|
||||
expect(screen.getByTestId("av")).toHaveAttribute("data-slot", "avatar");
|
||||
});
|
||||
|
||||
it("defaults size to 'default'", () => {
|
||||
render(<Avatar data-testid="av" />);
|
||||
expect(screen.getByTestId("av")).toHaveAttribute("data-size", "default");
|
||||
});
|
||||
|
||||
it.each(["default", "sm", "lg"] as const)("supports size=%s", (size) => {
|
||||
render(<Avatar data-testid="av" size={size} />);
|
||||
expect(screen.getByTestId("av")).toHaveAttribute("data-size", size);
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Avatar data-testid="av" className="my-class" />);
|
||||
expect(screen.getByTestId("av")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AvatarFallback", () => {
|
||||
it("renders fallback content", () => {
|
||||
render(
|
||||
<Avatar>
|
||||
<AvatarFallback data-testid="fb">JD</AvatarFallback>
|
||||
</Avatar>,
|
||||
);
|
||||
// Note: fallback only renders if image fails — but data-slot should be set.
|
||||
// We just confirm no crash and that fallback is present in DOM.
|
||||
const fb = screen.queryByTestId("fb");
|
||||
// AvatarFallback may or may not be in the DOM depending on image state.
|
||||
// Just ensure it doesn't throw on render.
|
||||
expect(fb === null || fb.getAttribute("data-slot") === "avatar-fallback").toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AvatarBadge", () => {
|
||||
it("renders as span with data-slot='avatar-badge'", () => {
|
||||
render(<AvatarBadge data-testid="b" />);
|
||||
const badge = screen.getByTestId("b");
|
||||
expect(badge.tagName).toBe("SPAN");
|
||||
expect(badge).toHaveAttribute("data-slot", "avatar-badge");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AvatarGroup", () => {
|
||||
it("renders as div with data-slot='avatar-group'", () => {
|
||||
render(<AvatarGroup data-testid="g" />);
|
||||
const group = screen.getByTestId("g");
|
||||
expect(group.tagName).toBe("DIV");
|
||||
expect(group).toHaveAttribute("data-slot", "avatar-group");
|
||||
});
|
||||
|
||||
it("supports children", () => {
|
||||
render(
|
||||
<AvatarGroup data-testid="g">
|
||||
<Avatar />
|
||||
<Avatar />
|
||||
</AvatarGroup>,
|
||||
);
|
||||
expect(screen.getByTestId("g").children).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AvatarGroupCount", () => {
|
||||
it("renders count children", () => {
|
||||
render(<AvatarGroupCount>+3</AvatarGroupCount>);
|
||||
expect(screen.getByText("+3")).toHaveAttribute("data-slot", "avatar-group-count");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AvatarImage", () => {
|
||||
it("accepts src prop without throwing", () => {
|
||||
render(
|
||||
<Avatar>
|
||||
<AvatarImage src="https://example.com/x.png" alt="user" />
|
||||
</Avatar>,
|
||||
);
|
||||
// Image may not render until loaded; we just verify no crash
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Badge } from "./badge";
|
||||
|
||||
describe("Badge", () => {
|
||||
it("renders children inside a span by default", () => {
|
||||
render(<Badge>New</Badge>);
|
||||
const badge = screen.getByText("New");
|
||||
expect(badge.tagName).toBe("SPAN");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Badge className="my-extra">x</Badge>);
|
||||
expect(screen.getByText("x")).toHaveClass("my-extra");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["default"],
|
||||
["secondary"],
|
||||
["destructive"],
|
||||
["outline"],
|
||||
["ghost"],
|
||||
["link"],
|
||||
] as const)("renders variant=%s without throwing", (variant) => {
|
||||
render(<Badge variant={variant}>{variant}</Badge>);
|
||||
expect(screen.getByText(variant)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("forwards aria attributes", () => {
|
||||
render(<Badge aria-label="status indicator">3</Badge>);
|
||||
expect(screen.getByLabelText("status indicator")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports a custom render function", () => {
|
||||
render(<Badge render={(props) => <a {...props} href="/x" />}>link</Badge>);
|
||||
const anchor = screen.getByRole("link", { name: "link" });
|
||||
expect(anchor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BrandIcon } from "./brand-icon";
|
||||
|
||||
describe("BrandIcon", () => {
|
||||
it("renders two img elements (light + dark variants)", () => {
|
||||
render(<BrandIcon />);
|
||||
const imgs = screen.getAllByAltText("Reactive Resume");
|
||||
expect(imgs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("uses 'logo' as default variant", () => {
|
||||
render(<BrandIcon />);
|
||||
const imgs = screen.getAllByAltText("Reactive Resume");
|
||||
expect(imgs.some((img) => img.getAttribute("src") === "/logo/dark.svg")).toBe(true);
|
||||
expect(imgs.some((img) => img.getAttribute("src") === "/logo/light.svg")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses 'icon' variant when specified", () => {
|
||||
render(<BrandIcon variant="icon" />);
|
||||
const imgs = screen.getAllByAltText("Reactive Resume");
|
||||
expect(imgs.some((img) => img.getAttribute("src") === "/icon/dark.svg")).toBe(true);
|
||||
expect(imgs.some((img) => img.getAttribute("src") === "/icon/light.svg")).toBe(true);
|
||||
});
|
||||
|
||||
it("merges custom className on both imgs", () => {
|
||||
render(<BrandIcon className="my-custom" />);
|
||||
const imgs = screen.getAllByAltText("Reactive Resume");
|
||||
for (const img of imgs) {
|
||||
expect(img).toHaveClass("my-custom");
|
||||
}
|
||||
});
|
||||
|
||||
it("hides dark variant by default (light mode); dark mode reveals it via dark:block", () => {
|
||||
render(<BrandIcon />);
|
||||
const imgs = screen.getAllByAltText("Reactive Resume");
|
||||
const darkImg = imgs.find((img) => img.getAttribute("src") === "/logo/dark.svg");
|
||||
expect(darkImg).toHaveClass("hidden");
|
||||
expect(darkImg).toHaveClass("dark:block");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ButtonGroup, ButtonGroupSeparator, ButtonGroupText } from "./button-group";
|
||||
|
||||
describe("ButtonGroup", () => {
|
||||
it("renders a fieldset with data-slot='button-group'", () => {
|
||||
render(<ButtonGroup data-testid="g" />);
|
||||
const g = screen.getByTestId("g");
|
||||
expect(g.tagName).toBe("FIELDSET");
|
||||
expect(g).toHaveAttribute("data-slot", "button-group");
|
||||
});
|
||||
|
||||
it("respects horizontal orientation (default)", () => {
|
||||
render(<ButtonGroup data-testid="g" orientation="horizontal" />);
|
||||
expect(screen.getByTestId("g")).toHaveAttribute("data-orientation", "horizontal");
|
||||
});
|
||||
|
||||
it("respects vertical orientation", () => {
|
||||
render(<ButtonGroup data-testid="g" orientation="vertical" />);
|
||||
expect(screen.getByTestId("g")).toHaveAttribute("data-orientation", "vertical");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<ButtonGroup data-testid="g" className="my-custom" />);
|
||||
expect(screen.getByTestId("g")).toHaveClass("my-custom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ButtonGroupText", () => {
|
||||
it("renders content inside a div by default", () => {
|
||||
render(<ButtonGroupText>Hello</ButtonGroupText>);
|
||||
expect(screen.getByText("Hello").tagName).toBe("DIV");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<ButtonGroupText className="my-class">x</ButtonGroupText>);
|
||||
expect(screen.getByText("x")).toHaveClass("my-class");
|
||||
});
|
||||
|
||||
it("supports custom render function", () => {
|
||||
render(<ButtonGroupText render={(props) => <span {...props} />}>label</ButtonGroupText>);
|
||||
expect(screen.getByText("label").tagName).toBe("SPAN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ButtonGroupSeparator", () => {
|
||||
it("defaults to vertical orientation", () => {
|
||||
render(<ButtonGroupSeparator data-testid="sep" />);
|
||||
expect(screen.getByTestId("sep")).toHaveAttribute("data-orientation", "vertical");
|
||||
});
|
||||
|
||||
it("supports horizontal orientation", () => {
|
||||
render(<ButtonGroupSeparator data-testid="sep" orientation="horizontal" />);
|
||||
expect(screen.getByTestId("sep")).toHaveAttribute("data-orientation", "horizontal");
|
||||
});
|
||||
|
||||
it("uses data-slot='button-group-separator'", () => {
|
||||
render(<ButtonGroupSeparator data-testid="sep" />);
|
||||
expect(screen.getByTestId("sep")).toHaveAttribute("data-slot", "button-group-separator");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Button } from "./button";
|
||||
|
||||
describe("Button", () => {
|
||||
it("renders children", () => {
|
||||
render(<Button>Click me</Button>);
|
||||
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults to type='button' to prevent accidental form submits", () => {
|
||||
render(<Button>Submit</Button>);
|
||||
expect(screen.getByRole("button")).toHaveAttribute("type", "button");
|
||||
});
|
||||
|
||||
it("respects an explicit type override", () => {
|
||||
render(<Button type="submit">Submit</Button>);
|
||||
expect(screen.getByRole("button")).toHaveAttribute("type", "submit");
|
||||
});
|
||||
|
||||
it("applies the data-slot attribute for shadcn/base-ui slotting", () => {
|
||||
render(<Button>x</Button>);
|
||||
expect(screen.getByRole("button")).toHaveAttribute("data-slot", "button");
|
||||
});
|
||||
|
||||
it("merges custom className with variant classes", () => {
|
||||
render(<Button className="my-custom-class">x</Button>);
|
||||
expect(screen.getByRole("button")).toHaveClass("my-custom-class");
|
||||
});
|
||||
|
||||
it("calls onClick when clicked", async () => {
|
||||
const onClick = vi.fn();
|
||||
render(<Button onClick={onClick}>Click</Button>);
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not fire onClick when disabled", async () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<Button disabled onClick={onClick}>
|
||||
Click
|
||||
</Button>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders disabled state", () => {
|
||||
render(<Button disabled>x</Button>);
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["default"],
|
||||
["outline"],
|
||||
["secondary"],
|
||||
["ghost"],
|
||||
["destructive"],
|
||||
["link"],
|
||||
] as const)("renders variant=%s without throwing", (variant) => {
|
||||
render(<Button variant={variant}>x</Button>);
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["default"],
|
||||
["xs"],
|
||||
["sm"],
|
||||
["lg"],
|
||||
["icon"],
|
||||
["icon-xs"],
|
||||
["icon-sm"],
|
||||
["icon-lg"],
|
||||
] as const)("renders size=%s without throwing", (size) => {
|
||||
render(<Button size={size}>x</Button>);
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("forwards aria-label", () => {
|
||||
render(<Button aria-label="Close">×</Button>);
|
||||
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./card";
|
||||
|
||||
describe("Card", () => {
|
||||
it("renders with data-slot='card'", () => {
|
||||
render(<Card data-testid="c">x</Card>);
|
||||
expect(screen.getByTestId("c")).toHaveAttribute("data-slot", "card");
|
||||
});
|
||||
|
||||
it("defaults size to 'default'", () => {
|
||||
render(<Card data-testid="c">x</Card>);
|
||||
expect(screen.getByTestId("c")).toHaveAttribute("data-size", "default");
|
||||
});
|
||||
|
||||
it("supports size='sm'", () => {
|
||||
render(
|
||||
<Card data-testid="c" size="sm">
|
||||
x
|
||||
</Card>,
|
||||
);
|
||||
expect(screen.getByTestId("c")).toHaveAttribute("data-size", "sm");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(
|
||||
<Card data-testid="c" className="my-custom">
|
||||
x
|
||||
</Card>,
|
||||
);
|
||||
expect(screen.getByTestId("c")).toHaveClass("my-custom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Card subcomponents", () => {
|
||||
it.each([
|
||||
["CardHeader", CardHeader, "card-header"],
|
||||
["CardTitle", CardTitle, "card-title"],
|
||||
["CardDescription", CardDescription, "card-description"],
|
||||
["CardAction", CardAction, "card-action"],
|
||||
["CardContent", CardContent, "card-content"],
|
||||
["CardFooter", CardFooter, "card-footer"],
|
||||
] as const)("%s renders with data-slot='%s'", (_name, Component, slot) => {
|
||||
render(
|
||||
<Component data-testid="el">
|
||||
<span>child</span>
|
||||
</Component>,
|
||||
);
|
||||
expect(screen.getByTestId("el")).toHaveAttribute("data-slot", slot);
|
||||
});
|
||||
|
||||
it("CardHeader merges custom className", () => {
|
||||
render(
|
||||
<CardHeader data-testid="h" className="my-class">
|
||||
x
|
||||
</CardHeader>,
|
||||
);
|
||||
expect(screen.getByTestId("h")).toHaveClass("my-class");
|
||||
});
|
||||
|
||||
it("CardTitle merges custom className", () => {
|
||||
render(
|
||||
<CardTitle data-testid="t" className="my-class">
|
||||
x
|
||||
</CardTitle>,
|
||||
);
|
||||
expect(screen.getByTestId("t")).toHaveClass("my-class");
|
||||
});
|
||||
|
||||
it("CardFooter merges custom className", () => {
|
||||
render(
|
||||
<CardFooter data-testid="f" className="my-class">
|
||||
x
|
||||
</CardFooter>,
|
||||
);
|
||||
expect(screen.getByTestId("f")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Card composition", () => {
|
||||
it("composes a full card", () => {
|
||||
render(
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Title</CardTitle>
|
||||
<CardDescription>Description</CardDescription>
|
||||
<CardAction>Action</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>Content</CardContent>
|
||||
<CardFooter>Footer</CardFooter>
|
||||
</Card>,
|
||||
);
|
||||
expect(screen.getByText("Title")).toBeInTheDocument();
|
||||
expect(screen.getByText("Description")).toBeInTheDocument();
|
||||
expect(screen.getByText("Action")).toBeInTheDocument();
|
||||
expect(screen.getByText("Content")).toBeInTheDocument();
|
||||
expect(screen.getByText("Footer")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Checkbox } from "./checkbox";
|
||||
|
||||
describe("Checkbox", () => {
|
||||
it("renders with data-slot='checkbox'", () => {
|
||||
render(<Checkbox data-testid="cb" />);
|
||||
expect(screen.getByTestId("cb")).toHaveAttribute("data-slot", "checkbox");
|
||||
});
|
||||
|
||||
it("defaults to unchecked", () => {
|
||||
render(<Checkbox data-testid="cb" />);
|
||||
const cb = screen.getByTestId("cb");
|
||||
// base-ui sets aria-checked
|
||||
expect(cb.getAttribute("aria-checked")).toBe("false");
|
||||
});
|
||||
|
||||
it("renders as checked via defaultChecked", () => {
|
||||
render(<Checkbox data-testid="cb" defaultChecked />);
|
||||
expect(screen.getByTestId("cb").getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("can be controlled via checked prop", () => {
|
||||
render(<Checkbox data-testid="cb" checked onCheckedChange={() => {}} />);
|
||||
expect(screen.getByTestId("cb").getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("calls onCheckedChange when toggled", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Checkbox data-testid="cb" onCheckedChange={onChange} />);
|
||||
await userEvent.click(screen.getByTestId("cb"));
|
||||
expect(onChange).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("respects disabled state via aria-disabled (base-ui uses span+role)", () => {
|
||||
render(<Checkbox data-testid="cb" disabled />);
|
||||
const cb = screen.getByTestId("cb");
|
||||
expect(cb.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(cb).toHaveAttribute("data-disabled");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Checkbox data-testid="cb" className="my-cb" />);
|
||||
expect(screen.getByTestId("cb")).toHaveClass("my-cb");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxCollection,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
ComboboxRoot,
|
||||
ComboboxSeparator,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
useFilter,
|
||||
} from "./combobox";
|
||||
|
||||
const items = ["Apple", "Banana", "Cherry"];
|
||||
|
||||
const renderCombobox = (overrides: Partial<Parameters<typeof ComboboxRoot>[0]> = {}) =>
|
||||
render(
|
||||
<ComboboxRoot items={items} {...overrides}>
|
||||
<ComboboxInput placeholder="Search" />
|
||||
<ComboboxContent>
|
||||
<ComboboxList>
|
||||
{items.map((item) => (
|
||||
<ComboboxItem key={item} value={item}>
|
||||
{item}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>,
|
||||
);
|
||||
|
||||
describe("ComboboxRoot/Input", () => {
|
||||
it("renders ComboboxInput with data-slot='combobox-input' (input is from primitive)", () => {
|
||||
const { container } = renderCombobox();
|
||||
// The InputGroupInput wraps with data-slot 'input-group-control' which is from input-group
|
||||
expect(container.querySelector("[data-slot=input-group-control]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render content when closed", () => {
|
||||
renderCombobox();
|
||||
expect(screen.queryByText("Apple")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders content when open=true", () => {
|
||||
renderCombobox({ open: true });
|
||||
expect(screen.getByText("Apple")).toBeInTheDocument();
|
||||
expect(screen.getByText("Banana")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComboboxValue", () => {
|
||||
it("renders inside primitive without throwing", () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<ComboboxRoot items={items} value="Apple">
|
||||
<ComboboxValue />
|
||||
</ComboboxRoot>,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComboboxItem", () => {
|
||||
it("renders with data-slot='combobox-item'", () => {
|
||||
renderCombobox({ open: true });
|
||||
expect(document.querySelectorAll("[data-slot=combobox-item]").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComboboxList / ComboboxGroup / ComboboxLabel", () => {
|
||||
it("ComboboxList renders with data-slot='combobox-list'", () => {
|
||||
renderCombobox({ open: true });
|
||||
expect(document.querySelector("[data-slot=combobox-list]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ComboboxGroup and ComboboxLabel render with their slots", () => {
|
||||
render(
|
||||
<ComboboxRoot items={items} open>
|
||||
<ComboboxInput />
|
||||
<ComboboxContent>
|
||||
<ComboboxList>
|
||||
<ComboboxGroup items={items}>
|
||||
<ComboboxLabel>Fruits</ComboboxLabel>
|
||||
<ComboboxItem value="Apple">Apple</ComboboxItem>
|
||||
</ComboboxGroup>
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>,
|
||||
);
|
||||
expect(document.querySelector("[data-slot=combobox-group]")).toBeInTheDocument();
|
||||
expect(document.querySelector("[data-slot=combobox-label]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComboboxSeparator and ComboboxEmpty", () => {
|
||||
it("ComboboxSeparator renders with data-slot='combobox-separator'", () => {
|
||||
render(
|
||||
<ComboboxRoot items={items} open>
|
||||
<ComboboxInput />
|
||||
<ComboboxContent>
|
||||
<ComboboxList>
|
||||
<ComboboxItem value="Apple">Apple</ComboboxItem>
|
||||
<ComboboxSeparator />
|
||||
<ComboboxItem value="Banana">Banana</ComboboxItem>
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>,
|
||||
);
|
||||
expect(document.querySelector("[data-slot=combobox-separator]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ComboboxEmpty renders with data-slot='combobox-empty'", () => {
|
||||
render(
|
||||
<ComboboxRoot items={[]} open>
|
||||
<ComboboxInput />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No results</ComboboxEmpty>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>,
|
||||
);
|
||||
expect(document.querySelector("[data-slot=combobox-empty]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComboboxCollection", () => {
|
||||
it("renders without throwing", () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<ComboboxRoot items={items} open>
|
||||
<ComboboxInput />
|
||||
<ComboboxContent>
|
||||
<ComboboxList>
|
||||
<ComboboxCollection items={items}>
|
||||
{(item) => <ComboboxItem value={item}>{item}</ComboboxItem>}
|
||||
</ComboboxCollection>
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComboboxClear", () => {
|
||||
it("renders inside ComboboxInput when showClear=true", () => {
|
||||
const { container } = render(
|
||||
<ComboboxRoot items={items} value="Apple">
|
||||
<ComboboxInput showClear />
|
||||
</ComboboxRoot>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=combobox-clear]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComboboxTrigger", () => {
|
||||
it("renders an input-group-button addon when showTrigger=true (default)", () => {
|
||||
const { container } = renderCombobox();
|
||||
// ComboboxTrigger is rendered inside InputGroupButton which sets its own data-slot.
|
||||
expect(container.querySelector("[data-slot=input-group-button]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides input-group-button addon when showTrigger=false and showClear=false", () => {
|
||||
const { container } = render(
|
||||
<ComboboxRoot items={items}>
|
||||
<ComboboxInput showTrigger={false} />
|
||||
</ComboboxRoot>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=input-group-button]")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ComboboxTrigger renders standalone with data-slot='combobox-trigger'", () => {
|
||||
const { container } = render(
|
||||
<ComboboxRoot items={items}>
|
||||
<ComboboxTrigger>Open</ComboboxTrigger>
|
||||
</ComboboxRoot>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=combobox-trigger]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComboboxChips and friends", () => {
|
||||
it("renders ComboboxChips with data-slot='combobox-chips'", () => {
|
||||
const { container } = render(
|
||||
<ComboboxRoot items={items} multiple value={["Apple"]}>
|
||||
<ComboboxChips>
|
||||
<ComboboxChipsInput />
|
||||
</ComboboxChips>
|
||||
</ComboboxRoot>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=combobox-chips]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders ComboboxChipsInput with data-slot='combobox-chip-input'", () => {
|
||||
const { container } = render(
|
||||
<ComboboxRoot items={items} multiple value={["Apple"]}>
|
||||
<ComboboxChips>
|
||||
<ComboboxChipsInput />
|
||||
</ComboboxChips>
|
||||
</ComboboxRoot>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=combobox-chip-input]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFilter", () => {
|
||||
it("is exported as a function", () => {
|
||||
expect(typeof useFilter).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useComboboxAnchor", () => {
|
||||
it("is exported as a function", () => {
|
||||
expect(typeof useComboboxAnchor).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "./command";
|
||||
|
||||
describe("Command", () => {
|
||||
it("renders with data-slot='command'", () => {
|
||||
const { container } = render(
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandItem>x</CommandItem>
|
||||
</CommandList>
|
||||
</Command>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=command]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className on root", () => {
|
||||
const { container } = render(
|
||||
<Command className="my-cmd">
|
||||
<CommandList />
|
||||
</Command>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=command]")).toHaveClass("my-cmd");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommandInput", () => {
|
||||
it("renders with data-slot='command-input'", () => {
|
||||
const { container } = render(
|
||||
<Command>
|
||||
<CommandInput placeholder="Search..." />
|
||||
</Command>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=command-input]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("wrapper uses data-slot='command-input-wrapper'", () => {
|
||||
const { container } = render(
|
||||
<Command>
|
||||
<CommandInput />
|
||||
</Command>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=command-input-wrapper]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommandList / CommandGroup", () => {
|
||||
it("CommandList uses data-slot='command-list'", () => {
|
||||
const { container } = render(
|
||||
<Command>
|
||||
<CommandList>x</CommandList>
|
||||
</Command>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=command-list]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("CommandEmpty renders without throwing in tree", () => {
|
||||
// CommandEmpty only mounts under specific cmdk states — just verify no crash
|
||||
expect(() =>
|
||||
render(
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandEmpty>No results</CommandEmpty>
|
||||
</CommandList>
|
||||
</Command>,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("CommandGroup uses data-slot='command-group'", () => {
|
||||
const { container } = render(
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandGroup heading="Group 1">
|
||||
<CommandItem>Item</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=command-group]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommandItem", () => {
|
||||
it("uses data-slot='command-item'", () => {
|
||||
const { container } = render(
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandItem>Hello</CommandItem>
|
||||
</CommandList>
|
||||
</Command>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=command-item]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommandSeparator", () => {
|
||||
it("uses data-slot='command-separator'", () => {
|
||||
const { container } = render(
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandSeparator />
|
||||
</CommandList>
|
||||
</Command>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=command-separator]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommandShortcut", () => {
|
||||
it("uses data-slot='command-shortcut'", () => {
|
||||
render(<CommandShortcut>⌘K</CommandShortcut>);
|
||||
expect(screen.getByText("⌘K")).toHaveAttribute("data-slot", "command-shortcut");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<CommandShortcut className="my-class">x</CommandShortcut>);
|
||||
expect(screen.getByText("x")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from "./context-menu";
|
||||
|
||||
const renderWithMenu = (children: React.ReactNode) =>
|
||||
render(
|
||||
<ContextMenu open>
|
||||
<ContextMenuTrigger>x</ContextMenuTrigger>
|
||||
<ContextMenuContent>{children}</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
);
|
||||
|
||||
describe("ContextMenu", () => {
|
||||
it("trigger uses data-slot='context-menu-trigger'", () => {
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger data-testid="trigger">Right-click me</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>x</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
);
|
||||
expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "context-menu-trigger");
|
||||
});
|
||||
|
||||
it("does not render content by default", () => {
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>x</ContextMenuTrigger>
|
||||
<ContextMenuContent>Hidden</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
);
|
||||
expect(screen.queryByText("Hidden")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextMenuItem", () => {
|
||||
it("uses data-slot='context-menu-item'", () => {
|
||||
renderWithMenu(<ContextMenuItem>Item</ContextMenuItem>);
|
||||
expect(screen.getByText("Item")).toHaveAttribute("data-slot", "context-menu-item");
|
||||
});
|
||||
|
||||
it("defaults variant='default'", () => {
|
||||
renderWithMenu(<ContextMenuItem>Item</ContextMenuItem>);
|
||||
expect(screen.getByText("Item")).toHaveAttribute("data-variant", "default");
|
||||
});
|
||||
|
||||
it("supports variant='destructive'", () => {
|
||||
renderWithMenu(<ContextMenuItem variant="destructive">Delete</ContextMenuItem>);
|
||||
expect(screen.getByText("Delete")).toHaveAttribute("data-variant", "destructive");
|
||||
});
|
||||
|
||||
it("supports inset prop", () => {
|
||||
renderWithMenu(<ContextMenuItem inset>Inset</ContextMenuItem>);
|
||||
expect(screen.getByText("Inset")).toHaveAttribute("data-inset", "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextMenuLabel", () => {
|
||||
it("uses data-slot='context-menu-label'", () => {
|
||||
renderWithMenu(
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuLabel>Group label</ContextMenuLabel>
|
||||
</ContextMenuGroup>,
|
||||
);
|
||||
expect(screen.getByText("Group label")).toHaveAttribute("data-slot", "context-menu-label");
|
||||
});
|
||||
|
||||
it("supports inset prop", () => {
|
||||
renderWithMenu(
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuLabel inset>Inset label</ContextMenuLabel>
|
||||
</ContextMenuGroup>,
|
||||
);
|
||||
expect(screen.getByText("Inset label")).toHaveAttribute("data-inset", "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextMenuShortcut", () => {
|
||||
it("uses data-slot='context-menu-shortcut'", () => {
|
||||
render(<ContextMenuShortcut>⌘K</ContextMenuShortcut>);
|
||||
expect(screen.getByText("⌘K")).toHaveAttribute("data-slot", "context-menu-shortcut");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<ContextMenuShortcut className="my-class">x</ContextMenuShortcut>);
|
||||
expect(screen.getByText("x")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextMenuCheckboxItem and ContextMenuRadioItem", () => {
|
||||
it("CheckboxItem renders within menu", () => {
|
||||
renderWithMenu(<ContextMenuCheckboxItem checked>Toggle</ContextMenuCheckboxItem>);
|
||||
expect(screen.getByText("Toggle")).toHaveAttribute("data-slot", "context-menu-checkbox-item");
|
||||
});
|
||||
|
||||
it("RadioItem renders within menu", () => {
|
||||
renderWithMenu(
|
||||
<ContextMenuRadioGroup value="a">
|
||||
<ContextMenuRadioItem value="a">A</ContextMenuRadioItem>
|
||||
</ContextMenuRadioGroup>,
|
||||
);
|
||||
expect(screen.getByText("A")).toHaveAttribute("data-slot", "context-menu-radio-item");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextMenuSub", () => {
|
||||
it("SubTrigger uses data-slot='context-menu-sub-trigger'", () => {
|
||||
renderWithMenu(
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>More</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem>Inner</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>,
|
||||
);
|
||||
expect(screen.getByText("More")).toHaveAttribute("data-slot", "context-menu-sub-trigger");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "./dialog";
|
||||
|
||||
describe("Dialog", () => {
|
||||
it("does not render content by default", () => {
|
||||
render(
|
||||
<Dialog>
|
||||
<DialogTrigger>Open</DialogTrigger>
|
||||
<DialogContent>Hidden body</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.queryByText("Hidden body")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens content via defaultOpen", () => {
|
||||
render(
|
||||
<Dialog defaultOpen>
|
||||
<DialogTrigger>Open</DialogTrigger>
|
||||
<DialogContent>Visible body</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.getByText("Visible body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens content via controlled open", () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogTrigger>Open</DialogTrigger>
|
||||
<DialogContent>Visible body</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.getByText("Visible body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens on trigger click", async () => {
|
||||
render(
|
||||
<Dialog>
|
||||
<DialogTrigger>Open</DialogTrigger>
|
||||
<DialogContent>Body text</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Open"));
|
||||
expect(await screen.findByText("Body text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders close button by default in DialogContent", () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogTrigger>Open</DialogTrigger>
|
||||
<DialogContent>body</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.getByText("Close")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides close button when showCloseButton=false", () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogTrigger>Open</DialogTrigger>
|
||||
<DialogContent showCloseButton={false}>body</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.queryByText("Close")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DialogTitle", () => {
|
||||
it("uses data-slot='dialog-title'", () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogTrigger>Open</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>My Title</DialogTitle>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.getByText("My Title")).toHaveAttribute("data-slot", "dialog-title");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DialogDescription", () => {
|
||||
it("uses data-slot='dialog-description'", () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogTrigger>Open</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogDescription>My Description</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.getByText("My Description")).toHaveAttribute("data-slot", "dialog-description");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DialogHeader", () => {
|
||||
it("uses data-slot='dialog-header'", () => {
|
||||
render(<DialogHeader data-testid="h">x</DialogHeader>);
|
||||
expect(screen.getByTestId("h")).toHaveAttribute("data-slot", "dialog-header");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DialogFooter", () => {
|
||||
it("uses data-slot='dialog-footer'", () => {
|
||||
render(<DialogFooter data-testid="f">x</DialogFooter>);
|
||||
expect(screen.getByTestId("f")).toHaveAttribute("data-slot", "dialog-footer");
|
||||
});
|
||||
|
||||
it("renders close button when showCloseButton=true", () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogTrigger>x</DialogTrigger>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogFooter showCloseButton>body</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.getByText("Close")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render close button by default", () => {
|
||||
render(<DialogFooter>just content</DialogFooter>);
|
||||
expect(screen.queryByText("Close")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "./dropdown-menu";
|
||||
|
||||
describe("DropdownMenu", () => {
|
||||
it("trigger has data-slot='dropdown-menu-trigger'", () => {
|
||||
render(
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger data-testid="trigger">Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>x</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "dropdown-menu-trigger");
|
||||
});
|
||||
|
||||
it("does not render content by default", () => {
|
||||
render(
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>Hidden item</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.queryByText("Hidden item")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders content when controlled open", () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Item 1</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.getByText("Item 1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DropdownMenuItem", () => {
|
||||
it("uses data-slot='dropdown-menu-item'", () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>x</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Item</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.getByText("Item")).toHaveAttribute("data-slot", "dropdown-menu-item");
|
||||
});
|
||||
|
||||
it("defaults variant='default'", () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>x</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Item</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.getByText("Item")).toHaveAttribute("data-variant", "default");
|
||||
});
|
||||
|
||||
it("supports variant='destructive'", () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>x</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.getByText("Delete")).toHaveAttribute("data-variant", "destructive");
|
||||
});
|
||||
|
||||
it("supports inset prop", () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>x</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem inset>Inset</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.getByText("Inset")).toHaveAttribute("data-inset", "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DropdownMenuLabel", () => {
|
||||
it("uses data-slot='dropdown-menu-label'", () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>x</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Group label</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Item</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.getByText("Group label")).toHaveAttribute("data-slot", "dropdown-menu-label");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DropdownMenuSeparator", () => {
|
||||
it("uses data-slot='dropdown-menu-separator'", () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>x</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>1</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>2</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
// Portal content renders at document.body
|
||||
expect(document.querySelector("[data-slot=dropdown-menu-separator]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DropdownMenuShortcut", () => {
|
||||
it("uses data-slot='dropdown-menu-shortcut'", () => {
|
||||
render(<DropdownMenuShortcut>⌘K</DropdownMenuShortcut>);
|
||||
expect(screen.getByText("⌘K")).toHaveAttribute("data-slot", "dropdown-menu-shortcut");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<DropdownMenuShortcut className="my-class">x</DropdownMenuShortcut>);
|
||||
expect(screen.getByText("x")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DropdownMenuCheckboxItem", () => {
|
||||
it("uses data-slot='dropdown-menu-checkbox-item'", () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>x</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuCheckboxItem checked>Toggle</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.getByText("Toggle")).toHaveAttribute("data-slot", "dropdown-menu-checkbox-item");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DropdownMenuRadioItem", () => {
|
||||
it("uses data-slot='dropdown-menu-radio-item'", () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>x</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuRadioGroup value="a">
|
||||
<DropdownMenuRadioItem value="a">A</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.getByText("A")).toHaveAttribute("data-slot", "dropdown-menu-radio-item");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DropdownMenuSub", () => {
|
||||
it("renders nested submenu trigger", () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>x</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>More</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem>Inner</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.getByText("More")).toHaveAttribute("data-slot", "dropdown-menu-sub-trigger");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "./form";
|
||||
|
||||
describe("FormItem", () => {
|
||||
it("renders with data-slot='form-item'", () => {
|
||||
render(<FormItem data-testid="i">x</FormItem>);
|
||||
expect(screen.getByTestId("i")).toHaveAttribute("data-slot", "form-item");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(
|
||||
<FormItem data-testid="i" className="my-class">
|
||||
x
|
||||
</FormItem>,
|
||||
);
|
||||
expect(screen.getByTestId("i")).toHaveClass("my-class");
|
||||
});
|
||||
|
||||
it("provides id and hasError context to children", () => {
|
||||
render(
|
||||
<FormItem hasError>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl render={(props) => <input {...props} data-testid="input" />} />
|
||||
</FormItem>,
|
||||
);
|
||||
|
||||
const label = screen.getByText("Name");
|
||||
expect(label).toHaveAttribute("data-error", "true");
|
||||
|
||||
const input = screen.getByTestId("input");
|
||||
expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
});
|
||||
|
||||
it("hasError defaults to false", () => {
|
||||
render(
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl render={(props) => <input {...props} data-testid="input" />} />
|
||||
</FormItem>,
|
||||
);
|
||||
|
||||
const input = screen.getByTestId("input");
|
||||
expect(input).toHaveAttribute("aria-invalid", "false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormLabel", () => {
|
||||
it("renders with data-slot='form-label' and connects htmlFor to control via context id", () => {
|
||||
render(
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl render={(props) => <input {...props} type="email" />} />
|
||||
</FormItem>,
|
||||
);
|
||||
|
||||
const label = screen.getByText("Email");
|
||||
expect(label).toHaveAttribute("data-slot", "form-label");
|
||||
const htmlFor = label.getAttribute("for");
|
||||
expect(htmlFor).toMatch(/-form-item$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormControl", () => {
|
||||
it("sets aria-describedby pointing only to description when no error", () => {
|
||||
render(
|
||||
<FormItem>
|
||||
<FormControl render={(props) => <input {...props} data-testid="input" />} />
|
||||
<FormDescription>Help text</FormDescription>
|
||||
</FormItem>,
|
||||
);
|
||||
|
||||
const input = screen.getByTestId("input");
|
||||
const describedBy = input.getAttribute("aria-describedby");
|
||||
expect(describedBy).toMatch(/-form-item-description$/);
|
||||
expect(describedBy).not.toContain("message");
|
||||
});
|
||||
|
||||
it("includes message in aria-describedby when hasError=true", () => {
|
||||
render(
|
||||
<FormItem hasError>
|
||||
<FormControl render={(props) => <input {...props} data-testid="input" />} />
|
||||
</FormItem>,
|
||||
);
|
||||
|
||||
const input = screen.getByTestId("input");
|
||||
const describedBy = input.getAttribute("aria-describedby");
|
||||
expect(describedBy).toContain("description");
|
||||
expect(describedBy).toContain("message");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormDescription", () => {
|
||||
it("uses data-slot='form-description'", () => {
|
||||
render(
|
||||
<FormItem>
|
||||
<FormDescription>Help</FormDescription>
|
||||
</FormItem>,
|
||||
);
|
||||
expect(screen.getByText("Help")).toHaveAttribute("data-slot", "form-description");
|
||||
});
|
||||
|
||||
it("id ends with '-form-item-description'", () => {
|
||||
render(
|
||||
<FormItem>
|
||||
<FormDescription>Help</FormDescription>
|
||||
</FormItem>,
|
||||
);
|
||||
expect(screen.getByText("Help").getAttribute("id")).toMatch(/-form-item-description$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormMessage", () => {
|
||||
it("returns null when no errors", () => {
|
||||
const { container } = render(
|
||||
<FormItem>
|
||||
<FormMessage />
|
||||
</FormItem>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=form-message]")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns null when errors is empty", () => {
|
||||
const { container } = render(
|
||||
<FormItem>
|
||||
<FormMessage errors={[]} />
|
||||
</FormItem>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=form-message]")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders string error message", () => {
|
||||
render(
|
||||
<FormItem hasError>
|
||||
<FormMessage errors={["String error"]} />
|
||||
</FormItem>,
|
||||
);
|
||||
expect(screen.getByText("String error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error with .message property", () => {
|
||||
render(
|
||||
<FormItem hasError>
|
||||
<FormMessage errors={[{ message: "Object error" }]} />
|
||||
</FormItem>,
|
||||
);
|
||||
expect(screen.getByText("Object error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("skips falsy and unrecognized errors and shows the first valid one", () => {
|
||||
render(
|
||||
<FormItem hasError>
|
||||
<FormMessage errors={[null, undefined, { wrong: "field" }, "Valid"]} />
|
||||
</FormItem>,
|
||||
);
|
||||
expect(screen.getByText("Valid")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns null when no extractable error message in array", () => {
|
||||
const { container } = render(
|
||||
<FormItem>
|
||||
<FormMessage errors={[null, undefined, { wrong: "field" }]} />
|
||||
</FormItem>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=form-message]")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses destructive class when hasError=true", () => {
|
||||
render(
|
||||
<FormItem hasError>
|
||||
<FormMessage errors={["Bad"]} />
|
||||
</FormItem>,
|
||||
);
|
||||
expect(screen.getByText("Bad")).toHaveClass("text-destructive");
|
||||
});
|
||||
|
||||
it("uses muted class when hasError=false but errors are passed", () => {
|
||||
render(
|
||||
<FormItem>
|
||||
<FormMessage errors={["Hint"]} />
|
||||
</FormItem>,
|
||||
);
|
||||
expect(screen.getByText("Hint")).toHaveClass("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./hover-card";
|
||||
|
||||
describe("HoverCard", () => {
|
||||
it("trigger has data-slot='hover-card-trigger'", () => {
|
||||
render(
|
||||
<HoverCard>
|
||||
<HoverCardTrigger data-testid="trigger">Hover</HoverCardTrigger>
|
||||
<HoverCardContent>preview</HoverCardContent>
|
||||
</HoverCard>,
|
||||
);
|
||||
expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "hover-card-trigger");
|
||||
});
|
||||
|
||||
it("does not render content by default", () => {
|
||||
render(
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>Hover</HoverCardTrigger>
|
||||
<HoverCardContent>Hidden preview</HoverCardContent>
|
||||
</HoverCard>,
|
||||
);
|
||||
expect(screen.queryByText("Hidden preview")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders content when controlled open", () => {
|
||||
render(
|
||||
<HoverCard open>
|
||||
<HoverCardTrigger>Hover</HoverCardTrigger>
|
||||
<HoverCardContent>Visible preview</HoverCardContent>
|
||||
</HoverCard>,
|
||||
);
|
||||
expect(screen.getByText("Visible preview")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("HoverCardContent uses data-slot='hover-card-content' when shown", () => {
|
||||
render(
|
||||
<HoverCard open>
|
||||
<HoverCardTrigger>x</HoverCardTrigger>
|
||||
<HoverCardContent>visible</HoverCardContent>
|
||||
</HoverCard>,
|
||||
);
|
||||
expect(screen.getByText("visible")).toHaveAttribute("data-slot", "hover-card-content");
|
||||
});
|
||||
|
||||
it("merges custom className on HoverCardContent", () => {
|
||||
render(
|
||||
<HoverCard open>
|
||||
<HoverCardTrigger>x</HoverCardTrigger>
|
||||
<HoverCardContent className="my-class">visible</HoverCardContent>
|
||||
</HoverCard>,
|
||||
);
|
||||
expect(screen.getByText("visible")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
InputGroupTextarea,
|
||||
} from "./input-group";
|
||||
|
||||
describe("InputGroup", () => {
|
||||
it("renders a fieldset with data-slot='input-group'", () => {
|
||||
render(<InputGroup data-testid="g" />);
|
||||
const g = screen.getByTestId("g");
|
||||
expect(g.tagName).toBe("FIELDSET");
|
||||
expect(g).toHaveAttribute("data-slot", "input-group");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<InputGroup data-testid="g" className="my-class" />);
|
||||
expect(screen.getByTestId("g")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InputGroupAddon", () => {
|
||||
it("defaults align to 'inline-start'", () => {
|
||||
render(<InputGroupAddon data-testid="addon" />);
|
||||
expect(screen.getByTestId("addon")).toHaveAttribute("data-align", "inline-start");
|
||||
});
|
||||
|
||||
it.each(["inline-start", "inline-end", "block-start", "block-end"] as const)("supports align=%s", (align) => {
|
||||
render(<InputGroupAddon data-testid="addon" align={align} />);
|
||||
expect(screen.getByTestId("addon")).toHaveAttribute("data-align", align);
|
||||
});
|
||||
|
||||
it("uses data-slot='input-group-addon'", () => {
|
||||
render(<InputGroupAddon data-testid="addon" />);
|
||||
expect(screen.getByTestId("addon")).toHaveAttribute("data-slot", "input-group-addon");
|
||||
});
|
||||
|
||||
it("focuses sibling input on click outside of buttons", () => {
|
||||
render(
|
||||
<InputGroup>
|
||||
<InputGroupAddon data-testid="addon">addon</InputGroupAddon>
|
||||
<InputGroupInput data-testid="i" />
|
||||
</InputGroup>,
|
||||
);
|
||||
|
||||
const addon = screen.getByTestId("addon");
|
||||
fireEvent.click(addon);
|
||||
expect(document.activeElement).toBe(screen.getByTestId("i"));
|
||||
});
|
||||
|
||||
it("focuses sibling input on Enter key", () => {
|
||||
render(
|
||||
<InputGroup>
|
||||
<InputGroupAddon data-testid="addon">addon</InputGroupAddon>
|
||||
<InputGroupInput data-testid="i" />
|
||||
</InputGroup>,
|
||||
);
|
||||
|
||||
const addon = screen.getByTestId("addon");
|
||||
fireEvent.keyDown(addon, { key: "Enter" });
|
||||
expect(document.activeElement).toBe(screen.getByTestId("i"));
|
||||
});
|
||||
|
||||
it("focuses sibling input on Space key", () => {
|
||||
render(
|
||||
<InputGroup>
|
||||
<InputGroupAddon data-testid="addon">addon</InputGroupAddon>
|
||||
<InputGroupInput data-testid="i" />
|
||||
</InputGroup>,
|
||||
);
|
||||
|
||||
const addon = screen.getByTestId("addon");
|
||||
fireEvent.keyDown(addon, { key: " " });
|
||||
expect(document.activeElement).toBe(screen.getByTestId("i"));
|
||||
});
|
||||
|
||||
it("ignores unrelated keys", () => {
|
||||
render(
|
||||
<InputGroup>
|
||||
<InputGroupAddon data-testid="addon">addon</InputGroupAddon>
|
||||
<InputGroupInput data-testid="i" />
|
||||
</InputGroup>,
|
||||
);
|
||||
|
||||
const addon = screen.getByTestId("addon");
|
||||
fireEvent.keyDown(addon, { key: "a" });
|
||||
expect(document.activeElement).not.toBe(screen.getByTestId("i"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("InputGroupButton", () => {
|
||||
it("defaults to type='button' and variant='ghost'", () => {
|
||||
render(<InputGroupButton data-testid="b">x</InputGroupButton>);
|
||||
const button = screen.getByTestId("b");
|
||||
expect(button).toHaveAttribute("type", "button");
|
||||
});
|
||||
|
||||
it("respects type override", () => {
|
||||
render(
|
||||
<InputGroupButton data-testid="b" type="submit">
|
||||
x
|
||||
</InputGroupButton>,
|
||||
);
|
||||
expect(screen.getByTestId("b")).toHaveAttribute("type", "submit");
|
||||
});
|
||||
|
||||
it("applies data-size for size variants", () => {
|
||||
render(
|
||||
<InputGroupButton data-testid="b" size="sm">
|
||||
x
|
||||
</InputGroupButton>,
|
||||
);
|
||||
expect(screen.getByTestId("b")).toHaveAttribute("data-size", "sm");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InputGroupText", () => {
|
||||
it("renders as a span", () => {
|
||||
render(<InputGroupText>label</InputGroupText>);
|
||||
expect(screen.getByText("label").tagName).toBe("SPAN");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<InputGroupText className="my-class">x</InputGroupText>);
|
||||
expect(screen.getByText("x")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InputGroupInput", () => {
|
||||
it("uses data-slot='input-group-control'", () => {
|
||||
render(<InputGroupInput data-testid="i" />);
|
||||
expect(screen.getByTestId("i")).toHaveAttribute("data-slot", "input-group-control");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InputGroupTextarea", () => {
|
||||
it("uses data-slot='input-group-control' and renders textarea", () => {
|
||||
render(<InputGroupTextarea data-testid="t" />);
|
||||
const t = screen.getByTestId("t");
|
||||
expect(t.tagName).toBe("TEXTAREA");
|
||||
expect(t).toHaveAttribute("data-slot", "input-group-control");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Input } from "./input";
|
||||
|
||||
describe("Input", () => {
|
||||
it("renders an input element", () => {
|
||||
render(<Input data-testid="x" />);
|
||||
expect(screen.getByTestId("x").tagName).toBe("INPUT");
|
||||
});
|
||||
|
||||
it("applies data-slot='input'", () => {
|
||||
render(<Input data-testid="x" />);
|
||||
expect(screen.getByTestId("x")).toHaveAttribute("data-slot", "input");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Input data-testid="x" className="custom-input" />);
|
||||
expect(screen.getByTestId("x")).toHaveClass("custom-input");
|
||||
});
|
||||
|
||||
it("forwards type attribute", () => {
|
||||
render(<Input data-testid="x" type="email" />);
|
||||
expect(screen.getByTestId("x")).toHaveAttribute("type", "email");
|
||||
});
|
||||
|
||||
it("calls onChange when typing", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Input data-testid="x" onChange={onChange} />);
|
||||
await userEvent.type(screen.getByTestId("x"), "abc");
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards placeholder", () => {
|
||||
render(<Input placeholder="Enter name" />);
|
||||
expect(screen.getByPlaceholderText("Enter name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("respects disabled state", async () => {
|
||||
render(<Input data-testid="x" disabled />);
|
||||
const input = screen.getByTestId("x");
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
|
||||
it("supports controlled value", () => {
|
||||
render(<Input data-testid="x" value="hello" onChange={() => {}} />);
|
||||
expect(screen.getByTestId("x")).toHaveValue("hello");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Kbd, KbdGroup } from "./kbd";
|
||||
|
||||
describe("Kbd", () => {
|
||||
it("renders as a <kbd> element with data-slot='kbd'", () => {
|
||||
render(<Kbd data-testid="k">Ctrl</Kbd>);
|
||||
const k = screen.getByTestId("k");
|
||||
expect(k.tagName).toBe("KBD");
|
||||
expect(k).toHaveAttribute("data-slot", "kbd");
|
||||
});
|
||||
|
||||
it("renders the key text", () => {
|
||||
render(<Kbd>⌘ K</Kbd>);
|
||||
expect(screen.getByText("⌘ K")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(
|
||||
<Kbd data-testid="k" className="my-class">
|
||||
x
|
||||
</Kbd>,
|
||||
);
|
||||
expect(screen.getByTestId("k")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("KbdGroup", () => {
|
||||
it("renders as a <kbd> wrapper with data-slot='kbd-group'", () => {
|
||||
render(<KbdGroup data-testid="g">x</KbdGroup>);
|
||||
const g = screen.getByTestId("g");
|
||||
expect(g.tagName).toBe("KBD");
|
||||
expect(g).toHaveAttribute("data-slot", "kbd-group");
|
||||
});
|
||||
|
||||
it("groups multiple Kbd elements", () => {
|
||||
render(
|
||||
<KbdGroup>
|
||||
<Kbd>Ctrl</Kbd>
|
||||
<Kbd>K</Kbd>
|
||||
</KbdGroup>,
|
||||
);
|
||||
expect(screen.getByText("Ctrl")).toBeInTheDocument();
|
||||
expect(screen.getByText("K")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(
|
||||
<KbdGroup data-testid="g" className="my-group">
|
||||
x
|
||||
</KbdGroup>,
|
||||
);
|
||||
expect(screen.getByTestId("g")).toHaveClass("my-group");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Label } from "./label";
|
||||
|
||||
describe("Label", () => {
|
||||
it("renders as a <label> element", () => {
|
||||
render(<Label>Name</Label>);
|
||||
expect(screen.getByText("Name").tagName).toBe("LABEL");
|
||||
});
|
||||
|
||||
it("applies data-slot='label'", () => {
|
||||
render(<Label>x</Label>);
|
||||
expect(screen.getByText("x")).toHaveAttribute("data-slot", "label");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Label className="my-custom">x</Label>);
|
||||
expect(screen.getByText("x")).toHaveClass("my-custom");
|
||||
});
|
||||
|
||||
it("supports htmlFor association with inputs", () => {
|
||||
render(
|
||||
<>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<input id="name" />
|
||||
</>,
|
||||
);
|
||||
const input = screen.getByLabelText("Name");
|
||||
expect(input.tagName).toBe("INPUT");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger } from "./popover";
|
||||
|
||||
describe("Popover", () => {
|
||||
it("trigger element has data-slot='popover-trigger'", () => {
|
||||
render(
|
||||
<Popover>
|
||||
<PopoverTrigger data-testid="trigger">Open</PopoverTrigger>
|
||||
<PopoverContent>content</PopoverContent>
|
||||
</Popover>,
|
||||
);
|
||||
expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "popover-trigger");
|
||||
});
|
||||
|
||||
it("opens content on trigger click", async () => {
|
||||
render(
|
||||
<Popover>
|
||||
<PopoverTrigger>Open</PopoverTrigger>
|
||||
<PopoverContent>Hidden text</PopoverContent>
|
||||
</Popover>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Hidden text")).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("Open"));
|
||||
expect(await screen.findByText("Hidden text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports default open via prop", () => {
|
||||
render(
|
||||
<Popover defaultOpen>
|
||||
<PopoverTrigger>Open</PopoverTrigger>
|
||||
<PopoverContent>Initially visible</PopoverContent>
|
||||
</Popover>,
|
||||
);
|
||||
expect(screen.getByText("Initially visible")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("can be controlled via open prop", () => {
|
||||
render(
|
||||
<Popover open>
|
||||
<PopoverTrigger>Open</PopoverTrigger>
|
||||
<PopoverContent>Controlled</PopoverContent>
|
||||
</Popover>,
|
||||
);
|
||||
expect(screen.getByText("Controlled")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PopoverHeader", () => {
|
||||
it("uses data-slot='popover-header'", () => {
|
||||
render(<PopoverHeader data-testid="h">Title</PopoverHeader>);
|
||||
expect(screen.getByTestId("h")).toHaveAttribute("data-slot", "popover-header");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(
|
||||
<PopoverHeader data-testid="h" className="my-class">
|
||||
x
|
||||
</PopoverHeader>,
|
||||
);
|
||||
expect(screen.getByTestId("h")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PopoverTitle", () => {
|
||||
it("uses data-slot='popover-title'", () => {
|
||||
render(
|
||||
<Popover open>
|
||||
<PopoverTrigger>Open</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverTitle>My title</PopoverTitle>
|
||||
</PopoverContent>
|
||||
</Popover>,
|
||||
);
|
||||
expect(screen.getByText("My title")).toHaveAttribute("data-slot", "popover-title");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PopoverDescription", () => {
|
||||
it("uses data-slot='popover-description'", () => {
|
||||
render(
|
||||
<Popover open>
|
||||
<PopoverTrigger>Open</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverDescription>My description</PopoverDescription>
|
||||
</PopoverContent>
|
||||
</Popover>,
|
||||
);
|
||||
expect(screen.getByText("My description")).toHaveAttribute("data-slot", "popover-description");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PopoverContent", () => {
|
||||
it("uses data-slot='popover-content'", () => {
|
||||
render(
|
||||
<Popover open>
|
||||
<PopoverTrigger>Open</PopoverTrigger>
|
||||
<PopoverContent>visible content</PopoverContent>
|
||||
</Popover>,
|
||||
);
|
||||
const content = screen.getByText("visible content");
|
||||
expect(content).toHaveAttribute("data-slot", "popover-content");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(
|
||||
<Popover open>
|
||||
<PopoverTrigger>Open</PopoverTrigger>
|
||||
<PopoverContent className="my-class">visible</PopoverContent>
|
||||
</Popover>,
|
||||
);
|
||||
expect(screen.getByText("visible")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Progress, ProgressIndicator, ProgressLabel, ProgressTrack, ProgressValue } from "./progress";
|
||||
|
||||
describe("Progress", () => {
|
||||
it("renders with data-slot='progress'", () => {
|
||||
render(<Progress data-testid="p" value={50} />);
|
||||
expect(screen.getByTestId("p")).toHaveAttribute("data-slot", "progress");
|
||||
});
|
||||
|
||||
it("auto-renders track and indicator children", () => {
|
||||
const { container } = render(<Progress value={50} data-testid="p" />);
|
||||
expect(container.querySelector("[data-slot=progress-track]")).toBeInTheDocument();
|
||||
expect(container.querySelector("[data-slot=progress-indicator]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("forwards value attribute via aria-valuenow", () => {
|
||||
render(<Progress data-testid="p" value={75} />);
|
||||
expect(screen.getByTestId("p")).toHaveAttribute("aria-valuenow", "75");
|
||||
});
|
||||
|
||||
it("supports custom children rendered before the track", () => {
|
||||
render(
|
||||
<Progress data-testid="p" value={50}>
|
||||
<ProgressLabel>My Progress</ProgressLabel>
|
||||
</Progress>,
|
||||
);
|
||||
expect(screen.getByText("My Progress")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProgressLabel", () => {
|
||||
it("renders with data-slot='progress-label'", () => {
|
||||
render(
|
||||
<Progress value={50}>
|
||||
<ProgressLabel>Label</ProgressLabel>
|
||||
</Progress>,
|
||||
);
|
||||
expect(screen.getByText("Label")).toHaveAttribute("data-slot", "progress-label");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProgressValue", () => {
|
||||
it("renders with data-slot='progress-value'", () => {
|
||||
render(
|
||||
<Progress value={50}>
|
||||
<ProgressValue />
|
||||
</Progress>,
|
||||
);
|
||||
// The value text is rendered by base-ui — just verify the slot exists
|
||||
const { container } = render(
|
||||
<Progress value={50}>
|
||||
<ProgressValue data-testid="v" />
|
||||
</Progress>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=progress-value]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProgressTrack and ProgressIndicator", () => {
|
||||
it("can be rendered explicitly with custom classes", () => {
|
||||
const { container } = render(
|
||||
<Progress value={25}>
|
||||
<ProgressTrack className="track-custom">
|
||||
<ProgressIndicator className="ind-custom" />
|
||||
</ProgressTrack>
|
||||
</Progress>,
|
||||
);
|
||||
expect(container.querySelectorAll("[data-slot=progress-track]")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "./resizable";
|
||||
|
||||
describe("ResizableGroup", () => {
|
||||
it("renders with data-slot='resizable-panel-group'", () => {
|
||||
const { container } = render(
|
||||
<ResizableGroup direction="horizontal">
|
||||
<ResizablePanel>A</ResizablePanel>
|
||||
<ResizableSeparator />
|
||||
<ResizablePanel>B</ResizablePanel>
|
||||
</ResizableGroup>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=resizable-panel-group]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
const { container } = render(
|
||||
<ResizableGroup direction="horizontal" className="my-group">
|
||||
<ResizablePanel>x</ResizablePanel>
|
||||
</ResizableGroup>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=resizable-panel-group]")).toHaveClass("my-group");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResizablePanel", () => {
|
||||
it("renders with data-slot='resizable-panel'", () => {
|
||||
const { container } = render(
|
||||
<ResizableGroup direction="horizontal">
|
||||
<ResizablePanel>x</ResizablePanel>
|
||||
</ResizableGroup>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=resizable-panel]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResizableSeparator", () => {
|
||||
it("renders with data-slot='resizable-handle' and no inner handle by default", () => {
|
||||
const { container } = render(
|
||||
<ResizableGroup direction="horizontal">
|
||||
<ResizablePanel>A</ResizablePanel>
|
||||
<ResizableSeparator />
|
||||
<ResizablePanel>B</ResizablePanel>
|
||||
</ResizableGroup>,
|
||||
);
|
||||
const handle = container.querySelector("[data-slot=resizable-handle]");
|
||||
expect(handle).toBeInTheDocument();
|
||||
// No inner handle div by default
|
||||
expect(handle?.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders inner handle when withHandle=true", () => {
|
||||
const { container } = render(
|
||||
<ResizableGroup direction="horizontal">
|
||||
<ResizablePanel>A</ResizablePanel>
|
||||
<ResizableSeparator withHandle />
|
||||
<ResizablePanel>B</ResizablePanel>
|
||||
</ResizableGroup>,
|
||||
);
|
||||
const handle = container.querySelector("[data-slot=resizable-handle]");
|
||||
expect(handle?.children.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ScrollArea } from "./scroll-area";
|
||||
|
||||
describe("ScrollArea", () => {
|
||||
it("renders with data-slot='scroll-area'", () => {
|
||||
render(
|
||||
<ScrollArea data-testid="sa">
|
||||
<div>content</div>
|
||||
</ScrollArea>,
|
||||
);
|
||||
expect(screen.getByTestId("sa")).toHaveAttribute("data-slot", "scroll-area");
|
||||
});
|
||||
|
||||
it("renders viewport with children", () => {
|
||||
const { container } = render(
|
||||
<ScrollArea>
|
||||
<div>content</div>
|
||||
</ScrollArea>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=scroll-area-viewport]")).toBeInTheDocument();
|
||||
expect(screen.getByText("content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className on root", () => {
|
||||
render(
|
||||
<ScrollArea data-testid="sa" className="my-class">
|
||||
<div>x</div>
|
||||
</ScrollArea>,
|
||||
);
|
||||
expect(screen.getByTestId("sa")).toHaveClass("my-class");
|
||||
});
|
||||
|
||||
it("preserves the relative positioning class", () => {
|
||||
render(
|
||||
<ScrollArea data-testid="sa">
|
||||
<div>x</div>
|
||||
</ScrollArea>,
|
||||
);
|
||||
expect(screen.getByTestId("sa")).toHaveClass("relative");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Separator } from "./separator";
|
||||
|
||||
describe("Separator", () => {
|
||||
it("renders with data-slot='separator'", () => {
|
||||
render(<Separator data-testid="sep" />);
|
||||
expect(screen.getByTestId("sep")).toHaveAttribute("data-slot", "separator");
|
||||
});
|
||||
|
||||
it("defaults to horizontal orientation", () => {
|
||||
render(<Separator data-testid="sep" />);
|
||||
expect(screen.getByTestId("sep")).toHaveAttribute("data-orientation", "horizontal");
|
||||
});
|
||||
|
||||
it("supports vertical orientation", () => {
|
||||
render(<Separator data-testid="sep" orientation="vertical" />);
|
||||
expect(screen.getByTestId("sep")).toHaveAttribute("data-orientation", "vertical");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Separator data-testid="sep" className="my-sep" />);
|
||||
expect(screen.getByTestId("sep")).toHaveClass("my-sep");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "./sheet";
|
||||
|
||||
describe("Sheet", () => {
|
||||
it("does not render content by default", () => {
|
||||
render(
|
||||
<Sheet>
|
||||
<SheetTrigger>Open</SheetTrigger>
|
||||
<SheetContent>Hidden body</SheetContent>
|
||||
</Sheet>,
|
||||
);
|
||||
expect(screen.queryByText("Hidden body")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens via defaultOpen", () => {
|
||||
render(
|
||||
<Sheet defaultOpen>
|
||||
<SheetTrigger>Open</SheetTrigger>
|
||||
<SheetContent>Visible body</SheetContent>
|
||||
</Sheet>,
|
||||
);
|
||||
expect(screen.getByText("Visible body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders default 'right' side", () => {
|
||||
render(
|
||||
<Sheet open>
|
||||
<SheetTrigger>Open</SheetTrigger>
|
||||
<SheetContent>visible</SheetContent>
|
||||
</Sheet>,
|
||||
);
|
||||
const content = screen.getByText("visible");
|
||||
expect(content).toHaveAttribute("data-side", "right");
|
||||
});
|
||||
|
||||
it.each(["top", "right", "bottom", "left"] as const)("supports side=%s", (side) => {
|
||||
render(
|
||||
<Sheet open>
|
||||
<SheetTrigger>x</SheetTrigger>
|
||||
<SheetContent side={side}>visible {side}</SheetContent>
|
||||
</Sheet>,
|
||||
);
|
||||
expect(screen.getByText(`visible ${side}`)).toHaveAttribute("data-side", side);
|
||||
});
|
||||
|
||||
it("renders close button by default", () => {
|
||||
render(
|
||||
<Sheet open>
|
||||
<SheetTrigger>x</SheetTrigger>
|
||||
<SheetContent>x</SheetContent>
|
||||
</Sheet>,
|
||||
);
|
||||
expect(screen.getByText("Close")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides close button with showCloseButton=false", () => {
|
||||
render(
|
||||
<Sheet open>
|
||||
<SheetTrigger>x</SheetTrigger>
|
||||
<SheetContent showCloseButton={false}>x</SheetContent>
|
||||
</Sheet>,
|
||||
);
|
||||
expect(screen.queryByText("Close")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SheetHeader / SheetFooter", () => {
|
||||
it("SheetHeader uses data-slot='sheet-header'", () => {
|
||||
render(<SheetHeader data-testid="h">x</SheetHeader>);
|
||||
expect(screen.getByTestId("h")).toHaveAttribute("data-slot", "sheet-header");
|
||||
});
|
||||
|
||||
it("SheetFooter uses data-slot='sheet-footer'", () => {
|
||||
render(<SheetFooter data-testid="f">x</SheetFooter>);
|
||||
expect(screen.getByTestId("f")).toHaveAttribute("data-slot", "sheet-footer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SheetTitle / SheetDescription", () => {
|
||||
it("SheetTitle uses data-slot='sheet-title'", () => {
|
||||
render(
|
||||
<Sheet open>
|
||||
<SheetTrigger>x</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetTitle>Title</SheetTitle>
|
||||
</SheetContent>
|
||||
</Sheet>,
|
||||
);
|
||||
expect(screen.getByText("Title")).toHaveAttribute("data-slot", "sheet-title");
|
||||
});
|
||||
|
||||
it("SheetDescription uses data-slot='sheet-description'", () => {
|
||||
render(
|
||||
<Sheet open>
|
||||
<SheetTrigger>x</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetDescription>Description</SheetDescription>
|
||||
</SheetContent>
|
||||
</Sheet>,
|
||||
);
|
||||
expect(screen.getByText("Description")).toHaveAttribute("data-slot", "sheet-description");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
} from "./sidebar";
|
||||
|
||||
const renderInProvider = (children: React.ReactNode, props: Partial<Parameters<typeof SidebarProvider>[0]> = {}) =>
|
||||
render(
|
||||
<SidebarProvider {...props}>
|
||||
<Sidebar>{children}</Sidebar>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
|
||||
describe("SidebarProvider", () => {
|
||||
it("renders children inside a sidebar-wrapper div", () => {
|
||||
const { container } = render(
|
||||
<SidebarProvider>
|
||||
<div data-testid="child">child</div>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=sidebar-wrapper]")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className on wrapper", () => {
|
||||
const { container } = render(
|
||||
<SidebarProvider className="my-wrapper">
|
||||
<div>x</div>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=sidebar-wrapper]")).toHaveClass("my-wrapper");
|
||||
});
|
||||
|
||||
it("calls onOpenChange when toggled in controlled mode", async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<SidebarProvider open={true} onOpenChange={onOpenChange}>
|
||||
<Sidebar>x</Sidebar>
|
||||
<SidebarTrigger />
|
||||
</SidebarProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /toggle sidebar/i }));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("toggles via Cmd/Ctrl+B keyboard shortcut", async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<SidebarProvider open={true} onOpenChange={onOpenChange}>
|
||||
<Sidebar>x</Sidebar>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
|
||||
await userEvent.keyboard("{Control>}b{/Control}");
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sidebar", () => {
|
||||
it("renders with data-slot='sidebar' when collapsible='none'", () => {
|
||||
const { container } = render(
|
||||
<SidebarProvider>
|
||||
<Sidebar collapsible="none">x</Sidebar>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=sidebar]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders with data-state='expanded' by default", () => {
|
||||
const { container } = render(
|
||||
<SidebarProvider defaultOpen>
|
||||
<Sidebar>x</Sidebar>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
// data-state is on the desktop wrapper
|
||||
const sidebar = container.querySelector("[data-slot=sidebar]");
|
||||
expect(sidebar).toHaveAttribute("data-state", "expanded");
|
||||
});
|
||||
|
||||
it("renders with data-state='collapsed' when defaultOpen=false", () => {
|
||||
const { container } = render(
|
||||
<SidebarProvider defaultOpen={false}>
|
||||
<Sidebar>x</Sidebar>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
const sidebar = container.querySelector("[data-slot=sidebar]");
|
||||
expect(sidebar).toHaveAttribute("data-state", "collapsed");
|
||||
});
|
||||
|
||||
it.each(["sidebar", "floating", "inset"] as const)("supports variant=%s", (variant) => {
|
||||
const { container } = render(
|
||||
<SidebarProvider>
|
||||
<Sidebar variant={variant}>x</Sidebar>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
const sidebar = container.querySelector("[data-slot=sidebar]");
|
||||
expect(sidebar).toHaveAttribute("data-variant", variant);
|
||||
});
|
||||
|
||||
it.each(["left", "right"] as const)("supports side=%s", (side) => {
|
||||
const { container } = render(
|
||||
<SidebarProvider>
|
||||
<Sidebar side={side}>x</Sidebar>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
const sidebar = container.querySelector("[data-slot=sidebar]");
|
||||
expect(sidebar).toHaveAttribute("data-side", side);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarTrigger", () => {
|
||||
it("renders with sr-only label 'Toggle Sidebar'", () => {
|
||||
render(
|
||||
<SidebarProvider>
|
||||
<SidebarTrigger />
|
||||
</SidebarProvider>,
|
||||
);
|
||||
expect(screen.getByText("Toggle Sidebar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has data-slot='sidebar-trigger' and data-sidebar='trigger'", () => {
|
||||
render(
|
||||
<SidebarProvider>
|
||||
<SidebarTrigger data-testid="trigger" />
|
||||
</SidebarProvider>,
|
||||
);
|
||||
const trigger = screen.getByTestId("trigger");
|
||||
expect(trigger).toHaveAttribute("data-slot", "sidebar-trigger");
|
||||
expect(trigger).toHaveAttribute("data-sidebar", "trigger");
|
||||
});
|
||||
|
||||
it("invokes user onClick before toggling", async () => {
|
||||
const onClick = vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<SidebarProvider open={true} onOpenChange={onOpenChange}>
|
||||
<SidebarTrigger onClick={onClick} />
|
||||
</SidebarProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /toggle sidebar/i }));
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarRail", () => {
|
||||
it("renders with data-slot='sidebar-rail' and aria-label='Toggle Sidebar'", () => {
|
||||
render(
|
||||
<SidebarProvider>
|
||||
<SidebarRail />
|
||||
</SidebarProvider>,
|
||||
);
|
||||
const rail = screen.getByLabelText("Toggle Sidebar");
|
||||
expect(rail).toHaveAttribute("data-slot", "sidebar-rail");
|
||||
});
|
||||
|
||||
it("toggles sidebar on click", async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<SidebarProvider open={true} onOpenChange={onOpenChange}>
|
||||
<SidebarRail />
|
||||
</SidebarProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByLabelText("Toggle Sidebar"));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarInset", () => {
|
||||
it("renders as <main> with data-slot='sidebar-inset'", () => {
|
||||
render(<SidebarInset data-testid="inset">x</SidebarInset>);
|
||||
const inset = screen.getByTestId("inset");
|
||||
expect(inset.tagName).toBe("MAIN");
|
||||
expect(inset).toHaveAttribute("data-slot", "sidebar-inset");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarInput", () => {
|
||||
it("renders with data-slot='sidebar-input'", () => {
|
||||
render(<SidebarInput data-testid="i" />);
|
||||
expect(screen.getByTestId("i")).toHaveAttribute("data-slot", "sidebar-input");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarHeader / SidebarFooter / SidebarSeparator", () => {
|
||||
it("SidebarHeader uses data-slot='sidebar-header'", () => {
|
||||
render(<SidebarHeader data-testid="h">x</SidebarHeader>);
|
||||
expect(screen.getByTestId("h")).toHaveAttribute("data-slot", "sidebar-header");
|
||||
});
|
||||
|
||||
it("SidebarFooter uses data-slot='sidebar-footer'", () => {
|
||||
render(<SidebarFooter data-testid="f">x</SidebarFooter>);
|
||||
expect(screen.getByTestId("f")).toHaveAttribute("data-slot", "sidebar-footer");
|
||||
});
|
||||
|
||||
it("SidebarSeparator uses data-slot='sidebar-separator'", () => {
|
||||
render(<SidebarSeparator data-testid="s" />);
|
||||
expect(screen.getByTestId("s")).toHaveAttribute("data-slot", "sidebar-separator");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarContent / SidebarGroup", () => {
|
||||
it("SidebarContent uses data-slot='sidebar-content'", () => {
|
||||
render(<SidebarContent data-testid="c">x</SidebarContent>);
|
||||
expect(screen.getByTestId("c")).toHaveAttribute("data-slot", "sidebar-content");
|
||||
});
|
||||
|
||||
it("SidebarGroup uses data-slot='sidebar-group'", () => {
|
||||
render(<SidebarGroup data-testid="g">x</SidebarGroup>);
|
||||
expect(screen.getByTestId("g")).toHaveAttribute("data-slot", "sidebar-group");
|
||||
});
|
||||
|
||||
it("SidebarGroupContent uses data-slot='sidebar-group-content'", () => {
|
||||
render(<SidebarGroupContent data-testid="g">x</SidebarGroupContent>);
|
||||
expect(screen.getByTestId("g")).toHaveAttribute("data-slot", "sidebar-group-content");
|
||||
});
|
||||
|
||||
it("SidebarGroupLabel uses data-slot='sidebar-group-label'", () => {
|
||||
render(<SidebarGroupLabel data-testid="l">Group</SidebarGroupLabel>);
|
||||
expect(screen.getByTestId("l")).toHaveAttribute("data-slot", "sidebar-group-label");
|
||||
});
|
||||
|
||||
it("SidebarGroupAction uses data-slot='sidebar-group-action'", () => {
|
||||
render(<SidebarGroupAction data-testid="a">+</SidebarGroupAction>);
|
||||
expect(screen.getByTestId("a")).toHaveAttribute("data-slot", "sidebar-group-action");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarMenu and items", () => {
|
||||
it("SidebarMenu uses data-slot='sidebar-menu' and renders as <ul>", () => {
|
||||
render(<SidebarMenu data-testid="m" />);
|
||||
const menu = screen.getByTestId("m");
|
||||
expect(menu.tagName).toBe("UL");
|
||||
expect(menu).toHaveAttribute("data-slot", "sidebar-menu");
|
||||
});
|
||||
|
||||
it("SidebarMenuItem uses data-slot='sidebar-menu-item' and renders as <li>", () => {
|
||||
render(<SidebarMenuItem data-testid="i">x</SidebarMenuItem>);
|
||||
const item = screen.getByTestId("i");
|
||||
expect(item.tagName).toBe("LI");
|
||||
expect(item).toHaveAttribute("data-slot", "sidebar-menu-item");
|
||||
});
|
||||
|
||||
it("SidebarMenuButton uses data-slot='sidebar-menu-button'", () => {
|
||||
render(
|
||||
<SidebarProvider>
|
||||
<SidebarMenuButton data-testid="b">Click</SidebarMenuButton>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("b")).toHaveAttribute("data-slot", "sidebar-menu-button");
|
||||
});
|
||||
|
||||
it("SidebarMenuButton supports isActive", () => {
|
||||
render(
|
||||
<SidebarProvider>
|
||||
<SidebarMenuButton data-testid="b" isActive>
|
||||
x
|
||||
</SidebarMenuButton>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
// data-active is set as a presence attribute when isActive is truthy
|
||||
expect(screen.getByTestId("b")).toHaveAttribute("data-active");
|
||||
});
|
||||
|
||||
it("SidebarMenuAction uses data-slot='sidebar-menu-action'", () => {
|
||||
render(<SidebarMenuAction data-testid="a">x</SidebarMenuAction>);
|
||||
expect(screen.getByTestId("a")).toHaveAttribute("data-slot", "sidebar-menu-action");
|
||||
});
|
||||
|
||||
it("SidebarMenuBadge uses data-slot='sidebar-menu-badge'", () => {
|
||||
render(<SidebarMenuBadge data-testid="b">5</SidebarMenuBadge>);
|
||||
expect(screen.getByTestId("b")).toHaveAttribute("data-slot", "sidebar-menu-badge");
|
||||
});
|
||||
|
||||
it("SidebarMenuSkeleton renders without throwing", () => {
|
||||
render(<SidebarMenuSkeleton data-testid="sk" />);
|
||||
expect(screen.getByTestId("sk")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("SidebarMenuSkeleton supports showIcon", () => {
|
||||
const { container } = render(<SidebarMenuSkeleton showIcon />);
|
||||
// Should render the icon Skeleton
|
||||
expect(container.querySelectorAll("[data-slot=skeleton]").length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarMenuSub family", () => {
|
||||
it("SidebarMenuSub uses data-slot='sidebar-menu-sub' and is <ul>", () => {
|
||||
render(<SidebarMenuSub data-testid="ms" />);
|
||||
const ms = screen.getByTestId("ms");
|
||||
expect(ms.tagName).toBe("UL");
|
||||
expect(ms).toHaveAttribute("data-slot", "sidebar-menu-sub");
|
||||
});
|
||||
|
||||
it("SidebarMenuSubItem uses data-slot='sidebar-menu-sub-item' and is <li>", () => {
|
||||
render(<SidebarMenuSubItem data-testid="ms">x</SidebarMenuSubItem>);
|
||||
const ms = screen.getByTestId("ms");
|
||||
expect(ms.tagName).toBe("LI");
|
||||
expect(ms).toHaveAttribute("data-slot", "sidebar-menu-sub-item");
|
||||
});
|
||||
|
||||
it("SidebarMenuSubButton uses data-slot='sidebar-menu-sub-button'", () => {
|
||||
render(<SidebarMenuSubButton data-testid="b">x</SidebarMenuSubButton>);
|
||||
expect(screen.getByTestId("b")).toHaveAttribute("data-slot", "sidebar-menu-sub-button");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sidebar composition", () => {
|
||||
it("composes a complete sidebar without errors", () => {
|
||||
expect(() =>
|
||||
renderInProvider(
|
||||
<>
|
||||
<SidebarHeader>Header</SidebarHeader>
|
||||
<SidebarSeparator />
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Group 1</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>Item</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>Footer</SidebarFooter>
|
||||
</>,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Skeleton } from "./skeleton";
|
||||
|
||||
describe("Skeleton", () => {
|
||||
it("renders with data-slot='skeleton'", () => {
|
||||
render(<Skeleton data-testid="sk" />);
|
||||
expect(screen.getByTestId("sk")).toHaveAttribute("data-slot", "skeleton");
|
||||
});
|
||||
|
||||
it("applies pulse animation classes", () => {
|
||||
render(<Skeleton data-testid="sk" />);
|
||||
expect(screen.getByTestId("sk")).toHaveClass("animate-pulse");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Skeleton className="h-10 w-20" data-testid="sk" />);
|
||||
const skeleton = screen.getByTestId("sk");
|
||||
expect(skeleton).toHaveClass("h-10");
|
||||
expect(skeleton).toHaveClass("w-20");
|
||||
});
|
||||
|
||||
it("forwards arbitrary props", () => {
|
||||
render(<Skeleton data-testid="sk" aria-busy="true" />);
|
||||
expect(screen.getByTestId("sk")).toHaveAttribute("aria-busy", "true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Slider } from "./slider";
|
||||
|
||||
describe("Slider", () => {
|
||||
it("renders with data-slot='slider'", () => {
|
||||
render(<Slider data-testid="s" defaultValue={[50]} />);
|
||||
expect(screen.getByTestId("s")).toHaveAttribute("data-slot", "slider");
|
||||
});
|
||||
|
||||
it("renders one thumb per value", () => {
|
||||
const { container } = render(<Slider defaultValue={[10, 90]} />);
|
||||
expect(container.querySelectorAll("[data-slot=slider-thumb]")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("renders single thumb for single value", () => {
|
||||
const { container } = render(<Slider defaultValue={[50]} />);
|
||||
expect(container.querySelectorAll("[data-slot=slider-thumb]")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders track and range", () => {
|
||||
const { container } = render(<Slider defaultValue={[50]} />);
|
||||
expect(container.querySelector("[data-slot=slider-track]")).toBeInTheDocument();
|
||||
expect(container.querySelector("[data-slot=slider-range]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("respects custom min and max", () => {
|
||||
render(<Slider data-testid="s" defaultValue={[5]} min={0} max={10} />);
|
||||
// Value, min, max are reflected on the root element via aria attrs in some envs
|
||||
expect(screen.getByTestId("s")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Slider data-testid="s" defaultValue={[50]} className="custom-slider" />);
|
||||
expect(screen.getByTestId("s")).toHaveClass("custom-slider");
|
||||
});
|
||||
|
||||
it("renders 2 thumbs with default min/max if no defaultValue or value passed", () => {
|
||||
const { container } = render(<Slider />);
|
||||
// Source code: defaults to [min, max] when neither value nor defaultValue is array
|
||||
expect(container.querySelectorAll("[data-slot=slider-thumb]")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Toaster } from "./sonner";
|
||||
|
||||
describe("Toaster", () => {
|
||||
it("renders without errors when wrapped in ThemeProvider", () => {
|
||||
const { container } = render(
|
||||
<ThemeProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
// Sonner renders into document.body, but we just verify no crash
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("forwards arbitrary props to underlying Sonner", () => {
|
||||
const { container } = render(
|
||||
<ThemeProvider>
|
||||
<Toaster position="top-right" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not throw when rendered without theme context", () => {
|
||||
// Toaster falls back to "system" theme when next-themes context is absent.
|
||||
expect(() => render(<Toaster />)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Spinner } from "./spinner";
|
||||
|
||||
describe("Spinner", () => {
|
||||
it("renders with role='status' for screen readers", () => {
|
||||
render(<Spinner />);
|
||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has accessible label 'Loading'", () => {
|
||||
render(<Spinner />);
|
||||
expect(screen.getByLabelText("Loading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Spinner className="my-spinner" />);
|
||||
expect(screen.getByRole("status")).toHaveClass("my-spinner");
|
||||
});
|
||||
|
||||
it("applies animate-spin by default", () => {
|
||||
render(<Spinner />);
|
||||
expect(screen.getByRole("status")).toHaveClass("animate-spin");
|
||||
});
|
||||
|
||||
it("uses currentColor by default (mapped to SVG fill/stroke by Phosphor)", () => {
|
||||
render(<Spinner />);
|
||||
const svg = screen.getByRole("status");
|
||||
// Phosphor maps the `color` prop to inline style (fill/stroke), not an attribute.
|
||||
// The hex/RGB or "currentColor" appears somewhere in style/attr — verify presence in serialized output.
|
||||
expect(svg.outerHTML).toContain("currentColor");
|
||||
});
|
||||
|
||||
it("respects color override", () => {
|
||||
render(<Spinner color="red" />);
|
||||
const svg = screen.getByRole("status");
|
||||
expect(svg.outerHTML).toMatch(/red/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Switch } from "./switch";
|
||||
|
||||
describe("Switch", () => {
|
||||
it("renders with data-slot='switch'", () => {
|
||||
render(<Switch data-testid="sw" />);
|
||||
expect(screen.getByTestId("sw")).toHaveAttribute("data-slot", "switch");
|
||||
});
|
||||
|
||||
it("defaults size to 'default'", () => {
|
||||
render(<Switch data-testid="sw" />);
|
||||
expect(screen.getByTestId("sw")).toHaveAttribute("data-size", "default");
|
||||
});
|
||||
|
||||
it("supports size='sm'", () => {
|
||||
render(<Switch data-testid="sw" size="sm" />);
|
||||
expect(screen.getByTestId("sw")).toHaveAttribute("data-size", "sm");
|
||||
});
|
||||
|
||||
it("starts unchecked by default", () => {
|
||||
render(<Switch data-testid="sw" />);
|
||||
expect(screen.getByTestId("sw").getAttribute("aria-checked")).toBe("false");
|
||||
});
|
||||
|
||||
it("respects defaultChecked", () => {
|
||||
render(<Switch data-testid="sw" defaultChecked />);
|
||||
expect(screen.getByTestId("sw").getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("calls onCheckedChange when toggled", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Switch data-testid="sw" onCheckedChange={onChange} />);
|
||||
await userEvent.click(screen.getByTestId("sw"));
|
||||
expect(onChange).toHaveBeenCalledOnce();
|
||||
expect(onChange).toHaveBeenCalledWith(true, expect.anything());
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Switch data-testid="sw" className="my-custom" />);
|
||||
expect(screen.getByTestId("sw")).toHaveClass("my-custom");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs";
|
||||
|
||||
const renderTabs = (props: Partial<Parameters<typeof Tabs>[0]> = {}) => {
|
||||
return render(
|
||||
<Tabs defaultValue="a" {...props}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="a">Tab A</TabsTrigger>
|
||||
<TabsTrigger value="b">Tab B</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="a">Content A</TabsContent>
|
||||
<TabsContent value="b">Content B</TabsContent>
|
||||
</Tabs>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("Tabs", () => {
|
||||
it("uses data-slot='tabs'", () => {
|
||||
const { container } = renderTabs();
|
||||
expect(container.querySelector("[data-slot=tabs]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults orientation to 'horizontal'", () => {
|
||||
const { container } = renderTabs();
|
||||
expect(container.querySelector("[data-slot=tabs]")).toHaveAttribute("data-orientation", "horizontal");
|
||||
});
|
||||
|
||||
it("supports vertical orientation", () => {
|
||||
const { container } = renderTabs({ orientation: "vertical" });
|
||||
expect(container.querySelector("[data-slot=tabs]")).toHaveAttribute("data-orientation", "vertical");
|
||||
});
|
||||
|
||||
it("renders the default selected tab content", () => {
|
||||
renderTabs();
|
||||
expect(screen.getByText("Content A")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to the other tab on click", async () => {
|
||||
renderTabs();
|
||||
await userEvent.click(screen.getByText("Tab B"));
|
||||
expect(screen.getByText("Content B")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className on root", () => {
|
||||
const { container } = render(
|
||||
<Tabs defaultValue="a" className="my-tabs">
|
||||
<TabsList>
|
||||
<TabsTrigger value="a">A</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="a">x</TabsContent>
|
||||
</Tabs>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=tabs]")).toHaveClass("my-tabs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabsList", () => {
|
||||
it("uses data-slot='tabs-list'", () => {
|
||||
const { container } = renderTabs();
|
||||
expect(container.querySelector("[data-slot=tabs-list]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults variant to 'default'", () => {
|
||||
const { container } = renderTabs();
|
||||
expect(container.querySelector("[data-slot=tabs-list]")).toHaveAttribute("data-variant", "default");
|
||||
});
|
||||
|
||||
it("supports variant='line'", () => {
|
||||
const { container } = render(
|
||||
<Tabs defaultValue="a">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="a">A</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="a">x</TabsContent>
|
||||
</Tabs>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=tabs-list]")).toHaveAttribute("data-variant", "line");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabsTrigger", () => {
|
||||
it("uses data-slot='tabs-trigger'", () => {
|
||||
const { container } = renderTabs();
|
||||
expect(container.querySelector("[data-slot=tabs-trigger]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabsContent", () => {
|
||||
it("uses data-slot='tabs-content'", () => {
|
||||
const { container } = renderTabs();
|
||||
expect(container.querySelector("[data-slot=tabs-content]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Textarea } from "./textarea";
|
||||
|
||||
describe("Textarea", () => {
|
||||
it("renders a <textarea> element", () => {
|
||||
render(<Textarea data-testid="t" />);
|
||||
expect(screen.getByTestId("t").tagName).toBe("TEXTAREA");
|
||||
});
|
||||
|
||||
it("applies data-slot='textarea'", () => {
|
||||
render(<Textarea data-testid="t" />);
|
||||
expect(screen.getByTestId("t")).toHaveAttribute("data-slot", "textarea");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Textarea data-testid="t" className="custom" />);
|
||||
expect(screen.getByTestId("t")).toHaveClass("custom");
|
||||
});
|
||||
|
||||
it("calls onChange when typing", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Textarea data-testid="t" onChange={onChange} />);
|
||||
await userEvent.type(screen.getByTestId("t"), "hi");
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("respects disabled state", () => {
|
||||
render(<Textarea data-testid="t" disabled />);
|
||||
expect(screen.getByTestId("t")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("forwards rows and cols", () => {
|
||||
render(<Textarea data-testid="t" rows={5} cols={40} />);
|
||||
expect(screen.getByTestId("t")).toHaveAttribute("rows", "5");
|
||||
expect(screen.getByTestId("t")).toHaveAttribute("cols", "40");
|
||||
});
|
||||
|
||||
it("supports controlled value", () => {
|
||||
render(<Textarea data-testid="t" value="hello" onChange={() => {}} />);
|
||||
expect(screen.getByTestId("t")).toHaveValue("hello");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Toggle } from "./toggle";
|
||||
|
||||
describe("Toggle", () => {
|
||||
it("renders with data-slot='toggle'", () => {
|
||||
render(<Toggle data-testid="t">on</Toggle>);
|
||||
expect(screen.getByTestId("t")).toHaveAttribute("data-slot", "toggle");
|
||||
});
|
||||
|
||||
it("starts unpressed by default", () => {
|
||||
render(<Toggle data-testid="t">on</Toggle>);
|
||||
expect(screen.getByTestId("t").getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
it("can be controlled via pressed prop", () => {
|
||||
render(
|
||||
<Toggle data-testid="t" pressed>
|
||||
on
|
||||
</Toggle>,
|
||||
);
|
||||
expect(screen.getByTestId("t").getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("calls onPressedChange when clicked", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Toggle data-testid="t" onPressedChange={onChange}>
|
||||
x
|
||||
</Toggle>,
|
||||
);
|
||||
await userEvent.click(screen.getByTestId("t"));
|
||||
expect(onChange).toHaveBeenCalledWith(true, expect.anything());
|
||||
});
|
||||
|
||||
it.each([["default"], ["outline"]] as const)("renders variant=%s", (variant) => {
|
||||
render(
|
||||
<Toggle data-testid="t" variant={variant}>
|
||||
x
|
||||
</Toggle>,
|
||||
);
|
||||
expect(screen.getByTestId("t")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([["default"], ["sm"], ["lg"]] as const)("renders size=%s", (size) => {
|
||||
render(
|
||||
<Toggle data-testid="t" size={size}>
|
||||
x
|
||||
</Toggle>,
|
||||
);
|
||||
expect(screen.getByTestId("t")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(
|
||||
<Toggle data-testid="t" className="my-custom">
|
||||
x
|
||||
</Toggle>,
|
||||
);
|
||||
expect(screen.getByTestId("t")).toHaveClass("my-custom");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
|
||||
|
||||
describe("Tooltip", () => {
|
||||
it("trigger has data-slot='tooltip-trigger'", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger data-testid="trigger">Hover me</TooltipTrigger>
|
||||
<TooltipContent>Helpful info</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "tooltip-trigger");
|
||||
});
|
||||
|
||||
it("does not show content by default", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>Hover me</TooltipTrigger>
|
||||
<TooltipContent>Helpful info</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.queryByText("Helpful info")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows content when controlled open", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<Tooltip open>
|
||||
<TooltipTrigger>Hover me</TooltipTrigger>
|
||||
<TooltipContent>Helpful info</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByText("Helpful info")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("content has data-slot='tooltip-content' when shown", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<Tooltip open>
|
||||
<TooltipTrigger>Hover me</TooltipTrigger>
|
||||
<TooltipContent>Helpful info</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByText("Helpful info")).toHaveAttribute("data-slot", "tooltip-content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TooltipProvider", () => {
|
||||
it("renders children without errors", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<div data-testid="child">child</div>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("accepts custom delay", () => {
|
||||
render(
|
||||
<TooltipProvider delay={500}>
|
||||
<div>x</div>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
// No throw = pass
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TooltipContent", () => {
|
||||
it("merges custom className when shown", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<Tooltip open>
|
||||
<TooltipTrigger>x</TooltipTrigger>
|
||||
<TooltipContent className="my-tooltip">visible</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByText("visible")).toHaveClass("my-tooltip");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseColorString, rgbaStringToHex } from "./color";
|
||||
|
||||
describe("rgbaStringToHex", () => {
|
||||
it("converts opaque rgb to hex", () => {
|
||||
expect(rgbaStringToHex("rgb(255, 0, 0)")).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("converts opaque rgba to hex (alpha not represented)", () => {
|
||||
expect(rgbaStringToHex("rgba(0, 255, 0, 1)")).toBe("#00ff00");
|
||||
});
|
||||
|
||||
it("converts black", () => {
|
||||
expect(rgbaStringToHex("rgb(0, 0, 0)")).toBe("#000000");
|
||||
});
|
||||
|
||||
it("converts white", () => {
|
||||
expect(rgbaStringToHex("rgb(255, 255, 255)")).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("converts arbitrary mid-range color", () => {
|
||||
expect(rgbaStringToHex("rgb(128, 64, 200)")).toBe("#8040c8");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseColorString", () => {
|
||||
describe("rgb format", () => {
|
||||
it("parses rgb without alpha as alpha=1", () => {
|
||||
expect(parseColorString("rgb(255, 128, 64)")).toEqual({ r: 255, g: 128, b: 64, a: 1 });
|
||||
});
|
||||
|
||||
it("parses rgba with alpha", () => {
|
||||
expect(parseColorString("rgba(10, 20, 30, 0.5)")).toEqual({ r: 10, g: 20, b: 30, a: 0.5 });
|
||||
});
|
||||
|
||||
it("parses rgba with alpha=0", () => {
|
||||
expect(parseColorString("rgba(0, 0, 0, 0)")).toEqual({ r: 0, g: 0, b: 0, a: 0 });
|
||||
});
|
||||
|
||||
it("handles extra whitespace in rgb", () => {
|
||||
expect(parseColorString("rgb( 255 , 0 , 0 )")).toEqual({ r: 255, g: 0, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", () => {
|
||||
expect(parseColorString(" rgb(1, 2, 3) ")).toEqual({ r: 1, g: 2, b: 3, a: 1 });
|
||||
});
|
||||
|
||||
it("returns null for malformed rgb", () => {
|
||||
expect(parseColorString("rgb(255, 0)")).toBeNull();
|
||||
expect(parseColorString("rgb(a, b, c)")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hex format", () => {
|
||||
it("parses 6-digit hex", () => {
|
||||
expect(parseColorString("#ff8040")).toEqual({ r: 255, g: 128, b: 64, a: 1 });
|
||||
});
|
||||
|
||||
it("parses 6-digit hex uppercase", () => {
|
||||
expect(parseColorString("#FF8040")).toEqual({ r: 255, g: 128, b: 64, a: 1 });
|
||||
});
|
||||
|
||||
it("parses 3-digit hex by doubling each digit", () => {
|
||||
expect(parseColorString("#f80")).toEqual({ r: 0xff, g: 0x88, b: 0x00, a: 1 });
|
||||
});
|
||||
|
||||
it("parses #000 as black", () => {
|
||||
expect(parseColorString("#000")).toEqual({ r: 0, g: 0, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
it("parses #fff as white", () => {
|
||||
expect(parseColorString("#fff")).toEqual({ r: 255, g: 255, b: 255, a: 1 });
|
||||
});
|
||||
|
||||
it("returns null for hex without #", () => {
|
||||
expect(parseColorString("ff0000")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid hex length", () => {
|
||||
expect(parseColorString("#ff00")).toBeNull();
|
||||
expect(parseColorString("#ff00ff00")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for hex with non-hex chars", () => {
|
||||
expect(parseColorString("#zz0000")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid input", () => {
|
||||
it("returns null for empty string", () => {
|
||||
expect(parseColorString("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for plain color names", () => {
|
||||
expect(parseColorString("red")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for hsl format", () => {
|
||||
expect(parseColorString("hsl(0, 100%, 50%)")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatDate, formatPeriod, formatSingleDate } from "./date";
|
||||
|
||||
describe("formatDate", () => {
|
||||
it("formats YYYY-MM as 'Month Year'", () => {
|
||||
expect(formatDate("2024-03")).toBe("March 2024");
|
||||
});
|
||||
|
||||
it("formats YYYY-MM-DD as 'Month Year' by default (day excluded)", () => {
|
||||
expect(formatDate("2024-03-15")).toBe("March 2024");
|
||||
});
|
||||
|
||||
it("formats YYYY-MM-DD with includeDay=true as 'Month DD, Year'", () => {
|
||||
expect(formatDate("2024-03-15", true)).toBe("March 15, 2024");
|
||||
});
|
||||
|
||||
it("returns YYYY unchanged when only year provided", () => {
|
||||
expect(formatDate("2024")).toBe("2024");
|
||||
});
|
||||
|
||||
it("handles January (month 1)", () => {
|
||||
expect(formatDate("2024-01")).toBe("January 2024");
|
||||
});
|
||||
|
||||
it("handles December (month 12)", () => {
|
||||
expect(formatDate("2024-12")).toBe("December 2024");
|
||||
});
|
||||
|
||||
it("includes day even with single-digit days", () => {
|
||||
expect(formatDate("2024-03-05", true)).toBe("March 05, 2024");
|
||||
});
|
||||
|
||||
it("returns 'undefined' month name when out-of-range month is supplied", () => {
|
||||
// Defensive: behavior documents what happens with bad input
|
||||
expect(formatDate("2024-13")).toBe("undefined 2024");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPeriod", () => {
|
||||
it("returns empty string when both dates missing", () => {
|
||||
expect(formatPeriod()).toBe("");
|
||||
});
|
||||
|
||||
it("returns end date alone when start is missing", () => {
|
||||
expect(formatPeriod(undefined, "2024-05")).toBe("2024-05");
|
||||
});
|
||||
|
||||
it("returns start - Present when end is missing", () => {
|
||||
expect(formatPeriod("2024-01")).toBe("January 2024 - Present");
|
||||
});
|
||||
|
||||
it("formats both ends when both provided", () => {
|
||||
expect(formatPeriod("2020-06", "2024-03")).toBe("June 2020 - March 2024");
|
||||
});
|
||||
|
||||
it("treats empty string start same as undefined", () => {
|
||||
expect(formatPeriod("", "2024-05")).toBe("2024-05");
|
||||
});
|
||||
|
||||
it("treats empty string end same as undefined", () => {
|
||||
expect(formatPeriod("2024-01", "")).toBe("January 2024 - Present");
|
||||
});
|
||||
|
||||
it("returns empty when start empty and end empty", () => {
|
||||
expect(formatPeriod("", "")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSingleDate", () => {
|
||||
it("returns empty string when date is undefined", () => {
|
||||
expect(formatSingleDate()).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when date is empty string", () => {
|
||||
expect(formatSingleDate("")).toBe("");
|
||||
});
|
||||
|
||||
it("formats full date with day included", () => {
|
||||
expect(formatSingleDate("2024-03-15")).toBe("March 15, 2024");
|
||||
});
|
||||
|
||||
it("formats year-month even with includeDay flag implied", () => {
|
||||
expect(formatSingleDate("2024-03")).toBe("March 2024");
|
||||
});
|
||||
|
||||
it("returns plain year when only year present", () => {
|
||||
expect(formatSingleDate("2024")).toBe("2024");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterFieldValues } from "./field";
|
||||
|
||||
describe("filterFieldValues", () => {
|
||||
it("returns map of fields whose values are non-empty", () => {
|
||||
const fields = [{ key: "name" as const }, { key: "email" as const }];
|
||||
const result = filterFieldValues({ name: "Alice", email: "alice@example.com" }, ...fields);
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get("name")).toEqual({ key: "name" });
|
||||
expect(result.get("email")).toEqual({ key: "email" });
|
||||
});
|
||||
|
||||
it("filters out fields with empty string values", () => {
|
||||
const fields = [{ key: "name" as const }, { key: "email" as const }];
|
||||
const result = filterFieldValues({ name: "Alice", email: "" }, ...fields);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.has("email")).toBe(false);
|
||||
expect(result.has("name")).toBe(true);
|
||||
});
|
||||
|
||||
it("filters out fields with whitespace-only values", () => {
|
||||
const fields = [{ key: "name" as const }];
|
||||
const result = filterFieldValues({ name: " " }, ...fields);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("filters out fields with null values", () => {
|
||||
const fields = [{ key: "name" as const }];
|
||||
const result = filterFieldValues({ name: null }, ...fields);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("filters out fields with undefined values", () => {
|
||||
const fields = [{ key: "name" as const }];
|
||||
const result = filterFieldValues({ name: undefined }, ...fields);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("filters out fields with missing keys", () => {
|
||||
const fields = [{ key: "name" as const }];
|
||||
const result = filterFieldValues({}, ...fields);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves additional field properties on output", () => {
|
||||
const fields = [{ key: "name" as const, label: "Name", icon: "person" }];
|
||||
const result = filterFieldValues({ name: "Alice" }, ...fields);
|
||||
|
||||
expect(result.get("name")).toEqual({ key: "name", label: "Name", icon: "person" });
|
||||
});
|
||||
|
||||
it("returns empty map when no fields supplied", () => {
|
||||
const result = filterFieldValues({ name: "Alice" });
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves field order in iteration", () => {
|
||||
const fields = [{ key: "a" as const }, { key: "b" as const }, { key: "c" as const }];
|
||||
const result = filterFieldValues({ a: "1", b: "2", c: "3" }, ...fields);
|
||||
|
||||
const keys = [...result.keys()];
|
||||
expect(keys).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { downloadFromUrl, downloadWithAnchor, generateFilename } from "./file";
|
||||
|
||||
describe("generateFilename", () => {
|
||||
it("slugifies the prefix without extension", () => {
|
||||
expect(generateFilename("My Resume")).toBe("my-resume");
|
||||
});
|
||||
|
||||
it("appends extension when provided", () => {
|
||||
expect(generateFilename("My Resume", "pdf")).toBe("my-resume.pdf");
|
||||
});
|
||||
|
||||
it("appends extension verbatim (no extra dot)", () => {
|
||||
expect(generateFilename("Name", "json")).toBe("name.json");
|
||||
});
|
||||
|
||||
it("handles empty extension as no extension", () => {
|
||||
expect(generateFilename("foo", "")).toBe("foo");
|
||||
});
|
||||
|
||||
it("strips diacritics", () => {
|
||||
expect(generateFilename("Résumé", "pdf")).toBe("resume.pdf");
|
||||
});
|
||||
|
||||
it("handles empty prefix", () => {
|
||||
expect(generateFilename("", "pdf")).toBe(".pdf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadWithAnchor", () => {
|
||||
let createObjectURLSpy: ReturnType<typeof vi.fn>;
|
||||
let revokeObjectURLSpy: ReturnType<typeof vi.fn>;
|
||||
let originalCreate: typeof URL.createObjectURL;
|
||||
let originalRevoke: typeof URL.revokeObjectURL;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
originalCreate = URL.createObjectURL;
|
||||
originalRevoke = URL.revokeObjectURL;
|
||||
createObjectURLSpy = vi.fn(() => "blob:mock-url");
|
||||
revokeObjectURLSpy = vi.fn();
|
||||
URL.createObjectURL = createObjectURLSpy;
|
||||
URL.revokeObjectURL = revokeObjectURLSpy;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
URL.createObjectURL = originalCreate;
|
||||
URL.revokeObjectURL = originalRevoke;
|
||||
});
|
||||
|
||||
it("creates an anchor with correct href, rel, and download attributes", () => {
|
||||
const blob = new Blob(["hello"], { type: "text/plain" });
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
|
||||
downloadWithAnchor(blob, "test.txt");
|
||||
|
||||
expect(createObjectURLSpy).toHaveBeenCalledWith(blob);
|
||||
expect(clickSpy).toHaveBeenCalledOnce();
|
||||
|
||||
clickSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("removes the anchor from the DOM after click", () => {
|
||||
const blob = new Blob(["hello"]);
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
|
||||
const beforeChildCount = document.body.childElementCount;
|
||||
downloadWithAnchor(blob, "test.txt");
|
||||
const afterChildCount = document.body.childElementCount;
|
||||
|
||||
expect(afterChildCount).toBe(beforeChildCount);
|
||||
});
|
||||
|
||||
it("revokes the object URL after 5 seconds", () => {
|
||||
const blob = new Blob(["hello"]);
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
|
||||
downloadWithAnchor(blob, "test.txt");
|
||||
|
||||
expect(revokeObjectURLSpy).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(4999);
|
||||
expect(revokeObjectURLSpy).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith("blob:mock-url");
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadFromUrl", () => {
|
||||
let originalFetch: typeof global.fetch;
|
||||
let createObjectURLSpy: ReturnType<typeof vi.fn>;
|
||||
let revokeObjectURLSpy: ReturnType<typeof vi.fn>;
|
||||
let originalCreate: typeof URL.createObjectURL;
|
||||
let originalRevoke: typeof URL.revokeObjectURL;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = global.fetch;
|
||||
originalCreate = URL.createObjectURL;
|
||||
originalRevoke = URL.revokeObjectURL;
|
||||
createObjectURLSpy = vi.fn(() => "blob:mock-url");
|
||||
revokeObjectURLSpy = vi.fn();
|
||||
URL.createObjectURL = createObjectURLSpy;
|
||||
URL.revokeObjectURL = revokeObjectURLSpy;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
URL.createObjectURL = originalCreate;
|
||||
URL.revokeObjectURL = originalRevoke;
|
||||
});
|
||||
|
||||
it("fetches the URL and downloads the resulting blob", async () => {
|
||||
const mockBlob = new Blob(["data"], { type: "application/pdf" });
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve(new Response(mockBlob, { status: 200 })),
|
||||
) as unknown as typeof global.fetch;
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
|
||||
await downloadFromUrl("https://example.com/file.pdf", "file.pdf");
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith("https://example.com/file.pdf");
|
||||
expect(createObjectURLSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates fetch errors", async () => {
|
||||
global.fetch = vi.fn(() => Promise.reject(new Error("network down"))) as unknown as typeof global.fetch;
|
||||
await expect(downloadFromUrl("https://example.com", "x.pdf")).rejects.toThrow("network down");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { arrayToHtmlList, toHtmlDescription } from "./html";
|
||||
|
||||
describe("toHtmlDescription", () => {
|
||||
it("returns empty string when no inputs provided", () => {
|
||||
expect(toHtmlDescription()).toBe("");
|
||||
});
|
||||
|
||||
it("returns just <p> with summary when no highlights", () => {
|
||||
expect(toHtmlDescription("Summary here")).toBe("<p>Summary here</p>");
|
||||
});
|
||||
|
||||
it("returns just <ul> when only highlights", () => {
|
||||
expect(toHtmlDescription(undefined, ["a", "b"])).toBe("<ul><li>a</li><li>b</li></ul>");
|
||||
});
|
||||
|
||||
it("returns combined output with both summary and highlights", () => {
|
||||
expect(toHtmlDescription("Sum", ["a"])).toBe("<p>Sum</p><ul><li>a</li></ul>");
|
||||
});
|
||||
|
||||
it("omits ul when highlights is empty array", () => {
|
||||
expect(toHtmlDescription("Sum", [])).toBe("<p>Sum</p>");
|
||||
});
|
||||
|
||||
it("preserves order: summary first, list second", () => {
|
||||
const result = toHtmlDescription("S", ["x", "y", "z"]);
|
||||
expect(result.indexOf("<p>")).toBeLessThan(result.indexOf("<ul>"));
|
||||
});
|
||||
|
||||
it("treats empty string summary as falsy", () => {
|
||||
expect(toHtmlDescription("", ["a"])).toBe("<ul><li>a</li></ul>");
|
||||
});
|
||||
|
||||
it("does not escape HTML in inputs (caller's responsibility)", () => {
|
||||
// Document existing behavior: caller must sanitize before passing
|
||||
expect(toHtmlDescription("<script>")).toBe("<p><script></p>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("arrayToHtmlList", () => {
|
||||
it("returns empty string for empty array", () => {
|
||||
expect(arrayToHtmlList([])).toBe("");
|
||||
});
|
||||
|
||||
it("renders single item list", () => {
|
||||
expect(arrayToHtmlList(["one"])).toBe("<ul><li>one</li></ul>");
|
||||
});
|
||||
|
||||
it("renders multi-item list", () => {
|
||||
expect(arrayToHtmlList(["one", "two", "three"])).toBe("<ul><li>one</li><li>two</li><li>three</li></ul>");
|
||||
});
|
||||
|
||||
it("preserves item order", () => {
|
||||
const result = arrayToHtmlList(["c", "a", "b"]);
|
||||
expect(result.indexOf("c")).toBeLessThan(result.indexOf("a"));
|
||||
expect(result.indexOf("a")).toBeLessThan(result.indexOf("b"));
|
||||
});
|
||||
|
||||
it("does not escape HTML in items", () => {
|
||||
expect(arrayToHtmlList(["<b>bold</b>"])).toBe("<ul><li><b>bold</b></li></ul>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseLevel } from "./level";
|
||||
|
||||
describe("parseLevel", () => {
|
||||
describe("invalid input", () => {
|
||||
it("returns 0 for undefined", () => {
|
||||
expect(parseLevel()).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for empty string", () => {
|
||||
expect(parseLevel("")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for unrecognized text", () => {
|
||||
expect(parseLevel("hello world")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("numeric values", () => {
|
||||
it("returns 0 for '0'", () => {
|
||||
expect(parseLevel("0")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 5 for '5'", () => {
|
||||
expect(parseLevel("5")).toBe(5);
|
||||
});
|
||||
|
||||
it("returns 3 for '3'", () => {
|
||||
expect(parseLevel("3")).toBe(3);
|
||||
});
|
||||
|
||||
it("falls through to text matching for out-of-range numerics", () => {
|
||||
// 6 is out of range so the numeric branch fails, no text matches → 0
|
||||
expect(parseLevel("6")).toBe(0);
|
||||
});
|
||||
|
||||
it("falls through for negative numerics", () => {
|
||||
expect(parseLevel("-1")).toBe(0);
|
||||
});
|
||||
|
||||
it("parses leading-numeric strings via parseInt", () => {
|
||||
// "3 stars" → parseInt yields 3, in [0,5] → returns 3
|
||||
expect(parseLevel("3 stars")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("text levels", () => {
|
||||
it("returns 5 for 'native'", () => {
|
||||
expect(parseLevel("native")).toBe(5);
|
||||
});
|
||||
|
||||
it("returns 5 for 'expert'", () => {
|
||||
expect(parseLevel("Expert")).toBe(5);
|
||||
});
|
||||
|
||||
it("returns 5 for 'master'", () => {
|
||||
expect(parseLevel("master")).toBe(5);
|
||||
});
|
||||
|
||||
it("returns 4 for 'fluent'", () => {
|
||||
expect(parseLevel("fluent")).toBe(4);
|
||||
});
|
||||
|
||||
it("returns 4 for 'advanced'", () => {
|
||||
expect(parseLevel("Advanced")).toBe(4);
|
||||
});
|
||||
|
||||
it("returns 4 for 'proficient'", () => {
|
||||
expect(parseLevel("proficient")).toBe(4);
|
||||
});
|
||||
|
||||
it("returns 3 for 'intermediate'", () => {
|
||||
expect(parseLevel("intermediate")).toBe(3);
|
||||
});
|
||||
|
||||
it("returns 3 for 'conversational'", () => {
|
||||
expect(parseLevel("conversational")).toBe(3);
|
||||
});
|
||||
|
||||
it("returns 2 for 'beginner'", () => {
|
||||
expect(parseLevel("beginner")).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 2 for 'basic'", () => {
|
||||
expect(parseLevel("basic")).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 2 for 'elementary'", () => {
|
||||
expect(parseLevel("Elementary")).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 1 for 'novice'", () => {
|
||||
expect(parseLevel("novice")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CEFR levels", () => {
|
||||
it("returns 5 for C2", () => {
|
||||
expect(parseLevel("C2")).toBe(5);
|
||||
});
|
||||
|
||||
it("returns 4 for C1", () => {
|
||||
expect(parseLevel("c1")).toBe(4);
|
||||
});
|
||||
|
||||
it("returns 3 for B2", () => {
|
||||
expect(parseLevel("B2")).toBe(3);
|
||||
});
|
||||
|
||||
it("returns 2 for B1", () => {
|
||||
expect(parseLevel("b1")).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 1 for A2", () => {
|
||||
expect(parseLevel("A2")).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 1 for A1", () => {
|
||||
expect(parseLevel("a1")).toBe(1);
|
||||
});
|
||||
|
||||
it("matches CEFR levels embedded in surrounding text", () => {
|
||||
expect(parseLevel("Level: B2")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("priority of matchers", () => {
|
||||
it("expert beats CEFR when both substrings appear", () => {
|
||||
// "expert" matches first and short-circuits before CEFR check
|
||||
expect(parseLevel("expert C2")).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultLocale, isLocale } from "./locale";
|
||||
|
||||
describe("defaultLocale", () => {
|
||||
it("is en-US", () => {
|
||||
expect(defaultLocale).toBe("en-US");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLocale", () => {
|
||||
it("returns true for non-empty string", () => {
|
||||
expect(isLocale("en-US")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for any non-empty string (no validation of locale shape)", () => {
|
||||
expect(isLocale("xyz")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for empty string", () => {
|
||||
expect(isLocale("")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for number", () => {
|
||||
expect(isLocale(42)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null", () => {
|
||||
expect(isLocale(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined", () => {
|
||||
expect(isLocale(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for object", () => {
|
||||
expect(isLocale({})).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for array", () => {
|
||||
expect(isLocale([])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { findWorkspaceRoot, getLocalDataDirectory } from "./monorepo.node";
|
||||
|
||||
describe("findWorkspaceRoot", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Resolve symlinks so comparisons match findWorkspaceRoot's realpathSync output.
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "rr-monorepo-test-")));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns the directory containing pnpm-workspace.yaml", () => {
|
||||
writeFileSync(join(tempDir, "pnpm-workspace.yaml"), "packages: ['*']");
|
||||
|
||||
expect(findWorkspaceRoot(tempDir)).toBe(tempDir);
|
||||
});
|
||||
|
||||
it("walks up directories to find pnpm-workspace.yaml", () => {
|
||||
const nested = join(tempDir, "apps", "web");
|
||||
mkdirSync(nested, { recursive: true });
|
||||
writeFileSync(join(tempDir, "pnpm-workspace.yaml"), "packages: ['*']");
|
||||
|
||||
expect(findWorkspaceRoot(nested)).toBe(tempDir);
|
||||
});
|
||||
|
||||
it("returns null if no workspace manifest is found", () => {
|
||||
// Use a temp dir that has no pnpm-workspace.yaml above it. Any descendant of /tmp
|
||||
// either has the file or eventually hits /. We use a deep-enough sibling here:
|
||||
const isolated = join(tempDir, "isolated");
|
||||
mkdirSync(isolated, { recursive: true });
|
||||
// Walk up from isolated; nothing in tempDir has a workspace manifest.
|
||||
// findWorkspaceRoot will return null when it reaches the filesystem root.
|
||||
// Verify behavior: at minimum, it should not return our tempDir.
|
||||
const result = findWorkspaceRoot(isolated);
|
||||
expect(result).not.toBe(tempDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLocalDataDirectory", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "rr-data-test-")));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns override when provided", () => {
|
||||
expect(getLocalDataDirectory("/custom/path")).toBe("/custom/path");
|
||||
});
|
||||
|
||||
it("returns workspaceRoot/data when manifest is found", () => {
|
||||
writeFileSync(join(tempDir, "pnpm-workspace.yaml"), "packages: ['*']");
|
||||
|
||||
expect(getLocalDataDirectory(undefined, tempDir)).toBe(join(tempDir, "data"));
|
||||
});
|
||||
|
||||
it("falls back to cwd/data when no manifest found", () => {
|
||||
const isolated = join(tempDir, "isolated");
|
||||
mkdirSync(isolated, { recursive: true });
|
||||
|
||||
const result = getLocalDataDirectory(undefined, isolated);
|
||||
// Should end with /data and not be the manifest workspace path
|
||||
expect(result.endsWith("data")).toBe(true);
|
||||
});
|
||||
|
||||
it("override takes precedence even if workspace exists", () => {
|
||||
writeFileSync(join(tempDir, "pnpm-workspace.yaml"), "packages: ['*']");
|
||||
|
||||
expect(getLocalDataDirectory("/elsewhere", tempDir)).toBe("/elsewhere");
|
||||
});
|
||||
|
||||
it("manifest file actually exists for the test setup", () => {
|
||||
writeFileSync(join(tempDir, "pnpm-workspace.yaml"), "packages: ['*']");
|
||||
expect(existsSync(join(tempDir, "pnpm-workspace.yaml"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getNetworkIcon } from "./network-icons";
|
||||
|
||||
describe("getNetworkIcon", () => {
|
||||
it("returns 'star' default when network is undefined", () => {
|
||||
expect(getNetworkIcon()).toBe("star");
|
||||
});
|
||||
|
||||
it("returns 'star' default when network is empty string", () => {
|
||||
expect(getNetworkIcon("")).toBe("star");
|
||||
});
|
||||
|
||||
it("returns 'star' default when no match found", () => {
|
||||
expect(getNetworkIcon("unknown-network")).toBe("star");
|
||||
});
|
||||
|
||||
it("matches case-insensitively", () => {
|
||||
expect(getNetworkIcon("GitHub")).toBe("github-logo");
|
||||
expect(getNetworkIcon("LINKEDIN")).toBe("linkedin-logo");
|
||||
});
|
||||
|
||||
it("matches GitHub", () => {
|
||||
expect(getNetworkIcon("github")).toBe("github-logo");
|
||||
});
|
||||
|
||||
it("matches LinkedIn", () => {
|
||||
expect(getNetworkIcon("linkedin")).toBe("linkedin-logo");
|
||||
});
|
||||
|
||||
it("matches Twitter and X variants", () => {
|
||||
expect(getNetworkIcon("twitter")).toBe("twitter-logo");
|
||||
// "x" alone matches, including in unrelated words
|
||||
expect(getNetworkIcon("x")).toBe("twitter-logo");
|
||||
expect(getNetworkIcon("x.com")).toBe("twitter-logo");
|
||||
});
|
||||
|
||||
it("matches Facebook", () => {
|
||||
expect(getNetworkIcon("facebook")).toBe("facebook-logo");
|
||||
});
|
||||
|
||||
it("matches Instagram", () => {
|
||||
expect(getNetworkIcon("instagram")).toBe("instagram-logo");
|
||||
});
|
||||
|
||||
it("matches YouTube", () => {
|
||||
expect(getNetworkIcon("youtube")).toBe("youtube-logo");
|
||||
});
|
||||
|
||||
it("matches Stack Overflow variants", () => {
|
||||
expect(getNetworkIcon("stackoverflow")).toBe("stack-overflow-logo");
|
||||
expect(getNetworkIcon("stack-overflow")).toBe("stack-overflow-logo");
|
||||
});
|
||||
|
||||
it("matches Medium", () => {
|
||||
expect(getNetworkIcon("medium")).toBe("medium-logo");
|
||||
});
|
||||
|
||||
it("matches Dev.to variants to 'code'", () => {
|
||||
expect(getNetworkIcon("dev.to")).toBe("code");
|
||||
expect(getNetworkIcon("devto")).toBe("code");
|
||||
});
|
||||
|
||||
it("matches Dribbble", () => {
|
||||
expect(getNetworkIcon("dribbble")).toBe("dribbble-logo");
|
||||
});
|
||||
|
||||
it("matches Behance", () => {
|
||||
expect(getNetworkIcon("behance")).toBe("behance-logo");
|
||||
});
|
||||
|
||||
it("matches GitLab to 'git-branch'", () => {
|
||||
expect(getNetworkIcon("gitlab")).toBe("git-branch");
|
||||
});
|
||||
|
||||
it("matches Bitbucket and CodePen to 'code'", () => {
|
||||
expect(getNetworkIcon("bitbucket")).toBe("code");
|
||||
expect(getNetworkIcon("codepen")).toBe("code");
|
||||
});
|
||||
|
||||
it("matches when keyword is substring of input", () => {
|
||||
expect(getNetworkIcon("My GitHub Profile")).toBe("github-logo");
|
||||
});
|
||||
|
||||
it("returns first matching icon (priority order)", () => {
|
||||
// "github" comes before "x" in the map, so github wins
|
||||
// (and this is also how it should behave for ambiguous strings)
|
||||
expect(getNetworkIcon("xgithub")).toBe("github-logo");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rateLimitConfig, TRUSTED_IP_HEADERS } from "./rate-limit";
|
||||
|
||||
describe("TRUSTED_IP_HEADERS", () => {
|
||||
it("includes Cloudflare connecting headers", () => {
|
||||
expect(TRUSTED_IP_HEADERS).toContain("CF-Connecting-IP");
|
||||
expect(TRUSTED_IP_HEADERS).toContain("CF-Connecting-IPv6");
|
||||
});
|
||||
|
||||
it("includes True-Client-IP", () => {
|
||||
expect(TRUSTED_IP_HEADERS).toContain("True-Client-IP");
|
||||
});
|
||||
|
||||
it("includes X-Forwarded-For and X-Real-IP", () => {
|
||||
expect(TRUSTED_IP_HEADERS).toContain("X-Forwarded-For");
|
||||
expect(TRUSTED_IP_HEADERS).toContain("X-Real-IP");
|
||||
});
|
||||
|
||||
it("contains exactly the documented set of trusted headers", () => {
|
||||
expect(TRUSTED_IP_HEADERS).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rateLimitConfig", () => {
|
||||
describe("betterAuth.global", () => {
|
||||
it("is enabled with sensible defaults", () => {
|
||||
expect(rateLimitConfig.betterAuth.global.enabled).toBe(true);
|
||||
expect(rateLimitConfig.betterAuth.global.window).toBe(60);
|
||||
expect(rateLimitConfig.betterAuth.global.max).toBe(60);
|
||||
});
|
||||
|
||||
it("rate-limits sign-in/email more strictly than global default", () => {
|
||||
expect(rateLimitConfig.betterAuth.global.customRules["/sign-in/email"]).toEqual({ window: 60, max: 5 });
|
||||
});
|
||||
|
||||
it("rate-limits sign-up/email even more strictly", () => {
|
||||
expect(rateLimitConfig.betterAuth.global.customRules["/sign-up/email"]).toEqual({ window: 60, max: 3 });
|
||||
});
|
||||
|
||||
it("uses long window for password reset to deter abuse", () => {
|
||||
expect(rateLimitConfig.betterAuth.global.customRules["/request-password-reset"]).toEqual({
|
||||
window: 600,
|
||||
max: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("rate-limits 2FA verification with longer windows", () => {
|
||||
expect(rateLimitConfig.betterAuth.global.customRules["/two-factor/verify-otp"].window).toBe(600);
|
||||
expect(rateLimitConfig.betterAuth.global.customRules["/two-factor/verify-totp"].window).toBe(600);
|
||||
expect(rateLimitConfig.betterAuth.global.customRules["/two-factor/verify-backup-code"].window).toBe(600);
|
||||
});
|
||||
|
||||
it("allows generous polling for username availability", () => {
|
||||
expect(rateLimitConfig.betterAuth.global.customRules["/is-username-available"]).toEqual({
|
||||
window: 60,
|
||||
max: 20,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("betterAuth.oauthProvider", () => {
|
||||
it("rate-limits register most strictly", () => {
|
||||
expect(rateLimitConfig.betterAuth.oauthProvider.register.max).toBe(5);
|
||||
});
|
||||
|
||||
it("allows higher introspect/userinfo throughput", () => {
|
||||
expect(rateLimitConfig.betterAuth.oauthProvider.introspect.max).toBe(60);
|
||||
expect(rateLimitConfig.betterAuth.oauthProvider.userinfo.max).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("betterAuth.apiKey", () => {
|
||||
it("is enabled with hourly window of 1000 requests", () => {
|
||||
expect(rateLimitConfig.betterAuth.apiKey.enabled).toBe(true);
|
||||
expect(rateLimitConfig.betterAuth.apiKey.timeWindow).toBe(60 * 60 * 1000);
|
||||
expect(rateLimitConfig.betterAuth.apiKey.maxRequests).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("orpc", () => {
|
||||
it("limits resume password reset to 5 per 10 minutes", () => {
|
||||
expect(rateLimitConfig.orpc.resumePassword).toEqual({ maxRequests: 5, window: 10 * 60 * 1000 });
|
||||
});
|
||||
|
||||
it("limits PDF export to 5 per minute", () => {
|
||||
expect(rateLimitConfig.orpc.pdfExport).toEqual({ maxRequests: 5, window: 60 * 1000 });
|
||||
});
|
||||
|
||||
it("limits AI requests to 20 per minute", () => {
|
||||
expect(rateLimitConfig.orpc.aiRequest).toEqual({ maxRequests: 20, window: 60 * 1000 });
|
||||
});
|
||||
|
||||
it("provides reasonable mutation throughput for resume edits", () => {
|
||||
expect(rateLimitConfig.orpc.resumeMutations).toEqual({ maxRequests: 60, window: 60 * 1000 });
|
||||
});
|
||||
|
||||
it("limits storage uploads more strictly than deletes", () => {
|
||||
expect(rateLimitConfig.orpc.storageUpload.maxRequests).toBeLessThan(
|
||||
rateLimitConfig.orpc.storageDelete.maxRequests,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toSafeDocxLink } from "./link-utils";
|
||||
|
||||
describe("toSafeDocxLink", () => {
|
||||
it("returns null for empty string", () => {
|
||||
expect(toSafeDocxLink("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for whitespace-only string", () => {
|
||||
expect(toSafeDocxLink(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", () => {
|
||||
expect(toSafeDocxLink(" https://example.com ")).toBe("https://example.com/");
|
||||
});
|
||||
|
||||
it("accepts https URLs", () => {
|
||||
expect(toSafeDocxLink("https://example.com")).toBe("https://example.com/");
|
||||
});
|
||||
|
||||
it("accepts http URLs", () => {
|
||||
expect(toSafeDocxLink("http://example.com")).toBe("http://example.com/");
|
||||
});
|
||||
|
||||
it("preserves path and query", () => {
|
||||
expect(toSafeDocxLink("https://example.com/p?a=1")).toBe("https://example.com/p?a=1");
|
||||
});
|
||||
|
||||
it("returns mailto: for valid email", () => {
|
||||
expect(toSafeDocxLink("mailto:user@example.com")).toBe("mailto:user@example.com");
|
||||
});
|
||||
|
||||
it("returns null for empty mailto:", () => {
|
||||
expect(toSafeDocxLink("mailto:")).toBeNull();
|
||||
expect(toSafeDocxLink("mailto: ")).toBeNull();
|
||||
});
|
||||
|
||||
it("trims email body within mailto:", () => {
|
||||
expect(toSafeDocxLink("mailto: user@example.com ")).toBe("mailto:user@example.com");
|
||||
});
|
||||
|
||||
it("rejects javascript: protocol", () => {
|
||||
expect(toSafeDocxLink("javascript:alert(1)")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects data: protocol", () => {
|
||||
expect(toSafeDocxLink("data:text/html,<script>alert(1)</script>")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects file: protocol", () => {
|
||||
expect(toSafeDocxLink("file:///etc/passwd")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed URLs", () => {
|
||||
expect(toSafeDocxLink("not a url at all")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects ftp: protocol", () => {
|
||||
expect(toSafeDocxLink("ftp://example.com")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { applyResumePatches, createResumePatches, jsonPatchOperationSchema, ResumePatchError } from "./patch";
|
||||
|
||||
describe("jsonPatchOperationSchema", () => {
|
||||
it("validates add op", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "add", path: "/foo", value: 1 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("requires value for add", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "add", path: "/foo" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("validates remove op without value", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "remove", path: "/foo" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("validates replace op", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "replace", path: "/foo", value: 2 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("validates move op with from", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "move", path: "/a", from: "/b" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("requires from for move", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "move", path: "/a" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("validates copy op with from", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "copy", path: "/a", from: "/b" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("validates test op with value", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "test", path: "/a", value: 1 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown op", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "swap", path: "/a", value: 1 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("permits any value type for add (unknown)", () => {
|
||||
const result = jsonPatchOperationSchema.safeParse({ op: "add", path: "/x", value: { nested: { array: [1] } } });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createResumePatches", () => {
|
||||
it("returns empty array when documents are equal", () => {
|
||||
const patches = createResumePatches(defaultResumeData, defaultResumeData);
|
||||
expect(patches).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits a replace op when a scalar field changes", () => {
|
||||
const next = { ...defaultResumeData, basics: { ...defaultResumeData.basics, name: "Alice" } };
|
||||
const patches = createResumePatches(defaultResumeData, next);
|
||||
|
||||
expect(patches.some((p) => p.op === "replace" && p.path === "/basics/name")).toBe(true);
|
||||
});
|
||||
|
||||
it("captures multiple changes in distinct operations", () => {
|
||||
const next = {
|
||||
...defaultResumeData,
|
||||
basics: { ...defaultResumeData.basics, name: "Alice", email: "alice@example.com" },
|
||||
};
|
||||
const patches = createResumePatches(defaultResumeData, next);
|
||||
|
||||
expect(patches.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyResumePatches", () => {
|
||||
it("applies a single replace op", () => {
|
||||
const result = applyResumePatches(defaultResumeData, [{ op: "replace", path: "/basics/name", value: "Alice" }]);
|
||||
|
||||
expect(result.basics.name).toBe("Alice");
|
||||
// Original is preserved (not mutated)
|
||||
expect(defaultResumeData.basics.name).toBe("");
|
||||
});
|
||||
|
||||
it("does not mutate the input data", () => {
|
||||
const before = JSON.stringify(defaultResumeData);
|
||||
applyResumePatches(defaultResumeData, [{ op: "replace", path: "/basics/name", value: "Bob" }]);
|
||||
expect(JSON.stringify(defaultResumeData)).toBe(before);
|
||||
});
|
||||
|
||||
it("applies multiple ops in sequence", () => {
|
||||
const result = applyResumePatches(defaultResumeData, [
|
||||
{ op: "replace", path: "/basics/name", value: "Alice" },
|
||||
{ op: "replace", path: "/basics/email", value: "alice@example.com" },
|
||||
]);
|
||||
|
||||
expect(result.basics.name).toBe("Alice");
|
||||
expect(result.basics.email).toBe("alice@example.com");
|
||||
});
|
||||
|
||||
it("throws ResumePatchError for unresolvable path", () => {
|
||||
expect(() =>
|
||||
applyResumePatches(defaultResumeData, [{ op: "replace", path: "/does/not/exist", value: "x" }]),
|
||||
).toThrow(ResumePatchError);
|
||||
});
|
||||
|
||||
it("throws ResumePatchError for failed test op", () => {
|
||||
try {
|
||||
applyResumePatches(defaultResumeData, [{ op: "test", path: "/basics/name", value: "wrong-value" }]);
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ResumePatchError);
|
||||
const patchError = error as ResumePatchError;
|
||||
expect(patchError.code).toBe("TEST_OPERATION_FAILED");
|
||||
expect(patchError.index).toBe(0);
|
||||
expect(patchError.message).toContain("Test operation failed");
|
||||
}
|
||||
});
|
||||
|
||||
it("includes the failing operation in the error", () => {
|
||||
try {
|
||||
applyResumePatches(defaultResumeData, [{ op: "test", path: "/basics/name", value: "wrong" }]);
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect((error as ResumePatchError).operation).toEqual({
|
||||
op: "test",
|
||||
path: "/basics/name",
|
||||
value: "wrong",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back if patched document fails schema validation", () => {
|
||||
// Setting picture.size to a value outside schema bounds (min 32, max 512)
|
||||
expect(() =>
|
||||
applyResumePatches(defaultResumeData, [{ op: "replace", path: "/picture/size", value: 9999 }]),
|
||||
).toThrow(/Patch produced invalid resume data/);
|
||||
});
|
||||
|
||||
it("supports adding to arrays", () => {
|
||||
const result = applyResumePatches(defaultResumeData, [
|
||||
{
|
||||
op: "add",
|
||||
path: "/sections/skills/items/-",
|
||||
value: {
|
||||
id: "abcdef0123456789",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "TypeScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
keywords: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.sections.skills.items).toHaveLength(1);
|
||||
expect(result.sections.skills.items[0]?.name).toBe("TypeScript");
|
||||
});
|
||||
|
||||
it("supports remove operation on existing path", () => {
|
||||
// First add an item, then remove it
|
||||
const withItem = applyResumePatches(defaultResumeData, [
|
||||
{
|
||||
op: "add",
|
||||
path: "/sections/skills/items/-",
|
||||
value: {
|
||||
id: "abcdef0123456789",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
name: "Go",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
keywords: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const removed = applyResumePatches(withItem, [{ op: "remove", path: "/sections/skills/items/0" }]);
|
||||
expect(removed.sections.skills.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResumePatchError", () => {
|
||||
it("captures all expected properties", () => {
|
||||
const error = new ResumePatchError("CODE", "msg", 2, { op: "test", path: "/x", value: 1 });
|
||||
expect(error.name).toBe("ResumePatchError");
|
||||
expect(error.code).toBe("CODE");
|
||||
expect(error.message).toBe("msg");
|
||||
expect(error.index).toBe(2);
|
||||
expect(error.operation).toEqual({ op: "test", path: "/x", value: 1 });
|
||||
});
|
||||
|
||||
it("is instanceof Error", () => {
|
||||
const error = new ResumePatchError("X", "y", 0, { op: "remove", path: "/x" });
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isObject, sanitizeCss, sanitizeHtml } from "./sanitize";
|
||||
|
||||
describe("sanitizeHtml", () => {
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(sanitizeHtml("")).toBe("");
|
||||
});
|
||||
|
||||
it("preserves allowed tags", () => {
|
||||
const result = sanitizeHtml("<p>Hello <strong>world</strong></p>");
|
||||
expect(result).toContain("<p>");
|
||||
expect(result).toContain("<strong>");
|
||||
expect(result).toContain("Hello");
|
||||
expect(result).toContain("world");
|
||||
});
|
||||
|
||||
it("strips disallowed tags like <script>", () => {
|
||||
const result = sanitizeHtml("<p>Hello</p><script>alert(1)</script>");
|
||||
expect(result).not.toContain("<script>");
|
||||
expect(result).not.toContain("alert(1)");
|
||||
expect(result).toContain("<p>Hello</p>");
|
||||
});
|
||||
|
||||
it("strips disallowed attributes like onerror", () => {
|
||||
const result = sanitizeHtml('<img src="x" onerror="alert(1)" />');
|
||||
expect(result).not.toContain("onerror");
|
||||
});
|
||||
|
||||
it("strips iframe and object tags", () => {
|
||||
expect(sanitizeHtml("<iframe src='evil'></iframe>")).not.toContain("iframe");
|
||||
expect(sanitizeHtml("<object data='evil'></object>")).not.toContain("<object");
|
||||
});
|
||||
|
||||
it("preserves links with safe href", () => {
|
||||
const result = sanitizeHtml('<a href="https://example.com">Click</a>');
|
||||
expect(result).toContain("https://example.com");
|
||||
});
|
||||
|
||||
it("blocks javascript: URLs in href", () => {
|
||||
const result = sanitizeHtml('<a href="javascript:alert(1)">x</a>');
|
||||
expect(result).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("preserves table structure tags", () => {
|
||||
const result = sanitizeHtml("<table><thead><tr><th>h</th></tr></thead><tbody><tr><td>d</td></tr></tbody></table>");
|
||||
expect(result).toContain("<table>");
|
||||
expect(result).toContain("<thead>");
|
||||
expect(result).toContain("<tbody>");
|
||||
});
|
||||
|
||||
it("preserves list tags", () => {
|
||||
const result = sanitizeHtml("<ul><li>one</li><li>two</li></ul>");
|
||||
expect(result).toContain("<ul>");
|
||||
expect(result).toContain("<li>");
|
||||
});
|
||||
|
||||
it("preserves headings", () => {
|
||||
const result = sanitizeHtml("<h1>Title</h1><h2>Sub</h2>");
|
||||
expect(result).toContain("<h1>");
|
||||
expect(result).toContain("<h2>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeCss", () => {
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(sanitizeCss("")).toBe("");
|
||||
});
|
||||
|
||||
it("strips CSS comments", () => {
|
||||
const result = sanitizeCss("/* malicious */ body { color: red; }");
|
||||
expect(result).not.toContain("/*");
|
||||
expect(result).not.toContain("malicious");
|
||||
});
|
||||
|
||||
it("decodes hex CSS escapes before sanitizing", () => {
|
||||
// \\6a\\61... encodes "javascript" — the decoder should expose this and the javascript: pattern then strips
|
||||
const css = "a { background: \\6a avascript:alert(1) }";
|
||||
const result = sanitizeCss(css);
|
||||
expect(result.toLowerCase()).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("strips javascript: protocol", () => {
|
||||
const result = sanitizeCss("a { background: javascript:alert(1) }");
|
||||
expect(result.toLowerCase()).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("strips expression() syntax", () => {
|
||||
const result = sanitizeCss("a { width: expression(alert(1)) }");
|
||||
expect(result.toLowerCase()).not.toContain("expression(");
|
||||
});
|
||||
|
||||
it("strips behavior: declarations", () => {
|
||||
const result = sanitizeCss("a { behavior: url(#default#VML); }");
|
||||
expect(result.toLowerCase()).not.toContain("behavior:");
|
||||
});
|
||||
|
||||
it("strips -moz-binding declarations", () => {
|
||||
const result = sanitizeCss("a { -moz-binding: url(evil.xml#xss); }");
|
||||
expect(result.toLowerCase()).not.toContain("-moz-binding");
|
||||
});
|
||||
|
||||
it("strips @import directives", () => {
|
||||
const result = sanitizeCss("@import url('evil.css'); body { color: red; }");
|
||||
expect(result).not.toContain("@import");
|
||||
});
|
||||
|
||||
it("strips @font-face blocks", () => {
|
||||
const result = sanitizeCss("@font-face { font-family: x; src: url(evil); } body {}");
|
||||
expect(result).not.toContain("@font-face");
|
||||
});
|
||||
|
||||
it("strips url() values", () => {
|
||||
const result = sanitizeCss("a { background: url(evil.png); }");
|
||||
expect(result).not.toContain("url(");
|
||||
});
|
||||
|
||||
it("strips standalone src: declarations", () => {
|
||||
const result = sanitizeCss("src: url(x.woff);");
|
||||
expect(result).not.toContain("src:");
|
||||
});
|
||||
|
||||
it("preserves benign CSS", () => {
|
||||
const result = sanitizeCss("body { color: red; font-size: 14px; }");
|
||||
expect(result).toContain("color: red");
|
||||
expect(result).toContain("font-size: 14px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isObject", () => {
|
||||
it("returns true for plain objects", () => {
|
||||
expect(isObject({})).toBe(true);
|
||||
expect(isObject({ a: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for arrays", () => {
|
||||
expect(isObject([])).toBe(false);
|
||||
expect(isObject([1, 2])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null", () => {
|
||||
expect(isObject(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined", () => {
|
||||
expect(isObject(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for primitives", () => {
|
||||
expect(isObject(0)).toBe(false);
|
||||
expect(isObject("string")).toBe(false);
|
||||
expect(isObject(true)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for class instances (objects)", () => {
|
||||
expect(isObject(new Date())).toBe(true);
|
||||
});
|
||||
|
||||
it("narrows the type correctly", () => {
|
||||
const value: unknown = { foo: "bar" };
|
||||
if (isObject(value)) {
|
||||
// should compile-check: value has Record<string, unknown>
|
||||
expect(typeof value.foo).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateId, generateRandomName, getInitials, slugify, stripHtml, toUsername } from "./string";
|
||||
|
||||
describe("generateId", () => {
|
||||
it("returns a UUIDv7 string", () => {
|
||||
const id = generateId();
|
||||
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
});
|
||||
|
||||
it("returns unique values across invocations", () => {
|
||||
const ids = new Set(Array.from({ length: 50 }, () => generateId()));
|
||||
expect(ids.size).toBe(50);
|
||||
});
|
||||
|
||||
it("returns ids that sort lexicographically by creation time", async () => {
|
||||
const first = generateId();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
const second = generateId();
|
||||
expect(second.localeCompare(first)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("slugify", () => {
|
||||
it("lowercases and replaces spaces with hyphens", () => {
|
||||
expect(slugify("Hello World")).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("preserves camelCase due to decamelize: false", () => {
|
||||
expect(slugify("HelloWorld")).toBe("helloworld");
|
||||
});
|
||||
|
||||
it("strips diacritics and accents", () => {
|
||||
expect(slugify("Café")).toBe("cafe");
|
||||
expect(slugify("naïve résumé")).toBe("naive-resume");
|
||||
});
|
||||
|
||||
it("removes punctuation", () => {
|
||||
expect(slugify("Hello, World!")).toBe("hello-world");
|
||||
expect(slugify("a/b\\c.d")).toBe("a-b-c-d");
|
||||
});
|
||||
|
||||
it("collapses repeated separators", () => {
|
||||
expect(slugify("a b---c")).toBe("a-b-c");
|
||||
});
|
||||
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(slugify("")).toBe("");
|
||||
});
|
||||
|
||||
it("handles unicode emojis by stripping them", () => {
|
||||
expect(slugify("Hello 🌍 World")).toBe("hello-world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInitials", () => {
|
||||
it("returns first letters of two-word names uppercased", () => {
|
||||
expect(getInitials("John Doe")).toBe("JD");
|
||||
});
|
||||
|
||||
it("uses only first two parts for longer names", () => {
|
||||
expect(getInitials("John Michael Doe")).toBe("JM");
|
||||
});
|
||||
|
||||
it("returns single uppercase initial for single word", () => {
|
||||
expect(getInitials("John")).toBe("J");
|
||||
});
|
||||
|
||||
it("uppercases lowercase input", () => {
|
||||
expect(getInitials("jane smith")).toBe("JS");
|
||||
});
|
||||
|
||||
it("handles empty string by returning empty string", () => {
|
||||
expect(getInitials("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toUsername", () => {
|
||||
it("lowercases and trims", () => {
|
||||
expect(toUsername(" JohnDoe ")).toBe("johndoe");
|
||||
});
|
||||
|
||||
it("removes characters outside [a-z0-9._-]", () => {
|
||||
expect(toUsername("john!@#doe$%^")).toBe("johndoe");
|
||||
});
|
||||
|
||||
it("preserves dots, underscores, and hyphens", () => {
|
||||
expect(toUsername("john.doe_test-123")).toBe("john.doe_test-123");
|
||||
});
|
||||
|
||||
it("truncates to 64 characters max", () => {
|
||||
const longName = "a".repeat(100);
|
||||
expect(toUsername(longName)).toHaveLength(64);
|
||||
});
|
||||
|
||||
it("handles unicode characters by stripping them", () => {
|
||||
expect(toUsername("Café")).toBe("caf");
|
||||
expect(toUsername("日本語")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for whitespace-only input", () => {
|
||||
expect(toUsername(" ")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateRandomName", () => {
|
||||
it("returns a string with three capitalized words", () => {
|
||||
const name = generateRandomName();
|
||||
const words = name.split(" ");
|
||||
expect(words).toHaveLength(3);
|
||||
for (const word of words) {
|
||||
expect(word[0]).toBe(word[0]?.toUpperCase());
|
||||
}
|
||||
});
|
||||
|
||||
it("produces varying values", () => {
|
||||
const names = new Set(Array.from({ length: 20 }, () => generateRandomName()));
|
||||
// Allow rare collisions but expect variety from a 20-sample pool
|
||||
expect(names.size).toBeGreaterThan(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripHtml", () => {
|
||||
it("removes simple tags and trims", () => {
|
||||
expect(stripHtml("<p>Hello</p>")).toBe("Hello");
|
||||
});
|
||||
|
||||
it("removes nested tags and preserves text", () => {
|
||||
expect(stripHtml("<div><span>Hello</span> <strong>World</strong></div>")).toBe("Hello World");
|
||||
});
|
||||
|
||||
it("returns empty string for undefined input", () => {
|
||||
expect(stripHtml(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(stripHtml("")).toBe("");
|
||||
});
|
||||
|
||||
it("handles self-closing tags", () => {
|
||||
expect(stripHtml("Line one<br/>Line two")).toBe("Line oneLine two");
|
||||
});
|
||||
|
||||
it("strips attributes within tags", () => {
|
||||
expect(stripHtml('<a href="https://example.com" title="link">Click</a>')).toBe("Click");
|
||||
});
|
||||
|
||||
it("preserves text containing angle brackets when not malformed tags", () => {
|
||||
// Greater-than after a closed tag stays
|
||||
expect(stripHtml("<p>5 > 3</p>")).toBe("5 > 3");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cn } from "./style";
|
||||
|
||||
describe("cn", () => {
|
||||
it("joins simple class strings", () => {
|
||||
expect(cn("a", "b", "c")).toBe("a b c");
|
||||
});
|
||||
|
||||
it("filters out falsy values", () => {
|
||||
expect(cn("a", null, undefined, false, "b", 0)).toBe("a b");
|
||||
});
|
||||
|
||||
it("conditionally applies classes via object syntax", () => {
|
||||
expect(cn({ a: true, b: false, c: true })).toBe("a c");
|
||||
});
|
||||
|
||||
it("flattens arrays of inputs", () => {
|
||||
expect(cn(["a", "b"], ["c"])).toBe("a b c");
|
||||
});
|
||||
|
||||
it("merges conflicting tailwind classes (later wins)", () => {
|
||||
expect(cn("p-4", "p-2")).toBe("p-2");
|
||||
expect(cn("text-red-500", "text-blue-500")).toBe("text-blue-500");
|
||||
});
|
||||
|
||||
it("preserves non-conflicting tailwind classes", () => {
|
||||
expect(cn("text-red-500", "bg-white")).toContain("text-red-500");
|
||||
expect(cn("text-red-500", "bg-white")).toContain("bg-white");
|
||||
});
|
||||
|
||||
it("handles responsive variants distinctly from base classes", () => {
|
||||
expect(cn("p-4", "md:p-2")).toBe("p-4 md:p-2");
|
||||
});
|
||||
|
||||
it("returns empty string for no input", () => {
|
||||
expect(cn()).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when all inputs are falsy", () => {
|
||||
expect(cn(null, undefined, false)).toBe("");
|
||||
});
|
||||
|
||||
it("merges twMerge group conflicts (e.g., flex-row vs flex-col)", () => {
|
||||
expect(cn("flex-row", "flex-col")).toBe("flex-col");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isAllowedExternalUrl,
|
||||
isAllowedOAuthRedirectUri,
|
||||
isPrivateOrLoopbackHost,
|
||||
parseAllowedHostList,
|
||||
parseUrl,
|
||||
} from "./url-security.node";
|
||||
|
||||
describe("isPrivateOrLoopbackHost", () => {
|
||||
describe("loopback hostnames", () => {
|
||||
it("matches localhost", () => {
|
||||
expect(isPrivateOrLoopbackHost("localhost")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches LOCALHOST (case-insensitive)", () => {
|
||||
expect(isPrivateOrLoopbackHost("LOCALHOST")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches subdomains of localhost", () => {
|
||||
expect(isPrivateOrLoopbackHost("api.localhost")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches IPv6 loopback ::1", () => {
|
||||
expect(isPrivateOrLoopbackHost("::1")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches bracketed IPv6 loopback [::1]", () => {
|
||||
expect(isPrivateOrLoopbackHost("[::1]")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("private IPv4 ranges", () => {
|
||||
it("matches 10.0.0.0/8", () => {
|
||||
expect(isPrivateOrLoopbackHost("10.0.0.1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("10.255.255.255")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 127.0.0.0/8 (loopback)", () => {
|
||||
expect(isPrivateOrLoopbackHost("127.0.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 169.254.0.0/16 (link-local)", () => {
|
||||
expect(isPrivateOrLoopbackHost("169.254.1.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 172.16.0.0/12", () => {
|
||||
expect(isPrivateOrLoopbackHost("172.16.0.1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("172.31.255.255")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT match outside 172.16-31", () => {
|
||||
expect(isPrivateOrLoopbackHost("172.15.0.1")).toBe(false);
|
||||
expect(isPrivateOrLoopbackHost("172.32.0.1")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches 192.168.0.0/16", () => {
|
||||
expect(isPrivateOrLoopbackHost("192.168.0.1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("192.168.255.255")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 0.0.0.0/8", () => {
|
||||
expect(isPrivateOrLoopbackHost("0.0.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT match public IPs", () => {
|
||||
expect(isPrivateOrLoopbackHost("8.8.8.8")).toBe(false);
|
||||
expect(isPrivateOrLoopbackHost("1.1.1.1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("private IPv6 ranges", () => {
|
||||
it("matches unique-local fc00::/7", () => {
|
||||
expect(isPrivateOrLoopbackHost("fc00::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackHost("fd12::1")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches link-local fe80::/10", () => {
|
||||
expect(isPrivateOrLoopbackHost("fe80::1")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT match global IPv6", () => {
|
||||
expect(isPrivateOrLoopbackHost("2001:db8::1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-IP, non-loopback hostnames", () => {
|
||||
it("returns false for public domain", () => {
|
||||
expect(isPrivateOrLoopbackHost("example.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for arbitrary string", () => {
|
||||
expect(isPrivateOrLoopbackHost("not-a-real-host")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseUrl", () => {
|
||||
it("returns URL object for valid URL", () => {
|
||||
const url = parseUrl("https://example.com/path");
|
||||
expect(url).not.toBeNull();
|
||||
expect(url?.hostname).toBe("example.com");
|
||||
});
|
||||
|
||||
it("returns null for invalid URL", () => {
|
||||
expect(parseUrl("not a url")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(parseUrl("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for relative URL", () => {
|
||||
expect(parseUrl("/path/only")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAllowedHostList", () => {
|
||||
it("returns empty Set for undefined", () => {
|
||||
expect(parseAllowedHostList()).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("returns empty Set for empty string", () => {
|
||||
expect(parseAllowedHostList("")).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("parses single host", () => {
|
||||
expect(parseAllowedHostList("example.com")).toEqual(new Set(["example.com"]));
|
||||
});
|
||||
|
||||
it("parses comma-separated list", () => {
|
||||
expect(parseAllowedHostList("example.com,api.example.com")).toEqual(new Set(["example.com", "api.example.com"]));
|
||||
});
|
||||
|
||||
it("trims whitespace around entries", () => {
|
||||
expect(parseAllowedHostList(" a.com , b.com ")).toEqual(new Set(["a.com", "b.com"]));
|
||||
});
|
||||
|
||||
it("lowercases entries", () => {
|
||||
expect(parseAllowedHostList("Example.COM")).toEqual(new Set(["example.com"]));
|
||||
});
|
||||
|
||||
it("filters out empty entries", () => {
|
||||
expect(parseAllowedHostList("a.com,,b.com,")).toEqual(new Set(["a.com", "b.com"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAllowedExternalUrl", () => {
|
||||
const allowed = new Set(["api.example.com", "https://full.example.com"]);
|
||||
|
||||
it("returns false for malformed URLs", () => {
|
||||
expect(isAllowedExternalUrl("not a url", allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for non-https protocols", () => {
|
||||
expect(isAllowedExternalUrl("http://api.example.com", allowed)).toBe(false);
|
||||
expect(isAllowedExternalUrl("ftp://api.example.com", allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when URL contains credentials", () => {
|
||||
expect(isAllowedExternalUrl("https://user:pass@api.example.com", allowed)).toBe(false);
|
||||
expect(isAllowedExternalUrl("https://user@api.example.com", allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for private/loopback hosts", () => {
|
||||
expect(isAllowedExternalUrl("https://localhost", allowed)).toBe(false);
|
||||
expect(isAllowedExternalUrl("https://127.0.0.1", allowed)).toBe(false);
|
||||
expect(isAllowedExternalUrl("https://192.168.1.1", allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches by hostname", () => {
|
||||
expect(isAllowedExternalUrl("https://api.example.com/path", allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches by full origin", () => {
|
||||
expect(isAllowedExternalUrl("https://full.example.com/x", allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-listed hostnames", () => {
|
||||
expect(isAllowedExternalUrl("https://evil.com", allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches case-insensitively in hostname", () => {
|
||||
expect(isAllowedExternalUrl("https://API.EXAMPLE.COM", allowed)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAllowedOAuthRedirectUri", () => {
|
||||
const trustedOrigins = ["https://app.example.com"];
|
||||
const allowed = new Set(["api.example.com"]);
|
||||
|
||||
it("returns false for malformed URI", () => {
|
||||
expect(isAllowedOAuthRedirectUri("nope", trustedOrigins, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when credentials present", () => {
|
||||
expect(isAllowedOAuthRedirectUri("https://u:p@app.example.com", trustedOrigins, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when fragment present", () => {
|
||||
expect(isAllowedOAuthRedirectUri("https://app.example.com/cb#x", trustedOrigins, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows http for loopback (localhost)", () => {
|
||||
expect(isAllowedOAuthRedirectUri("http://localhost:3000/cb", trustedOrigins, allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows http for 127.0.0.1", () => {
|
||||
expect(isAllowedOAuthRedirectUri("http://127.0.0.1/cb", trustedOrigins, allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows http for IPv6 loopback", () => {
|
||||
expect(isAllowedOAuthRedirectUri("http://[::1]/cb", trustedOrigins, allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects http for non-loopback hosts", () => {
|
||||
expect(isAllowedOAuthRedirectUri("http://example.com/cb", trustedOrigins, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-https/non-http protocols", () => {
|
||||
expect(isAllowedOAuthRedirectUri("ftp://example.com/cb", trustedOrigins, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects https with private/loopback host", () => {
|
||||
expect(isAllowedOAuthRedirectUri("https://192.168.1.1/cb", trustedOrigins, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches trusted origins", () => {
|
||||
expect(isAllowedOAuthRedirectUri("https://app.example.com/cb", trustedOrigins, allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches against allowed origins set", () => {
|
||||
expect(isAllowedOAuthRedirectUri("https://api.example.com/cb", trustedOrigins, allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-allowed public host", () => {
|
||||
expect(isAllowedOAuthRedirectUri("https://evil.com/cb", trustedOrigins, allowed)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createUrl } from "./url";
|
||||
|
||||
describe("createUrl", () => {
|
||||
it("returns empty url and label when no url provided", () => {
|
||||
expect(createUrl()).toEqual({ url: "", label: "" });
|
||||
});
|
||||
|
||||
it("returns empty pair when url is empty string (falsy)", () => {
|
||||
expect(createUrl("")).toEqual({ url: "", label: "" });
|
||||
});
|
||||
|
||||
it("uses url as label when no label provided", () => {
|
||||
expect(createUrl("https://example.com")).toEqual({
|
||||
url: "https://example.com",
|
||||
label: "https://example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves provided label", () => {
|
||||
expect(createUrl("https://example.com", "Example")).toEqual({
|
||||
url: "https://example.com",
|
||||
label: "Example",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to url when label is empty string", () => {
|
||||
expect(createUrl("https://example.com", "")).toEqual({
|
||||
url: "https://example.com",
|
||||
label: "https://example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not validate the url format (caller's responsibility)", () => {
|
||||
expect(createUrl("not-a-url", "Label")).toEqual({
|
||||
url: "not-a-url",
|
||||
label: "Label",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1 +1,53 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, vi } from "vitest";
|
||||
|
||||
// React Testing Library auto-cleanup hooks into afterEach as a global, but Vitest
|
||||
// only exposes `afterEach` globally when `test.globals: true` is set. We register
|
||||
// the cleanup explicitly so component tests do not leak DOM between runs.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// jsdom polyfills for browser APIs that some libraries (cmdk, base-ui)
|
||||
// rely on but jsdom does not implement.
|
||||
if (typeof globalThis.ResizeObserver === "undefined") {
|
||||
globalThis.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
}
|
||||
|
||||
if (typeof globalThis.IntersectionObserver === "undefined") {
|
||||
globalThis.IntersectionObserver = class IntersectionObserver {
|
||||
root = null;
|
||||
rootMargin = "";
|
||||
thresholds: ReadonlyArray<number> = [];
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
}
|
||||
|
||||
// scrollIntoView is used by cmdk and other libs; jsdom does not implement it.
|
||||
if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = function scrollIntoView() {};
|
||||
}
|
||||
|
||||
// matchMedia is used by next-themes and other UI libs; jsdom does not provide it.
|
||||
if (typeof window !== "undefined" && !window.matchMedia) {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user