mirror of
https://github.com/gethomepage/homepage.git
synced 2026-02-08 00:40:52 +08:00
test: add core component coverage (tabs, toggles, bookmarks)
This commit is contained in:
32
src/components/bookmarks/item.test.jsx
Normal file
32
src/components/bookmarks/item.test.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
vi.mock("components/resolvedicon", () => ({
|
||||
default: ({ icon }) => <div data-testid="resolved-icon" data-icon={icon} />,
|
||||
}));
|
||||
|
||||
import Item from "./item";
|
||||
|
||||
describe("components/bookmarks/item", () => {
|
||||
it("falls back description to href hostname and uses settings.target", () => {
|
||||
renderWithProviders(<Item bookmark={{ name: "A", href: "http://example.com/x", abbr: "A" }} iconOnly={false} />, {
|
||||
settings: { target: "_self", cardBlur: "" },
|
||||
});
|
||||
|
||||
expect(screen.getByText("example.com")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link").getAttribute("target")).toBe("_self");
|
||||
});
|
||||
|
||||
it("renders icon-only layout with icon when provided", () => {
|
||||
renderWithProviders(
|
||||
<Item bookmark={{ name: "A", href: "http://example.com/x", abbr: "A", icon: "mdi-home" }} iconOnly />,
|
||||
{ settings: { target: "_self" } },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("resolved-icon").getAttribute("data-icon")).toBe("mdi-home");
|
||||
});
|
||||
});
|
||||
38
src/components/bookmarks/list.test.jsx
Normal file
38
src/components/bookmarks/list.test.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { Item } = vi.hoisted(() => ({
|
||||
Item: vi.fn(({ bookmark, iconOnly }) => (
|
||||
<li data-testid="bookmark-item" data-name={bookmark.name} data-icononly={String(iconOnly)} />
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("components/bookmarks/item", () => ({
|
||||
default: Item,
|
||||
}));
|
||||
|
||||
import List from "./list";
|
||||
|
||||
describe("components/bookmarks/list", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders items with iconOnly when iconsOnly is set", () => {
|
||||
render(<List bookmarks={[{ name: "A", href: "http://a" }]} layout={{ iconsOnly: true }} bookmarksStyle="text" />);
|
||||
|
||||
expect(Item).toHaveBeenCalled();
|
||||
expect(Item.mock.calls[0][0].iconOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("applies gridTemplateColumns in icons style", () => {
|
||||
const { container } = render(
|
||||
<List bookmarks={[{ name: "A", href: "http://a" }]} layout={{ header: false }} bookmarksStyle="icons" />,
|
||||
);
|
||||
|
||||
const ul = container.querySelector("ul");
|
||||
expect(ul.style.gridTemplateColumns).toContain("minmax(60px");
|
||||
});
|
||||
});
|
||||
38
src/components/errorboundry.test.jsx
Normal file
38
src/components/errorboundry.test.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import ErrorBoundary from "./errorboundry";
|
||||
|
||||
describe("components/errorboundry", () => {
|
||||
it("renders children when no error is thrown", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>ok</div>
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("ok")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a fallback UI when a child throws", () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
const Boom = () => {
|
||||
throw new Error("boom");
|
||||
};
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Boom />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Something went wrong.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Error: boom")).toBeInTheDocument();
|
||||
} finally {
|
||||
consoleSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
39
src/components/favicon.test.jsx
Normal file
39
src/components/favicon.test.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ColorContext } from "utils/contexts/color";
|
||||
|
||||
import Favicon from "./favicon";
|
||||
|
||||
describe("components/favicon", () => {
|
||||
it("appends a shortcut icon link after rendering the SVG to canvas", async () => {
|
||||
const drawImage = vi.fn();
|
||||
const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ drawImage });
|
||||
const toDataURLSpy = vi
|
||||
.spyOn(HTMLCanvasElement.prototype, "toDataURL")
|
||||
.mockReturnValue("data:image/x-icon;base64,AAA");
|
||||
|
||||
const { container } = render(
|
||||
<ColorContext.Provider value={{ color: "slate", setColor: vi.fn() }}>
|
||||
<Favicon />
|
||||
</ColorContext.Provider>,
|
||||
);
|
||||
|
||||
const img = container.querySelector("img");
|
||||
await waitFor(() => {
|
||||
expect(typeof img.onload).toBe("function");
|
||||
});
|
||||
|
||||
img.onload();
|
||||
|
||||
const link = document.head.querySelector('link[rel="shortcut icon"]');
|
||||
expect(link).not.toBeNull();
|
||||
expect(link.getAttribute("href")).toBe("data:image/x-icon;base64,AAA");
|
||||
expect(drawImage).toHaveBeenCalled();
|
||||
|
||||
getContextSpy.mockRestore();
|
||||
toDataURLSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
50
src/components/resolvedicon.test.jsx
Normal file
50
src/components/resolvedicon.test.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SettingsContext } from "utils/contexts/settings";
|
||||
import { ThemeContext } from "utils/contexts/theme";
|
||||
|
||||
vi.mock("next/image", () => ({
|
||||
default: ({ src, alt }) => <div data-testid="next-image" data-src={src} data-alt={alt} />,
|
||||
}));
|
||||
|
||||
import ResolvedIcon from "./resolvedicon";
|
||||
|
||||
function renderWithContexts(ui, { settings = {}, theme = "dark" } = {}) {
|
||||
return render(
|
||||
<SettingsContext.Provider value={{ settings, setSettings: () => {} }}>
|
||||
<ThemeContext.Provider value={{ theme, setTheme: vi.fn() }}>{ui}</ThemeContext.Provider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("components/resolvedicon", () => {
|
||||
it("renders direct URL icons via next/image", () => {
|
||||
renderWithContexts(<ResolvedIcon icon="http://example.com/x.png" alt="x" />);
|
||||
expect(screen.getByTestId("next-image").getAttribute("data-src")).toBe("http://example.com/x.png");
|
||||
});
|
||||
|
||||
it("renders selfh.st icons for sh- prefix with extension", () => {
|
||||
renderWithContexts(<ResolvedIcon icon="sh-test.webp" alt="x" />);
|
||||
expect(screen.getByTestId("next-image").getAttribute("data-src")).toContain("/webp/test.webp");
|
||||
});
|
||||
|
||||
it("renders mdi icons as a masked div and supports custom hex colors", () => {
|
||||
const { container } = renderWithContexts(<ResolvedIcon icon="mdi-home-#ff00ff" />, {
|
||||
settings: { iconStyle: "theme" },
|
||||
theme: "dark",
|
||||
});
|
||||
|
||||
const div = container.querySelector("div");
|
||||
// Browser normalizes hex colors to rgb() strings on assignment.
|
||||
expect(div.style.background).toMatch(/(#ff00ff|rgb\(255, 0, 255\))/);
|
||||
expect(div.getAttribute("style")).toContain("home.svg");
|
||||
});
|
||||
|
||||
it("falls back to dashboard-icons for .svg", () => {
|
||||
renderWithContexts(<ResolvedIcon icon="foo.svg" />);
|
||||
expect(screen.getByTestId("next-image").getAttribute("data-src")).toContain("/dashboard-icons/svg/foo.svg");
|
||||
});
|
||||
});
|
||||
32
src/components/tab.test.jsx
Normal file
32
src/components/tab.test.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { TabContext } from "utils/contexts/tab";
|
||||
|
||||
import Tab, { slugifyAndEncode } from "./tab";
|
||||
|
||||
describe("components/tab", () => {
|
||||
it("slugifyAndEncode lowercases and encodes spaces", () => {
|
||||
expect(slugifyAndEncode("My Tab")).toBe("my-tab");
|
||||
expect(slugifyAndEncode(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("marks the matching tab as selected and updates hash on click", () => {
|
||||
const setActiveTab = vi.fn();
|
||||
|
||||
render(
|
||||
<TabContext.Provider value={{ activeTab: "my-tab", setActiveTab }}>
|
||||
<Tab tab="My Tab" />
|
||||
</TabContext.Provider>,
|
||||
);
|
||||
|
||||
const btn = screen.getByRole("tab");
|
||||
expect(btn.getAttribute("aria-selected")).toBe("true");
|
||||
|
||||
fireEvent.click(btn);
|
||||
expect(setActiveTab).toHaveBeenCalledWith("my-tab");
|
||||
expect(window.location.hash).toBe("#my-tab");
|
||||
});
|
||||
});
|
||||
59
src/components/toggles/color.test.jsx
Normal file
59
src/components/toggles/color.test.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ColorContext } from "utils/contexts/color";
|
||||
|
||||
// Stub Popover/Transition to always render children.
|
||||
vi.mock("@headlessui/react", async () => {
|
||||
const React = await import("react");
|
||||
const { Fragment } = React;
|
||||
|
||||
function passthrough({ as: As = "div", children, ...props }) {
|
||||
if (As === Fragment) return <>{typeof children === "function" ? children({ open: true }) : children}</>;
|
||||
const content = typeof children === "function" ? children({ open: true }) : children;
|
||||
return <As {...props}>{content}</As>;
|
||||
}
|
||||
|
||||
function Popover({ children }) {
|
||||
return <div>{typeof children === "function" ? children({ open: true }) : children}</div>;
|
||||
}
|
||||
function PopoverButton(props) {
|
||||
return <button type="button" {...props} />;
|
||||
}
|
||||
function PopoverPanel(props) {
|
||||
return <div {...props} />;
|
||||
}
|
||||
Popover.Button = PopoverButton;
|
||||
Popover.Panel = PopoverPanel;
|
||||
|
||||
return { Popover, Transition: passthrough };
|
||||
});
|
||||
|
||||
import ColorToggle from "./color";
|
||||
|
||||
describe("components/toggles/color", () => {
|
||||
it("renders nothing when no active color is set", () => {
|
||||
const { container } = render(
|
||||
<ColorContext.Provider value={{ color: null, setColor: vi.fn() }}>
|
||||
<ColorToggle />
|
||||
</ColorContext.Provider>,
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("invokes setColor when a color button is clicked", () => {
|
||||
const setColor = vi.fn();
|
||||
render(
|
||||
<ColorContext.Provider value={{ color: "slate", setColor }}>
|
||||
<ColorToggle />
|
||||
</ColorContext.Provider>,
|
||||
);
|
||||
|
||||
// Buttons contain a sr-only span with the color name.
|
||||
const blue = screen.getByText("blue").closest("button");
|
||||
fireEvent.click(blue);
|
||||
expect(setColor).toHaveBeenCalledWith("blue");
|
||||
});
|
||||
});
|
||||
27
src/components/toggles/revalidate.test.jsx
Normal file
27
src/components/toggles/revalidate.test.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import Revalidate from "./revalidate";
|
||||
|
||||
describe("components/toggles/revalidate", () => {
|
||||
it("calls /api/revalidate and reloads when ok", async () => {
|
||||
const reload = vi.fn();
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: true });
|
||||
vi.stubGlobal("location", { reload });
|
||||
|
||||
render(<Revalidate />);
|
||||
const icon = document.querySelector("svg");
|
||||
fireEvent.click(icon);
|
||||
|
||||
// allow promise chain to flush
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith("/api/revalidate");
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
fetchSpy.mockRestore();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
33
src/components/toggles/theme.test.jsx
Normal file
33
src/components/toggles/theme.test.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ThemeContext } from "utils/contexts/theme";
|
||||
|
||||
import ThemeToggle from "./theme";
|
||||
|
||||
describe("components/toggles/theme", () => {
|
||||
it("renders nothing when theme is missing", () => {
|
||||
const { container } = render(
|
||||
<ThemeContext.Provider value={{ theme: null, setTheme: vi.fn() }}>
|
||||
<ThemeToggle />
|
||||
</ThemeContext.Provider>,
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("toggles from dark to light when clicked", () => {
|
||||
const setTheme = vi.fn();
|
||||
render(
|
||||
<ThemeContext.Provider value={{ theme: "dark", setTheme }}>
|
||||
<ThemeToggle />
|
||||
</ThemeContext.Provider>,
|
||||
);
|
||||
|
||||
// The toggle is a clickable icon rendered as an svg (react-icons).
|
||||
const toggles = document.querySelectorAll("svg");
|
||||
fireEvent.click(toggles[1]);
|
||||
expect(setTheme).toHaveBeenCalledWith("light");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user