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
+13 -6
View File
@@ -28,6 +28,7 @@ import { MOCK_STUDIO_INSTALL_CONTEXT } from "../../helpers/studio-install-contex
import type { StudioAssetGateway } from "../../../src/features/tech-log/application/ports/studio-asset-gateway.ts";
import { StudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
import type { Asset } from "../../../src/features/tech-log/contracts/studio/contract.ts";
import { createManagementGatewayStub } from "../../helpers/management-gateway-stub.ts";
// jsdom does not implement `<dialog>` -- same polyfill `studio-save-navigation.test.tsx`
// and `studio-decision-authoring.test.tsx` already use for the other Studio dialogs.
@@ -433,7 +434,8 @@ test("inserts the directive at the saved cursor position and the live preview re
render(
<MemoryRouter initialEntries={[`/studio/documents/${FIXTURE_IDS.redisAdapterCase}/edit`]}>
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<StudioProvider
createManagementGateway={createManagementGatewayStub} createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<DocumentEditorScreen documentId={FIXTURE_IDS.redisAdapterCase} />
</StudioProvider>
</MemoryRouter>,
@@ -538,7 +540,8 @@ test("an asset uploaded through the mock composition previews live and passes mo
render(
<MemoryRouter initialEntries={[`/studio/documents/${created.id}/edit`]}>
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<StudioProvider
createManagementGateway={createManagementGatewayStub} createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<DocumentEditorScreen documentId={created.id} />
</StudioProvider>
</MemoryRouter>,
@@ -1259,7 +1262,8 @@ function caseDraft(bodyMarkdown: string) {
function renderInstantPreview(bodyMarkdown: string, assets: readonly Asset[]) {
render(
<MemoryRouter>
<StudioProvider createGateway={() => createMockStudioGateway()}>
<StudioProvider
createManagementGateway={createManagementGatewayStub} createGateway={() => createMockStudioGateway()}>
<InstantPreview draft={caseDraft(bodyMarkdown)} catalog={PREVIEW_CATALOG} assets={assets} />
</StudioProvider>
</MemoryRouter>,
@@ -1479,7 +1483,8 @@ test("an asset uploaded with alt text inserts that alt and the document validate
render(
<MemoryRouter initialEntries={[`/studio/documents/${created.id}/edit`]}>
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<StudioProvider
createManagementGateway={createManagementGatewayStub} createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<DocumentEditorScreen documentId={created.id} />
</StudioProvider>
</MemoryRouter>,
@@ -1524,7 +1529,8 @@ test("a decorative upload inserts an empty alt and the document is still publish
render(
<MemoryRouter initialEntries={[`/studio/documents/${created.id}/edit`]}>
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<StudioProvider
createManagementGateway={createManagementGatewayStub} createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<DocumentEditorScreen documentId={created.id} />
</StudioProvider>
</MemoryRouter>,
@@ -1797,7 +1803,8 @@ test("a Picker search never drops an already-inserted asset out of Instant Previ
render(
<MemoryRouter initialEntries={[`/studio/documents/${FIXTURE_IDS.redisAdapterCase}/edit`]}>
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<StudioProvider
createManagementGateway={createManagementGatewayStub} createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<DocumentEditorScreen documentId={FIXTURE_IDS.redisAdapterCase} />
</StudioProvider>
</MemoryRouter>,
@@ -16,7 +16,10 @@ type Assert<Condition extends true> = Condition;
type TechLogFeatureInputExposesNoMissingOrAdditionalKeys = Assert<
Equal<
keyof ApplicationFeatureInputs["tech-log"],
"publicContent" | "createStudioGateway" | "createStudioAssetGateway"
| "publicContent"
| "createStudioGateway"
| "createStudioAssetGateway"
| "createManagementGateway"
>
>;
type TechLogFeatureInputRegistryValueMatchesFeatureContract = Assert<
@@ -28,6 +28,7 @@ describe("TechLog navigation derivation", () => {
["작업본", "/studio/documents"],
["게시 기록", "/studio/publications"],
["새 문서", "/studio/documents/new"],
["주제·프로젝트", "/studio/taxonomy"],
]);
});
@@ -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");
@@ -26,6 +26,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,
@@ -63,7 +64,7 @@ afterEach(() => {
vi.unstubAllGlobals();
});
function renderDocumentRoute(routeId: DocumentRouteId, initialEntry: string) {
async function renderDocumentRoute(routeId: DocumentRouteId, initialEntry: string) {
const definition = TECH_LOG_ROUTE_REGISTRY[routeId];
const runtime = TECH_LOG_ROUTE_RUNTIME_CONTRACT[routeId];
const Component = routeComponents[routeId];
@@ -95,6 +96,7 @@ function renderDocumentRoute(routeId: DocumentRouteId, initialEntry: string) {
);
const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
const view = render(
renderWithQueryProviders(
<ApplicationProvider
application={createTestApplication({
featureInputs: { "tech-log": techLog },
@@ -102,7 +104,10 @@ function renderDocumentRoute(routeId: DocumentRouteId, initialEntry: string) {
>
<RouterProvider router={router} />
</ApplicationProvider>,
);
));
// 포트가 async 가 되면서 첫 페인트에는 데이터가 없다. 화면이 정착한 뒤
// 단언하도록 여기서 한 번 기다린다 — 각 테스트에 흩어 놓으면 빠뜨린 곳이 생긴다.
await screen.findByRole("main");
return { ...view, router };
}
@@ -195,8 +200,8 @@ describe("TechLog canonical Public documents", () => {
},
])(
"renders $path with exact metadata, evidence, and ordered relations",
({ routeId, path, title, kind, topic, project, published, evidence, relations }) => {
const { container } = renderDocumentRoute(routeId, path);
async ({ routeId, path, title, kind, topic, project, published, evidence, relations }) => {
const { container } = await renderDocumentRoute(routeId, path);
const main = screen.getByRole("main");
expect(within(main).getByRole("heading", { level: 1, name: title })).toBeVisible();
@@ -222,8 +227,8 @@ describe("TechLog canonical Public documents", () => {
},
);
it("preserves the specialized Fetch Join layout, TOC, rendered blocks, anchors, and evidence media", () => {
const { container } = renderDocumentRoute(
it("preserves the specialized Fetch Join layout, TOC, rendered blocks, anchors, and evidence media", async () => {
const { container } = await renderDocumentRoute(
"TECH_LOG_CASE",
"/cases/collection-fetch-join-pagination",
);
@@ -253,8 +258,8 @@ describe("TechLog canonical Public documents", () => {
expect(image).toHaveAttribute("loading", "lazy");
});
it("uses the generic Case markup and omits source relations for the canonical empty state", () => {
const generic = renderDocumentRoute(
it("uses the generic Case markup and omits source relations for the canonical empty state", async () => {
const generic = await renderDocumentRoute(
"TECH_LOG_CASE",
"/cases/redis-adapter-ttl-boundary",
);
@@ -264,7 +269,7 @@ describe("TechLog canonical Public documents", () => {
);
generic.unmount();
const empty = renderDocumentRoute(
const empty = await renderDocumentRoute(
"TECH_LOG_CASE",
"/cases/collection-fetch-join-pagination?state=relations-empty",
);
@@ -273,8 +278,8 @@ describe("TechLog canonical Public documents", () => {
expect(screen.queryByRole("heading", { name: "이 기록의 연결" })).not.toBeInTheDocument();
});
it("preserves Reference rule order and resolved Question evidence and empty copy", () => {
const reference = renderDocumentRoute(
it("preserves Reference rule order and resolved Question evidence and empty copy", async () => {
const reference = await renderDocumentRoute(
"TECH_LOG_REFERENCE",
"/references/state-and-nonce-boundary",
);
@@ -290,7 +295,7 @@ describe("TechLog canonical Public documents", () => {
expect(screen.getByText("마지막 검증 2026.08.09")).toBeVisible();
reference.unmount();
renderDocumentRoute(
await renderDocumentRoute(
"TECH_LOG_QUESTION",
"/questions/collection-fetch-join-with-pagination",
);
@@ -311,16 +316,16 @@ describe("TechLog topics and Public not-found routing", () => {
["jpa", "JPA", "목록 조회, 연관 로딩과 페이지 경계를 함께 검증한 기록입니다.", "3개의 관련 기록"],
["authentication", "Authentication", "브라우저와 Edge, Resource Server 사이의 인증 책임과 신뢰 경계를 검증한 기록입니다.", "2개의 관련 기록"],
["redis", "Redis", "애플리케이션 정책과 Redis 저장 명령의 책임 경계를 검증한 기록입니다.", "1개의 관련 기록"],
])("aggregates /topics/%s with exact copy and count", (slug, title, description, count) => {
renderDocumentRoute("TECH_LOG_TOPIC", `/topics/${slug}`);
])("aggregates /topics/%s with exact copy and count", async (slug, title, description, count) => {
await renderDocumentRoute("TECH_LOG_TOPIC", `/topics/${slug}`);
expect(screen.getByRole("heading", { level: 1, name: title })).toBeVisible();
expect(screen.getByText(description)).toBeVisible();
expect(screen.getByText(count)).toBeVisible();
});
it("keeps JPA topic records in canonical published order", () => {
const { container } = renderDocumentRoute("TECH_LOG_TOPIC", "/topics/jpa");
it("keeps JPA topic records in canonical published order", async () => {
const { container } = await renderDocumentRoute("TECH_LOG_TOPIC", "/topics/jpa");
expect(
Array.from(container.querySelectorAll(".public-record-list > li > a"), (link) =>
@@ -339,7 +344,7 @@ describe("TechLog topics and Public not-found routing", () => {
["TECH_LOG_QUESTION" as const, "/questions/not-registered"],
["TECH_LOG_TOPIC" as const, "/topics/not-registered"],
])("keeps the unreachable client fallback accessible for %s", async (routeId, path) => {
const { container, router } = renderDocumentRoute(routeId, path);
const { container, router } = await renderDocumentRoute(routeId, path);
expect(router.state.location.pathname).toBe(path);
expect(
@@ -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",
);
+16 -11
View File
@@ -11,6 +11,7 @@ import { FatalErrorState } from "../../../src/features/tech-log/presentation/pub
import { PublicShell } from "../../../src/features/tech-log/presentation/public/public-shell.tsx";
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
import { createTestApplication } from "../../helpers/create-test-application.ts";
import { renderWithQueryProviders } from "../../helpers/query-providers.tsx";
const originalShowModal = HTMLDialogElement.prototype.showModal;
const originalClose = HTMLDialogElement.prototype.close;
@@ -47,7 +48,7 @@ afterEach(() => {
});
});
function renderShell(initialEntry = "/projects") {
async function renderShell(initialEntry = "/projects") {
const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
const router = createMemoryRouter(
[
@@ -65,6 +66,7 @@ function renderShell(initialEntry = "/projects") {
{ initialEntries: [initialEntry] },
);
const view = render(
renderWithQueryProviders(
<ApplicationProvider
application={createTestApplication({
featureInputs: { "tech-log": techLog },
@@ -72,13 +74,16 @@ function renderShell(initialEntry = "/projects") {
>
<RouterProvider router={router} />
</ApplicationProvider>,
);
));
// 포트가 async 가 되면서 첫 페인트에는 데이터가 없다. 화면이 정착한 뒤
// 단언하도록 여기서 한 번 기다린다 — 각 테스트에 흩어 놓으면 빠뜨린 곳이 생긴다.
await screen.findByRole("main");
return { ...view, router };
}
describe("TechLog Public shell", () => {
it("preserves source landmark order, navigation copy, current state, and footer", () => {
const view = renderShell();
it("preserves source landmark order, navigation copy, current state, and footer", async () => {
const view = await renderShell();
const frame = view.container.querySelector(".site-frame");
expect(
@@ -127,7 +132,7 @@ describe("TechLog Public shell", () => {
it("uses the canonical root route for brand navigation", async () => {
const user = userEvent.setup();
const { router } = renderShell("/projects/backend-skeleton");
const { router } = await renderShell("/projects/backend-skeleton");
await user.click(screen.getByRole("link", { name: "TechLog 홈" }));
@@ -136,7 +141,7 @@ describe("TechLog Public shell", () => {
it("opens search by click, closes on Escape, and restores trigger focus", async () => {
const user = userEvent.setup();
renderShell();
await renderShell();
const trigger = screen.getByRole("button", { name: "TechLog 검색 열기" });
await user.click(trigger);
@@ -155,7 +160,7 @@ describe("TechLog Public shell", () => {
it("opens the native search trigger from the keyboard", async () => {
const user = userEvent.setup();
renderShell();
await renderShell();
const trigger = screen.getByRole("button", { name: "TechLog 검색 열기" });
trigger.focus();
@@ -169,7 +174,7 @@ describe("TechLog Public shell", () => {
it("returns source-equivalent results and navigates to their canonical route", async () => {
const user = userEvent.setup();
const { router } = renderShell();
const { router } = await renderShell();
await user.click(
screen.getByRole("button", { name: "TechLog 검색 열기" }),
);
@@ -191,7 +196,7 @@ describe("TechLog Public shell", () => {
it("navigates the full-search action with its canonical query intact", async () => {
const user = userEvent.setup();
const { router } = renderShell();
const { router } = await renderShell();
await user.click(
screen.getByRole("button", { name: "TechLog 검색 열기" }),
);
@@ -210,7 +215,7 @@ describe("TechLog Public shell", () => {
it("uses native mobile disclosure activation and closes it after selection", async () => {
const user = userEvent.setup();
const view = renderShell();
const view = await renderShell();
const details = view.container.querySelector<HTMLDetailsElement>(
"details.mobile-nav",
);
@@ -236,7 +241,7 @@ describe("TechLog Public shell", () => {
it("closes a natively opened mobile disclosure before opening one search dialog", async () => {
const user = userEvent.setup();
const view = renderShell();
const view = await renderShell();
const details = view.container.querySelector<HTMLDetailsElement>(
"details.mobile-nav",
);
@@ -38,6 +38,7 @@ const expectedRoutes = [
["TECH_LOG_STUDIO_PUBLICATIONS", "/studio/publications", "STUDIO", null, null],
["TECH_LOG_STUDIO_PUBLICATION_PREVIEW", "/studio/publications/:publicationEventId/preview", "STUDIO", "TechLogPublicationEventIdParams", null],
["TECH_LOG_STUDIO_ASSETS", "/studio/assets", "STUDIO", null, null],
["TECH_LOG_STUDIO_TAXONOMY", "/studio/taxonomy", "STUDIO", null, null],
["TECH_LOG_STUDIO_NOT_FOUND", "/studio/*", "STUDIO", "TechLogStudioSplat", null],
["NOT_FOUND", "*", "PUBLIC", "NotFoundSplat", null],
] as const;
@@ -69,6 +70,7 @@ const expectedTitles = {
TECH_LOG_STUDIO_PUBLICATIONS: "게시 기록",
TECH_LOG_STUDIO_PUBLICATION_PREVIEW: "게시 Snapshot",
TECH_LOG_STUDIO_ASSETS: "Asset",
TECH_LOG_STUDIO_TAXONOMY: "주제와 프로젝트",
TECH_LOG_STUDIO_NOT_FOUND: "Studio 화면을 찾을 수 없습니다",
NOT_FOUND: "페이지를 찾을 수 없습니다.",
} as const;
@@ -142,12 +144,13 @@ describe("TechLog route boundary contract", () => {
["TECH_LOG_STUDIO_DOCUMENTS", "STUDIO", "작업본", 10],
["TECH_LOG_STUDIO_DOCUMENT_NEW", "STUDIO", "새 문서", 30],
["TECH_LOG_STUDIO_PUBLICATIONS", "STUDIO", "게시 기록", 20],
["TECH_LOG_STUDIO_TAXONOMY", "STUDIO", "주제·프로젝트", 40],
]);
for (const locale of ["ko-KR", "en-US"] as const) {
const catalog: Readonly<Record<string, string>> =
TECH_LOG_MESSAGE_CATALOGS[locale];
expect(Object.keys(catalog)).toHaveLength(56);
expect(Object.keys(catalog)).toHaveLength(58);
for (const [routeId, title] of Object.entries(expectedTitles)) {
expect(catalog[`route.${routeId}.title`]).toBe(title);
expect(catalog[`route.${routeId}.navigation`]).toBe(title);
@@ -60,6 +60,7 @@ test("installs TechLog beside the retained reference feature through application
assert.deepEqual(Object.keys(installed), ["reference-feature", "tech-log"]);
assert.deepEqual(Object.keys(installed["tech-log"]).sort(), [
"createManagementGateway",
"createStudioAssetGateway",
"createStudioGateway",
"publicContent",
@@ -14,6 +14,7 @@ import type { WorkingCopyInput } from "../../../src/features/tech-log/contracts/
import { DocumentEditorScreen } from "../../../src/features/tech-log/presentation/studio/components/document-editor-screen.tsx";
import { NewDocumentForm } from "../../../src/features/tech-log/presentation/studio/components/new-document-form.tsx";
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
import { createManagementGatewayStub } from "../../helpers/management-gateway-stub.ts";
type DecisionInput = Extract<WorkingCopyInput, { kind: "PROJECT_DECISION" }>;
@@ -54,6 +55,7 @@ function RouterStudioProvider({
const navigate = useNavigate();
return (
<StudioProvider
createManagementGateway={createManagementGatewayStub}
createGateway={() => gateway}
navigate={(href) => { void navigate(href); }}
>
@@ -140,7 +142,8 @@ describe("TechLog Studio project decision authoring", () => {
});
render(
<MemoryRouter initialEntries={[`/studio/documents/${document.id}/edit`]}>
<StudioProvider createGateway={() => gateway}>
<StudioProvider
createManagementGateway={createManagementGatewayStub} createGateway={() => gateway}>
<DocumentEditorScreen documentId={document.id} />
</StudioProvider>
</MemoryRouter>,
@@ -12,6 +12,7 @@ import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtur
import type { StudioGateway } from "../../../src/features/tech-log/application/ports/studio-gateway.ts";
import { DocumentEditorScreen } from "../../../src/features/tech-log/presentation/studio/components/document-editor-screen.tsx";
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
import { createManagementGatewayStub } from "../../helpers/management-gateway-stub.ts";
afterEach(() => vi.restoreAllMocks());
@@ -26,7 +27,8 @@ function renderEditor(
.input.createStudioAssetGateway();
return render(
<MemoryRouter initialEntries={[`/studio/documents/${documentId}/edit`]}>
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<StudioProvider
createManagementGateway={createManagementGatewayStub} createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<DocumentEditorScreen documentId={documentId} />
</StudioProvider>
</MemoryRouter>,
@@ -24,6 +24,7 @@ import { PublicationEventPreviewScreen } from "../../../src/features/tech-log/pr
import { PublicationList } from "../../../src/features/tech-log/presentation/studio/components/publication-list.tsx";
import { PublishScreen } from "../../../src/features/tech-log/presentation/studio/components/publish-screen.tsx";
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
import { createManagementGatewayStub } from "../../helpers/management-gateway-stub.ts";
const originalShowModal = HTMLDialogElement.prototype.showModal;
const originalClose = HTMLDialogElement.prototype.close;
@@ -85,7 +86,8 @@ function renderInStudio(
) {
return render(
<MemoryRouter initialEntries={["/studio"]}>
<StudioProvider createGateway={() => gateway} navigate={navigate}>
<StudioProvider
createManagementGateway={createManagementGatewayStub} createGateway={() => gateway} navigate={navigate}>
{node}
</StudioProvider>
</MemoryRouter>,
@@ -23,6 +23,7 @@ import {
useStudio,
useStudioEditorSession,
} from "../../../src/features/tech-log/presentation/studio/use-studio.ts";
import { createManagementGatewayStub } from "../../helpers/management-gateway-stub.ts";
const originalShowModal = HTMLDialogElement.prototype.showModal;
const originalClose = HTMLDialogElement.prototype.close;
@@ -86,7 +87,8 @@ function renderEditor(documentId: string, gateway: StudioGateway) {
.input.createStudioAssetGateway();
return render(
<MemoryRouter initialEntries={[`/studio/documents/${documentId}/edit`]}>
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<StudioProvider
createManagementGateway={createManagementGatewayStub} createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<DocumentEditorScreen documentId={documentId} />
<Announcement />
</StudioProvider>
@@ -214,7 +216,8 @@ describe("TechLog Studio dirty navigation", () => {
const saved = await getSavedDocument(gateway);
render(
<MemoryRouter initialEntries={[`/studio/documents/${saved.id}/edit`]}>
<StudioProvider createGateway={() => gateway} navigate={(href) => destinations.push(href)}>
<StudioProvider
createManagementGateway={createManagementGatewayStub} createGateway={() => gateway} navigate={(href) => destinations.push(href)}>
<DirtyNavigationProbe saved={saved} />
</StudioProvider>
</MemoryRouter>,
@@ -251,7 +254,8 @@ describe("TechLog Studio dirty navigation", () => {
const saved = await getSavedDocument(gateway);
render(
<MemoryRouter initialEntries={[`/studio/documents/${saved.id}/edit`]}>
<StudioProvider createGateway={() => gateway} navigate={(href) => destinations.push(href)}>
<StudioProvider
createManagementGateway={createManagementGatewayStub} createGateway={() => gateway} navigate={(href) => destinations.push(href)}>
<DirtyNavigationProbe saved={saved} />
</StudioProvider>
</MemoryRouter>,
@@ -283,7 +287,8 @@ describe("TechLog Studio dirty navigation", () => {
const saved = await getSavedDocument(base);
render(
<MemoryRouter initialEntries={[`/studio/documents/${saved.id}/edit`]}>
<StudioProvider createGateway={() => gateway} navigate={(href) => destinations.push(href)}>
<StudioProvider
createManagementGateway={createManagementGatewayStub} createGateway={() => gateway} navigate={(href) => destinations.push(href)}>
<DirtyNavigationProbe saved={saved} />
</StudioProvider>
</MemoryRouter>,
@@ -16,6 +16,7 @@ import { StudioDashboard } from "../../../src/features/tech-log/presentation/stu
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
import { createTestApplication } from "../../helpers/create-test-application.ts";
import { createManagementGatewayStub } from "../../helpers/management-gateway-stub.ts";
afterEach(() => vi.restoreAllMocks());
@@ -30,6 +31,7 @@ function RouterStudioProvider({
const navigate = useNavigate();
return (
<StudioProvider
createManagementGateway={createManagementGatewayStub}
createGateway={() => gateway}
navigate={(href) => {
void navigate(href);
@@ -15,10 +15,11 @@ import { createExternalAuthSessionAdapter } from "../../../src/adapters/auth/ext
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
import { SessionProvider } from "../../../src/presentation/providers/session-provider.tsx";
import { createTestApplication } from "../../helpers/create-test-application.ts";
import { renderWithQueryProviders } from "../../helpers/query-providers.tsx";
afterEach(() => vi.restoreAllMocks());
function renderStudio(
async function renderStudio(
initialEntry: string,
createStudioGateway: () => StudioGateway,
) {
@@ -62,12 +63,16 @@ function renderStudio(
},
});
const view = render(
renderWithQueryProviders(
<ApplicationProvider application={application}>
<SessionProvider>
<RouterProvider router={router} />
</SessionProvider>
</ApplicationProvider>,
);
));
// 포트가 async 가 되면서 첫 페인트에는 데이터가 없다. 화면이 정착한 뒤
// 단언하도록 여기서 한 번 기다린다 — 각 테스트에 흩어 놓으면 빠뜨린 곳이 생긴다.
await screen.findByRole("main");
return { ...view, router };
}
@@ -77,7 +82,7 @@ describe("TechLog Studio shell", () => {
const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
const createGateway = vi.fn(() => gateway);
const { container, router } = renderStudio("/studio", createGateway);
const { container, router } = await renderStudio("/studio", createGateway);
expect(await screen.findByRole("heading", { level: 1, name: "작업 흐름" })).toBeVisible();
expect(createGateway).toHaveBeenCalledTimes(1);
@@ -90,6 +95,7 @@ describe("TechLog Studio shell", () => {
["작업본", "/studio/documents"],
["게시 기록", "/studio/publications"],
["새 문서", "/studio/documents/new"],
["주제·프로젝트", "/studio/taxonomy"],
["공개 사이트 보기", "/"],
]);
expect(screen.getByRole("link", { name: "공개 사이트 보기" })).toHaveAttribute("href", "/");
@@ -117,7 +123,7 @@ describe("TechLog Studio shell", () => {
.mockReturnValueOnce(first)
.mockReturnValueOnce(second);
renderStudio("/studio", createGateway);
await renderStudio("/studio", createGateway);
await waitFor(() => expect(firstSignal).toBeDefined());
await user.click(screen.getByRole("button", { name: "Studio 메뉴 열기" }));
expect(screen.getByRole("button", { name: "Studio 메뉴 닫기" })).toBeVisible();
@@ -131,12 +137,12 @@ describe("TechLog Studio shell", () => {
expect(secondDashboard).toHaveBeenCalledTimes(1);
});
it("keeps unknown Studio routes inside the Studio shell without authentication UI", () => {
it("keeps unknown Studio routes inside the Studio shell without authentication UI", async () => {
const createGateway = vi.fn(
() => createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(),
);
renderStudio("/studio/does-not-exist", createGateway);
await renderStudio("/studio/does-not-exist", createGateway);
expect(screen.getByRole("banner")).toHaveClass("studio-header");
expect(screen.getByRole("heading", { level: 1, name: "Studio 화면을 찾을 수 없습니다" })).toBeVisible();
@@ -13,6 +13,7 @@ import { DocumentEditorScreen } from "../../../src/features/tech-log/presentatio
import { PublicPreviewScreen } from "../../../src/features/tech-log/presentation/studio/components/public-preview-screen.tsx";
import { ValidationScreen } from "../../../src/features/tech-log/presentation/studio/components/validation-screen.tsx";
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
import { createManagementGatewayStub } from "../../helpers/management-gateway-stub.ts";
class NoopIntersectionObserver implements IntersectionObserver {
readonly root = null;
@@ -44,7 +45,8 @@ function renderStudio(
) {
return render(
<MemoryRouter initialEntries={["/studio"]}>
<StudioProvider createGateway={() => gateway}>{child}</StudioProvider>
<StudioProvider
createManagementGateway={createManagementGatewayStub} createGateway={() => gateway}>{child}</StudioProvider>
</MemoryRouter>,
);
}
@@ -130,7 +132,8 @@ describe("TechLog Studio validation workflow", () => {
view.rerender(
<MemoryRouter initialEntries={["/studio"]}>
<StudioProvider createGateway={() => gateway}>
<StudioProvider
createManagementGateway={createManagementGatewayStub} createGateway={() => gateway}>
<ValidationScreen documentId={FIXTURE_IDS.stateNonceReference} />
</StudioProvider>
</MemoryRouter>,
+23 -17
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { render } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
@@ -14,6 +14,7 @@ import { FatalErrorState } from "../../../src/features/tech-log/presentation/pub
import { PublicShell } from "../../../src/features/tech-log/presentation/public/public-shell.tsx";
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
import { createTestApplication } from "../../helpers/create-test-application.ts";
import { renderWithQueryProviders } from "../../helpers/query-providers.tsx";
let styleElement: HTMLStyleElement;
@@ -33,7 +34,7 @@ afterAll(() => {
styleElement.remove();
});
function renderPublicSurface(node: React.ReactNode) {
async function renderPublicSurface(node: React.ReactNode) {
const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
const shell = createElement(PublicShell, { children: node });
const router = createElement(
@@ -41,19 +42,24 @@ function renderPublicSurface(node: React.ReactNode) {
{ initialEntries: ["/projects"] },
shell,
);
return render(
createElement(ApplicationProvider, {
application: createTestApplication({
featureInputs: { "tech-log": techLog },
const view = render(
renderWithQueryProviders(
createElement(ApplicationProvider, {
application: createTestApplication({
featureInputs: { "tech-log": techLog },
}),
children: router,
}),
children: router,
}),
),
);
// 포트가 async 가 되면서 첫 페인트에는 데이터가 없다. 화면이 정착한 뒤 단언한다.
await screen.findByRole("main");
return view;
}
describe("TechLog consumer-visible style contract", () => {
it("applies the source typography, palette, shell width, and header spacing", () => {
const view = renderPublicSurface(
it("applies the source typography, palette, shell width, and header spacing", async () => {
const view = await renderPublicSurface(
createElement(
"main",
{ id: "main-content", className: "shell" },
@@ -97,8 +103,8 @@ describe("TechLog consumer-visible style contract", () => {
expect(getComputedStyle(wordmark).fontWeight).toBe("680");
});
it("aligns code, table, and evidence widths with the article body", () => {
const view = renderPublicSurface(
it("aligns code, table, and evidence widths with the article body", async () => {
const view = await renderPublicSurface(
createElement(
"main",
{ id: "main-content", className: "shell" },
@@ -152,7 +158,7 @@ describe("TechLog consumer-visible style contract", () => {
it("preserves keyboard focus and the real search-field focus style", async () => {
const user = userEvent.setup();
const view = renderPublicSurface(
const view = await renderPublicSurface(
createElement(
"main",
{ id: "main-content", className: "shell" },
@@ -181,8 +187,8 @@ describe("TechLog consumer-visible style contract", () => {
);
});
it("keeps every header and dialog control at the source minimum target size", () => {
const view = renderPublicSurface(
it("keeps every header and dialog control at the source minimum target size", async () => {
const view = await renderPublicSurface(
createElement(
"main",
{ id: "main-content", className: "shell" },
@@ -218,8 +224,8 @@ describe("TechLog consumer-visible style contract", () => {
expect(getComputedStyle(dialogInner!).padding).toBe("27px 28px 22px");
});
it("preserves footer spacing and fatal-action sizing", () => {
const view = renderPublicSurface(
it("preserves footer spacing and fatal-action sizing", async () => {
const view = await renderPublicSurface(
createElement(FatalErrorState, {
traceId: "PUBLIC-500",
retryHref: "/",
@@ -26,6 +26,8 @@ function contextValue(
return {
gateway: {} as never,
assetGateway,
// 이 테스트는 Asset 접근자만 고정한다 — 관리 게이트웨이는 쓰이지 않으므로 자리만 채운다.
managementGateway: {} as never,
resolvePublishedLabel: () => undefined,
now: () => new Date("2026-08-14T01:00:00.000Z"),
editor: null,