419 lines
16 KiB
TypeScript
419 lines
16 KiB
TypeScript
// @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");
|
|
});
|
|
});
|