주제 필터를 걸면 「조건에 맞는 공개 기록이 없습니다」만 남았다. 선택지가
`<option>{이름}</option>` 이라 값이 없어 이름이 그대로 나갔고 — `topic=OAuth/OIDC 인증
경계` — API 는 slug 로 거르므로 0건을 돌려줬다. 프로젝트 선택지는 처음부터
`value={slug}` 였고, 그래서 프로젝트만 멀쩡했다. 주제도 같은 모양으로 맞춘다.
테스트가 이 결함을 통과시킨 이유는 픽스처의 주제 이름이 `JPA`, `Authentication` 처럼
slug 와 구분되지 않는 값이어서다. 이름과 slug 가 다른 값을 쓰는 운영에서만 드러났다.
선택지의 값이 slug 인지 직접 묻는 단언을 넣는다.
`RecordFilters.topic` 은 어댑터마다 뜻이 달랐다. 정적 어댑터는 이름으로, HTTP 어댑터는
그 값을 그대로 API 에 넘겨 slug 로 걸렀다. 프로젝트가 이미 slug/제목 둘 다 받는 것과
같이 주제도 둘 다 받게 해서 두 어댑터가 같은 값을 이해하게 한다. 주제 페이지도 이름
대신 경로의 slug 로 묻는다.
「전체」를 고른 칸은 조건이 아니다. 빈 값까지 실어 보내고 있었고, URL 이 지저분해질 뿐
아니라 이 값을 그대로 API 에 넘기는 화면에서는 `topic=` 이 "slug 가 빈 문자열인 주제"로
해석되어 0건이 된다.
편집기는 미리보기를 붙박이로 두고 자체 스크롤을 줬다. 편집기를 내려도 미리보기는
제자리였고, 보려면 그 안을 따로 굴려야 했다 — 나란히 둔 이유가 둘을 같이 보는 것인데
움직임이 갈라지면 그 이점이 없다. 둘 다 페이지 스크롤을 그대로 타게 한다.
폭도 넓힌다. `.studio-main` 은 모든 Studio 화면이 1180px 를 함께 쓰는데, 본문 두 벌이
들어가야 하는 이 화면에서는 한 칸이 566px 였다. 편집기가 놓인 경우에만 1600px 로
넓히고 헤더도 같이 넓혀 좌우 끝을 맞춘다. 다른 화면은 그대로다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
584 lines
23 KiB
TypeScript
584 lines
23 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 { MOCK_STUDIO_INSTALL_CONTEXT } from "../../helpers/studio-install-context.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";
|
|
import { renderWithQueryProviders } from "../../helpers/query-providers.tsx";
|
|
|
|
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>;
|
|
}>;
|
|
|
|
async 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(MOCK_STUDIO_INSTALL_CONTEXT).input;
|
|
const view = render(
|
|
renderWithQueryProviders(
|
|
<ApplicationProvider
|
|
application={
|
|
options.application ??
|
|
createTestApplication({
|
|
featureInputs: { "tech-log": techLog },
|
|
})
|
|
}
|
|
>
|
|
<RouterProvider router={router} />
|
|
</ApplicationProvider>,
|
|
));
|
|
// 포트가 async 가 되면서 첫 페인트에는 데이터가 없다. 화면이 정착한 뒤
|
|
// 단언하도록 여기서 한 번 기다린다 — 각 테스트에 흩어 놓으면 빠뜨린 곳이 생긴다.
|
|
// `main` 을 기다리지 않는다 — 이 파일에는 의도적으로 렌더에 실패하는 라우트도 있고,
|
|
// 그때는 main 이 아예 없다. 로딩 표면이 사라지는 것이 두 경우 모두에 맞는 기준이다.
|
|
await waitFor(() =>
|
|
expect(document.querySelector(".state-surface--loading")).toBeNull(),
|
|
);
|
|
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 } = await 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 } = await 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 } = await 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", async () => {
|
|
const emptyView = await renderDiscoveryRoute("TECH_LOG_HOME", "/?state=latest-empty");
|
|
expect(screen.getByText("아직 공개된 기록이 없습니다.")).toHaveClass(
|
|
"latest-state--empty",
|
|
);
|
|
emptyView.unmount();
|
|
|
|
const latestErrorView = await renderDiscoveryRoute(
|
|
"TECH_LOG_HOME",
|
|
"/?state=latest-error",
|
|
);
|
|
expect(screen.getByRole("alert")).toHaveTextContent(
|
|
"최근 기록을 불러오지 못했습니다.",
|
|
);
|
|
expect(screen.getByRole("link", { name: "다시 시도" })).toHaveAttribute(
|
|
"href",
|
|
"/#latest",
|
|
);
|
|
latestErrorView.unmount();
|
|
|
|
const errorView = await renderDiscoveryRoute("TECH_LOG_HOME", "/?state=site-error");
|
|
expect(
|
|
screen.getByRole("heading", { name: "페이지를 불러오지 못했습니다." }),
|
|
).toBeVisible();
|
|
expect(screen.getByText("PREVIEW-HOME-500")).toBeVisible();
|
|
errorView.unmount();
|
|
|
|
await 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 } = await renderDiscoveryRoute(
|
|
"TECH_LOG_EXPLORE",
|
|
"/explore?type=CASE&topic=jpa&project=backend-skeleton",
|
|
);
|
|
|
|
expect(screen.getByRole("heading", { level: 1, name: "탐색" })).toBeVisible();
|
|
expect(
|
|
screen.getByText("유형과 기술 주제, 프로젝트를 조합해 공개 기록을 찾습니다."),
|
|
).toBeVisible();
|
|
// 필터의 선택지는 카탈로그가 도착한 뒤 채워지고, select 의 값도 그때 설정된다.
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText("유형")).toHaveValue("CASE");
|
|
expect(screen.getByLabelText("주제")).toHaveValue("jpa");
|
|
expect(screen.getByLabelText("프로젝트")).toHaveValue("backend-skeleton");
|
|
});
|
|
// 주제 선택지는 보이는 이름과 보내는 값이 다르다. 값이 이름이면 slug 로 거르는 API 가
|
|
// 0건을 돌려주고, 화면은 「조건에 맞는 공개 기록이 없습니다」만 남는다 — 운영에서 실제로
|
|
// 그랬다. 목록에서 유도한 값이라 이 단언이 없으면 조용히 되돌아간다.
|
|
expect(
|
|
within(screen.getByLabelText("주제")).getByRole("option", { name: "Authentication" }),
|
|
).toHaveValue("authentication");
|
|
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", async () => {
|
|
await 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", async () => {
|
|
await 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 } = await 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(MOCK_STUDIO_INSTALL_CONTEXT).input;
|
|
const application = createTestApplication({
|
|
diagnostics: { record },
|
|
releaseInfo: {
|
|
getCurrent: async () => release,
|
|
refresh: async () => release,
|
|
},
|
|
featureInputs: { "tech-log": techLog },
|
|
});
|
|
const { router, container } = await 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 } = await 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 } = await renderDiscoveryRoute("TECH_LOG_SEARCH", "/search");
|
|
const input = await screen.findByRole("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 } = await renderDiscoveryRoute(
|
|
"TECH_LOG_SEARCH",
|
|
"/search?q=%20%20&q=JPA",
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(router.state.location.search).toBe("");
|
|
});
|
|
expect(await screen.findByRole("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", async () => {
|
|
await 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", async () => {
|
|
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");
|
|
});
|
|
});
|