feat: port TechLog shells and styles

This commit is contained in:
DongHyeonka
2026-08-15 21:20:47 +09:00
parent 01316763e3
commit 627df884dd
14 changed files with 4124 additions and 8 deletions
@@ -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();
});
});
@@ -0,0 +1,142 @@
// @vitest-environment jsdom
import { render } from "@testing-library/react";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { createElement } from "react";
import { MemoryRouter } from "react-router-dom";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
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";
let styleElement: HTMLStyleElement;
beforeAll(() => {
styleElement = document.createElement("style");
styleElement.textContent = readFileSync(
resolve(
process.cwd(),
"src/features/tech-log/presentation/styles/globals.css",
),
"utf8",
);
document.head.append(styleElement);
});
afterAll(() => {
styleElement.remove();
});
function renderPublicSurface(node: React.ReactNode) {
const techLog = createTechLogFeatureInstalledInput().input;
const shell = createElement(PublicShell, { children: node });
const router = createElement(
MemoryRouter,
{ initialEntries: ["/projects"] },
shell,
);
return render(
createElement(ApplicationProvider, {
application: createTestApplication({
featureInputs: { "tech-log": techLog },
}),
children: router,
}),
);
}
describe("TechLog consumer-visible style contract", () => {
it("applies the source typography, palette, shell width, and header spacing", () => {
const view = renderPublicSurface(
createElement(
"main",
{ id: "main-content", className: "shell" },
"공개 본문",
),
);
const rootStyle = getComputedStyle(document.documentElement);
const bodyStyle = getComputedStyle(document.body);
const headerInner = view.container.querySelector<HTMLElement>(
".header-inner",
);
const wordmark = view.getByRole("link", { name: "TechLog 홈" });
expect(rootStyle.getPropertyValue("--canvas")).toBe("#f7f7f5");
expect(rootStyle.getPropertyValue("--ink")).toBe("#17181b");
expect(rootStyle.getPropertyValue("--signal")).toBe("#3e5cc7");
expect(rootStyle.getPropertyValue("--shell")).toBe("1180px");
expect(rootStyle.getPropertyValue("--body-copy")).toBe("42rem");
expect(bodyStyle.fontFamily).toContain("Pretendard Variable");
expect(bodyStyle.fontSize).toBe("16px");
expect(bodyStyle.fontWeight).toBe("400");
expect(bodyStyle.lineHeight).toBe("1.6");
expect(bodyStyle.minWidth).toBe("320px");
expect(headerInner).not.toBeNull();
expect(getComputedStyle(headerInner!).display).toBe("grid");
expect(getComputedStyle(headerInner!).minHeight).toBe("76px");
expect(getComputedStyle(headerInner!).gap).toBe("48px");
expect(getComputedStyle(wordmark).fontSize).toBe("17px");
expect(getComputedStyle(wordmark).fontWeight).toBe("680");
});
it("keeps every header and dialog control at the source minimum target size", () => {
const view = renderPublicSurface(
createElement(
"main",
{ id: "main-content", className: "shell" },
"공개 본문",
),
);
const wordmark = view.getByRole("link", { name: "TechLog 홈" });
const navLink = view.container.querySelector<HTMLElement>(".nav-link");
const searchButton = view.getByRole("button", {
name: "TechLog 검색 열기",
});
const closeButton = view.container.querySelector<HTMLElement>(
".dialog-close",
);
const searchField = view.container.querySelector<HTMLElement>(
".search-field",
);
const dialogInner = view.container.querySelector<HTMLElement>(
".search-dialog-inner",
);
expect(getComputedStyle(wordmark).minHeight).toBe("44px");
expect(getComputedStyle(navLink!).minHeight).toBe("44px");
expect(getComputedStyle(searchButton).minHeight).toBe("44px");
expect(getComputedStyle(closeButton!).minWidth).toBe("52px");
expect(getComputedStyle(closeButton!).minHeight).toBe("44px");
expect(getComputedStyle(searchField!).minHeight).toBe("56px");
expect(getComputedStyle(searchField!).marginTop).toBe("24px");
expect(getComputedStyle(searchField!).backgroundColor).toBe(
"rgb(255, 255, 255)",
);
expect(getComputedStyle(dialogInner!).padding).toBe("27px 28px 22px");
});
it("preserves footer spacing and fatal-action sizing", () => {
const view = renderPublicSurface(
createElement(FatalErrorState, {
traceId: "PUBLIC-500",
retryHref: "/",
}),
);
const footerInner = view.container.querySelector<HTMLElement>(
".footer-inner",
);
const retry = view.getByRole("link", { name: "다시 시도" });
expect(getComputedStyle(footerInner!).minHeight).toBe("172px");
expect(getComputedStyle(footerInner!).gap).toBe("40px");
expect(getComputedStyle(retry).minHeight).toBe("44px");
expect(getComputedStyle(retry).marginTop).toBe("34px");
});
});