From ef1d5cc54894f097796ee47377cc6f3b5b8fb219 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sat, 15 Aug 2026 22:29:57 +0900 Subject: [PATCH] feat: port TechLog discovery screens --- .../tech-log/domain/public/focus-state.ts | 41 ++ .../public/components/explore-filter-form.tsx | 75 ++++ .../public/components/home-focus.tsx | 136 ++++++ .../public/components/latest-index.tsx | 75 ++++ .../public/components/public-record-list.tsx | 41 ++ .../public/pages/explore-kind-page.tsx | 49 ++ .../public/pages/explore-page.tsx | 33 ++ .../presentation/public/pages/home-page.tsx | 182 ++++++++ .../presentation/public/pages/search-page.tsx | 34 ++ src/presentation/routes/app-router.tsx | 39 +- src/presentation/routes/route-input.tsx | 22 + .../public-discovery-screens.test.tsx | 418 ++++++++++++++++++ 12 files changed, 1130 insertions(+), 15 deletions(-) create mode 100644 src/features/tech-log/domain/public/focus-state.ts create mode 100644 src/features/tech-log/presentation/public/components/explore-filter-form.tsx create mode 100644 src/features/tech-log/presentation/public/components/home-focus.tsx create mode 100644 src/features/tech-log/presentation/public/components/latest-index.tsx create mode 100644 src/features/tech-log/presentation/public/components/public-record-list.tsx create mode 100644 src/features/tech-log/presentation/public/pages/explore-kind-page.tsx create mode 100644 src/features/tech-log/presentation/public/pages/explore-page.tsx create mode 100644 src/features/tech-log/presentation/public/pages/home-page.tsx create mode 100644 src/features/tech-log/presentation/public/pages/search-page.tsx create mode 100644 tests/features/tech-log/public-discovery-screens.test.tsx diff --git a/src/features/tech-log/domain/public/focus-state.ts b/src/features/tech-log/domain/public/focus-state.ts new file mode 100644 index 0000000..08259d3 --- /dev/null +++ b/src/features/tech-log/domain/public/focus-state.ts @@ -0,0 +1,41 @@ +export function normalizeFocus( + value: string | null | undefined, + availableKeys: readonly string[], +): string | null { + if (availableKeys.length === 0) return null; + return value && availableKeys.includes(value) ? value : availableKeys[0]; +} + +/** + * A missing query is the default home URL, while an explicitly empty or + * invalid query must be replaced with the selected fallback. + */ +export function shouldNormalizeFocusUrl( + requestedKey: string | undefined, + normalizedKey: string, +): boolean { + return requestedKey !== undefined && requestedKey !== normalizedKey; +} + +export function moveFocus( + current: string, + key: string, + availableKeys: readonly string[], +): string | null { + if (availableKeys.length === 0) return null; + + const currentIndex = Math.max(0, availableKeys.indexOf(current)); + + if (key === "Home") return availableKeys[0]; + if (key === "End") return availableKeys[availableKeys.length - 1]; + if (key === "ArrowLeft") { + return availableKeys[ + (currentIndex - 1 + availableKeys.length) % availableKeys.length + ]; + } + if (key === "ArrowRight") { + return availableKeys[(currentIndex + 1) % availableKeys.length]; + } + + return availableKeys[currentIndex]; +} diff --git a/src/features/tech-log/presentation/public/components/explore-filter-form.tsx b/src/features/tech-log/presentation/public/components/explore-filter-form.tsx new file mode 100644 index 0000000..27e351a --- /dev/null +++ b/src/features/tech-log/presentation/public/components/explore-filter-form.tsx @@ -0,0 +1,75 @@ +import { Link, useNavigate } from "react-router-dom"; + +import type { RecordKind } from "../../../application/ports/public-content-queries.ts"; +import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; +import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; + +export function ExploreFilterForm({ + action, + kind, + topic, + project, + showType = true, +}: { + action: string; + kind?: RecordKind; + topic?: string; + project?: string; + showType?: boolean; +}) { + const navigate = useNavigate(); + const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); + const publicRecords = publicContent.listRecords(); + const topics = [...new Set(publicRecords.map((record) => record.topic))].sort(); + const projectPrefix = "/projects/"; + const projects = publicContent + .searchPublicContent("") + .filter((entity) => entity.contentType === "PROJECT") + .flatMap((entity) => { + if (!entity.path.startsWith(projectPrefix)) return []; + const item = publicContent.getProject( + decodeURIComponent(entity.path.slice(projectPrefix.length)), + ); + return item ? [{ slug: item.slug, title: item.title }] : []; + }); + const normalizedTopic = topic?.toLocaleLowerCase("ko-KR"); + const selectedTopic = topics.find( + (item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic, + ); + const normalizedProject = project?.toLocaleLowerCase("ko-KR"); + const selectedProject = projects.find( + (item) => + item.slug.toLocaleLowerCase("ko-KR") === normalizedProject || + item.title.toLocaleLowerCase("ko-KR") === normalizedProject, + )?.slug; + const hasActiveFilter = Boolean((showType && kind) || topic || project); + const formKey = [kind, topic, project, showType].join(":"); + + function submit(event: React.FormEvent) { + event.preventDefault(); + const data = new FormData(event.currentTarget); + const search = new URLSearchParams(); + for (const [key, value] of data) { + if (typeof value === "string") search.append(key, value); + } + void navigate(`${action}?${search.toString()}`); + } + + return ( +
+ {showType ? ( + + ) : null} + + + + {hasActiveFilter ? 필터 초기화 : null} +
+ ); +} diff --git a/src/features/tech-log/presentation/public/components/home-focus.tsx b/src/features/tech-log/presentation/public/components/home-focus.tsx new file mode 100644 index 0000000..50e7e34 --- /dev/null +++ b/src/features/tech-log/presentation/public/components/home-focus.tsx @@ -0,0 +1,136 @@ +import { useEffect, useRef, useState } from "react"; +import { Link, useLocation, useNavigate } from "react-router-dom"; + +import type { + FocusKey, + HomeFocusItem, +} from "../../../application/ports/public-content-queries.ts"; +import { + moveFocus, + shouldNormalizeFocusUrl, +} from "../../../domain/public/focus-state.ts"; + +type HomeFocusProps = { + items: ReadonlyArray; + initialKey: FocusKey; + requestedKey?: string; +}; + +export function HomeFocus({ + items, + initialKey, + requestedKey, +}: HomeFocusProps) { + const [activeKey, setActiveKey] = useState(initialKey); + const buttons = useRef>([]); + const location = useLocation(); + const navigate = useNavigate(); + const keys = items.map((item) => item.key); + const activeItem = items.find((item) => item.key === activeKey) ?? items[0]; + + function replaceFocus(key: FocusKey) { + const search = new URLSearchParams(location.search); + search.set("focus", key); + void navigate( + { + pathname: location.pathname, + search: `?${search.toString()}`, + hash: location.hash, + }, + { replace: true }, + ); + } + + useEffect(() => { + if (!shouldNormalizeFocusUrl(requestedKey, activeKey)) return; + replaceFocus(activeKey); + }); + + function select(key: FocusKey, moveKeyboardFocus = false) { + setActiveKey(key); + replaceFocus(key); + + if (moveKeyboardFocus) { + requestAnimationFrame(() => { + buttons.current[keys.indexOf(key)]?.focus(); + }); + } + } + + function onKeyDown(event: React.KeyboardEvent) { + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) { + return; + } + + event.preventDefault(); + const next = moveFocus(activeKey, event.key, keys); + if (next === "current" || next === "question" || next === "decision") { + select(next, true); + } + } + + if (!activeItem) return null; + + const usesTabs = items.length > 1; + const renderedItems = usesTabs ? items : [activeItem]; + + return ( + <> + {usesTabs ? ( +
+ {items.map((item, index) => { + const selected = item.key === activeKey; + return ( + + ); + })} +
+ ) : null} + + {renderedItems.map((item) => { + const selected = item.key === activeKey; + return ( + + ); + })} + + ); +} diff --git a/src/features/tech-log/presentation/public/components/latest-index.tsx b/src/features/tech-log/presentation/public/components/latest-index.tsx new file mode 100644 index 0000000..9942107 --- /dev/null +++ b/src/features/tech-log/presentation/public/components/latest-index.tsx @@ -0,0 +1,75 @@ +import { Link } from "react-router-dom"; + +export type LatestEntry = { + id: string; + typeLabel: string; + title: string; + summary: string; + date: string; + dateTime: string; + topic: string; + project: string; + path: string; +}; + +type LatestIndexProps = { + entries: ReadonlyArray; + status?: "ready" | "error"; +}; + +export function LatestIndex({ + entries, + status = "ready", +}: LatestIndexProps) { + return ( +
+
+
+

Index

+

최근 기록

+
+ + 모든 기록 탐색 + +
+ + {status === "error" ? ( +
+

최근 기록을 불러오지 못했습니다.

+ + 다시 시도 + +
+ ) : entries.length === 0 ? ( +

+ 아직 공개된 기록이 없습니다. +

+ ) : ( +
    + {entries.map((entry) => ( +
  1. + +
    + {entry.typeLabel} + +
    +
    +

    {entry.title}

    +

    {entry.summary}

    +

    + {entry.topic} · {entry.project} +

    +
    +
    + {entry.topic} + {entry.project} +
    + + +
  2. + ))} +
+ )} +
+ ); +} diff --git a/src/features/tech-log/presentation/public/components/public-record-list.tsx b/src/features/tech-log/presentation/public/components/public-record-list.tsx new file mode 100644 index 0000000..d76989b --- /dev/null +++ b/src/features/tech-log/presentation/public/components/public-record-list.tsx @@ -0,0 +1,41 @@ +import { Link } from "react-router-dom"; + +import type { PublicRecord } from "../../../application/ports/public-content-queries.ts"; + +const kindLabels = { + CASE: "Case", + REFERENCE: "Reference", + QUESTION: "Open Question", +} as const; + +export function PublicRecordList({ + records, + emptyMessage = "조건에 맞는 공개 기록이 없습니다.", +}: { + records: ReadonlyArray; + emptyMessage?: string; +}) { + if (records.length === 0) { + return

{emptyMessage}

; + } + + return ( +
    + {records.map((record) => ( +
  1. + + {kindLabels[record.kind]} +
    + {record.title} +

    {record.summary}

    + + {record.topic} · {record.projectTitle} + +
    + + +
  2. + ))} +
+ ); +} diff --git a/src/features/tech-log/presentation/public/pages/explore-kind-page.tsx b/src/features/tech-log/presentation/public/pages/explore-kind-page.tsx new file mode 100644 index 0000000..a772ad9 --- /dev/null +++ b/src/features/tech-log/presentation/public/pages/explore-kind-page.tsx @@ -0,0 +1,49 @@ +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 { ExploreFilterForm } from "../components/explore-filter-form.tsx"; +import { PublicRecordList } from "../components/public-record-list.tsx"; + +const kinds = { + cases: { kind: "CASE", title: "Case", description: "문제를 재현하고 관찰한 값에서 설계 결론까지 따라갑니다." }, + references: { kind: "REFERENCE", title: "Reference", description: "다시 확인할 수 있는 기술 기준과 적용 범위를 정리합니다." }, + questions: { kind: "QUESTION", title: "Open Question", description: "확인한 사실과 미지수, 다음 검증을 공개적으로 추적합니다." }, +} as const; + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function getKindConfig(value: string | undefined) { + if (value === "cases" || value === "references" || value === "questions") { + return kinds[value]; + } + return undefined; +} + +export function ExploreKindPage() { + const { params, search } = useRouteInput<"TECH_LOG_EXPLORE_KIND">(); + const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); + const kind = optionalString(params.kind); + const config = getKindConfig(kind); + if (!config) return ; + const topic = optionalString(search.topic); + const project = optionalString(search.project); + const records = publicContent.listRecords({ + kind: config.kind, + ...(topic ? { topic } : {}), + ...(project ? { project } : {}), + }); + + return
+

