// @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( gateway} createAssetGateway={() => assetGateway}> , ); } function labelsIn(container: HTMLElement, selector: string): string[] { return Array.from( container.querySelectorAll(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) { calls.save += 1; return base.saveDocument(...args); }, validateDocument(...args: Parameters) { calls.validate += 1; return base.validateDocument(...args); }, createPreview(...args: Parameters) { calls.preview += 1; return base.createPreview(...args); }, publishDocument(...args: Parameters) { 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); 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() .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); }); });