Files
tech-log-frontend/tests/features/tech-log/studio-screens-smoke.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

163 lines
7.6 KiB
TypeScript

// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { MemoryRouter, useLocation, useNavigate } 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 type { DocumentPage } from "../../../src/features/tech-log/contracts/studio/contract.ts";
import { DocumentList } from "../../../src/features/tech-log/presentation/studio/components/document-list.tsx";
import { NewDocumentForm } from "../../../src/features/tech-log/presentation/studio/components/new-document-form.tsx";
import { StudioDashboard } from "../../../src/features/tech-log/presentation/studio/components/studio-dashboard.tsx";
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());
function LocationProbe() {
return <output aria-label="현재 경로">{useLocation().pathname}</output>;
}
function RouterStudioProvider({
children,
gateway,
}: Readonly<{ children: ReactNode; gateway: StudioGateway }>) {
const navigate = useNavigate();
return (
<StudioProvider
createManagementGateway={createManagementGatewayStub}
createGateway={() => gateway}
navigate={(href) => {
void navigate(href);
}}
>
{children}
</StudioProvider>
);
}
function renderScreen(children: ReactNode, gateway: StudioGateway) {
const input = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
return render(
<ApplicationProvider
application={createTestApplication({
featureInputs: { "tech-log": { ...input, createStudioGateway: () => gateway } },
})}
>
<MemoryRouter initialEntries={["/studio"]}>
<RouterStudioProvider gateway={gateway}>{children}</RouterStudioProvider>
</MemoryRouter>
</ApplicationProvider>,
);
}
describe("TechLog Studio index screens", () => {
it("shows the source dashboard totals, workflow sections, status links, and sample rows", async () => {
const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
const { container } = renderScreen(<StudioDashboard />, gateway);
const summary = await screen.findByLabelText("Studio 요약");
expect(summary).toHaveTextContent("전체 작업본7");
expect(summary).toHaveTextContent("검증할 기록5");
expect(summary).toHaveTextContent("게시 준비0");
expect(summary).toHaveTextContent("게시 기록4");
expect(screen.getByRole("heading", { level: 1, name: "작업 흐름" })).toBeVisible();
expect(
Array.from(container.querySelectorAll(".studio-section-title a"), (link) => [
link.textContent,
link.getAttribute("href"),
]),
).toEqual([
["전체 보기", "/studio/documents"],
["전체 보기", "/studio/documents"],
["전체 보기", "/studio/documents"],
["게시 기록 보기", "/studio/publications"],
]);
expect(screen.getAllByText("컬렉션 Fetch Join과 페이징은 왜 충돌하는가").length).toBeGreaterThan(0);
});
it("searches and filters documents and renders the source empty state", async () => {
const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
renderScreen(<DocumentList />, gateway);
expect(await screen.findByText("7개의 작업본")).toBeVisible();
fireEvent.change(screen.getByLabelText("검색"), { target: { value: "Fetch Join" } });
fireEvent.submit(screen.getByRole("search"));
expect(await screen.findByText("1개의 작업본")).toBeVisible();
expect(screen.getByText("컬렉션 Fetch Join과 페이징은 왜 충돌하는가")).toBeVisible();
fireEvent.change(screen.getByLabelText("종류"), { target: { value: "QUESTION" } });
expect(await screen.findByText("0개의 작업본")).toBeVisible();
expect(screen.getByRole("heading", { level: 2, name: "조건에 맞는 작업본이 없습니다" })).toBeVisible();
});
it("cancels an obsolete list request and advances cursor pagination", async () => {
const base = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
const all = await base.listDocuments({ limit: 20 });
let obsoleteSignal: AbortSignal | undefined;
const firstPage: DocumentPage = { items: all.items.slice(0, 1), nextCursor: "next-page" };
const secondPage: DocumentPage = { items: all.items.slice(1, 2), nextCursor: null };
const listDocuments = vi.fn<StudioGateway["listDocuments"]>()
.mockImplementationOnce((_query, { signal } = {}) => {
obsoleteSignal = signal;
return new Promise(() => {});
})
.mockResolvedValueOnce(firstPage)
.mockResolvedValueOnce(secondPage);
const gateway = { ...base, listDocuments } satisfies StudioGateway;
renderScreen(<DocumentList />, gateway);
fireEvent.change(screen.getByLabelText("종류"), { target: { value: "CASE" } });
await waitFor(() => expect(obsoleteSignal?.aborted).toBe(true));
expect(await screen.findByText("1개의 작업본")).toBeVisible();
await userEvent.click(screen.getByRole("button", { name: "다음 작업본" }));
await waitFor(() => expect(listDocuments).toHaveBeenCalledTimes(3));
expect(screen.getByText(secondPage.items[0]!.title)).toBeVisible();
expect(screen.queryByRole("button", { name: "다음 작업본" })).not.toBeInTheDocument();
});
it("shows a retry action after a list failure and recovers", async () => {
const user = userEvent.setup();
const base = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
const recovered = await base.listDocuments({ limit: 20 });
const gateway = {
...base,
listDocuments: vi.fn<StudioGateway["listDocuments"]>()
.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce(recovered),
} satisfies StudioGateway;
renderScreen(<DocumentList />, gateway);
expect(await screen.findByRole("alert")).toHaveTextContent("작업본을 불러오지 못했습니다.");
await user.click(screen.getByRole("button", { name: "다시 시도" }));
expect(await screen.findByText("7개의 작업본")).toBeVisible();
expect(gateway.listDocuments).toHaveBeenCalledTimes(2);
});
it("creates the selected Question in the same session and redirects to its editor", async () => {
const user = userEvent.setup();
const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
renderScreen(
<>
<NewDocumentForm />
<LocationProbe />
</>,
gateway,
);
await user.click(screen.getByRole("radio", { name: /Question/ }));
await user.click(screen.getByRole("button", { name: "작업본 만들기" }));
await waitFor(() => expect(screen.getByLabelText("현재 경로")).toHaveTextContent(/^\/studio\/documents\/[0-9a-f-]+\/edit$/));
expect((await gateway.listDocuments({ kind: "QUESTION" })).items).toHaveLength(2);
});
});