// @vitest-environment jsdom import { act, 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 { lazy, type ComponentType } from "react"; 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 { PublicNotFoundPage as NotFoundPage } from "../../../src/features/tech-log/presentation/public/pages/public-not-found-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 { 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; type DiscoveryRenderOptions = Readonly<{ NotFoundComponent?: ComponentType; application?: ReturnType; }>; function renderDiscoveryRoute( routeId: RouteId, initialEntry: string, options: DiscoveryRenderOptions = {}, ) { 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: options.NotFoundComponent ?? 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("treats external focus URL changes as authoritative without stealing focus", async () => { const { router } = renderDiscoveryRoute( "TECH_LOG_HOME", "/?focus=current&state=latest-empty#latest", ); const current = screen.getByRole("tab", { name: "현재 작업" }); current.focus(); await router.navigate("/?focus=question&state=latest-empty#latest"); await waitFor(() => { expect(screen.getByRole("tab", { name: "열린 질문" })).toHaveAttribute( "aria-selected", "true", ); }); expect(router.state.location).toMatchObject({ pathname: "/", search: "?focus=question&state=latest-empty", hash: "#latest", }); expect(screen.getByRole("tab", { name: "열린 질문" })).toHaveAttribute( "tabindex", "0", ); expect(current).toHaveAttribute("aria-selected", "false"); expect(current).toHaveAttribute("tabindex", "-1"); expect(current).toHaveFocus(); expect( screen.getByRole("tabpanel", { name: "열린 질문" }), ).toBeVisible(); expect(document.getElementById("focus-panel-current")).toHaveAttribute( "hidden", ); await router.navigate("/?focus=decision&state=latest-empty#latest"); await waitFor(() => { expect(screen.getByRole("tab", { name: "최근 결정" })).toHaveAttribute( "aria-selected", "true", ); }); expect(router.state.location).toMatchObject({ pathname: "/", search: "?focus=decision&state=latest-empty", hash: "#latest", }); expect(screen.getByRole("tab", { name: "최근 결정" })).toHaveAttribute( "tabindex", "0", ); expect(screen.getByRole("tabpanel", { name: "최근 결정" })).toBeVisible(); 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(); }); it("recovers a rejecting registered not-found runtime under its own chunk contract", async () => { let rejectNotFound: ((reason?: unknown) => void) | undefined; const LazyNotFound = lazy( () => new Promise<{ default: typeof NotFoundPage }>((_resolve, reject) => { rejectNotFound = reject; }), ); const notFoundChunkRead = vi.fn(() => "assets/not-found.js"); const exploreKindChunkRead = vi.fn(() => "assets/explore-kind.js"); const routeChunks = { get "route-not-found"() { return notFoundChunkRead(); }, get "route-tech-log-explore-kind"() { return exploreKindChunkRead(); }, }; const release = { buildId: "task-7-test-build", releaseId: "task-7-test-release", configSchemaVersion: "1", apiContractVersion: "1", assetManifestHash: "task-7-test-hash", routeChunks, }; const record = vi.fn(); const techLog = createTechLogFeatureInstalledInput().input; const application = createTestApplication({ diagnostics: { record }, releaseInfo: { getCurrent: async () => release, refresh: async () => release, }, featureInputs: { "tech-log": techLog }, }); const { router, container } = renderDiscoveryRoute( "TECH_LOG_EXPLORE_KIND", "/explore/unknown", { NotFoundComponent: LazyNotFound, application }, ); expect( screen.getByText("화면을 준비하고 있습니다.").closest("section"), ).toHaveAttribute("data-surface", "none"); await waitFor(() => expect(rejectNotFound).toBeTypeOf("function")); await act(async () => { rejectNotFound?.( new TypeError("Failed to fetch dynamically imported module"), ); }); expect( await screen.findByRole("heading", { name: "화면 자산을 복구하지 못했습니다.", }), ).toBeVisible(); expect(notFoundChunkRead).toHaveBeenCalledOnce(); expect(exploreKindChunkRead).not.toHaveBeenCalled(); expect(router.state.location.pathname).toBe("/explore/unknown"); expect(container.querySelector(".site-frame")).not.toBeNull(); expect( screen.queryByRole("heading", { name: "화면을 표시하지 못했습니다." }), ).not.toBeInTheDocument(); const routeChangedRecords = record.mock.calls .map(([entry]) => entry) .filter((entry) => entry.eventId === "route.changed"); expect(routeChangedRecords).toEqual([ expect.objectContaining({ context: expect.objectContaining({ route_id: "TECH_LOG_EXPLORE_KIND", }), }), ]); expect( record.mock.calls.some(([entry]) => entry.eventId === "ui.render.failed"), ).toBe(false); }); }); 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"); }); });