// @vitest-environment jsdom import { render, screen, 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"; 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; type PublicIndexRouteId = keyof typeof routeComponents; 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: ( ), STUDIO: , }, "task-9-test-build", routeCodecs, ), { initialEntries: [initialEntry] }, ); const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input; const view = render( , ); return { ...view, router }; } function projectNavigation() { return screen.getByRole("navigation", { name: "프로젝트 탐색" }); } describe("TechLog project screens", () => { it("renders the exact ordered project index and project links", () => { const { container } = 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", ({ path, title, current, thesis, stats, topics }) => { const { container } = 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", (routeId, path, activeLabel) => { 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", ({ slug, expected }) => { const { container } = 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", () => { const { container } = 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 preserves self-fragment and record links", () => { const { container } = renderPublicRoute( "TECH_LOG_PROJECT_ACTIVITY", "/projects/backend-skeleton/activity", ); expect( Array.from(container.querySelectorAll(".project-activity-list > li > article"), (item) => ({ id: item.id, href: item.querySelector("a")?.getAttribute("href"), label: item.querySelector("a")?.textContent, })), ).toEqual([ { id: "fetch-join-case-published", href: "/cases/collection-fetch-join-pagination", label: "연결된 공개 기록 읽기", }, { id: "storage-contract", href: "/projects/backend-skeleton/activity#storage-contract", label: "이 활동 위치 열기", }, { id: "redis-case-published", href: "/cases/redis-adapter-ttl-boundary", label: "연결된 공개 기록 읽기", }, ]); }); }); describe("TechLog release and profile screens", () => { it("renders the ordered release index and complete version link", () => { const { container } = 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", () => { const { container } = 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", () => { const { container } = 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), ).toEqual(["Backend Architecture", "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 } = 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", () => { const { container, router } = 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(); }); });