Explore

{config.title}

{config.description}

+ +

공개 기록

{records.length}개의 공개 기록

+ 전체 탐색으로 돌아가기 +
; +} diff --git a/src/features/tech-log/presentation/public/pages/explore-page.tsx b/src/features/tech-log/presentation/public/pages/explore-page.tsx new file mode 100644 index 0000000..a8cc91c --- /dev/null +++ b/src/features/tech-log/presentation/public/pages/explore-page.tsx @@ -0,0 +1,33 @@ +import type { RecordKind } from "../../../application/ports/public-content-queries.ts"; +import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; +import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; +import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx"; +import { ExploreFilterForm } from "../components/explore-filter-form.tsx"; +import { PublicRecordList } from "../components/public-record-list.tsx"; + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export function ExplorePage() { + const { search } = useRouteInput<"TECH_LOG_EXPLORE">(); + const requestedKind = optionalString(search.type); + const topic = optionalString(search.topic); + const project = optionalString(search.project); + const kind = (["CASE", "REFERENCE", "QUESTION"] as const).find( + (item) => item === requestedKind, + ) satisfies RecordKind | undefined; + const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); + const records = publicContent.listRecords({ + ...(kind ? { kind } : {}), + ...(topic ? { topic } : {}), + ...(project ? { project } : {}), + }); + + return
+

