feat: port TechLog shells and styles
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMemoryRouter, RouterProvider } from "react-router-dom";
|
||||
|
||||
import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
||||
import { FatalErrorState } from "../../../src/features/tech-log/presentation/public/components/fatal-error-state.tsx";
|
||||
import { PublicShell } from "../../../src/features/tech-log/presentation/public/public-shell.tsx";
|
||||
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
|
||||
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
||||
|
||||
const originalShowModal = HTMLDialogElement.prototype.showModal;
|
||||
const originalClose = HTMLDialogElement.prototype.close;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
|
||||
configurable: true,
|
||||
value(this: HTMLDialogElement) {
|
||||
this.setAttribute("open", "");
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLDialogElement.prototype, "close", {
|
||||
configurable: true,
|
||||
value(this: HTMLDialogElement) {
|
||||
this.removeAttribute("open");
|
||||
this.dispatchEvent(new Event("close"));
|
||||
},
|
||||
});
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
|
||||
configurable: true,
|
||||
value: originalShowModal,
|
||||
});
|
||||
Object.defineProperty(HTMLDialogElement.prototype, "close", {
|
||||
configurable: true,
|
||||
value: originalClose,
|
||||
});
|
||||
});
|
||||
|
||||
function renderShell(initialEntry = "/projects") {
|
||||
const techLog = createTechLogFeatureInstalledInput().input;
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: "*",
|
||||
element: (
|
||||
<PublicShell>
|
||||
<main id="main-content" className="shell">
|
||||
공개 본문
|
||||
</main>
|
||||
</PublicShell>
|
||||
),
|
||||
},
|
||||
],
|
||||
{ initialEntries: [initialEntry] },
|
||||
);
|
||||
const view = render(
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
featureInputs: { "tech-log": techLog },
|
||||
})}
|
||||
>
|
||||
<RouterProvider router={router} />
|
||||
</ApplicationProvider>,
|
||||
);
|
||||
return { ...view, router };
|
||||
}
|
||||
|
||||
describe("TechLog Public shell", () => {
|
||||
it("preserves source landmark order, navigation copy, current state, and footer", () => {
|
||||
const view = renderShell();
|
||||
const frame = view.container.querySelector(".site-frame");
|
||||
|
||||
expect(
|
||||
Array.from(frame?.children ?? [], (child) => child.tagName),
|
||||
).toEqual(["A", "HEADER", "MAIN", "FOOTER"]);
|
||||
expect(screen.getByRole("link", { name: "본문으로 건너뛰기" })).toHaveAttribute(
|
||||
"href",
|
||||
"#main-content",
|
||||
);
|
||||
expect(screen.getByRole("banner")).toHaveClass("site-header");
|
||||
expect(screen.getByRole("main")).toHaveAttribute("id", "main-content");
|
||||
expect(screen.getByRole("contentinfo")).toHaveClass("site-footer");
|
||||
expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("navigation", { name: "모바일 주요 탐색" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
Array.from(
|
||||
view.container.querySelectorAll<HTMLAnchorElement>(".desktop-nav a"),
|
||||
(link) => link.textContent,
|
||||
),
|
||||
).toEqual(["탐색", "프로젝트", "변경 기록", "프로필"]);
|
||||
expect(
|
||||
Array.from(
|
||||
view.container.querySelectorAll<HTMLAnchorElement>(
|
||||
".mobile-nav nav a",
|
||||
),
|
||||
(link) => link.textContent,
|
||||
),
|
||||
).toEqual(["탐색", "프로젝트", "변경 기록", "프로필"]);
|
||||
for (const link of screen.getAllByRole("link", { name: "프로젝트" })) {
|
||||
expect(link).toHaveAttribute("aria-current", "page");
|
||||
}
|
||||
expect(screen.getByText("동현")).toHaveClass("footer-name");
|
||||
expect(
|
||||
screen.getByText("문제를 재현하고 검증해 운영 가능한 설계로 연결합니다."),
|
||||
).toBeVisible();
|
||||
expect(view.container.querySelector(".app-shell")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the canonical root route for brand navigation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { router } = renderShell("/projects/backend-skeleton");
|
||||
|
||||
await user.click(screen.getByRole("link", { name: "TechLog 홈" }));
|
||||
|
||||
expect(router.state.location.pathname).toBe("/");
|
||||
});
|
||||
|
||||
it("opens search by click, closes on Escape, and restores trigger focus", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderShell();
|
||||
const trigger = screen.getByRole("button", { name: "TechLog 검색 열기" });
|
||||
|
||||
await user.click(trigger);
|
||||
|
||||
const dialog = screen.getByRole("dialog", { name: "TechLog 검색" });
|
||||
const input = within(dialog).getByRole("searchbox", { name: "검색어" });
|
||||
expect(dialog).toHaveAttribute("open");
|
||||
expect(dialog).toHaveAttribute("aria-labelledby", `${dialog.id}-title`);
|
||||
expect(input).toHaveFocus();
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
expect(dialog).not.toHaveAttribute("open");
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
|
||||
it("opens the native search trigger from the keyboard", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderShell();
|
||||
const trigger = screen.getByRole("button", { name: "TechLog 검색 열기" });
|
||||
trigger.focus();
|
||||
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "TechLog 검색" })).toHaveAttribute(
|
||||
"open",
|
||||
);
|
||||
expect(screen.getByRole("searchbox", { name: "검색어" })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("returns source-equivalent results and navigates to their canonical route", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { router } = renderShell();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "TechLog 검색 열기" }),
|
||||
);
|
||||
const input = screen.getByRole("searchbox", { name: "검색어" });
|
||||
|
||||
await user.type(input, "Keycloak");
|
||||
|
||||
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
|
||||
const result = screen.getByRole("link", { name: /Auth Lab/ });
|
||||
expect(result).toHaveAttribute("href", "/projects/auth-lab");
|
||||
expect(
|
||||
screen.getByRole("link", { name: /전체 검색 결과 보기/ }),
|
||||
).toHaveAttribute("href", "/search?q=Keycloak");
|
||||
|
||||
await user.click(result);
|
||||
|
||||
expect(router.state.location.pathname).toBe("/projects/auth-lab");
|
||||
});
|
||||
|
||||
it("closes mobile navigation on Escape and before opening the one search dialog", async () => {
|
||||
const user = userEvent.setup();
|
||||
const view = renderShell();
|
||||
const details = view.container.querySelector<HTMLDetailsElement>(
|
||||
"details.mobile-nav",
|
||||
);
|
||||
const summary = details?.querySelector<HTMLElement>("summary");
|
||||
expect(details).not.toBeNull();
|
||||
expect(summary).not.toBeNull();
|
||||
|
||||
details!.open = true;
|
||||
fireEvent.keyDown(details!, { key: "Escape" });
|
||||
expect(details).not.toHaveAttribute("open");
|
||||
expect(summary).toHaveFocus();
|
||||
|
||||
details!.open = true;
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "TechLog 검색 열기" }),
|
||||
);
|
||||
expect(details).not.toHaveAttribute("open");
|
||||
expect(screen.getAllByRole("dialog", { hidden: true })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TechLog fatal Public state", () => {
|
||||
it("preserves error copy, trace relationship, and retry variants", async () => {
|
||||
const user = userEvent.setup();
|
||||
const retry = vi.fn();
|
||||
const view = render(
|
||||
<FatalErrorState traceId="PUBLIC-500" onRetry={retry} />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("main")).toHaveClass("shell", "fatal-state");
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "페이지를 불러오지 못했습니다." }),
|
||||
).toBeVisible();
|
||||
expect(view.container.querySelector(".trace-id code")).toHaveTextContent(
|
||||
"PUBLIC-500",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user