Publishing needs a topic and nothing could create one. The backend now owns that surface; this is its consumer — the management contract vendored, a gateway over its nine operations, and one Studio screen that lists, creates, and deletes topics and projects. The screen adds no CSS. It reuses the classes the working-copy list already uses, so it inherits Studio's spacing, type, and colour rather than growing a second visual vocabulary beside them. Scope stops at list/create/delete: renaming, phase changes, and visibility are implemented in the backend and declared in the contract, but their screens are a separate design. Two real defects surfaced while making the public port async, and both would have shipped: The search page and the header search dialog shared a query key. With an empty query, `["tech-log","search",""]` was identical for both, so react-query handed one surface the other's cache — different shapes — and the page died reading a field that was not there. Keys now name the surface. The explore filter's selects are uncontrolled and read `defaultValue`, which React applies once. Their options arrive later now, so the first render had nothing to match and the value stayed empty: a topic in the URL no longer showed as selected. The form key includes whether the catalog has arrived, so it remounts with the options present. Controlled inputs would be the other answer, but this form submits to build a URL — the URL owns the value. The route brought its own bookkeeping: a build chunk, a manual accessibility evidence file, and the CI artifact baseline that counts them. The gate pins a digest of its own shape precisely so a new route cannot slip in without that count being reviewed. Test harnesses that render public screens now assemble the query providers and await the settled paint, because the screens they render became async.
281 lines
9.9 KiB
TypeScript
281 lines
9.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { 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 { MOCK_STUDIO_INSTALL_CONTEXT } from "../../helpers/studio-install-context.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";
|
|
import { renderWithQueryProviders } from "../../helpers/query-providers.tsx";
|
|
|
|
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,
|
|
});
|
|
});
|
|
|
|
async function renderShell(initialEntry = "/projects") {
|
|
const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
|
|
const router = createMemoryRouter(
|
|
[
|
|
{
|
|
path: "*",
|
|
element: (
|
|
<PublicShell>
|
|
<main id="main-content" className="shell">
|
|
공개 본문
|
|
</main>
|
|
</PublicShell>
|
|
),
|
|
},
|
|
],
|
|
{ initialEntries: [initialEntry] },
|
|
);
|
|
const view = render(
|
|
renderWithQueryProviders(
|
|
<ApplicationProvider
|
|
application={createTestApplication({
|
|
featureInputs: { "tech-log": techLog },
|
|
})}
|
|
>
|
|
<RouterProvider router={router} />
|
|
</ApplicationProvider>,
|
|
));
|
|
// 포트가 async 가 되면서 첫 페인트에는 데이터가 없다. 화면이 정착한 뒤
|
|
// 단언하도록 여기서 한 번 기다린다 — 각 테스트에 흩어 놓으면 빠뜨린 곳이 생긴다.
|
|
await screen.findByRole("main");
|
|
return { ...view, router };
|
|
}
|
|
|
|
describe("TechLog Public shell", () => {
|
|
it("preserves source landmark order, navigation copy, current state, and footer", async () => {
|
|
const view = await 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();
|
|
const expectedNavigation = [
|
|
["탐색", "/explore"],
|
|
["프로젝트", "/projects"],
|
|
["변경 기록", "/releases"],
|
|
["프로필", "/profile"],
|
|
];
|
|
expect(
|
|
Array.from(
|
|
view.container.querySelectorAll<HTMLAnchorElement>(".desktop-nav a"),
|
|
(link) => [link.textContent, link.getAttribute("href")],
|
|
),
|
|
).toEqual(expectedNavigation);
|
|
expect(
|
|
Array.from(
|
|
view.container.querySelectorAll<HTMLAnchorElement>(
|
|
".mobile-nav nav a",
|
|
),
|
|
(link) => [link.textContent, link.getAttribute("href")],
|
|
),
|
|
).toEqual(expectedNavigation);
|
|
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 } = await 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();
|
|
await 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();
|
|
await 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 } = await 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("navigates the full-search action with its canonical query intact", async () => {
|
|
const user = userEvent.setup();
|
|
const { router } = await renderShell();
|
|
await user.click(
|
|
screen.getByRole("button", { name: "TechLog 검색 열기" }),
|
|
);
|
|
await user.type(
|
|
screen.getByRole("searchbox", { name: "검색어" }),
|
|
"Keycloak",
|
|
);
|
|
|
|
await user.click(
|
|
screen.getByRole("link", { name: /전체 검색 결과 보기/ }),
|
|
);
|
|
|
|
expect(router.state.location.pathname).toBe("/search");
|
|
expect(router.state.location.search).toBe("?q=Keycloak");
|
|
});
|
|
|
|
it("uses native mobile disclosure activation and closes it after selection", async () => {
|
|
const user = userEvent.setup();
|
|
const view = await renderShell();
|
|
const details = view.container.querySelector<HTMLDetailsElement>(
|
|
"details.mobile-nav",
|
|
);
|
|
const summary = details?.querySelector<HTMLElement>("summary");
|
|
expect(details).not.toBeNull();
|
|
expect(summary).not.toBeNull();
|
|
|
|
await user.click(summary!);
|
|
expect(details).toHaveAttribute("open");
|
|
|
|
await user.click(
|
|
within(details!).getByRole("link", { name: "탐색" }),
|
|
);
|
|
expect(details).not.toHaveAttribute("open");
|
|
|
|
await user.click(summary!);
|
|
expect(details).toHaveAttribute("open");
|
|
summary!.focus();
|
|
await user.keyboard("{Escape}");
|
|
expect(details).not.toHaveAttribute("open");
|
|
expect(summary).toHaveFocus();
|
|
});
|
|
|
|
it("closes a natively opened mobile disclosure before opening one search dialog", async () => {
|
|
const user = userEvent.setup();
|
|
const view = await renderShell();
|
|
const details = view.container.querySelector<HTMLDetailsElement>(
|
|
"details.mobile-nav",
|
|
);
|
|
const summary = details?.querySelector<HTMLElement>("summary");
|
|
expect(details).not.toBeNull();
|
|
expect(summary).not.toBeNull();
|
|
|
|
await user.click(summary!);
|
|
expect(details).toHaveAttribute("open");
|
|
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();
|
|
});
|
|
});
|