Files
tech-log-frontend/tests/features/tech-log/public-discovery-screens.test.tsx
T
DongHyeonka 11c2713139 feat: let Studio create the topics and projects publishing requires
Publishing needs a topic and nothing could create one. The backend now owns
that surface; this is its consumer — the management contract vendored, a
gateway over its nine operations, and one Studio screen that lists, creates,
and deletes topics and projects.

The screen adds no CSS. It reuses the classes the working-copy list already
uses, so it inherits Studio's spacing, type, and colour rather than growing a
second visual vocabulary beside them. Scope stops at list/create/delete:
renaming, phase changes, and visibility are implemented in the backend and
declared in the contract, but their screens are a separate design.

Two real defects surfaced while making the public port async, and both would
have shipped:

The search page and the header search dialog shared a query key. With an empty
query, `["tech-log","search",""]` was identical for both, so react-query
handed one surface the other's cache — different shapes — and the page died
reading a field that was not there. Keys now name the surface.

The explore filter's selects are uncontrolled and read `defaultValue`, which
React applies once. Their options arrive later now, so the first render had
nothing to match and the value stayed empty: a topic in the URL no longer
showed as selected. The form key includes whether the catalog has arrived, so
it remounts with the options present. Controlled inputs would be the other
answer, but this form submits to build a URL — the URL owns the value.

The route brought its own bookkeeping: a build chunk, a manual accessibility
evidence file, and the CI artifact baseline that counts them. The gate pins a
digest of its own shape precisely so a new route cannot slip in without that
count being reviewed.

Test harnesses that render public screens now assemble the query providers and
await the settled paint, because the screens they render became async.
2026-08-20 23:40:15 +09:00

578 lines
22 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");
});
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");
});
});