Files
tech-log-frontend/tests/features/tech-log/style-contract.test.ts
T
DongHyeonka 11c2713139 feat: let Studio create the topics and projects publishing requires
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.
2026-08-20 23:40:15 +09:00

246 lines
8.9 KiB
TypeScript

// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
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 { 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";
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();
});
async function renderPublicSurface(node: React.ReactNode) {
const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
const shell = createElement(PublicShell, { children: node });
const router = createElement(
MemoryRouter,
{ initialEntries: ["/projects"] },
shell,
);
const view = render(
renderWithQueryProviders(
createElement(ApplicationProvider, {
application: createTestApplication({
featureInputs: { "tech-log": techLog },
}),
children: router,
}),
),
);
// 포트가 async 가 되면서 첫 페인트에는 데이터가 없다. 화면이 정착한 뒤 단언한다.
await screen.findByRole("main");
return view;
}
describe("TechLog consumer-visible style contract", () => {
it("applies the source typography, palette, shell width, and header spacing", async () => {
const view = await 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 main = view.getByRole("main");
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(rootStyle.color).toBe("var(--ink)");
expect(rootStyle.background).toBe("var(--canvas)");
expect(bodyStyle.color).toBe("var(--ink)");
expect(bodyStyle.background).toContain(
"linear-gradient(to bottom, rgba(255, 255, 255, 0.45), transparent 420px)",
);
expect(bodyStyle.background).toContain("var(--canvas)");
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(main).width).toBe(
"min(var(--shell), calc(100% - 80px))",
);
expect(getComputedStyle(wordmark).fontSize).toBe("17px");
expect(getComputedStyle(wordmark).fontWeight).toBe("680");
});
it("aligns code, table, and evidence widths with the article body", async () => {
const view = await renderPublicSurface(
createElement(
"main",
{ id: "main-content", className: "shell" },
createElement(
"article",
{ className: "document-body" },
createElement(
"section",
null,
createElement("p", null, "본문"),
createElement(
"figure",
{ className: "code-block" },
createElement("pre", null, createElement("code", null, "code")),
),
createElement(
"div",
{ className: "data-table-wrap" },
createElement("table", null, createElement("tbody")),
),
createElement(
"figure",
{ className: "evidence-figure" },
createElement("img", { alt: "근거 자료" }),
),
),
),
),
);
const article = view.container.querySelector<HTMLElement>(".document-body");
const code = view.container.querySelector<HTMLElement>(".code-block");
const codeScroller = view.container.querySelector<HTMLElement>(
".code-block pre",
);
const table = view.container.querySelector<HTMLElement>(".data-table-wrap");
const evidence = view.container.querySelector<HTMLElement>(
".evidence-figure",
);
expect(getComputedStyle(article!).width).toBe("min(100%, var(--body-copy))");
for (const material of [code, table, evidence]) {
expect(getComputedStyle(material!).width).toBe(
"min(var(--body-copy), 100%)",
);
expect(getComputedStyle(material!).transform).toBe("none");
}
expect(getComputedStyle(codeScroller!).overflowX).toBe("auto");
expect(getComputedStyle(table!).overflowX).toBe("auto");
});
it("preserves keyboard focus and the real search-field focus style", async () => {
const user = userEvent.setup();
const view = await renderPublicSurface(
createElement(
"main",
{ id: "main-content", className: "shell" },
"공개 본문",
),
);
const wordmark = view.getByRole("link", { name: "TechLog 홈" });
const searchInput = view.container.querySelector<HTMLInputElement>(
".search-field input",
);
const searchField = view.container.querySelector<HTMLElement>(
".search-field",
);
await user.tab();
await user.tab();
expect(wordmark).toHaveFocus();
searchInput!.focus();
expect(searchInput).toHaveFocus();
expect(searchField).not.toBeNull();
expect(searchField!.matches(":focus-within")).toBe(true);
expect(getComputedStyle(searchField!).boxShadow).toBe(
"0 0 0 3px var(--signal-soft)",
);
});
it("keeps every header and dialog control at the source minimum target size", async () => {
const view = await 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", async () => {
const view = await 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");
});
});