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
397 lines
17 KiB
TypeScript
397 lines
17 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
import { type ComponentType } from "react";
|
|
import {
|
|
createMemoryRouter,
|
|
Outlet,
|
|
RouterProvider,
|
|
} from "react-router-dom";
|
|
import { 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 {
|
|
TECH_LOG_ROUTE_REGISTRY,
|
|
TECH_LOG_ROUTE_RUNTIME_CONTRACT,
|
|
} from "../../../src/features/tech-log/contracts/tech-log-route-contract.ts";
|
|
import { CasePage } from "../../../src/features/tech-log/presentation/public/pages/case-page.tsx";
|
|
import { ProfilePage } from "../../../src/features/tech-log/presentation/public/pages/profile-page.tsx";
|
|
import { ProjectActivityPage } from "../../../src/features/tech-log/presentation/public/pages/project-activity-page.tsx";
|
|
import { ProjectDecisionsPage } from "../../../src/features/tech-log/presentation/public/pages/project-decisions-page.tsx";
|
|
import { ProjectOverviewPage } from "../../../src/features/tech-log/presentation/public/pages/project-overview-page.tsx";
|
|
import { ProjectRecordsPage } from "../../../src/features/tech-log/presentation/public/pages/project-records-page.tsx";
|
|
import { ProjectsPage } from "../../../src/features/tech-log/presentation/public/pages/projects-page.tsx";
|
|
import { PublicNotFoundPage } from "../../../src/features/tech-log/presentation/public/pages/public-not-found-page.tsx";
|
|
import { ReleasePage } from "../../../src/features/tech-log/presentation/public/pages/release-page.tsx";
|
|
import { ReleasesPage } from "../../../src/features/tech-log/presentation/public/pages/releases-page.tsx";
|
|
import { PublicShell } from "../../../src/features/tech-log/presentation/public/public-shell.tsx";
|
|
import { TECH_LOG_ROUTE_CODECS } from "../../../src/features/tech-log/presentation/tech-log-route-codecs.ts";
|
|
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
|
|
import { createGroupedRouteObjects } from "../../../src/presentation/routes/app-router.tsx";
|
|
import { PLATFORM_ROUTE_CODECS } from "../../../src/presentation/routes/platform-route-codecs.ts";
|
|
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
|
import { renderWithQueryProviders } from "../../helpers/query-providers.tsx";
|
|
|
|
const routeCodecs = Object.freeze({
|
|
...PLATFORM_ROUTE_CODECS,
|
|
...TECH_LOG_ROUTE_CODECS,
|
|
});
|
|
|
|
const routeComponents = {
|
|
TECH_LOG_PROJECTS: ProjectsPage,
|
|
TECH_LOG_PROJECT: ProjectOverviewPage,
|
|
TECH_LOG_PROJECT_RECORDS: ProjectRecordsPage,
|
|
TECH_LOG_PROJECT_DECISIONS: ProjectDecisionsPage,
|
|
TECH_LOG_PROJECT_ACTIVITY: ProjectActivityPage,
|
|
TECH_LOG_RELEASES: ReleasesPage,
|
|
TECH_LOG_RELEASE: ReleasePage,
|
|
TECH_LOG_PROFILE: ProfilePage,
|
|
TECH_LOG_CASE: CasePage,
|
|
NOT_FOUND: PublicNotFoundPage,
|
|
} as const satisfies Record<string, ComponentType>;
|
|
|
|
type PublicIndexRouteId = keyof typeof routeComponents;
|
|
|
|
async function renderPublicRoute(routeId: PublicIndexRouteId, initialEntry: string) {
|
|
const routeIds = routeId === "NOT_FOUND" ? [routeId] : [routeId, "NOT_FOUND" as const];
|
|
const registry = Object.fromEntries(
|
|
routeIds.map((id) => [id, TECH_LOG_ROUTE_REGISTRY[id]]),
|
|
);
|
|
const runtime = Object.fromEntries(
|
|
routeIds.map((id) => [
|
|
id,
|
|
{
|
|
moduleId: TECH_LOG_ROUTE_RUNTIME_CONTRACT[id].moduleId,
|
|
Component: routeComponents[id],
|
|
},
|
|
]),
|
|
);
|
|
const router = createMemoryRouter(
|
|
createGroupedRouteObjects(
|
|
registry,
|
|
runtime,
|
|
{
|
|
PUBLIC: (
|
|
<PublicShell>
|
|
<Outlet />
|
|
</PublicShell>
|
|
),
|
|
STUDIO: <Outlet />,
|
|
},
|
|
"task-9-test-build",
|
|
routeCodecs,
|
|
),
|
|
{ initialEntries: [initialEntry] },
|
|
);
|
|
const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
|
|
const view = render(
|
|
renderWithQueryProviders(
|
|
<ApplicationProvider
|
|
application={createTestApplication({
|
|
featureInputs: { "tech-log": techLog },
|
|
})}
|
|
>
|
|
<RouterProvider router={router} />
|
|
</ApplicationProvider>,
|
|
));
|
|
// 포트가 async 가 되면서 첫 페인트에는 데이터가 없다. 화면이 정착한 뒤
|
|
// 단언하도록 여기서 한 번 기다린다 — 각 테스트에 흩어 놓으면 빠뜨린 곳이 생긴다.
|
|
//
|
|
// `main` 이 있다는 것만으로는 더 이상 정착이 아니다. index 화면들은 고정 카피인
|
|
// 헤더를 네트워크와 무관하게 즉시 그리므로 (그게 목적이다), `main` 은 데이터가
|
|
// 오기 전에 존재한다. 기다려야 하는 것은 대기 중이던 구역이 대기를 멈추는 것이다.
|
|
await screen.findByRole("main");
|
|
await waitFor(() => {
|
|
expect(document.querySelector('[aria-busy="true"]')).toBeNull();
|
|
});
|
|
return { ...view, router };
|
|
}
|
|
|
|
function projectNavigation() {
|
|
return screen.getByRole("navigation", { name: "프로젝트 탐색" });
|
|
}
|
|
|
|
describe("TechLog project screens", () => {
|
|
it("renders the exact ordered project index and project links", async () => {
|
|
const { container } = await renderPublicRoute("TECH_LOG_PROJECTS", "/projects");
|
|
const main = screen.getByRole("main");
|
|
|
|
expect(within(main).getByRole("heading", { level: 1, name: "프로젝트" })).toBeVisible();
|
|
expect(
|
|
within(main).getByText(
|
|
"Case와 Reference, Question을 실제 설계 목표와 결정의 흐름으로 묶어 봅니다.",
|
|
),
|
|
).toBeVisible();
|
|
expect(
|
|
Array.from(container.querySelectorAll(".project-index-list > li > a"), (link) => ({
|
|
href: link.getAttribute("href"),
|
|
text: link.textContent,
|
|
})),
|
|
).toEqual([
|
|
{
|
|
href: "/projects/backend-skeleton",
|
|
text: "01Backend SkeletonDESIGN저장소·Redis·JPA·MongoDB 같은 기술을 붙일 때 애플리케이션 경계를 다시 만들지 않도록 공통 계약을 정리하는 프로젝트입니다.현재 목표Filesystem과 Object Storage를 하나의 StoragePort로 통합다음 작업MinIO Adapter와 공통 계약 테스트 연결↗",
|
|
},
|
|
{
|
|
href: "/projects/auth-lab",
|
|
text: "02Auth LabVALIDATION브라우저·Edge·Spring 사이에서 Token과 Session의 책임을 나누고 인증 경계를 검증하는 프로젝트입니다.현재 목표oauth2-proxy 뒤의 Spring 신뢰 경계 결정다음 작업JWT 재검증안과 신뢰 헤더안의 위협 모델 비교↗",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
path: "/projects/backend-skeleton",
|
|
title: "Backend Skeleton",
|
|
current: "개요",
|
|
thesis: "기술별 기능을 많이 제공하는 것보다 교체 가능한 경계와 검증 가능한 계약을 먼저 고정합니다.",
|
|
stats: ["4공개 기록", "2설계 결정", "3활동 기록"],
|
|
topics: ["Backend Architecture", "JPA", "Redis", "Storage"],
|
|
},
|
|
{
|
|
path: "/projects/auth-lab",
|
|
title: "Auth Lab",
|
|
current: "개요",
|
|
thesis: "인증 방식을 이름으로 비교하지 않고 Code 교환, Token 보관, 요청 검증의 실제 주체를 기준으로 나눕니다.",
|
|
stats: ["2공개 기록", "1설계 결정", "2활동 기록"],
|
|
topics: ["Authentication", "OAuth 2.0", "OIDC", "Keycloak"],
|
|
},
|
|
])("renders $path overview with source counts and topics", async ({ path, title, current, thesis, stats, topics }) => {
|
|
const { container } = await renderPublicRoute("TECH_LOG_PROJECT", path);
|
|
const main = screen.getByRole("main");
|
|
|
|
expect(within(main).getByRole("heading", { level: 1, name: title })).toBeVisible();
|
|
expect(within(main).getByText(thesis)).toBeVisible();
|
|
expect(
|
|
within(projectNavigation()).getByRole("link", { name: current }),
|
|
).toHaveAttribute("aria-current", "page");
|
|
expect(
|
|
Array.from(container.querySelectorAll(".project-stat-links a"), (link) => link.textContent),
|
|
).toEqual(stats);
|
|
expect(
|
|
Array.from(container.querySelectorAll(".project-topics li"), (item) => item.textContent),
|
|
).toEqual(topics);
|
|
});
|
|
|
|
it.each([
|
|
["TECH_LOG_PROJECT" as const, "/projects/backend-skeleton", "개요"],
|
|
["TECH_LOG_PROJECT_RECORDS" as const, "/projects/backend-skeleton/records", "기록"],
|
|
["TECH_LOG_PROJECT_DECISIONS" as const, "/projects/backend-skeleton/decisions", "결정"],
|
|
["TECH_LOG_PROJECT_ACTIVITY" as const, "/projects/backend-skeleton/activity", "활동"],
|
|
])("derives the active project tab from %s location", async (routeId, path, activeLabel) => {
|
|
await renderPublicRoute(routeId, path);
|
|
|
|
expect(
|
|
within(projectNavigation())
|
|
.getAllByRole("link")
|
|
.map((link) => [link.textContent, link.getAttribute("aria-current")]),
|
|
).toEqual([
|
|
["개요", activeLabel === "개요" ? "page" : null],
|
|
["기록", activeLabel === "기록" ? "page" : null],
|
|
["결정", activeLabel === "결정" ? "page" : null],
|
|
["활동", activeLabel === "활동" ? "page" : null],
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
slug: "backend-skeleton",
|
|
expected: [
|
|
"/cases/collection-fetch-join-pagination",
|
|
"/references/jpa-list-fetch-strategy",
|
|
"/cases/redis-adapter-ttl-boundary",
|
|
"/questions/collection-fetch-join-with-pagination",
|
|
],
|
|
},
|
|
{
|
|
slug: "auth-lab",
|
|
expected: [
|
|
"/references/state-and-nonce-boundary",
|
|
"/questions/validate-edge-token-again",
|
|
],
|
|
},
|
|
])("keeps $slug records filtered and published in source order", async ({ slug, expected }) => {
|
|
const { container } = await renderPublicRoute(
|
|
"TECH_LOG_PROJECT_RECORDS",
|
|
`/projects/${slug}/records`,
|
|
);
|
|
|
|
expect(
|
|
Array.from(container.querySelectorAll(".public-record-list > li > a"), (link) =>
|
|
link.getAttribute("href"),
|
|
),
|
|
).toEqual(expected);
|
|
expect(screen.getByText(`${expected.length}개의 공개 기록`)).toBeVisible();
|
|
});
|
|
|
|
it("keeps project decisions ordered with stable IDs and evidence links", async () => {
|
|
const { container } = await renderPublicRoute(
|
|
"TECH_LOG_PROJECT_DECISIONS",
|
|
"/projects/backend-skeleton/decisions",
|
|
);
|
|
|
|
expect(
|
|
Array.from(container.querySelectorAll(".project-decision-list > li > article"), (item) => ({
|
|
id: item.id,
|
|
title: item.querySelector("h2")?.textContent,
|
|
})),
|
|
).toEqual([
|
|
{ id: "storage-port-unification", title: "파일 저장 계약을 하나로 통합합니다" },
|
|
{ id: "feed-pagination-boundary", title: "목록 페이징과 컬렉션 로딩을 분리합니다" },
|
|
]);
|
|
expect(
|
|
Array.from(container.querySelectorAll(".project-decision-list a"), (link) =>
|
|
link.getAttribute("href"),
|
|
),
|
|
).toEqual([
|
|
"/projects/backend-skeleton/activity#storage-contract",
|
|
"/cases/redis-adapter-ttl-boundary",
|
|
"/cases/collection-fetch-join-pagination",
|
|
"/references/jpa-list-fetch-strategy",
|
|
]);
|
|
});
|
|
|
|
/*
|
|
활동은 로그다 — 언제 무엇을 올렸는지만 적는다. 각 줄에 그 기록으로 가는 링크가 있으면 같은
|
|
글에 닿는 길이 둘이 되고, 읽는 사람은 "기록"과 "활동"이 어떻게 다른지 매번 다시 판단해야 한다.
|
|
글을 읽는 자리는 기록 화면 하나로 둔다.
|
|
*/
|
|
it("keeps project activity ordered and leaves reading to the records screen", async () => {
|
|
const { container } = await renderPublicRoute(
|
|
"TECH_LOG_PROJECT_ACTIVITY",
|
|
"/projects/backend-skeleton/activity",
|
|
);
|
|
|
|
const items = Array.from(
|
|
container.querySelectorAll(".project-activity-list > li > article"),
|
|
);
|
|
expect(items.map((item) => item.id)).toEqual([
|
|
"fetch-join-case-published",
|
|
"storage-contract",
|
|
"redis-case-published",
|
|
]);
|
|
expect(items.flatMap((item) => Array.from(item.querySelectorAll("a")))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("TechLog release and profile screens", () => {
|
|
it("renders the ordered release index and complete version link", async () => {
|
|
const { container } = await renderPublicRoute("TECH_LOG_RELEASES", "/releases");
|
|
|
|
expect(screen.getByRole("heading", { level: 1, name: "변경 기록" })).toBeVisible();
|
|
expect(
|
|
screen.getByText(
|
|
"릴리즈 버전별로 무엇을 바꿨고 왜 바꿨는지, 공개 화면에 어떤 영향이 생겼는지 남깁니다.",
|
|
),
|
|
).toBeVisible();
|
|
expect(
|
|
Array.from(container.querySelectorAll(".release-index-list > li > a"), (link) => ({
|
|
href: link.getAttribute("href"),
|
|
version: link.querySelector(".release-version")?.textContent,
|
|
title: link.querySelector("h2")?.textContent,
|
|
})),
|
|
).toEqual([
|
|
{
|
|
href: "/releases/0.1.0",
|
|
version: "v0.1.0",
|
|
title: "TechLog Public·Studio 경계를 확정했습니다",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("renders exact release sections and ordered cross-links", async () => {
|
|
const { container } = await renderPublicRoute("TECH_LOG_RELEASE", "/releases/0.1.0");
|
|
const main = screen.getByRole("main");
|
|
|
|
expect(
|
|
screen.getByRole("heading", {
|
|
level: 1,
|
|
name: "TechLog Public·Studio 경계를 확정했습니다",
|
|
}),
|
|
).toBeVisible();
|
|
expect(
|
|
Array.from(container.querySelectorAll(".release-document > section h2"), (heading) =>
|
|
heading.textContent,
|
|
),
|
|
).toEqual(["무엇이 바뀌었나", "왜 바꿨나", "영향", "연결된 화면"]);
|
|
expect(within(main).getByText("불변 게시 Snapshot", { exact: false })).toBeVisible();
|
|
expect(
|
|
within(main).getByText(
|
|
"Studio는 API 계약을 따르는 세션 전용 프론트엔드 시뮬레이션으로 구성했습니다.",
|
|
),
|
|
).toBeVisible();
|
|
expect(
|
|
Array.from(container.querySelectorAll(".release-related-links a"), (link) =>
|
|
link.getAttribute("href"),
|
|
),
|
|
).toEqual(["/", "/explore", "/cases/collection-fetch-join-pagination"]);
|
|
});
|
|
|
|
it("renders the grounded profile, principles, project links, and topics", async () => {
|
|
const { container } = await renderPublicRoute("TECH_LOG_PROFILE", "/profile");
|
|
const main = screen.getByRole("main");
|
|
|
|
expect(within(main).getByRole("heading", { level: 1, name: "동현" })).toBeVisible();
|
|
expect(
|
|
within(main).getByText("문제를 재현하고 검증해 운영 가능한 설계로 연결합니다."),
|
|
).toBeVisible();
|
|
expect(
|
|
Array.from(container.querySelectorAll(".profile-principles h3"), (heading) =>
|
|
heading.textContent,
|
|
),
|
|
).toEqual([
|
|
"관찰한 사실과 판단을 나눕니다",
|
|
"결론보다 경계를 남깁니다",
|
|
"프로젝트 맥락으로 다시 연결합니다",
|
|
]);
|
|
expect(
|
|
Array.from(container.querySelectorAll(".profile-projects a"), (link) =>
|
|
link.getAttribute("href"),
|
|
),
|
|
).toEqual(["/projects/backend-skeleton", "/projects/auth-lab"]);
|
|
expect(
|
|
Array.from(container.querySelectorAll(".profile-topics li"), (item) => item.textContent),
|
|
// Derived from the catalogue, not a literal in the page. The old hard-coded
|
|
// list opened with "Backend Architecture", which no record in the fixture
|
|
// actually carries — the profile was advertising a topic that did not
|
|
// exist, and nothing could have caught it while the list lived in the JSX.
|
|
).toEqual(["JPA", "Authentication", "Redis"]);
|
|
expect(within(main).queryByText(/이메일|연락처|경력|소속|회사/)).not.toBeInTheDocument();
|
|
expect(container.querySelector('a[href^="mailto:"]')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("TechLog Public not-found runtime", () => {
|
|
it.each([
|
|
["TECH_LOG_PROJECT" as const, "/projects/missing-project"],
|
|
["TECH_LOG_PROJECT_RECORDS" as const, "/projects/missing-project/records"],
|
|
["TECH_LOG_RELEASE" as const, "/releases/9.9.9"],
|
|
["TECH_LOG_CASE" as const, "/cases/not-registered"],
|
|
])("keeps the client-only fallback accessible for %s", async (routeId, path) => {
|
|
const { container, router } = await renderPublicRoute(routeId, path);
|
|
|
|
expect(router.state.location.pathname).toBe(path);
|
|
expect(
|
|
await screen.findByRole("heading", { name: "페이지를 찾을 수 없습니다." }),
|
|
).toBeVisible();
|
|
expect(
|
|
screen.queryByRole("heading", { name: "화면을 표시하지 못했습니다." }),
|
|
).not.toBeInTheDocument();
|
|
expect(container.querySelector(".site-frame")).not.toBeNull();
|
|
});
|
|
|
|
it("keeps the client-only Public catch-all accessible", async () => {
|
|
const { container, router } = await renderPublicRoute(
|
|
"NOT_FOUND",
|
|
"/definitely-not-a-product-route",
|
|
);
|
|
|
|
expect(router.state.location.pathname).toBe("/definitely-not-a-product-route");
|
|
expect(
|
|
screen.getByRole("heading", { name: "페이지를 찾을 수 없습니다." }),
|
|
).toBeVisible();
|
|
expect(container.querySelector(".site-frame")).not.toBeNull();
|
|
});
|
|
});
|