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.
184 lines
7.3 KiB
TypeScript
184 lines
7.3 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { render, screen, waitFor, within } 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 { describe, expect, it } 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 { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
|
|
import type { StudioGateway } from "../../../src/features/tech-log/application/ports/studio-gateway.ts";
|
|
import type { WorkingCopyInput } from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
|
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" }>;
|
|
|
|
function decisionInput(): DecisionInput {
|
|
return {
|
|
kind: "PROJECT_DECISION",
|
|
title: "목록 페이징과 컬렉션 로딩을 분리합니다",
|
|
slug: "feed-pagination-boundary",
|
|
summary: "부모 페이지를 먼저 고정하고 연관 컬렉션을 별도로 조회합니다.",
|
|
topicId: FIXTURE_IDS.topicJpa,
|
|
projectId: FIXTURE_IDS.projectBackend,
|
|
relations: [
|
|
{
|
|
id: null,
|
|
targetId: FIXTURE_IDS.fetchJoinCase,
|
|
reason: "판단 근거",
|
|
order: 0,
|
|
},
|
|
],
|
|
decisionStatus: "ADOPTED",
|
|
decidedOn: "2026-08-11",
|
|
statement: "목록 조회와 컬렉션 로딩을 서로 다른 단계로 수행합니다.",
|
|
rationale: "Fetch Join이 DB LIMIT를 제거하는 문제를 피합니다.",
|
|
consequences: [
|
|
{
|
|
id: "99999999-9999-4999-8999-999999999991",
|
|
text: "목록 조회는 한 번의 SQL로 끝나지 않습니다.",
|
|
order: 0,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function RouterStudioProvider({
|
|
gateway,
|
|
children,
|
|
}: Readonly<{ gateway: StudioGateway; children: ReactNode }>) {
|
|
const navigate = useNavigate();
|
|
return (
|
|
<StudioProvider
|
|
createManagementGateway={createManagementGatewayStub}
|
|
createGateway={() => gateway}
|
|
navigate={(href) => { void navigate(href); }}
|
|
>
|
|
{children}
|
|
</StudioProvider>
|
|
);
|
|
}
|
|
|
|
function LocationProbe() {
|
|
return <output aria-label="현재 경로">{useLocation().pathname}</output>;
|
|
}
|
|
|
|
describe("TechLog Studio project decision authoring", () => {
|
|
it("creates Decision as the fourth working-copy type", async () => {
|
|
const user = userEvent.setup();
|
|
const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
|
|
render(
|
|
<MemoryRouter initialEntries={["/studio/documents/new"]}>
|
|
<RouterStudioProvider gateway={gateway}>
|
|
<NewDocumentForm />
|
|
<LocationProbe />
|
|
</RouterStudioProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
await user.click(screen.getByRole("radio", { name: /Decision/ }));
|
|
await user.click(screen.getByRole("button", { name: "작업본 만들기" }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText("현재 경로")).toHaveTextContent(
|
|
/^\/studio\/documents\/[0-9a-f-]+\/edit$/,
|
|
);
|
|
});
|
|
expect((await gateway.listDocuments({ kind: "PROJECT_DECISION" })).items)
|
|
.toHaveLength(1);
|
|
});
|
|
|
|
it("validates, previews, and publishes a project-scoped Decision", async () => {
|
|
const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
|
|
const document = await gateway.createDocument(decisionInput(), {
|
|
idempotencyKey: "create-project-decision",
|
|
});
|
|
const validation = await gateway.validateDocument(
|
|
document.id,
|
|
{ expectedVersion: document.version },
|
|
{ idempotencyKey: "validate-project-decision" },
|
|
);
|
|
expect(validation).toMatchObject({ status: "VALID", issues: [] });
|
|
|
|
const preview = await gateway.createPreview(
|
|
document.id,
|
|
{
|
|
expectedVersion: document.version,
|
|
validationId: validation.validationId,
|
|
},
|
|
{ idempotencyKey: "preview-project-decision" },
|
|
);
|
|
expect(preview.renderModel).toMatchObject({
|
|
kind: "PROJECT_DECISION",
|
|
status: "ADOPTED",
|
|
publicPath: "/projects/backend-skeleton/decisions#feed-pagination-boundary",
|
|
});
|
|
|
|
const published = await gateway.publishDocument(
|
|
document.id,
|
|
{
|
|
expectedVersion: document.version,
|
|
validationId: validation.validationId,
|
|
previewId: preview.previewId,
|
|
acknowledgedWarningCodes: [],
|
|
},
|
|
{ idempotencyKey: "publish-project-decision" },
|
|
);
|
|
expect(published.publication.publicPath).toBe(
|
|
"/projects/backend-skeleton/decisions#feed-pagination-boundary",
|
|
);
|
|
});
|
|
|
|
it("edits Decision fields and renders the project decision card preview", async () => {
|
|
const user = userEvent.setup();
|
|
const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
|
|
const document = await gateway.createDocument(decisionInput(), {
|
|
idempotencyKey: "edit-project-decision",
|
|
});
|
|
render(
|
|
<MemoryRouter initialEntries={[`/studio/documents/${document.id}/edit`]}>
|
|
<StudioProvider
|
|
createManagementGateway={createManagementGatewayStub} createGateway={() => gateway}>
|
|
<DocumentEditorScreen documentId={document.id} />
|
|
</StudioProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
expect(await screen.findByLabelText("결정 상태")).toHaveValue("ADOPTED");
|
|
expect(screen.getByLabelText("결정일")).toHaveValue("2026-08-11");
|
|
expect(screen.getByLabelText("결정문")).toHaveValue(
|
|
"목록 조회와 컬렉션 로딩을 서로 다른 단계로 수행합니다.",
|
|
);
|
|
expect(screen.getByLabelText("판단 이유")).toHaveValue(
|
|
"Fetch Join이 DB LIMIT를 제거하는 문제를 피합니다.",
|
|
);
|
|
expect(screen.getByLabelText("영향 1")).toHaveValue(
|
|
"목록 조회는 한 번의 SQL로 끝나지 않습니다.",
|
|
);
|
|
expect(screen.getByLabelText("근거 1 대상")).toHaveValue(
|
|
FIXTURE_IDS.fetchJoinCase,
|
|
);
|
|
expect(screen.getByRole("complementary", { name: "작업 상태" }))
|
|
.toHaveTextContent("종류Decision");
|
|
|
|
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
|
|
const preview = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
|
|
expect(
|
|
within(preview).getByRole("heading", {
|
|
name: "목록 페이징과 컬렉션 로딩을 분리합니다",
|
|
}),
|
|
).toBeVisible();
|
|
expect(within(preview).getByText("판단 이유")).toBeVisible();
|
|
expect(within(preview).getByText("영향")).toBeVisible();
|
|
expect(within(preview).getByText("근거 기록")).toBeVisible();
|
|
expect(within(preview).getByRole("link", {
|
|
name: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
|
})).toHaveAttribute("href", "/cases/collection-fetch-join-pagination");
|
|
});
|
|
});
|