Files
DongHyeonkaandClaude Opus 5 fd73bc88a1 fix: Decision 미리보기의 결정일 요구를 풀고, 화면 테스트가 실제 동작을 다시 말하게 한다
Decision 은 결정일이 없으면 미리보기가 열리지 않았다. 검증은 그것을 경고로만 다루므로
날짜 없이 게시할 수 있는데 렌더 모델이 필수로 요구했다 — 작성자는 "경고라면서 왜 안
되냐"를 만난다. 계약을 nullable 로 열고 화면이 "결정일 미정"이라고 말하게 한다.

한 칸의 실패가 화면을 통째로 날리지 않게 한다. `Promise.all([gateway.foo()])` 은 foo 가
거절하는 것만 잡는다 — 호출이 동기적으로 던지면 배열을 만드는 중에 터져 rejection
handler 를 지나지 못하고, 그러면 홈 focus 한 칸 때문에 대시보드 전체가 빈 화면이 된다.
프로젝트 편집도 같은 모양이라 함께 고친다.

`IntersectionObserver` 가 없는 환경을 견딘다. 목차는 픽스처 Case 하나에서만 쓰여 그런
환경을 만난 적이 없었는데, 모든 Case 가 목차를 받게 되면서 jsdom 에서 문서가 통째로
깨졌다. 없으면 "지금 읽는 절" 표시만 못 할 뿐이다.

픽스처의 최근 기록에서 릴리스를 뺀다. 서버의 `latestEntries` 는 공개 투영에서 고르므로
릴리스가 없고, 홈이 릴리스를 따로 읽어 합친다 — 픽스처가 넣으면 같은 릴리스가 두 번
나온다.

화면 테스트는 `test:unit` 이 아니라 `test:tech-log` 가 돌린다. 그것을 돌리지 않아 위
두 결함과, 라우트 두 개·`--body-copy`·Case 배치 통합·활동 링크 제거처럼 의도한 변경에
고정돼 있던 단언들이 23건 빨간 채로 여러 커밋을 지나갔다. 단언을 실제 동작으로 옮긴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 18:03:11 +09:00

248 lines
9.2 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");
// 42rem 은 영문 기준 측정값이었다. 한글 본문에서 좁았고, 바로 위의 유형/프로젝트 줄이
// shell 전체를 쓰고 있어 대비가 더 심했다. 읽는 단 전체가 이 값 하나를 따른다.
expect(rootStyle.getPropertyValue("--body-copy")).toBe("56rem");
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");
});
});