Publishing was reachable only by walking the whole chain blind. The editor offered one link — "저장본 검증" — and the word "게시" appeared nowhere until three screens later, so an author with a finished draft could not tell how to publish it. The working-copy list already named the next step, but the name was plain text with nowhere to go. The editor now shows the whole path: 검증 → 미리보기 → 게시, each a link. The list's next step is a link to that step. Neither weakens the gates — an unvalidated document is still refused at preview, an unpreviewed one at publish. What changes is that the order stops being a secret. What is blocking publication now appears where it gets fixed. The validation report lived on its own screen, so an author read the list, navigated back, and had to remember which field each item meant. The editor shows the same issues above the fields, in red, and says plainly when they describe an older saved version rather than the current one. The clock is read during render, not captured as an effect dependency. It is a new function on every render of the provider, so depending on it refetched the document endlessly — the editor never settled, and a tab click did not even register. A value you are asking about now does not belong in a dependency array.
270 lines
13 KiB
TypeScript
270 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 flow = screen.getByRole("navigation", { name: "게시까지의 단계" });
|
|
expect(
|
|
within(flow)
|
|
.getAllByRole("link")
|
|
.map((link) => [link.textContent, link.getAttribute("href")]),
|
|
).toEqual([
|
|
["검증", `/studio/documents/${FIXTURE_IDS.redisAdapterCase}/validation`],
|
|
["미리보기", `/studio/documents/${FIXTURE_IDS.redisAdapterCase}/preview`],
|
|
["게시", `/studio/documents/${FIXTURE_IDS.redisAdapterCase}/publish`],
|
|
]);
|
|
|
|
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(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);
|
|
});
|
|
});
|