// @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"; type DecisionInput = Extract; 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 ( gateway} navigate={(href) => { void navigate(href); }} > {children} ); } function LocationProbe() { return {useLocation().pathname}; } 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( , ); 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( gateway}> , ); 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"); }); });