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:
DongHyeonka
2026-08-20 23:40:15 +09:00
parent 4b62bf3b1f
commit 11c2713139
52 changed files with 16509 additions and 134 deletions
@@ -31,6 +31,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,
@@ -52,7 +53,7 @@ const routeComponents = {
type PublicIndexRouteId = keyof typeof routeComponents;
function renderPublicRoute(routeId: PublicIndexRouteId, initialEntry: string) {
async function renderPublicRoute(routeId: PublicIndexRouteId, initialEntry: string) {
const routeIds = routeId === "NOT_FOUND" ? [routeId] : [routeId, "NOT_FOUND" as const];
const registry = Object.fromEntries(
routeIds.map((id) => [id, TECH_LOG_ROUTE_REGISTRY[id]]),
@@ -85,6 +86,7 @@ function renderPublicRoute(routeId: PublicIndexRouteId, initialEntry: string) {
);
const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
const view = render(
renderWithQueryProviders(
<ApplicationProvider
application={createTestApplication({
featureInputs: { "tech-log": techLog },
@@ -92,7 +94,10 @@ function renderPublicRoute(routeId: PublicIndexRouteId, initialEntry: string) {
>
<RouterProvider router={router} />
</ApplicationProvider>,
);
));
// 포트가 async 가 되면서 첫 페인트에는 데이터가 없다. 화면이 정착한 뒤
// 단언하도록 여기서 한 번 기다린다 — 각 테스트에 흩어 놓으면 빠뜨린 곳이 생긴다.
await screen.findByRole("main");
return { ...view, router };
}
@@ -101,8 +106,8 @@ function projectNavigation() {
}
describe("TechLog project screens", () => {
it("renders the exact ordered project index and project links", () => {
const { container } = renderPublicRoute("TECH_LOG_PROJECTS", "/projects");
it("renders the exact ordered project index and project links", async () => {
const { container } = await renderPublicRoute("TECH_LOG_PROJECTS", "/projects");
const main = screen.getByRole("main");
expect(within(main).getByRole("heading", { level: 1, name: "프로젝트" })).toBeVisible();
@@ -145,8 +150,8 @@ describe("TechLog project screens", () => {
stats: ["2공개 기록", "1설계 결정", "2활동 기록"],
topics: ["Authentication", "OAuth 2.0", "OIDC", "Keycloak"],
},
])("renders $path overview with source counts and topics", ({ path, title, current, thesis, stats, topics }) => {
const { container } = renderPublicRoute("TECH_LOG_PROJECT", path);
])("renders $path overview with source counts and topics", async ({ path, title, current, thesis, stats, topics }) => {
const { container } = await renderPublicRoute("TECH_LOG_PROJECT", path);
const main = screen.getByRole("main");
expect(within(main).getByRole("heading", { level: 1, name: title })).toBeVisible();
@@ -167,8 +172,8 @@ describe("TechLog project screens", () => {
["TECH_LOG_PROJECT_RECORDS" as const, "/projects/backend-skeleton/records", "기록"],
["TECH_LOG_PROJECT_DECISIONS" as const, "/projects/backend-skeleton/decisions", "결정"],
["TECH_LOG_PROJECT_ACTIVITY" as const, "/projects/backend-skeleton/activity", "활동"],
])("derives the active project tab from %s location", (routeId, path, activeLabel) => {
renderPublicRoute(routeId, path);
])("derives the active project tab from %s location", async (routeId, path, activeLabel) => {
await renderPublicRoute(routeId, path);
expect(
within(projectNavigation())
@@ -199,8 +204,8 @@ describe("TechLog project screens", () => {
"/questions/validate-edge-token-again",
],
},
])("keeps $slug records filtered and published in source order", ({ slug, expected }) => {
const { container } = renderPublicRoute(
])("keeps $slug records filtered and published in source order", async ({ slug, expected }) => {
const { container } = await renderPublicRoute(
"TECH_LOG_PROJECT_RECORDS",
`/projects/${slug}/records`,
);
@@ -213,8 +218,8 @@ describe("TechLog project screens", () => {
expect(screen.getByText(`${expected.length}개의 공개 기록`)).toBeVisible();
});
it("keeps project decisions ordered with stable IDs and evidence links", () => {
const { container } = renderPublicRoute(
it("keeps project decisions ordered with stable IDs and evidence links", async () => {
const { container } = await renderPublicRoute(
"TECH_LOG_PROJECT_DECISIONS",
"/projects/backend-skeleton/decisions",
);
@@ -240,8 +245,8 @@ describe("TechLog project screens", () => {
]);
});
it("keeps project activity ordered and preserves self-fragment and record links", () => {
const { container } = renderPublicRoute(
it("keeps project activity ordered and preserves self-fragment and record links", async () => {
const { container } = await renderPublicRoute(
"TECH_LOG_PROJECT_ACTIVITY",
"/projects/backend-skeleton/activity",
);
@@ -273,8 +278,8 @@ describe("TechLog project screens", () => {
});
describe("TechLog release and profile screens", () => {
it("renders the ordered release index and complete version link", () => {
const { container } = renderPublicRoute("TECH_LOG_RELEASES", "/releases");
it("renders the ordered release index and complete version link", async () => {
const { container } = await renderPublicRoute("TECH_LOG_RELEASES", "/releases");
expect(screen.getByRole("heading", { level: 1, name: "변경 기록" })).toBeVisible();
expect(
@@ -297,8 +302,8 @@ describe("TechLog release and profile screens", () => {
]);
});
it("renders exact release sections and ordered cross-links", () => {
const { container } = renderPublicRoute("TECH_LOG_RELEASE", "/releases/0.1.0");
it("renders exact release sections and ordered cross-links", async () => {
const { container } = await renderPublicRoute("TECH_LOG_RELEASE", "/releases/0.1.0");
const main = screen.getByRole("main");
expect(
@@ -325,8 +330,8 @@ describe("TechLog release and profile screens", () => {
).toEqual(["/", "/explore", "/cases/collection-fetch-join-pagination"]);
});
it("renders the grounded profile, principles, project links, and topics", () => {
const { container } = renderPublicRoute("TECH_LOG_PROFILE", "/profile");
it("renders the grounded profile, principles, project links, and topics", async () => {
const { container } = await renderPublicRoute("TECH_LOG_PROFILE", "/profile");
const main = screen.getByRole("main");
expect(within(main).getByRole("heading", { level: 1, name: "동현" })).toBeVisible();
@@ -362,7 +367,7 @@ describe("TechLog Public not-found runtime", () => {
["TECH_LOG_RELEASE" as const, "/releases/9.9.9"],
["TECH_LOG_CASE" as const, "/cases/not-registered"],
])("keeps the client-only fallback accessible for %s", async (routeId, path) => {
const { container, router } = renderPublicRoute(routeId, path);
const { container, router } = await renderPublicRoute(routeId, path);
expect(router.state.location.pathname).toBe(path);
expect(
@@ -374,8 +379,8 @@ describe("TechLog Public not-found runtime", () => {
expect(container.querySelector(".site-frame")).not.toBeNull();
});
it("keeps the client-only Public catch-all accessible", () => {
const { container, router } = renderPublicRoute(
it("keeps the client-only Public catch-all accessible", async () => {
const { container, router } = await renderPublicRoute(
"NOT_FOUND",
"/definitely-not-a-product-route",
);