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.
This commit is contained in:
@@ -34,6 +34,7 @@ import { ApplicationProvider } from "../../../src/presentation/providers/applica
|
||||
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,
|
||||
@@ -54,7 +55,7 @@ type DiscoveryRenderOptions = Readonly<{
|
||||
application?: ReturnType<typeof createTestApplication>;
|
||||
}>;
|
||||
|
||||
function renderDiscoveryRoute<RouteId extends DiscoveryRouteId>(
|
||||
async function renderDiscoveryRoute<RouteId extends DiscoveryRouteId>(
|
||||
routeId: RouteId,
|
||||
initialEntry: string,
|
||||
options: DiscoveryRenderOptions = {},
|
||||
@@ -93,6 +94,7 @@ function renderDiscoveryRoute<RouteId extends DiscoveryRouteId>(
|
||||
);
|
||||
const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
|
||||
const view = render(
|
||||
renderWithQueryProviders(
|
||||
<ApplicationProvider
|
||||
application={
|
||||
options.application ??
|
||||
@@ -103,6 +105,13 @@ function renderDiscoveryRoute<RouteId extends DiscoveryRouteId>(
|
||||
>
|
||||
<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"];
|
||||
@@ -122,7 +131,7 @@ afterEach(() => {
|
||||
|
||||
describe("TechLog home discovery", () => {
|
||||
it("renders the exact identity, focus, latest ordering, and explore choices", async () => {
|
||||
const { router } = renderDiscoveryRoute(
|
||||
const { router } = await renderDiscoveryRoute(
|
||||
"TECH_LOG_HOME",
|
||||
"/?focus=invalid&focus=question",
|
||||
);
|
||||
@@ -174,7 +183,7 @@ describe("TechLog home discovery", () => {
|
||||
|
||||
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 { router } = await renderDiscoveryRoute("TECH_LOG_HOME", "/?focus=question");
|
||||
const question = screen.getByRole("tab", { name: "열린 질문" });
|
||||
question.focus();
|
||||
|
||||
@@ -195,7 +204,7 @@ describe("TechLog home discovery", () => {
|
||||
});
|
||||
|
||||
it("treats external focus URL changes as authoritative without stealing focus", async () => {
|
||||
const { router } = renderDiscoveryRoute(
|
||||
const { router } = await renderDiscoveryRoute(
|
||||
"TECH_LOG_HOME",
|
||||
"/?focus=current&state=latest-empty#latest",
|
||||
);
|
||||
@@ -250,14 +259,14 @@ describe("TechLog home discovery", () => {
|
||||
expect(screen.getAllByRole("tabpanel")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("preserves the source home empty and error states", () => {
|
||||
const emptyView = renderDiscoveryRoute("TECH_LOG_HOME", "/?state=latest-empty");
|
||||
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 = renderDiscoveryRoute(
|
||||
const latestErrorView = await renderDiscoveryRoute(
|
||||
"TECH_LOG_HOME",
|
||||
"/?state=latest-error",
|
||||
);
|
||||
@@ -270,14 +279,14 @@ describe("TechLog home discovery", () => {
|
||||
);
|
||||
latestErrorView.unmount();
|
||||
|
||||
const errorView = renderDiscoveryRoute("TECH_LOG_HOME", "/?state=site-error");
|
||||
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();
|
||||
|
||||
renderDiscoveryRoute("TECH_LOG_HOME", "/?state=focus-empty");
|
||||
await renderDiscoveryRoute("TECH_LOG_HOME", "/?state=focus-empty");
|
||||
expect(screen.queryByText("지금 집중하는 것")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -285,7 +294,7 @@ describe("TechLog home discovery", () => {
|
||||
describe("TechLog explore discovery", () => {
|
||||
it("filters by kind, topic, and project and keeps source result structure", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { router } = renderDiscoveryRoute(
|
||||
const { router } = await renderDiscoveryRoute(
|
||||
"TECH_LOG_EXPLORE",
|
||||
"/explore?type=CASE&topic=JPA&project=backend-skeleton",
|
||||
);
|
||||
@@ -294,9 +303,12 @@ describe("TechLog explore discovery", () => {
|
||||
expect(
|
||||
screen.getByText("유형과 기술 주제, 프로젝트를 조합해 공개 기록을 찾습니다."),
|
||||
).toBeVisible();
|
||||
expect(screen.getByLabelText("유형")).toHaveValue("CASE");
|
||||
expect(screen.getByLabelText("주제")).toHaveValue("JPA");
|
||||
expect(screen.getByLabelText("프로젝트")).toHaveValue("backend-skeleton");
|
||||
// 필터의 선택지는 카탈로그가 도착한 뒤 채워지고, 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과 페이징은 왜 충돌하는가/ }),
|
||||
@@ -337,8 +349,8 @@ describe("TechLog explore discovery", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders the distinct no-result state for an unmatched query filter", () => {
|
||||
renderDiscoveryRoute("TECH_LOG_EXPLORE", "/explore?topic=missing");
|
||||
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(
|
||||
@@ -351,8 +363,8 @@ describe("TechLog explore discovery", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps kind-specific copy, filtering, count, ordering, and back navigation", () => {
|
||||
renderDiscoveryRoute(
|
||||
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",
|
||||
);
|
||||
@@ -374,7 +386,7 @@ describe("TechLog explore discovery", () => {
|
||||
});
|
||||
|
||||
it("keeps the unreachable unknown-kind client fallback accessible", async () => {
|
||||
const { router, container } = renderDiscoveryRoute(
|
||||
const { router, container } = await renderDiscoveryRoute(
|
||||
"TECH_LOG_EXPLORE_KIND",
|
||||
"/explore/unknown",
|
||||
);
|
||||
@@ -425,7 +437,7 @@ describe("TechLog explore discovery", () => {
|
||||
},
|
||||
featureInputs: { "tech-log": techLog },
|
||||
});
|
||||
const { router, container } = renderDiscoveryRoute(
|
||||
const { router, container } = await renderDiscoveryRoute(
|
||||
"TECH_LOG_EXPLORE_KIND",
|
||||
"/explore/unknown",
|
||||
{ NotFoundComponent: LazyNotFound, application },
|
||||
@@ -473,7 +485,7 @@ describe("TechLog explore discovery", () => {
|
||||
describe("TechLog search discovery", () => {
|
||||
it("normalizes repeated queries, preserves result ordering, and navigates canonically", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { router } = renderDiscoveryRoute(
|
||||
const { router } = await renderDiscoveryRoute(
|
||||
"TECH_LOG_SEARCH",
|
||||
"/search?q=%20JPA%20&q=Redis&unknown=drop",
|
||||
);
|
||||
@@ -503,8 +515,8 @@ describe("TechLog search discovery", () => {
|
||||
|
||||
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: "검색어" });
|
||||
const { router } = await renderDiscoveryRoute("TECH_LOG_SEARCH", "/search");
|
||||
const input = await screen.findByRole("searchbox", { name: "검색어" });
|
||||
|
||||
await user.type(input, "Redis{Enter}");
|
||||
await waitFor(() => {
|
||||
@@ -523,7 +535,7 @@ describe("TechLog search discovery", () => {
|
||||
});
|
||||
|
||||
it("normalizes an explicitly empty first query and renders all canonical results", async () => {
|
||||
const { router } = renderDiscoveryRoute(
|
||||
const { router } = await renderDiscoveryRoute(
|
||||
"TECH_LOG_SEARCH",
|
||||
"/search?q=%20%20&q=JPA",
|
||||
);
|
||||
@@ -531,13 +543,13 @@ describe("TechLog search discovery", () => {
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.search).toBe("");
|
||||
});
|
||||
expect(screen.getByRole("heading", { name: "전체 검색 결과" })).toBeVisible();
|
||||
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", () => {
|
||||
renderDiscoveryRoute("TECH_LOG_SEARCH", "/search?q=존재하지않음");
|
||||
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(
|
||||
@@ -548,7 +560,7 @@ describe("TechLog search discovery", () => {
|
||||
});
|
||||
|
||||
describe("home focus domain", () => {
|
||||
it("normalizes selection and wraps all supported keyboard movements", () => {
|
||||
it("normalizes selection and wraps all supported keyboard movements", async () => {
|
||||
const keys = ["current", "question", "decision"] as const;
|
||||
|
||||
expect(normalizeFocus(undefined, keys)).toBe("current");
|
||||
|
||||
Reference in New Issue
Block a user