// @vitest-environment jsdom import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useEffect } from "react"; import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, 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 { StudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts"; import type { StudioGateway } from "../../../src/features/tech-log/application/ports/studio-gateway.ts"; import type { WorkingCopy, WorkingCopyInput, WorkingCopyDetail, } from "../../../src/features/tech-log/contracts/studio/contract.ts"; import { DocumentEditorScreen } from "../../../src/features/tech-log/presentation/studio/components/document-editor-screen.tsx"; import { GuardedStudioLink } from "../../../src/features/tech-log/presentation/studio/components/guarded-studio-link.tsx"; import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx"; import { useStudio, useStudioEditorSession, } from "../../../src/features/tech-log/presentation/studio/use-studio.ts"; import { createManagementGatewayStub } from "../../helpers/management-gateway-stub.ts"; const originalShowModal = HTMLDialogElement.prototype.showModal; const originalClose = HTMLDialogElement.prototype.close; beforeEach(() => { Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value(this: HTMLDialogElement) { this.setAttribute("open", ""); }, }); Object.defineProperty(HTMLDialogElement.prototype, "close", { configurable: true, value(this: HTMLDialogElement) { this.removeAttribute("open"); this.dispatchEvent(new Event("close")); }, }); }); afterEach(() => { vi.restoreAllMocks(); Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: originalShowModal, }); Object.defineProperty(HTMLDialogElement.prototype, "close", { configurable: true, value: originalClose, }); }); function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((nextResolve, nextReject) => { resolve = nextResolve; reject = nextReject; }); return { promise, resolve, reject }; } function inputOf(document: WorkingCopy): WorkingCopyInput { const { id, version, updatedAt, ...input } = document; void id; void version; void updatedAt; return input; } function Announcement() { const { requestAnnouncement } = useStudio(); return

{requestAnnouncement}