Explore

탐색

유형과 기술 주제, 프로젝트를 조합해 공개 기록을 찾습니다.

+ +

공개 기록

{records.length}개의 공개 기록

+ +
; +} diff --git a/src/features/tech-log/presentation/public/pages/home-page.tsx b/src/features/tech-log/presentation/public/pages/home-page.tsx new file mode 100644 index 0000000..e42a909 --- /dev/null +++ b/src/features/tech-log/presentation/public/pages/home-page.tsx @@ -0,0 +1,182 @@ +import { Link } from "react-router-dom"; + +import type { PublicContentQueries } from "../../../application/ports/public-content-queries.ts"; +import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; +import { publicSiteConfig } from "../../../contracts/public-site-config.ts"; +import { normalizeFocus } from "../../../domain/public/focus-state.ts"; +import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; +import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx"; +import { FatalErrorState } from "../components/fatal-error-state.tsx"; +import { HomeFocus } from "../components/home-focus.tsx"; +import { + LatestIndex, + type LatestEntry, +} from "../components/latest-index.tsx"; + +const exploreEntries = [ + { + label: "Case", + description: "문제를 따라가며 검증 과정을 읽습니다", + path: "/explore/cases", + }, + { + label: "Reference", + description: "다시 찾을 수 있는 기술 기준을 확인합니다", + path: "/explore/references", + }, + { + label: "OpenQuestion", + description: "아직 끝나지 않은 판단과 다음 검증을 봅니다", + path: "/explore/questions", + }, + { + label: "Project", + description: "여러 기록을 하나의 시스템 맥락에서 연결합니다", + path: "/projects", + }, +] as const; + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] { + const publicRecords = publicContent.listRecords(); + const publicRecordByPath = new Map( + publicRecords.map((record) => [record.path, record]), + ); + const searchableEntities = publicContent.searchPublicContent(""); + const projectPrefix = "/projects/"; + const projectSlugs = searchableEntities + .filter((entity) => entity.contentType === "PROJECT") + .flatMap((entity) => + entity.path.startsWith(projectPrefix) + ? [decodeURIComponent(entity.path.slice(projectPrefix.length))] + : [], + ); + const projectTimeline = projectSlugs.flatMap((projectSlug) => { + const project = publicContent.getProject(projectSlug); + if (!project) return []; + return publicContent.getProjectActivity(projectSlug).map((activity) => { + const record = publicRecordByPath.get( + activity.recordPath ?? activity.path, + ); + return { + id: activity.id, + typeLabel: + activity.type === "PUBLICATION" && record + ? record.kind + : "PROJECT ACTIVITY", + title: + activity.type === "PUBLICATION" && record + ? record.title + : activity.title, + summary: activity.summary, + date: activity.date, + dateTime: activity.dateTime, + topic: record?.topic ?? project.topics[0] ?? "", + project: project.title, + path: activity.path, + }; + }); + }); + const releaseTimeline = searchableEntities + .filter((entity) => entity.contentType === "RELEASE") + .flatMap((entity) => { + const prefix = "/releases/"; + if (!entity.path.startsWith(prefix)) return []; + const release = publicContent.getRelease( + decodeURIComponent(entity.path.slice(prefix.length)), + ); + if (!release) return []; + return [ + { + id: `release-${release.version}`, + typeLabel: "RELEASE", + title: release.title, + summary: release.summary, + date: release.publishedLabel, + dateTime: release.publishedAt, + topic: "TechLog", + project: "TechLog", + path: release.path, + }, + ]; + }); + + return [...projectTimeline, ...releaseTimeline].sort((left, right) => + right.dateTime.localeCompare(left.dateTime), + ); +} + +export function HomePage() { + const { search } = useRouteInput<"TECH_LOG_HOME">(); + const requestedKey = optionalString(search.focus); + const requestedState = optionalString(search.state); + const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); + const focusItems = publicContent.getHomeFocusItems(); + const availableFocusItems = requestedState === "focus-empty" ? [] : focusItems; + const normalizedKey = normalizeFocus( + requestedKey, + availableFocusItems.map((item) => item.key), + ); + const initialKey = + normalizedKey === "current" || + normalizedKey === "question" || + normalizedKey === "decision" + ? normalizedKey + : null; + + if (requestedState === "site-error") { + return ; + } + + const latestEntries = getLatestEntries(publicContent); + + return ( +
+
+

