Files
DongHyeonka 89a73c13c6 feat: delete a decision, manage assets while writing, and sweep before deploying
Four things an author could not do, and the check that should have caught them.

A Decision could not be deleted. Case, Reference and Question all could, so an
author who opened a decision draft had no way to close it. The contract gained
the operation and the list now offers it for every kind. Its path carries the
project because a decision belongs to one; a row with no project says so rather
than failing.

Assets could only be managed by leaving the document. The picker now deletes
one in place — the server still refuses an asset a document uses — so a
mistaken upload does not cost the author their editing session.

Zoom was decided for the author and could not be changed: only a DIAGRAM got
it, so a screenshot uploaded as an image or attachment went in with zoom off
and no way to turn it on. It now defaults on for images and the picker offers
the choice. The toggle is a picker control, not a document field, and carries
its own class — wearing the field class put it in the editor's field list.

`scripts/smoke/production-sweep.ts` walks every public and Studio screen and
the document flow, reporting console errors, failed API calls and error text.
It exists because verifying only the screen I had just changed is what let
broken screens reach production repeatedly; this runs before a deploy, not
after a report.
2026-08-21 17:20:42 +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);
});
});