feat: port TechLog discovery screens

This commit is contained in:
DongHyeonka
2026-08-15 22:29:57 +09:00
parent c9164c1a03
commit ef1d5cc548
12 changed files with 1130 additions and 15 deletions
@@ -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 extends DiscoveryRouteId>(
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: (
<PublicShell>
<Outlet />
</PublicShell>
),
STUDIO: <Outlet />,
},
"task-7-test-build",
routeCodecs,
),
{ initialEntries: [initialEntry] },
);
const techLog = createTechLogFeatureInstalledInput().input;
const view = render(
<ApplicationProvider
application={createTestApplication({
featureInputs: { "tech-log": techLog },
})}
>
<RouterProvider router={router} />
</ApplicationProvider>,
);
return { ...view, router } satisfies ReturnType<typeof render> & {
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");
});
});