Files
tech-log-frontend/tests/features/tech-log/studio-decision-authoring.test.tsx
DongHyeonkaandClaude Opus 5 60c8c82097 fix: 편집과 미리보기를 한 화면에서 보고, 저장·게시를 아래에 고정한다
편집기는 탭이었다. 고친 것이 어떻게 보이는지 확인하려면 편집하던 자리를 화면에서
치워야 했고, 돌아오면 스크롤 위치도 잃었다. 두 패널을 나란히 두면 그 왕복이 통째로
없어진다.

작업 상태와 저장·게시는 오른쪽 세로 rail 이었다. 그 자리는 편집기와 미리보기가 함께
쓸 가로 폭을 가져갔고, 편집 칸이 길어질수록 rail 은 위에 붙은 채 본문만 멀어졌다.
화면 아래에 고정하면 폭을 돌려주면서 스크롤 위치와 무관하게 손이 닿는다.

탭을 없앴으므로 각 패널이 스스로 이름을 가져야 한다. `aria-labelledby` 로 제목을
가리켜 landmark 로 만든다 — 탭 목록이 하던 "여기는 편집, 저기는 미리보기" 안내를
대신한다. `aside` 와 「작업 상태」라는 이름은 그대로 둔다. 자리가 바뀐 것이지 이
묶음이 무엇인지가 바뀐 것은 아니다.

미리보기가 늘 떠 있게 되면서 Picker 테스트의 단언도 함께 고쳤다. 「Picker 가
좁아졌는가」를 화면 전체에 묻고 있었는데, 이제는 미리보기 쪽의 같은 Asset 까지
세게 된다. 그 김에 `.studio-asset-panel` 의 `aria-labelledby` 가 role 없는 div 에서
아무 이름도 만들지 못하던 것도 `role="group"` 으로 실효화했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 15:49:18 +09:00

183 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 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");
const preview = screen.getByRole("region", { 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");
});
});