; } function renderEditor(documentId: string, gateway: StudioGateway) { // 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 DirtyNavigationProbe({ saved }: { saved: WorkingCopy }) { const { begin, updateDraft } = useStudioEditorSession(); useEffect(() => { begin(saved, inputOf(saved)); updateDraft({ ...inputOf(saved), title: "바뀐 제목" }); }, [begin, saved, updateDraft]); return 작업본; } async function getSavedDocument(gateway: StudioGateway) { return (await gateway.getDocument(FIXTURE_IDS.redisAdapterCase)).document; } describe("TechLog Studio save workflow", () => { it("shows save pending and success states and creates a fresh idempotency key for each command", async () => { const user = userEvent.setup(); const base = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(); const first = deferred(); const keys: string[] = []; let calls = 0; const gateway = { ...base, saveDocument(...args: Parameters) { keys.push(args[2].idempotencyKey); calls += 1; return calls === 1 ? first.promise.then(() => base.saveDocument(...args)) : base.saveDocument(...args); }, } satisfies StudioGateway; renderEditor(FIXTURE_IDS.redisAdapterCase, gateway); await user.clear(await screen.findByLabelText("제목")); await user.type(screen.getByLabelText("제목"), "첫 저장 제목"); await user.click(screen.getByRole("button", { name: "저장" })); expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장 중…"); expect(screen.getByRole("button", { name: "저장 중…" })).toBeDisabled(); first.resolve(); await waitFor(() => expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장됨")); expect(screen.getByTestId("announcement")).toHaveTextContent( "버전 5으로 저장했습니다.", ); await user.clear(screen.getByLabelText("제목")); await user.type(screen.getByLabelText("제목"), "두 번째 저장 제목"); await user.click(screen.getByRole("button", { name: "저장" })); await waitFor(() => expect(keys).toHaveLength(2)); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBeTruthy(); expect(keys[1]).not.toBe(keys[0]); }); it("keeps the local draft and disables overwrite after a revision conflict", async () => { const user = userEvent.setup(); const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(); renderEditor(FIXTURE_IDS.conflictCase, gateway); await user.clear(await screen.findByLabelText("제목")); await user.type(screen.getByLabelText("제목"), "내 충돌 초안"); await user.click(screen.getByRole("button", { name: "저장" })); expect(await screen.findByRole("alert")).toHaveTextContent( "서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.", ); expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장 충돌"); expect(screen.getByLabelText("제목")).toHaveValue("내 충돌 초안"); expect(screen.getByRole("button", { name: "저장" })).toBeDisabled(); }); it("surfaces a save error and retries with a new user-command key without losing input", async () => { const user = userEvent.setup(); const base = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(); const keys: string[] = []; const retryable = new StudioGatewayError({ type: "https://techlog.local/problems/studio-unavailable", title: "STUDIO_UNAVAILABLE", status: 503, detail: "Studio가 잠시 응답하지 않습니다.", code: "STUDIO_UNAVAILABLE", retryable: true, }); const saveDocument = vi .fn() .mockImplementationOnce((_id, _command, options) => { keys.push(options.idempotencyKey); return Promise.reject(retryable); }) .mockImplementation((...args) => { keys.push(args[2].idempotencyKey); return base.saveDocument(...args); }); renderEditor(FIXTURE_IDS.redisAdapterCase, { ...base, saveDocument }); await user.clear(await screen.findByLabelText("제목")); await user.type(screen.getByLabelText("제목"), "오류 뒤에도 남는 초안"); await user.click(screen.getByRole("button", { name: "저장" })); await waitFor(() => expect(screen.getByTestId("announcement")).toHaveTextContent( "Studio가 잠시 응답하지 않습니다.", )); expect(screen.getByLabelText("제목")).toHaveValue("오류 뒤에도 남는 초안"); expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장되지 않음"); await user.click(screen.getByRole("button", { name: "저장" })); await waitFor(() => expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장됨")); expect(saveDocument).toHaveBeenCalledTimes(2); expect(keys[1]).not.toBe(keys[0]); }); }); describe("TechLog Studio dirty navigation", () => { it("opens the source modal dialog, protects browser unload, and restores focus when staying", async () => { const user = userEvent.setup(); const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(); const destinations: string[] = []; const saved = await getSavedDocument(gateway); render( gateway} navigate={(href) => destinations.push(href)}> , ); await waitFor(() => { const unload = new Event("beforeunload", { cancelable: true }); window.dispatchEvent(unload); expect(unload.defaultPrevented).toBe(true); }); const trigger = screen.getByRole("link", { name: "작업본" }); trigger.focus(); await user.click(trigger); const dialog = screen.getByRole("dialog", { name: "저장하지 않은 변경" }); expect(dialog).toHaveAttribute("open"); expect(screen.getByRole("button", { name: "이 페이지에 머무르기" })).toHaveFocus(); expect(screen.getAllByRole("button").map((button) => button.textContent)).toEqual([ "이 페이지에 머무르기", "변경 버리기", "저장 후 이동", ]); await user.click(screen.getByRole("button", { name: "이 페이지에 머무르기" })); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); expect(trigger).toHaveFocus(); expect(destinations).toEqual([]); }); it("discards the draft and follows the pending internal destination", async () => { const user = userEvent.setup(); const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(); const destinations: string[] = []; const saved = await getSavedDocument(gateway); render( gateway} navigate={(href) => destinations.push(href)}> , ); await user.click(screen.getByRole("link", { name: "작업본" })); await user.click(screen.getByRole("button", { name: "변경 버리기" })); expect(destinations).toEqual(["/studio/documents"]); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); const unload = new Event("beforeunload", { cancelable: true }); window.dispatchEvent(unload); expect(unload.defaultPrevented).toBe(false); }); it("saves the draft before following the pending destination", async () => { const user = userEvent.setup(); const base = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(); const pending = deferred(); let key = ""; const gateway = { ...base, saveDocument(...args: Parameters) { key = args[2].idempotencyKey; return pending.promise; }, } satisfies StudioGateway; const destinations: string[] = []; const saved = await getSavedDocument(base); render( gateway} navigate={(href) => destinations.push(href)}> , ); await user.click(screen.getByRole("link", { name: "작업본" })); await user.click(screen.getByRole("button", { name: "저장 후 이동" })); expect(screen.getByRole("button", { name: "저장 중" })).toBeDisabled(); expect(destinations).toEqual([]); pending.resolve({ ...(await base.getDocument(saved.id)), document: { ...saved, version: saved.version + 1, title: "바뀐 제목" }, }); await waitFor(() => expect(destinations).toEqual(["/studio/documents"])); expect(key).toBeTruthy(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); });