diff --git a/src/features/tech-log/presentation/public/components/project-navigation.tsx b/src/features/tech-log/presentation/public/components/project-navigation.tsx new file mode 100644 index 0000000..c453e13 --- /dev/null +++ b/src/features/tech-log/presentation/public/components/project-navigation.tsx @@ -0,0 +1,34 @@ +import { Link, useLocation } from "react-router-dom"; + +import type { Project } from "../../../application/ports/public-content-queries.ts"; + +export type ProjectSection = "overview" | "records" | "decisions" | "activity"; + +const sections = [ + { key: "overview", label: "개요", suffix: "" }, + { key: "records", label: "기록", suffix: "/records" }, + { key: "decisions", label: "결정", suffix: "/decisions" }, + { key: "activity", label: "활동", suffix: "/activity" }, +] as const; + +export function ProjectNavigation({ project }: { project: Project }) { + const { pathname } = useLocation(); + const basePath = `/projects/${project.slug}`; + const current = + sections.find((section) => pathname === `${basePath}${section.suffix}`)?.key ?? + "overview"; + + return ( + + ); +} diff --git a/src/features/tech-log/presentation/public/components/project-page-header.tsx b/src/features/tech-log/presentation/public/components/project-page-header.tsx new file mode 100644 index 0000000..5871368 --- /dev/null +++ b/src/features/tech-log/presentation/public/components/project-page-header.tsx @@ -0,0 +1,25 @@ +import { Link } from "react-router-dom"; + +import type { Project } from "../../../application/ports/public-content-queries.ts"; +import { ProjectNavigation } from "./project-navigation.tsx"; + +export function ProjectPageHeader({ + project, + title, +}: { + project: Project; + title: string; +}) { + return ( +
+

+ Projects + + {project.title} +

+

{title}

+

{project.summary}

+ +
+ ); +} diff --git a/src/features/tech-log/presentation/public/pages/profile-page.tsx b/src/features/tech-log/presentation/public/pages/profile-page.tsx new file mode 100644 index 0000000..9a345f4 --- /dev/null +++ b/src/features/tech-log/presentation/public/pages/profile-page.tsx @@ -0,0 +1,90 @@ +import { Link } from "react-router-dom"; + +import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; +import { publicSiteConfig } from "../../../contracts/public-site-config.ts"; +import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; + +const principles = [ + { + title: "관찰한 사실과 판단을 나눕니다", + description: + "측정값, 문서 근거, 아직 확인하지 못한 가정을 같은 문장에 섞지 않습니다.", + }, + { + title: "결론보다 경계를 남깁니다", + description: + "어떤 조건에서 선택했고 어디까지 적용할 수 있는지 함께 기록합니다.", + }, + { + title: "프로젝트 맥락으로 다시 연결합니다", + description: + "Case와 Reference, Question, Decision이 따로 흩어지지 않게 실제 작업과 연결합니다.", + }, +] as const; + +const currentProjectSlugs = ["backend-skeleton", "auth-lab"] as const; +const topics = ["Backend Architecture", "JPA", "Authentication", "Redis"] as const; + +export function ProfilePage() { + const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); + const currentProjects = currentProjectSlugs.flatMap((slug) => { + const project = publicContent.getProject(slug); + return project ? [project] : []; + }); + + return ( +
+
+

Profile

+

{publicSiteConfig.operator}

+

{publicSiteConfig.identityStatement}

+
+
+
+

Principles

+

기록을 운영하는 원칙

+
+
    + {principles.map((principle, index) => ( +
  1. + {String(index + 1).padStart(2, "0")} +
    +

    {principle.title}

    +

    {principle.description}

    +
    +
  2. + ))} +
+
+
+
+

Current

+

현재 프로젝트

+
+ +
+
+

Topics

+

주요 관심 주제

+ +
+
+ ); +} diff --git a/src/features/tech-log/presentation/public/pages/project-activity-page.tsx b/src/features/tech-log/presentation/public/pages/project-activity-page.tsx new file mode 100644 index 0000000..421964f --- /dev/null +++ b/src/features/tech-log/presentation/public/pages/project-activity-page.tsx @@ -0,0 +1,44 @@ +import { Link } from "react-router-dom"; + +import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; +import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; +import { + RegisteredNotFoundRoute, + useRouteInput, +} from "../../../../../presentation/routes/route-input.tsx"; +import { ProjectPageHeader } from "../components/project-page-header.tsx"; + +export function ProjectActivityPage() { + const { params } = useRouteInput<"TECH_LOG_PROJECT_ACTIVITY">(); + const slug = typeof params.slug === "string" ? params.slug : ""; + const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); + const project = publicContent.getProject(slug); + + if (!project) return ; + + const activity = publicContent.getProjectActivity(slug); + return ( +
+ +
    + {activity.map((item) => ( +
  1. +
    +
    + {item.type} + +
    +

    {item.title}

    +

    {item.summary}

    + + {item.recordPath + ? "연결된 공개 기록 읽기" + : "이 활동 위치 열기"} + +
    +
  2. + ))} +
+
+ ); +} diff --git a/src/features/tech-log/presentation/public/pages/project-decisions-page.tsx b/src/features/tech-log/presentation/public/pages/project-decisions-page.tsx new file mode 100644 index 0000000..29f3873 --- /dev/null +++ b/src/features/tech-log/presentation/public/pages/project-decisions-page.tsx @@ -0,0 +1,65 @@ +import { Link } from "react-router-dom"; + +import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; +import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; +import { + RegisteredNotFoundRoute, + useRouteInput, +} from "../../../../../presentation/routes/route-input.tsx"; +import { ProjectPageHeader } from "../components/project-page-header.tsx"; + +export function ProjectDecisionsPage() { + const { params } = useRouteInput<"TECH_LOG_PROJECT_DECISIONS">(); + const slug = typeof params.slug === "string" ? params.slug : ""; + const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); + const project = publicContent.getProject(slug); + + if (!project) return ; + + const decisions = publicContent.getProjectDecisions(slug); + return ( +
+ +
    + {decisions.map((decision) => ( +
  1. +
    +
    +
    + {decision.status} + +
    +

    {decision.title}

    +

    {decision.statement}

    +
    +
    +

    판단 이유

    +

    {decision.rationale}

    +
    +
    +

    영향

    +
      + {decision.consequences.map((item) => ( +
    • {item}
    • + ))} +
    +
    +
    +

    근거 기록

    +
      + {decision.evidence.map((item) => ( +
    • + {item.title} +
    • + ))} +
    +
    +
    +
  2. + ))} +
+
+ ); +} diff --git a/src/features/tech-log/presentation/public/pages/project-overview-page.tsx b/src/features/tech-log/presentation/public/pages/project-overview-page.tsx new file mode 100644 index 0000000..d8ff59b --- /dev/null +++ b/src/features/tech-log/presentation/public/pages/project-overview-page.tsx @@ -0,0 +1,77 @@ +import { Link } from "react-router-dom"; + +import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; +import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; +import { + RegisteredNotFoundRoute, + useRouteInput, +} from "../../../../../presentation/routes/route-input.tsx"; +import { ProjectPageHeader } from "../components/project-page-header.tsx"; + +export function ProjectOverviewPage() { + const { params } = useRouteInput<"TECH_LOG_PROJECT">(); + const slug = typeof params.slug === "string" ? params.slug : ""; + const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); + const project = publicContent.getProject(slug); + + if (!project) return ; + + const records = publicContent.getProjectRecords(slug); + const decisions = publicContent.getProjectDecisions(slug); + const activity = publicContent.getProjectActivity(slug); + return ( +
+ +
+

Thesis

+

프로젝트가 지키는 기준

+

{project.thesis}

+
+
+
+
단계
+
{project.stage}
+
+
+
현재 목표
+
{project.currentGoal}
+
+
+
다음 작업
+
{project.nextStep}
+
+
+
+
+

Scope

+

연결된 작업 맥락

+
+
+ + {records.length} + 공개 기록 + + + {decisions.length} + 설계 결정 + + + {activity.length} + 활동 기록 + +
+
+
+

주요 주제

+
    + {project.topics.map((topic) => ( +
  • {topic}
  • + ))} +
+
+
+ ); +} diff --git a/src/features/tech-log/presentation/public/pages/project-records-page.tsx b/src/features/tech-log/presentation/public/pages/project-records-page.tsx new file mode 100644 index 0000000..c3cec6e --- /dev/null +++ b/src/features/tech-log/presentation/public/pages/project-records-page.tsx @@ -0,0 +1,29 @@ +import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; +import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; +import { + RegisteredNotFoundRoute, + useRouteInput, +} from "../../../../../presentation/routes/route-input.tsx"; +import { ProjectPageHeader } from "../components/project-page-header.tsx"; +import { PublicRecordList } from "../components/public-record-list.tsx"; + +export function ProjectRecordsPage() { + const { params } = useRouteInput<"TECH_LOG_PROJECT_RECORDS">(); + const slug = typeof params.slug === "string" ? params.slug : ""; + const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); + const project = publicContent.getProject(slug); + + if (!project) return ; + + const records = publicContent.getProjectRecords(slug); + return ( +
+ +
+

공개 기록

+

{records.length}개의 공개 기록

+
+ +
+ ); +} diff --git a/src/features/tech-log/presentation/public/pages/projects-page.tsx b/src/features/tech-log/presentation/public/pages/projects-page.tsx new file mode 100644 index 0000000..8e5cd32 --- /dev/null +++ b/src/features/tech-log/presentation/public/pages/projects-page.tsx @@ -0,0 +1,58 @@ +import { Link } from "react-router-dom"; + +import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; +import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; + +export function ProjectsPage() { + const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); + const projects = publicContent + .searchPublicContent("") + .filter((item) => item.contentType === "PROJECT") + .flatMap((item) => { + const project = publicContent.getProject(item.path.replace("/projects/", "")); + return project ? [project] : []; + }); + + return ( +
+
+

Projects

+

프로젝트

+

+ Case와 Reference, Question을 실제 설계 목표와 결정의 흐름으로 묶어 + 봅니다. +

+
+
    + {projects.map((project, index) => ( +
  1. + + {String(index + 1).padStart(2, "0")} +
    +
    +

    {project.title}

    + {project.stage} +
    +

    {project.summary}

    +
    +
    +
    현재 목표
    +
    {project.currentGoal}
    +
    +
    +
    다음 작업
    +
    {project.nextStep}
    +
    +
    +
    + + +
  2. + ))} +
+
+ ); +} diff --git a/src/features/tech-log/presentation/public/pages/public-not-found-page.tsx b/src/features/tech-log/presentation/public/pages/public-not-found-page.tsx new file mode 100644 index 0000000..b0c1a4c --- /dev/null +++ b/src/features/tech-log/presentation/public/pages/public-not-found-page.tsx @@ -0,0 +1,53 @@ +import type { CSSProperties } from "react"; + +const styles = { + error: { + fontFamily: + 'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"', + height: "100vh", + textAlign: "center", + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + }, + desc: { + display: "inline-block", + }, + h1: { + display: "inline-block", + margin: "0 20px 0 0", + padding: "0 23px 0 0", + fontSize: 24, + fontWeight: 500, + verticalAlign: "top", + lineHeight: "49px", + }, + h2: { + fontSize: 14, + fontWeight: 400, + lineHeight: "49px", + margin: 0, + }, +} as const satisfies Record; + +export function PublicNotFoundPage() { + return ( + <> + 404: This page could not be found. +
+
+ +

+ 404 +

+
+

This page could not be found.

+
+
+
+ + ); +} diff --git a/src/features/tech-log/presentation/public/pages/release-page.tsx b/src/features/tech-log/presentation/public/pages/release-page.tsx new file mode 100644 index 0000000..bbfa602 --- /dev/null +++ b/src/features/tech-log/presentation/public/pages/release-page.tsx @@ -0,0 +1,83 @@ +import { Link } from "react-router-dom"; + +import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; +import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; +import { + RegisteredNotFoundRoute, + useRouteInput, +} from "../../../../../presentation/routes/route-input.tsx"; + +export function ReleasePage() { + const { params } = useRouteInput<"TECH_LOG_RELEASE">(); + const version = typeof params.version === "string" ? params.version : ""; + const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); + const release = publicContent.getRelease(version); + + if (!release) return ; + + return ( +
+
+ +

Release v{release.version}

+

{release.title}

+

{release.summary}

+ +
+
+
+

01

+
+

무엇이 바뀌었나

+
    + {release.changes.map((item) => ( +
  • {item}
  • + ))} +
+
+
+
+

02

+
+

왜 바꿨나

+
    + {release.reasons.map((item) => ( +
  • {item}
  • + ))} +
+
+
+
+

03

+
+

영향

+
    + {release.impacts.map((item) => ( +
  • {item}
  • + ))} +
+
+
+
+

04

+
+ +
    + {release.related.map((item) => ( +
  • + + {item.title} + + +
  • + ))} +
+
+
+
+
+ ); +} diff --git a/src/features/tech-log/presentation/public/pages/releases-page.tsx b/src/features/tech-log/presentation/public/pages/releases-page.tsx new file mode 100644 index 0000000..a129c00 --- /dev/null +++ b/src/features/tech-log/presentation/public/pages/releases-page.tsx @@ -0,0 +1,50 @@ +import { Link } from "react-router-dom"; + +import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; +import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; + +export function ReleasesPage() { + const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); + const releases = publicContent + .searchPublicContent("") + .filter((item) => item.contentType === "RELEASE") + .flatMap((item) => { + const release = publicContent.getRelease(item.path.replace("/releases/", "")); + return release ? [release] : []; + }); + + return ( +
+
+

Releases

+

변경 기록

+

+ 릴리즈 버전별로 무엇을 바꿨고 왜 바꿨는지, 공개 화면에 어떤 영향이 + 생겼는지 남깁니다. +

+
+
    + {releases.map((release) => ( +
  1. + +
    + v{release.version} + +
    +
    +

    {release.title}

    +

    {release.summary}

    +
    + + +
  2. + ))} +
+
+ ); +} diff --git a/tests/features/tech-log/public-index-screens.test.tsx b/tests/features/tech-log/public-index-screens.test.tsx new file mode 100644 index 0000000..747b030 --- /dev/null +++ b/tests/features/tech-log/public-index-screens.test.tsx @@ -0,0 +1,379 @@ +// @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 { + 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().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"], + ["NOT_FOUND" as const, "/definitely-not-a-product-route"], + ])("renders exact in-shell Public fallback copy for %s", (routeId, path) => { + const { container, router } = renderPublicRoute(routeId, path); + + expect(router.state.location.pathname).toBe(path); + expect(screen.getByRole("heading", { level: 1, name: "404" })).toHaveClass( + "next-error-h1", + ); + expect( + screen.getByRole("heading", { level: 2, name: "This page could not be found." }), + ).toBeVisible(); + expect( + screen.queryByRole("heading", { name: "화면을 표시하지 못했습니다." }), + ).not.toBeInTheDocument(); + expect(container.querySelector(".site-frame")).not.toBeNull(); + }); +});