{publicSiteConfig.brandTitle}

+

{publicSiteConfig.identityStatement}

+
+ + {initialKey ? ( +
+

+ 지금 집중하는 것 +

+ +
+ ) : null} + + + +
+
+
+

Explore

+

어떤 맥락으로 읽을까요?

+
+
+
    + {exploreEntries.map((entry) => ( +
  • + + {entry.description} + {entry.label} + + +
  • + ))} +
+
+
+ ); +} diff --git a/src/features/tech-log/presentation/public/pages/search-page.tsx b/src/features/tech-log/presentation/public/pages/search-page.tsx new file mode 100644 index 0000000..117c2d8 --- /dev/null +++ b/src/features/tech-log/presentation/public/pages/search-page.tsx @@ -0,0 +1,34 @@ +import { Link, useNavigate } 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 { useRouteInput } from "../../../../../presentation/routes/route-input.tsx"; + +const labels = { CASE: "Case", REFERENCE: "Reference", QUESTION: "Open Question", PROJECT: "Project", RELEASE: "Release" } as const; + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export function SearchPage() { + const navigate = useNavigate(); + const { search } = useRouteInput<"TECH_LOG_SEARCH">(); + const query = optionalString(search.q)?.trim() ?? ""; + const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); + const results = publicContent.searchPublicContent(query); + + function submit(event: React.FormEvent) { + event.preventDefault(); + const data = new FormData(event.currentTarget); + const value = data.get("q"); + const nextQuery = typeof value === "string" ? value : ""; + void navigate(`/search?q=${encodeURIComponent(nextQuery)}`); + } + + return
+

Search

검색

제목과 요약, 주제, 프로젝트를 함께 검색합니다.

