Files
tech-log-frontend/tests/features/tech-log/public-discovery-screens.test.tsx
T

565 lines
21 KiB
TypeScript

// @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<typeof createTestApplication>;
}>;
function renderDiscoveryRoute<RouteId extends DiscoveryRouteId>(
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: (
<PublicShell>
<Outlet />
</PublicShell>
),
STUDIO: <Outlet />,
},
"task-7-test-build",
routeCodecs,
),
{ initialEntries: [initialEntry] },
);
const techLog = createTechLogFeatureInstalledInput().input;
const view = render(
<ApplicationProvider
application={
options.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("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("keeps the unreachable unknown-kind client fallback accessible", async () => {
const { router, container } = renderDiscoveryRoute(
"TECH_LOG_EXPLORE_KIND",
"/explore/unknown",
);
expect(router.state.location.pathname).toBe("/explore/unknown");
expect(
await screen.findByRole("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");
});
});