편집기는 탭이었다. 고친 것이 어떻게 보이는지 확인하려면 편집하던 자리를 화면에서 치워야 했고, 돌아오면 스크롤 위치도 잃었다. 두 패널을 나란히 두면 그 왕복이 통째로 없어진다. 작업 상태와 저장·게시는 오른쪽 세로 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
264 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|