+
+

{query ? `“${query}” 검색 결과` : "전체 검색 결과"}

{results.length}개의 검색 결과

+ {results.length ?
    {results.map((result) =>
  1. {labels[result.contentType]}
    {result.title}

    {result.summary}

    {result.topic || result.project ? {[result.topic, result.project].filter(Boolean).join(" · ")} : null}
  2. )}
:

일치하는 공개 기록이 없습니다.

} +
; +} diff --git a/src/presentation/routes/app-router.tsx b/src/presentation/routes/app-router.tsx index 887b5e3..a0c854e 100644 --- a/src/presentation/routes/app-router.tsx +++ b/src/presentation/routes/app-router.tsx @@ -48,7 +48,10 @@ import { ROUTE_RUNTIME, } from "../../features/installed-feature-runtimes.tsx"; import type { ParsedRouteInput } from "./route-contract.ts"; -import { RouteInputProvider } from "./route-input.tsx"; +import { + RegisteredNotFoundProvider, + RouteInputProvider, +} from "./route-input.tsx"; function RouteLoadingSurface({ definition }: { definition: RouteDefinition }) { const { message, resolve } = useLocale(); @@ -256,12 +259,14 @@ function RegisteredRoute({ definition, runtime, codecs, + NotFoundComponent, }: { routeId: RouteIdValue; buildId: string; definition: RouteDefinition; runtime: GroupedRouteRuntimeDefinition; codecs: RouteCodecRegistry; + NotFoundComponent?: ComponentType; }) { const params = useParams(); const [search] = useSearchParams(); @@ -280,20 +285,22 @@ function RegisteredRoute({ const content = ( - - - }> - - - - + + + + }> + + + + + ); const protectedContent = @@ -345,6 +352,7 @@ export function createGroupedRouteObjects( PUBLIC: [], STUDIO: [], }; + const NotFoundComponent = runtime.NOT_FOUND?.Component; for (const definition of Object.values(registry)) { const routeId = definition.routeId; const routeRuntime = runtime[definition.routeId]; @@ -358,6 +366,7 @@ export function createGroupedRouteObjects( definition={definition} runtime={routeRuntime} codecs={codecs} + NotFoundComponent={NotFoundComponent} /> ); if (definition.path === "/") { diff --git a/src/presentation/routes/route-input.tsx b/src/presentation/routes/route-input.tsx index 8d87744..a14d240 100644 --- a/src/presentation/routes/route-input.tsx +++ b/src/presentation/routes/route-input.tsx @@ -1,4 +1,5 @@ import { + type ComponentType, createContext, type ReactNode, useContext, @@ -7,6 +8,7 @@ import { import type { ParsedRouteInput, RouteId } from "./route-contract.ts"; const RouteInputContext = createContext | null>(null); +const RegisteredNotFoundContext = createContext(null); export function RouteInputProvider({ input, @@ -29,3 +31,23 @@ export function useRouteInput< if (!input) throw new Error("Registered route input is required"); return input as ParsedRouteInput; } + +export function RegisteredNotFoundProvider({ + Component, + children, +}: Readonly<{ + Component?: ComponentType; + children: ReactNode; +}>) { + return ( + + {children} + + ); +} + +export function RegisteredNotFoundRoute() { + const Component = useContext(RegisteredNotFoundContext); + if (!Component) throw new Error("Registered not-found runtime is required"); + return ; +} diff --git a/tests/features/tech-log/public-discovery-screens.test.tsx b/tests/features/tech-log/public-discovery-screens.test.tsx new file mode 100644 index 0000000..acb704e --- /dev/null +++ b/tests/features/tech-log/public-discovery-screens.test.tsx @@ -0,0 +1,418 @@ +// @vitest-environment jsdom + +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createMemoryRouter, + Outlet, + RouterProvider, + type RouterProviderProps, +} from "react-router-dom"; + +import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts"; +import { + TECH_LOG_ROUTE_REGISTRY, + TECH_LOG_ROUTE_RUNTIME_CONTRACT, + type TechLogRouteId, +} from "../../../src/features/tech-log/contracts/tech-log-route-contract.ts"; +import { + moveFocus, + normalizeFocus, + shouldNormalizeFocusUrl, +} from "../../../src/features/tech-log/domain/public/focus-state.ts"; +import { PublicShell } from "../../../src/features/tech-log/presentation/public/public-shell.tsx"; +import { ExploreKindPage } from "../../../src/features/tech-log/presentation/public/pages/explore-kind-page.tsx"; +import { ExplorePage } from "../../../src/features/tech-log/presentation/public/pages/explore-page.tsx"; +import { HomePage } from "../../../src/features/tech-log/presentation/public/pages/home-page.tsx"; +import { SearchPage } from "../../../src/features/tech-log/presentation/public/pages/search-page.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 NotFoundPage from "../../../src/presentation/pages/not-found-page.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_HOME: HomePage, + TECH_LOG_EXPLORE: ExplorePage, + TECH_LOG_EXPLORE_KIND: ExploreKindPage, + TECH_LOG_SEARCH: SearchPage, +} as const; + +type DiscoveryRouteId = keyof typeof routeComponents; + +function renderDiscoveryRoute( + routeId: RouteId, + initialEntry: string, +) { + const definition = TECH_LOG_ROUTE_REGISTRY[routeId]; + const runtime = TECH_LOG_ROUTE_RUNTIME_CONTRACT[routeId]; + const Component = routeComponents[routeId]; + const router = createMemoryRouter( + createGroupedRouteObjects( + { + [routeId]: definition, + NOT_FOUND: TECH_LOG_ROUTE_REGISTRY.NOT_FOUND, + }, + { + [routeId]: { + moduleId: runtime.moduleId, + Component, + }, + NOT_FOUND: { + moduleId: TECH_LOG_ROUTE_RUNTIME_CONTRACT.NOT_FOUND.moduleId, + Component: NotFoundPage, + }, + }, + { + PUBLIC: ( + + + + ), + STUDIO: , + }, + "task-7-test-build", + routeCodecs, + ), + { initialEntries: [initialEntry] }, + ); + const techLog = createTechLogFeatureInstalledInput().input; + const view = render( + + + , + ); + return { ...view, router } satisfies ReturnType & { + router: RouterProviderProps["router"]; + }; +} + +beforeEach(() => { + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("TechLog home discovery", () => { + it("renders the exact identity, focus, latest ordering, and explore choices", async () => { + const { router } = renderDiscoveryRoute( + "TECH_LOG_HOME", + "/?focus=invalid&focus=question", + ); + + expect(screen.getByRole("heading", { level: 1, name: "TechLog" })).toBeVisible(); + expect( + within(screen.getByRole("main")).getByText( + "문제를 재현하고 검증해 운영 가능한 설계로 연결합니다.", + ), + ).toHaveClass("identity-statement"); + expect(screen.getByText("지금 집중하는 것")).toHaveClass("section-kicker"); + + await waitFor(() => { + expect(router.state.location.search).toBe("?focus=current"); + }); + expect(screen.getByRole("tab", { name: "현재 작업" })).toHaveAttribute( + "aria-selected", + "true", + ); + for (const tab of screen.getAllByRole("tab")) { + const panelId = tab.getAttribute("aria-controls"); + expect(panelId).toBeTruthy(); + expect(document.getElementById(panelId!)).toHaveAttribute( + "aria-labelledby", + tab.id, + ); + } + + const latest = screen.getByRole("region", { name: "최근 기록" }); + expect( + within(latest).getAllByRole("heading", { level: 3 }).map((heading) => + heading.textContent, + ), + ).toEqual([ + "컬렉션 Fetch Join과 페이징은 왜 충돌하는가", + "파일 저장소 계약을 하나로 통합했습니다", + "Authorization Code Flow에서 state와 nonce의 경계", + "oauth2-proxy 뒤에서 토큰을 다시 검증할 것인가", + "Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유", + "TechLog Public·Studio 경계를 확정했습니다", + ]); + expect( + screen.getByRole("link", { name: /문제를 따라가며 검증 과정을 읽습니다.*Case/ }), + ).toHaveAttribute("href", "/explore/cases"); + expect( + screen.getByRole("link", { name: /여러 기록을 하나의 시스템 맥락에서 연결합니다.*Project/ }), + ).toHaveAttribute("href", "/projects"); + }); + + it("moves linked tabs with arrows, Home, and End while synchronizing the URL", async () => { + const user = userEvent.setup(); + const { router } = renderDiscoveryRoute("TECH_LOG_HOME", "/?focus=question"); + const question = screen.getByRole("tab", { name: "열린 질문" }); + question.focus(); + + await user.keyboard("{ArrowRight}"); + expect(screen.getByRole("tab", { name: "최근 결정" })).toHaveFocus(); + expect(router.state.location.search).toBe("?focus=decision"); + expect(screen.getByRole("heading", { name: /Filesystem과 Object Storage/ })).toBeVisible(); + + await user.keyboard("{ArrowRight}"); + expect(screen.getByRole("tab", { name: "현재 작업" })).toHaveFocus(); + expect(router.state.location.search).toBe("?focus=current"); + + await user.keyboard("{End}"); + expect(screen.getByRole("tab", { name: "최근 결정" })).toHaveFocus(); + await user.keyboard("{Home}"); + expect(screen.getByRole("tab", { name: "현재 작업" })).toHaveFocus(); + expect(screen.getAllByRole("tabpanel")).toHaveLength(1); + }); + + it("preserves the source home empty and error states", () => { + const emptyView = renderDiscoveryRoute("TECH_LOG_HOME", "/?state=latest-empty"); + expect(screen.getByText("아직 공개된 기록이 없습니다.")).toHaveClass( + "latest-state--empty", + ); + emptyView.unmount(); + + const latestErrorView = renderDiscoveryRoute( + "TECH_LOG_HOME", + "/?state=latest-error", + ); + expect(screen.getByRole("alert")).toHaveTextContent( + "최근 기록을 불러오지 못했습니다.", + ); + expect(screen.getByRole("link", { name: "다시 시도" })).toHaveAttribute( + "href", + "/#latest", + ); + latestErrorView.unmount(); + + const errorView = renderDiscoveryRoute("TECH_LOG_HOME", "/?state=site-error"); + expect( + screen.getByRole("heading", { name: "페이지를 불러오지 못했습니다." }), + ).toBeVisible(); + expect(screen.getByText("PREVIEW-HOME-500")).toBeVisible(); + errorView.unmount(); + + renderDiscoveryRoute("TECH_LOG_HOME", "/?state=focus-empty"); + expect(screen.queryByText("지금 집중하는 것")).not.toBeInTheDocument(); + }); +}); + +describe("TechLog explore discovery", () => { + it("filters by kind, topic, and project and keeps source result structure", async () => { + const user = userEvent.setup(); + const { router } = renderDiscoveryRoute( + "TECH_LOG_EXPLORE", + "/explore?type=CASE&topic=JPA&project=backend-skeleton", + ); + + expect(screen.getByRole("heading", { level: 1, name: "탐색" })).toBeVisible(); + expect( + screen.getByText("유형과 기술 주제, 프로젝트를 조합해 공개 기록을 찾습니다."), + ).toBeVisible(); + expect(screen.getByLabelText("유형")).toHaveValue("CASE"); + expect(screen.getByLabelText("주제")).toHaveValue("JPA"); + expect(screen.getByLabelText("프로젝트")).toHaveValue("backend-skeleton"); + expect(screen.getByText("1개의 공개 기록")).toBeVisible(); + expect( + screen.getByRole("link", { name: /컬렉션 Fetch Join과 페이징은 왜 충돌하는가/ }), + ).toHaveAttribute("href", "/cases/collection-fetch-join-pagination"); + + await user.selectOptions(screen.getByLabelText("유형"), "QUESTION"); + await user.selectOptions(screen.getByLabelText("주제"), "Authentication"); + await user.selectOptions(screen.getByLabelText("프로젝트"), "auth-lab"); + await user.click(screen.getByRole("button", { name: "적용" })); + + await waitFor(() => { + expect(router.state.location.search).toBe( + "?project=auth-lab&topic=Authentication&type=QUESTION", + ); + }); + expect(screen.getByText("1개의 공개 기록")).toBeVisible(); + expect( + screen.getByRole("link", { name: /oauth2-proxy가 전달한 토큰을 다시 검증해야 하는가/ }), + ).toBeVisible(); + + await user.click(screen.getByRole("link", { name: "필터 초기화" })); + await waitFor(() => { + expect(router.state.location.pathname).toBe("/explore"); + expect(router.state.location.search).toBe(""); + }); + expect(screen.getByText("6개의 공개 기록")).toBeVisible(); + expect( + within(screen.getByRole("main")) + .getAllByRole("listitem") + .map((item) => within(item).getByRole("link").getAttribute("href")), + ).toEqual([ + "/cases/collection-fetch-join-pagination", + "/references/jpa-list-fetch-strategy", + "/references/state-and-nonce-boundary", + "/questions/validate-edge-token-again", + "/cases/redis-adapter-ttl-boundary", + "/questions/collection-fetch-join-with-pagination", + ]); + }); + + it("renders the distinct no-result state for an unmatched query filter", () => { + renderDiscoveryRoute("TECH_LOG_EXPLORE", "/explore?topic=missing"); + + expect(screen.getByText("0개의 공개 기록")).toBeVisible(); + expect(screen.getByText("조건에 맞는 공개 기록이 없습니다.")).toHaveClass( + "public-empty-state", + ); + expect(screen.queryByRole("list", { name: "공개 기록" })).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "필터 초기화" })).toHaveAttribute( + "href", + "/explore", + ); + }); + + it("keeps kind-specific copy, filtering, count, ordering, and back navigation", () => { + renderDiscoveryRoute( + "TECH_LOG_EXPLORE_KIND", + "/explore/questions?topic=JPA&project=backend-skeleton", + ); + + expect( + screen.getByRole("heading", { level: 1, name: "Open Question" }), + ).toBeVisible(); + expect( + screen.getByText("확인한 사실과 미지수, 다음 검증을 공개적으로 추적합니다."), + ).toBeVisible(); + expect(screen.queryByLabelText("유형")).not.toBeInTheDocument(); + expect(screen.getByText("1개의 공개 기록")).toBeVisible(); + expect( + screen.getByRole("link", { name: /컬렉션 Fetch Join을 유지하면서 페이징할 수 있는가/ }), + ).toHaveAttribute("href", "/questions/collection-fetch-join-with-pagination"); + expect( + screen.getByRole("link", { name: "전체 탐색으로 돌아가기" }), + ).toHaveAttribute("href", "/explore"); + }); + + it("renders an unknown kind through the registered Public not-found runtime", () => { + const { router, container } = renderDiscoveryRoute( + "TECH_LOG_EXPLORE_KIND", + "/explore/unknown", + ); + + expect(router.state.location.pathname).toBe("/explore/unknown"); + expect( + screen.getByRole("heading", { name: "페이지를 찾을 수 없습니다." }), + ).toBeVisible(); + expect( + screen.queryByRole("heading", { name: "화면을 표시하지 못했습니다." }), + ).not.toBeInTheDocument(); + expect(container.querySelector(".site-frame")).not.toBeNull(); + }); +}); + +describe("TechLog search discovery", () => { + it("normalizes repeated queries, preserves result ordering, and navigates canonically", async () => { + const user = userEvent.setup(); + const { router } = renderDiscoveryRoute( + "TECH_LOG_SEARCH", + "/search?q=%20JPA%20&q=Redis&unknown=drop", + ); + + await waitFor(() => { + expect(router.state.location.search).toBe("?q=JPA"); + }); + expect(screen.getByRole("searchbox", { name: "검색어" })).toHaveValue("JPA"); + expect(screen.getByRole("heading", { name: "“JPA” 검색 결과" })).toBeVisible(); + expect(screen.getByText("4개의 검색 결과")).toBeVisible(); + expect( + screen.getAllByRole("listitem").map((item) => + within(item).queryByRole("link")?.getAttribute("href"), + ), + ).toEqual([ + "/cases/collection-fetch-join-pagination", + "/references/jpa-list-fetch-strategy", + "/questions/collection-fetch-join-with-pagination", + "/projects/backend-skeleton", + ]); + + await user.click( + screen.getByRole("link", { name: /JPA 목록 조회에서 Fetch 전략을 선택하는 기준/ }), + ); + expect(router.state.location.pathname).toBe("/references/jpa-list-fetch-strategy"); + }); + + it("submits from the keyboard and synchronizes the searchbox with URL changes", async () => { + const user = userEvent.setup(); + const { router } = renderDiscoveryRoute("TECH_LOG_SEARCH", "/search"); + const input = screen.getByRole("searchbox", { name: "검색어" }); + + await user.type(input, "Redis{Enter}"); + await waitFor(() => { + expect(router.state.location.search).toBe("?q=Redis"); + }); + expect(screen.getByRole("heading", { name: "“Redis” 검색 결과" })).toBeVisible(); + expect(screen.getByText("2개의 검색 결과")).toBeVisible(); + + await router.navigate("/search?q=Keycloak"); + await waitFor(() => { + expect(screen.getByRole("searchbox", { name: "검색어" })).toHaveValue( + "Keycloak", + ); + }); + expect(screen.getByText("1개의 검색 결과")).toBeVisible(); + }); + + it("normalizes an explicitly empty first query and renders all canonical results", async () => { + const { router } = renderDiscoveryRoute( + "TECH_LOG_SEARCH", + "/search?q=%20%20&q=JPA", + ); + + await waitFor(() => { + expect(router.state.location.search).toBe(""); + }); + expect(screen.getByRole("heading", { name: "전체 검색 결과" })).toBeVisible(); + expect(screen.getByText("9개의 검색 결과")).toBeVisible(); + expect(screen.getByRole("searchbox", { name: "검색어" })).toHaveValue(""); + }); + + it("renders the exact zero-result state without a result list", () => { + renderDiscoveryRoute("TECH_LOG_SEARCH", "/search?q=존재하지않음"); + + expect(screen.getByText("0개의 검색 결과")).toBeVisible(); + expect(screen.getByText("일치하는 공개 기록이 없습니다.")).toHaveClass( + "public-empty-state", + ); + expect(within(screen.getByRole("main")).queryByRole("list")).toBeNull(); + }); +}); + +describe("home focus domain", () => { + it("normalizes selection and wraps all supported keyboard movements", () => { + const keys = ["current", "question", "decision"] as const; + + expect(normalizeFocus(undefined, keys)).toBe("current"); + expect(normalizeFocus("missing", keys)).toBe("current"); + expect(normalizeFocus("question", keys)).toBe("question"); + expect(normalizeFocus("question", [])).toBeNull(); + expect(shouldNormalizeFocusUrl(undefined, "current")).toBe(false); + expect(shouldNormalizeFocusUrl("", "current")).toBe(true); + expect(moveFocus("current", "ArrowLeft", keys)).toBe("decision"); + expect(moveFocus("decision", "ArrowRight", keys)).toBe("current"); + expect(moveFocus("question", "Home", keys)).toBe("current"); + expect(moveFocus("question", "End", keys)).toBe("decision"); + }); +});