Files
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

264 lines
13 KiB
TypeScript

// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { MemoryRouter } 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 { 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 { DocumentEditorScreen } from "../../../src/features/tech-log/presentation/studio/components/document-editor-screen.tsx";
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
import { createManagementGatewayStub } from "../../helpers/management-gateway-stub.ts";
afterEach(() => vi.restoreAllMocks());
function renderEditor(
documentId: string,
gateway: StudioGateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(),
) {
// CASE editors mount the Asset Picker (Task 10), which uses the throwing
// `useStudioAssetGateway()` accessor -- a test harness that renders it must
// supply `createAssetGateway`, the same as `StudioShell` always does.
const assetGateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT)
.input.createStudioAssetGateway();
return render(
<MemoryRouter initialEntries={[`/studio/documents/${documentId}/edit`]}>
<StudioProvider
createManagementGateway={createManagementGatewayStub} createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<DocumentEditorScreen documentId={documentId} />
</StudioProvider>
</MemoryRouter>,
);
}
function labelsIn(container: HTMLElement, selector: string): string[] {
return Array.from(
container.querySelectorAll<HTMLElement>(selector),
(label) => label.querySelector(":scope > span")?.textContent ?? "",
);
}
describe("TechLog Studio document editor", () => {
it("loads the Case controls in source order, owns dirty state, and renders an instant local preview", async () => {
const user = userEvent.setup();
const base = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
const calls = { save: 0, validate: 0, preview: 0, publish: 0 };
const gateway = {
...base,
saveDocument(...args: Parameters<StudioGateway["saveDocument"]>) {
calls.save += 1;
return base.saveDocument(...args);
},
validateDocument(...args: Parameters<StudioGateway["validateDocument"]>) {
calls.validate += 1;
return base.validateDocument(...args);
},
createPreview(...args: Parameters<StudioGateway["createPreview"]>) {
calls.preview += 1;
return base.createPreview(...args);
},
publishDocument(...args: Parameters<StudioGateway["publishDocument"]>) {
calls.publish += 1;
return base.publishDocument(...args);
},
} satisfies StudioGateway;
const view = renderEditor(FIXTURE_IDS.redisAdapterCase, gateway);
expect(await screen.findByLabelText("제목")).toHaveValue(
"Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유",
);
expect(screen.getByLabelText("slug")).toHaveValue("redis-adapter-ttl-boundary");
expect(screen.getByLabelText("요약")).toHaveValue(
"만료 정책과 명령 실행 책임을 분리했습니다.",
);
expect(screen.getByLabelText("Topic")).toHaveValue(FIXTURE_IDS.topicRedis);
expect(screen.getByLabelText("Project")).toHaveValue(FIXTURE_IDS.projectBackend);
expect(labelsIn(view.container, ".studio-field")).toEqual([
"제목",
"slug",
"요약",
"Topic",
"Project",
"문제",
"결론",
"검증 환경",
"재현 조건",
"마지막 검증일",
"본문 Markdown",
]);
expect(screen.getByLabelText("문제")).toHaveValue("저장 기술이 정책을 소유했습니다.");
expect(screen.getByLabelText("본문 Markdown")).toHaveValue(
"## 책임 경계\n\n정책과 명령을 분리합니다.",
);
expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장됨");
await user.clear(screen.getByLabelText("제목"));
await user.type(screen.getByLabelText("제목"), "편집한 Redis 경계");
expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent(
"저장되지 않음",
);
expect(screen.getByRole("complementary", { name: "작업 상태" })).toHaveTextContent(
"저장 버전4종류CASE",
);
// 작성자가 밟는 단계는 저장과 게시 둘뿐이다. 예전에는 검증 → 미리보기 → 게시 세 화면을
// 차례로 거쳐야 했는데, 그 셋은 백엔드가 게시 요청에 요구하는 값을 만들기 위한 것이지
// 작성자에게 물어볼 것이 아니었다 — 지금은 게시 버튼 뒤에서 잇달아 부른다.
const rail = screen.getByRole("complementary", { name: "작업 상태" });
expect(within(rail).getAllByRole("button").map((button) => button.textContent)).toEqual([
"저장",
"게시",
]);
expect(within(rail).queryAllByRole("link")).toHaveLength(0);
// 편집과 미리보기는 한 화면에 함께 있다. 탭이었을 때는 고친 결과를 보려면 편집하던 자리를
// 화면에서 치워야 했고, 돌아오면 스크롤 위치도 잃었다 — 그 왕복이 없어야 한다는 것이
// 이 화면의 요구다. 아무것도 누르지 않은 채로 둘 다 보이는지 묻는다.
expect(screen.getByRole("region", { name: "문서 편집" })).toBeVisible();
expect(
within(screen.getByRole("region", { name: "즉시 미리보기" })).getByRole(
"heading",
{ level: 1, name: "편집한 Redis 경계" },
),
).toBeVisible();
expect(screen.queryByRole("tab", { name: "즉시 미리보기" })).toBeNull();
expect(screen.getByLabelText("제목")).toHaveValue("편집한 Redis 경계");
expect(calls).toEqual({ save: 0, validate: 0, preview: 0, publish: 0 });
});
it("adds, reorders, and removes relations with source accessibility names", async () => {
const user = userEvent.setup();
renderEditor(FIXTURE_IDS.redisAdapterCase);
await screen.findByLabelText("제목");
await user.click(screen.getByRole("button", { name: "관계 추가" }));
await user.click(screen.getByRole("button", { name: "관계 추가" }));
await user.selectOptions(
screen.getByLabelText("관계 1 대상"),
FIXTURE_IDS.fetchJoinCase,
);
await user.type(screen.getByLabelText("관계 1 이유"), "첫 번째 근거");
await user.selectOptions(
screen.getByLabelText("관계 2 대상"),
FIXTURE_IDS.stateNonceReference,
);
await user.type(screen.getByLabelText("관계 2 이유"), "두 번째 근거");
const relationRows = screen.getAllByLabelText(/관계 \d 대상/).map((select) =>
select.closest(".studio-relation-item") as HTMLElement,
);
await user.click(within(relationRows[1]!).getByRole("button", { name: "위로" }));
expect(screen.getByLabelText("관계 1 대상")).toHaveValue(
FIXTURE_IDS.stateNonceReference,
);
expect(screen.getByLabelText("관계 1 이유")).toHaveValue("두 번째 근거");
const firstRow = screen.getByLabelText("관계 1 대상").closest(
".studio-relation-item",
) as HTMLElement;
await user.click(within(firstRow).getByRole("button", { name: "삭제" }));
expect(screen.getByLabelText("관계 1 대상")).toHaveValue(FIXTURE_IDS.fetchJoinCase);
expect(screen.queryByLabelText("관계 2 대상")).not.toBeInTheDocument();
});
it("preserves Reference loaded values and ordered rule/text-list behavior", async () => {
const user = userEvent.setup();
renderEditor(FIXTURE_IDS.stateNonceReference);
expect(await screen.findByLabelText("목적")).toHaveValue(
"각 검증값의 책임을 다시 찾는 기준입니다.",
);
expect(screen.getByLabelText("규칙 1 제목")).toHaveValue("state는 요청을 연결합니다");
expect(screen.getByLabelText("적용 조건 1")).toHaveValue("Code Flow를 구성할 때");
expect(screen.getByLabelText("마지막 검증일")).toHaveValue("2026-08-13");
expect(screen.getByText("아직 입력한 항목이 없습니다.")).toBeVisible();
await user.click(screen.getByRole("button", { name: "규칙 추가" }));
await user.type(screen.getByLabelText("규칙 2 제목"), "nonce는 Token을 연결합니다");
const secondRule = screen.getByLabelText("규칙 2 제목").closest(
".studio-ordered-item",
) as HTMLElement;
await user.click(within(secondRule).getByRole("button", { name: "위로" }));
expect(screen.getByLabelText("규칙 1 제목")).toHaveValue(
"nonce는 Token을 연결합니다",
);
await user.click(
within(
screen.getByLabelText("규칙 1 제목").closest(".studio-ordered-item") as HTMLElement,
).getByRole("button", { name: "삭제" }),
);
expect(screen.getByLabelText("규칙 1 제목")).toHaveValue("state는 요청을 연결합니다");
await user.click(screen.getByRole("button", { name: "적용 조건 추가" }));
await user.type(screen.getByLabelText("적용 조건 2"), "두 번째 적용 조건");
const secondApplyWhen = screen.getByLabelText("적용 조건 2").closest(
".studio-ordered-item",
) as HTMLElement;
await user.click(within(secondApplyWhen).getByRole("button", { name: "위로" }));
expect(screen.getByLabelText("적용 조건 1")).toHaveValue("두 번째 적용 조건");
await user.click(
within(
screen.getByLabelText("적용 조건 1").closest(".studio-ordered-item") as HTMLElement,
).getByRole("button", { name: "삭제" }),
);
expect(screen.getByLabelText("적용 조건 1")).toHaveValue("Code Flow를 구성할 때");
expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent(
"저장되지 않음",
);
});
it("preserves Question field differences, list controls, and conditional resolution fields", async () => {
const user = userEvent.setup();
renderEditor(FIXTURE_IDS.edgeTokenQuestion);
expect(await screen.findByLabelText("질문 상태")).toHaveValue("OPEN");
expect(screen.getByLabelText("미지수 1")).toHaveValue("신뢰 헤더 위조 가능성");
expect(screen.getByRole("textbox", { name: "다음 검증" })).toHaveValue(
"위협 모델을 비교합니다.",
);
expect(screen.queryByLabelText("해결 요약")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "사실 추가" }));
await user.type(screen.getByLabelText("사실 1"), "Edge가 서명을 검증합니다");
await user.click(screen.getByRole("button", { name: "선택지 추가" }));
expect(screen.getByLabelText("선택지 1 제목")).toHaveValue("");
expect(screen.getByLabelText("선택지 1 설명")).toHaveValue("");
await user.selectOptions(screen.getByLabelText("질문 상태"), "RESOLVED");
expect(screen.getByLabelText("해결 요약")).toHaveValue("");
expect(screen.getByLabelText("해결 근거")).toHaveValue("");
expect(screen.getByLabelText("근거 링크 문구")).toHaveValue("");
expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent(
"저장되지 않음",
);
});
it("renders source not-found and retry surfaces for document loading", async () => {
const unknown = renderEditor("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
expect(
await screen.findByRole("heading", { name: "Studio 화면을 찾을 수 없습니다" }),
).toBeVisible();
expect(screen.getByRole("link", { name: "작업본으로 돌아가기" })).toHaveAttribute(
"href",
"/studio/documents",
);
unknown.unmount();
const base = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
const getDocument = vi
.fn<StudioGateway["getDocument"]>()
.mockRejectedValueOnce(new Error("offline"))
.mockImplementation((id, options) => base.getDocument(id, options));
renderEditor(FIXTURE_IDS.redisAdapterCase, { ...base, getDocument });
expect(await screen.findByRole("alert")).toHaveTextContent(
"문서를 불러오지 못했습니다offline",
);
fireEvent.click(screen.getByRole("button", { name: "다시 시도" }));
await waitFor(() => expect(screen.getByLabelText("제목")).toBeVisible());
expect(getDocument).toHaveBeenCalledTimes(2);
});
});