feat: port TechLog Studio editors

This commit is contained in:
DongHyeonka
2026-08-15 23:41:34 +09:00
parent 887f5e6eb1
commit 5933265975
16 changed files with 879 additions and 3 deletions
@@ -0,0 +1,253 @@
// @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 { 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";
afterEach(() => vi.restoreAllMocks());
function renderEditor(
documentId: string,
gateway: StudioGateway = createTechLogFeatureInstalledInput().input.createStudioGateway(),
) {
return render(
<MemoryRouter initialEntries={[`/studio/documents/${documentId}/edit`]}>
<StudioProvider createGateway={() => gateway}>
<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().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",
);
expect(screen.getByRole("link", { name: "저장본 검증" })).toHaveAttribute(
"href",
`/studio/documents/${FIXTURE_IDS.redisAdapterCase}/validation`,
);
const editTab = screen.getByRole("tab", { name: "편집" });
const previewTab = screen.getByRole("tab", { name: "즉시 미리보기" });
editTab.focus();
await user.keyboard("{ArrowRight}");
expect(previewTab).toHaveFocus();
expect(previewTab).toHaveAttribute("aria-selected", "true");
expect(
within(screen.getByRole("tabpanel", { name: "즉시 미리보기" })).getByRole(
"heading",
{ level: 1, name: "편집한 Redis 경계" },
),
).toBeVisible();
await user.keyboard("{Home}");
expect(editTab).toHaveFocus();
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().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);
});
});