// @vitest-environment jsdom import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import { afterAll, afterEach, beforeAll, 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 { PublicPreviewScreen } from "../../../src/features/tech-log/presentation/studio/components/public-preview-screen.tsx"; import { ValidationScreen } from "../../../src/features/tech-log/presentation/studio/components/validation-screen.tsx"; import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx"; import { createManagementGatewayStub } from "../../helpers/management-gateway-stub.ts"; class NoopIntersectionObserver implements IntersectionObserver { readonly root = null; readonly rootMargin = "0px"; readonly scrollMargin = "0px"; readonly thresholds = [0]; disconnect() {} observe() {} takeRecords(): IntersectionObserverEntry[] { return []; } unobserve() {} } beforeAll(() => { vi.stubGlobal("IntersectionObserver", NoopIntersectionObserver); }); afterAll(() => { vi.unstubAllGlobals(); }); afterEach(() => vi.restoreAllMocks()); function renderStudio( child: React.ReactNode, gateway: StudioGateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(), ) { return render( gateway}>{child} , ); } describe("TechLog Studio validation workflow", () => { it("reruns stale saved validation, reports exact freshness copy, and creates a new key per command", async () => { const user = userEvent.setup(); const base = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(); const keys: string[] = []; const gateway = { ...base, validateDocument(...args: Parameters) { keys.push(args[2].idempotencyKey); return base.validateDocument(...args); }, } satisfies StudioGateway; renderStudio(, gateway); expect(await screen.findByText("저장 후 다시 검증해야 합니다")).toBeVisible(); expect(screen.getByText("이전 결과")).toBeVisible(); await user.click(screen.getByRole("button", { name: "다시 검증" })); expect(await screen.findByText("검증이 완료되었습니다.")).toBeVisible(); expect(screen.getByText("현재")).toBeVisible(); expect(screen.getByRole("link", { name: "Public Preview 만들기" })).toHaveAttribute( "href", `/studio/documents/${FIXTURE_IDS.fetchJoinCase}/preview`, ); await user.click(screen.getByRole("button", { name: "다시 검증" })); await waitFor(() => expect(keys).toHaveLength(2)); expect(keys[0]).toBeTruthy(); expect(keys[1]).not.toBe(keys[0]); }); it("orders validation issues and links each JSON pointer to the affected editor field", async () => { const user = userEvent.setup(); const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(); const view = renderStudio( , gateway, ); const issue = await screen.findByText("사실이 필요합니다."); expect(issue.closest("a")).toHaveAttribute( "href", `/studio/documents/${FIXTURE_IDS.edgeTokenQuestion}/edit#studio-field-facts`, ); expect(screen.getByText("검증 오류를 수정해야 합니다")).toBeVisible(); expect(screen.getByRole("link", { name: "편집 화면에서 수정" })).toBeVisible(); await user.click(screen.getByRole("button", { name: "다시 검증" })); const warning = await screen.findByText("선택지 두 개를 권장합니다."); expect(warning.closest("a")).toHaveAttribute( "href", `/studio/documents/${FIXTURE_IDS.edgeTokenQuestion}/edit#studio-field-options`, ); view.unmount(); renderStudio(, gateway); await screen.findByRole("heading", { level: 1, name: "문서 편집" }); expect(document.querySelector("#studio-field-facts")).toBeInTheDocument(); expect(document.querySelector("#studio-field-options")).toBeInTheDocument(); }); it("aborts a route-obsolete validation read without surfacing an abort error", async () => { const base = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(); let firstSignal: AbortSignal | undefined; const getDocument = vi .fn() .mockImplementationOnce((_id, options) => { firstSignal = options?.signal; return new Promise((_resolve, reject) => { options?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError"))); }); }) .mockImplementation((id, options) => base.getDocument(id, options)); const gateway = { ...base, getDocument }; const view = renderStudio( , gateway, ); await waitFor(() => expect(firstSignal).toBeDefined()); view.rerender( gateway}> , ); await waitFor(() => expect(firstSignal?.aborted).toBe(true)); expect(await screen.findByText("현재 저장 버전의 검증을 통과했습니다")).toBeVisible(); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); }); describe("TechLog Studio Public Preview workflow", () => { it("creates a missing preview, shows the current state, and renders the shared Public document", async () => { const user = userEvent.setup(); const base = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(); const keys: string[] = []; const gateway = { ...base, createPreview(...args: Parameters) { keys.push(args[2].idempotencyKey); return base.createPreview(...args); }, } satisfies StudioGateway; const view = renderStudio( , gateway, ); expect(await screen.findByText("아직 만든 Public Preview가 없습니다")).toBeVisible(); await user.click(screen.getByRole("button", { name: "Public Preview 만들기" })); expect(await screen.findByText("현재 저장 버전의 Public Preview입니다.")).toBeVisible(); expect(screen.getByRole("link", { name: "게시 준비로 이동" })).toHaveAttribute( "href", `/studio/documents/${FIXTURE_IDS.stateNonceReference}/publish`, ); expect(screen.getByRole("heading", { level: 1, name: "Authorization Code Flow에서 state와 nonce의 경계" })).toBeVisible(); expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1); expect(view.container.querySelector("main main")).toBeNull(); expect(view.container.querySelector(".public-record-embedded")).toBeInTheDocument(); expect(keys).toHaveLength(1); expect(keys[0]).toBeTruthy(); }); it("keeps a stale preview inspectable and points to validation as the exact next action", async () => { renderStudio(); expect(await screen.findByText("저장본보다 이전에 만든 Public Preview입니다.")).toBeVisible(); expect(screen.getByText("STALE")).toBeVisible(); expect(screen.getByRole("heading", { level: 1, name: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가" })).toBeVisible(); expect(screen.getByRole("link", { name: "검증 화면으로 이동" })).toHaveAttribute( "href", `/studio/documents/${FIXTURE_IDS.fetchJoinCase}/validation`, ); expect(screen.queryByRole("button", { name: "Public Preview 다시 만들기" })).not.toBeInTheDocument(); }); it("keeps an expired preview inspectable and blocks recreation until validation", async () => { renderStudio(); expect(await screen.findByText("이 Public Preview는 만료되었습니다.")).toBeVisible(); expect(screen.getByText("EXPIRED")).toBeVisible(); expect(screen.getByRole("heading", { level: 1, name: "만료 미리보기 예시" })).toBeVisible(); expect(screen.getByRole("link", { name: "검증 화면으로 이동" })).toHaveAttribute( "href", `/studio/documents/${FIXTURE_IDS.expiredPreviewCase}/validation`, ); }); it("shows retryable preview-load errors and retries the read", async () => { const base = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(); const getDocument = vi .fn() .mockRejectedValueOnce(new Error("offline")) .mockImplementation((id, options) => base.getDocument(id, options)); renderStudio( , { ...base, getDocument }, ); expect(await screen.findByRole("alert")).toHaveTextContent( "Public Preview를 불러오지 못했습니다offline", ); fireEvent.click(screen.getByRole("button", { name: "다시 시도" })); expect(await screen.findByText("아직 만든 Public Preview가 없습니다")).toBeVisible(); expect(getDocument).toHaveBeenCalledTimes(2); }); it("blocks an expired validation until validation runs again", async () => { renderStudio(); expect(await screen.findByText("저장 후 다시 검증해야 합니다")).toBeVisible(); expect(screen.queryByRole("link", { name: "Public Preview 만들기" })).not.toBeInTheDocument(); }); it("renders the source not-found state for unknown document ids", async () => { renderStudio( , ); expect(await screen.findByRole("heading", { level: 1, name: "작업본을 찾을 수 없습니다" })).toBeVisible(); expect(screen.getByRole("link", { name: "작업본으로 돌아가기" })).toHaveAttribute( "href", "/studio/documents", ); }); });