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.
153 lines
7.2 KiB
TypeScript
153 lines
7.2 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { Outlet, RouterProvider, createMemoryRouter } from "react-router-dom";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
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 type { StudioGateway } from "../../../src/features/tech-log/application/ports/studio-gateway.ts";
|
|
import { StudioHomePage } from "../../../src/features/tech-log/presentation/studio/pages/studio-home-page.tsx";
|
|
import { StudioNotFoundPage } from "../../../src/features/tech-log/presentation/studio/pages/studio-not-found-page.tsx";
|
|
import { StudioShell } from "../../../src/features/tech-log/presentation/studio/studio-shell.tsx";
|
|
import { createExternalAuthSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.ts";
|
|
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());
|
|
|
|
async function renderStudio(
|
|
initialEntry: string,
|
|
createStudioGateway: () => StudioGateway,
|
|
) {
|
|
const router = createMemoryRouter(
|
|
[
|
|
{
|
|
path: "/studio",
|
|
element: (
|
|
<StudioShell>
|
|
<Outlet />
|
|
</StudioShell>
|
|
),
|
|
children: [
|
|
{ index: true, element: <StudioHomePage /> },
|
|
{ path: "documents", element: <p>작업본 라우트</p> },
|
|
{ path: "documents/new", element: <p>새 문서 라우트</p> },
|
|
{ path: "publications", element: <p>게시 기록 라우트</p> },
|
|
{ path: "*", element: <StudioNotFoundPage /> },
|
|
],
|
|
},
|
|
],
|
|
{ initialEntries: [initialEntry] },
|
|
);
|
|
const installed = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
|
|
const application = createTestApplication({
|
|
// The Studio shell draws its chrome only for an authenticated session — a
|
|
// signed-out visitor gets the sign-in surface and nothing else, which is
|
|
// what these tests are not about. In the real app AppRouter supplies both
|
|
// the session state and the provider; here the harness does.
|
|
session: createExternalAuthSessionAdapter({
|
|
readState: () => "authenticated" as const,
|
|
subscribe: () => () => {},
|
|
beginSignIn: async () => {},
|
|
signOut: async () => {},
|
|
attachCredential: async () => ({ headers: {} }),
|
|
recoverSession: async () => "restored" as const,
|
|
notifyUnauthenticated: () => {},
|
|
}),
|
|
featureInputs: {
|
|
"tech-log": { ...installed, createStudioGateway },
|
|
},
|
|
});
|
|
const view = render(
|
|
renderWithQueryProviders(
|
|
<ApplicationProvider application={application}>
|
|
<SessionProvider>
|
|
<RouterProvider router={router} />
|
|
</SessionProvider>
|
|
</ApplicationProvider>,
|
|
));
|
|
// 포트가 async 가 되면서 첫 페인트에는 데이터가 없다. 화면이 정착한 뒤
|
|
// 단언하도록 여기서 한 번 기다린다 — 각 테스트에 흩어 놓으면 빠뜨린 곳이 생긴다.
|
|
await screen.findByRole("main");
|
|
return { ...view, router };
|
|
}
|
|
|
|
describe("TechLog Studio shell", () => {
|
|
it("creates one application-provided gateway for child navigation and exposes the source header", async () => {
|
|
const user = userEvent.setup();
|
|
const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
|
|
const createGateway = vi.fn(() => gateway);
|
|
|
|
const { container, router } = await renderStudio("/studio", createGateway);
|
|
|
|
expect(await screen.findByRole("heading", { level: 1, name: "작업 흐름" })).toBeVisible();
|
|
expect(createGateway).toHaveBeenCalledTimes(1);
|
|
expect(
|
|
Array.from(
|
|
container.querySelectorAll<HTMLAnchorElement>(".studio-desktop-navigation a"),
|
|
(link) => [link.textContent, link.getAttribute("href")],
|
|
),
|
|
).toEqual([
|
|
["작업본", "/studio/documents"],
|
|
["게시 기록", "/studio/publications"],
|
|
["새 문서", "/studio/documents/new"],
|
|
["주제·프로젝트", "/studio/taxonomy"],
|
|
["공개 사이트 보기", "/"],
|
|
]);
|
|
expect(screen.getByRole("link", { name: "공개 사이트 보기" })).toHaveAttribute("href", "/");
|
|
expect(screen.queryByText(/로그인|sign in/i)).not.toBeInTheDocument();
|
|
|
|
await user.click(within(screen.getByRole("navigation", { name: "Studio 주 탐색" })).getByRole("link", { name: "작업본" }));
|
|
|
|
expect(router.state.location.pathname).toBe("/studio/documents");
|
|
expect(screen.getByText("작업본 라우트")).toBeVisible();
|
|
expect(createGateway).toHaveBeenCalledTimes(1);
|
|
expect(screen.getAllByRole("link", { name: "작업본" })[0]).toHaveAttribute("aria-current", "page");
|
|
});
|
|
|
|
it("recreates the provider generation and cancels obsolete work after a persisted pageshow", async () => {
|
|
const user = userEvent.setup();
|
|
let firstSignal: AbortSignal | undefined;
|
|
const first = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
|
|
const second = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
|
|
vi.spyOn(first, "getDashboard").mockImplementation(({ signal } = {}) => {
|
|
firstSignal = signal;
|
|
return new Promise(() => {});
|
|
});
|
|
const secondDashboard = vi.spyOn(second, "getDashboard");
|
|
const createGateway = vi.fn()
|
|
.mockReturnValueOnce(first)
|
|
.mockReturnValueOnce(second);
|
|
|
|
await renderStudio("/studio", createGateway);
|
|
await waitFor(() => expect(firstSignal).toBeDefined());
|
|
await user.click(screen.getByRole("button", { name: "Studio 메뉴 열기" }));
|
|
expect(screen.getByRole("button", { name: "Studio 메뉴 닫기" })).toBeVisible();
|
|
|
|
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
|
|
|
|
await waitFor(() => expect(createGateway).toHaveBeenCalledTimes(2));
|
|
expect(firstSignal?.aborted).toBe(true);
|
|
expect(screen.getByRole("button", { name: "Studio 메뉴 열기" })).toHaveAttribute("aria-expanded", "false");
|
|
expect(await screen.findByRole("heading", { level: 1, name: "작업 흐름" })).toBeVisible();
|
|
expect(secondDashboard).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps unknown Studio routes inside the Studio shell without authentication UI", async () => {
|
|
const createGateway = vi.fn(
|
|
() => createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(),
|
|
);
|
|
|
|
await renderStudio("/studio/does-not-exist", createGateway);
|
|
|
|
expect(screen.getByRole("banner")).toHaveClass("studio-header");
|
|
expect(screen.getByRole("heading", { level: 1, name: "Studio 화면을 찾을 수 없습니다" })).toBeVisible();
|
|
expect(screen.getByRole("link", { name: "작업본으로 돌아가기" })).toHaveAttribute("href", "/studio/documents");
|
|
expect(screen.queryByText(/로그인|sign in/i)).not.toBeInTheDocument();
|
|
});
|
|
});
|