// @vitest-environment jsdom import assert from "node:assert/strict"; import { afterAll, beforeAll, test } from "vitest"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import { AssetPicker, buildEvidenceDirective, } from "../../../src/features/tech-log/presentation/studio/components/asset-picker.tsx"; import { AssetUploadDialog, stateForError, stateForUploaded, } from "../../../src/features/tech-log/presentation/studio/components/asset-upload-dialog.tsx"; 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 { createMockStudioGateway } from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts"; import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts"; import type { StudioAssetGateway } from "../../../src/features/tech-log/application/ports/studio-asset-gateway.ts"; import { StudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts"; import type { Asset } from "../../../src/features/tech-log/contracts/studio/contract.ts"; // jsdom does not implement `` -- same polyfill `studio-save-navigation.test.tsx` // and `studio-decision-authoring.test.tsx` already use for the other Studio dialogs. // `beforeAll`/`afterAll` (not `beforeEach`/`afterEach`) so the polyfill stays in place // for the whole file's run, including the global `cleanup()` from `tests/setup.ts` // that unmounts components (and so runs dialog close-on-unmount effects) between tests. const originalShowModal = HTMLDialogElement.prototype.showModal; const originalClose = HTMLDialogElement.prototype.close; beforeAll(() => { 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")); }, }); }); afterAll(() => { Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: originalShowModal, }); Object.defineProperty(HTMLDialogElement.prototype, "close", { configurable: true, value: originalClose, }); }); const READY = { id: "11111111-1111-4111-8111-111111111111", assetKey: "fetch-strategy-boundary", kind: "DIAGRAM", mediaType: "image/svg+xml", originalFilename: "boundary.svg", byteSize: 4096, width: 1080, height: 420, altText: "Fetch Join 경계", decorative: false, managementStatus: "READY", publicPath: "/media/fetch-strategy-boundary.svg", usageCount: 1, version: 1, createdAt: "2026-08-14T01:00:00.000Z", updatedAt: "2026-08-14T01:00:00.000Z", }; const QUARANTINED = { ...READY, id: "22222222-2222-4222-8222-222222222222", assetKey: "unsafe", managementStatus: "QUARANTINED" }; function gatewayOf(items: unknown[]): StudioAssetGateway { return { async listAssets() { return { items, nextCursor: null } as never; }, async uploadAsset() { throw new Error("not used"); }, async getAsset() { throw new Error("not used"); }, async updateAssetMetadata() { throw new Error("not used"); }, async deleteAsset() {}, }; } test("builds the evidence directive with escaped attribute values", () => { assert.equal( buildEvidenceDirective({ assetKey: "fetch-strategy-boundary", alt: "Fetch Join 경계", caption: "그림 1", zoom: true, }), ':::evidence key="fetch-strategy-boundary" alt="Fetch Join 경계" caption="그림 1" zoom="true"\n:::', ); }); test("strips a double quote and folds a newline in an attribute value", () => { assert.equal( buildEvidenceDirective({ assetKey: "fetch-strategy-boundary", alt: 'he said "hi"\nsecond line', caption: "c", zoom: false, }), ':::evidence key="fetch-strategy-boundary" alt="he said hi second line" caption="c" zoom="false"\n:::', ); }); test("inserts the directive for the chosen asset", async () => { const user = userEvent.setup(); const inserted: string[] = []; render( inserted.push(value)} />); await user.click(await screen.findByRole("button", { name: /fetch-strategy-boundary/ })); assert.equal(inserted.length, 1); assert.ok(inserted[0]!.includes('key="fetch-strategy-boundary"')); assert.ok(inserted[0]!.startsWith(":::evidence ")); }); test("does not offer a QUARANTINED asset for insertion", async () => { render( {}} />); await screen.findByRole("button", { name: /fetch-strategy-boundary/ }); assert.equal(screen.queryByRole("button", { name: /unsafe/ }), null); }); test("reports the loaded Asset list once listAssets resolves", async () => { const loaded: readonly Asset[][] = []; render( {}} onLoaded={(assets) => (loaded as Asset[][]).push([...assets])} />, ); await screen.findByRole("button", { name: /fetch-strategy-boundary/ }); assert.equal(loaded.length, 1); assert.equal(loaded[0]!.length, 2); }); // --- upload outcome/error classification: pure functions, tested apart from the dialog --- test("stateForUploaded maps a READY asset to the READY outcome", () => { const asset = { ...READY } as unknown as Asset; assert.deepEqual(stateForUploaded(asset), { kind: "READY", asset }); }); test("stateForUploaded maps a QUARANTINED asset to the QUARANTINED outcome", () => { const asset = { ...READY, managementStatus: "QUARANTINED" } as unknown as Asset; assert.deepEqual(stateForUploaded(asset), { kind: "QUARANTINED", asset }); }); test("stateForUploaded maps a REJECTED asset to the REJECTED outcome", () => { const asset = { ...READY, managementStatus: "REJECTED" } as unknown as Asset; assert.deepEqual(stateForUploaded(asset), { kind: "REJECTED", asset }); }); test("stateForUploaded maps an ARCHIVED asset to the REJECTED outcome", () => { const asset = { ...READY, managementStatus: "ARCHIVED" } as unknown as Asset; assert.deepEqual(stateForUploaded(asset), { kind: "REJECTED", asset }); }); test("stateForError maps PAYLOAD_TOO_LARGE to TOO_LARGE", () => { const error = new StudioGatewayError({ type: "https://techlog.local/problems/payload-too-large", title: "PAYLOAD_TOO_LARGE", status: 413, detail: "파일이 너무 큽니다.", code: "PAYLOAD_TOO_LARGE", }); assert.deepEqual(stateForError(error), { kind: "TOO_LARGE" }); }); test("stateForError maps UNSUPPORTED_MEDIA_TYPE to UNSUPPORTED_TYPE", () => { const error = new StudioGatewayError({ type: "https://techlog.local/problems/unsupported-media-type", title: "UNSUPPORTED_MEDIA_TYPE", status: 415, detail: "지원하지 않는 형식입니다.", code: "UNSUPPORTED_MEDIA_TYPE", }); assert.deepEqual(stateForError(error), { kind: "UNSUPPORTED_TYPE" }); }); test("stateForError maps any other gateway error to TRANSPORT_FAILED with the server detail", () => { const error = new StudioGatewayError({ type: "https://techlog.local/problems/studio-unavailable", title: "STUDIO_UNAVAILABLE", status: 503, detail: "Studio가 잠시 응답하지 않습니다.", code: "STUDIO_UNAVAILABLE", }); assert.deepEqual(stateForError(error), { kind: "TRANSPORT_FAILED", message: "Studio가 잠시 응답하지 않습니다.", }); }); test("stateForError maps a non-gateway error to a generic TRANSPORT_FAILED", () => { assert.deepEqual(stateForError(new Error("network down")), { kind: "TRANSPORT_FAILED", message: "업로드를 전송하지 못했습니다.", }); }); // --- AssetUploadDialog: the 8 upload/outcome states the design calls for --- function uploadOnlyGateway( uploadAsset: StudioAssetGateway["uploadAsset"], ): StudioAssetGateway { return { async listAssets() { return { items: [], nextCursor: null }; }, uploadAsset, async getAsset() { throw new Error("not used"); }, async updateAssetMetadata() { throw new Error("not used"); }, async deleteAsset() {}, }; } function chooseFile(file: File) { const input = document.querySelector('input[type="file"]') as HTMLInputElement; fireEvent.change(input, { target: { files: [file] } }); } const SAMPLE_FILE = new File([""], "boundary.svg", { type: "image/svg+xml" }); test("reports a selection failure and never calls uploadAsset when no file is chosen", () => { let calls = 0; const gateway = uploadOnlyGateway(async () => { calls += 1; return READY as never; }); render( {}} onClose={() => {}} />); const input = document.querySelector('input[type="file"]') as HTMLInputElement; fireEvent.change(input, { target: { files: [] } }); assert.equal(screen.getByRole("status").textContent, "파일을 선택하지 못했습니다."); assert.equal(calls, 0); }); test("shows an uploading status and disables the file input while the transport is pending", async () => { let resolveUpload!: (asset: Asset) => void; const gateway = uploadOnlyGateway( () => new Promise((resolve) => { resolveUpload = resolve; }), ); render( {}} onClose={() => {}} />); chooseFile(SAMPLE_FILE); await waitFor(() => assert.equal(screen.getByRole("status").textContent, "업로드 중입니다.")); assert.equal((document.querySelector('input[type="file"]') as HTMLInputElement).disabled, true); resolveUpload(READY as unknown as Asset); await waitFor(() => assert.equal(screen.getByRole("status").textContent, "업로드했습니다.")); }); test("only calls onUploaded and shows success once the server returns READY", async () => { const uploaded: Asset[] = []; const gateway = uploadOnlyGateway(async () => READY as never); render( uploaded.push(asset)} onClose={() => {}} />, ); chooseFile(SAMPLE_FILE); await waitFor(() => assert.equal(screen.getByRole("status").textContent, "업로드했습니다.")); assert.equal(uploaded.length, 1); assert.equal(uploaded[0]!.assetKey, "fetch-strategy-boundary"); }); test("a QUARANTINED server outcome never calls onUploaded, even though the transport succeeded", async () => { const uploaded: Asset[] = []; const gateway = uploadOnlyGateway(async () => ({ ...READY, managementStatus: "QUARANTINED" }) as never); render( uploaded.push(asset)} onClose={() => {}} />, ); chooseFile(SAMPLE_FILE); await waitFor(() => assert.equal(screen.getByRole("status").textContent, "보안 검사에서 격리되어 사용할 수 없습니다.")); assert.equal(uploaded.length, 0); }); test("a REJECTED server outcome never calls onUploaded", async () => { const uploaded: Asset[] = []; const gateway = uploadOnlyGateway(async () => ({ ...READY, managementStatus: "REJECTED" }) as never); render( uploaded.push(asset)} onClose={() => {}} />, ); chooseFile(SAMPLE_FILE); await waitFor(() => assert.equal(screen.getByRole("status").textContent, "서버 검증에서 거절되어 사용할 수 없습니다.")); assert.equal(uploaded.length, 0); }); test("a PAYLOAD_TOO_LARGE transport rejection shows the size-exceeded message", async () => { const gateway = uploadOnlyGateway(async () => { throw new StudioGatewayError({ type: "https://techlog.local/problems/payload-too-large", title: "PAYLOAD_TOO_LARGE", status: 413, detail: "파일이 너무 큽니다.", code: "PAYLOAD_TOO_LARGE", }); }); render( {}} onClose={() => {}} />); chooseFile(SAMPLE_FILE); await waitFor(() => assert.equal(screen.getByRole("status").textContent, "파일 크기가 허용 범위를 넘었습니다.")); }); test("an UNSUPPORTED_MEDIA_TYPE transport rejection shows the unsupported-type message", async () => { const gateway = uploadOnlyGateway(async () => { throw new StudioGatewayError({ type: "https://techlog.local/problems/unsupported-media-type", title: "UNSUPPORTED_MEDIA_TYPE", status: 415, detail: "지원하지 않는 형식입니다.", code: "UNSUPPORTED_MEDIA_TYPE", }); }); render( {}} onClose={() => {}} />); chooseFile(SAMPLE_FILE); await waitFor(() => assert.equal(screen.getByRole("status").textContent, "지원하지 않는 파일 형식입니다.")); }); test("a plain network failure shows the generic transport-failed message", async () => { const gateway = uploadOnlyGateway(async () => { throw new Error("offline"); }); render( {}} onClose={() => {}} />); chooseFile(SAMPLE_FILE); await waitFor(() => assert.equal(screen.getByRole("status").textContent, "업로드를 전송하지 못했습니다.")); }); test("focuses the file input on open, traps Tab inside the dialog, and calls onClose from the close button", async () => { const user = userEvent.setup(); let closed = 0; const gateway = uploadOnlyGateway(async () => READY as never); render( {}} onClose={() => (closed += 1)} />); const input = document.querySelector('input[type="file"]') as HTMLInputElement; const closeButton = screen.getByRole("button", { name: "닫기" }); assert.equal(document.activeElement, input); input.focus(); await user.tab(); assert.equal(document.activeElement, closeButton); await user.tab(); assert.equal(document.activeElement, input); await user.click(closeButton); assert.equal(closed, 1); }); // --- CaseFields end-to-end: cursor-preserving insertion + Instant Preview resolves it live --- test("inserts the directive at the saved cursor position and the live preview renders it without an error panel", async () => { const user = userEvent.setup(); const gateway = createMockStudioGateway(); const DIAGRAM_ASSET = { ...READY, id: "33333333-3333-4333-8333-333333333331", assetKey: "cursor-test-diagram", altText: "커서 삽입 테스트 다이어그램", publicPath: "/media/cursor-test-diagram.svg", }; const assetGateway = gatewayOf([DIAGRAM_ASSET]); render( gateway} createAssetGateway={() => assetGateway}> , ); const textarea = (await screen.findByLabelText("본문 Markdown")) as HTMLTextAreaElement; const body = textarea.value; const cursor = body.indexOf("\n\n"); assert.ok(cursor > 0); textarea.focus(); textarea.setSelectionRange(cursor, cursor); await user.click(await screen.findByRole("button", { name: /cursor-test-diagram/ })); const expectedDirective = buildEvidenceDirective({ assetKey: "cursor-test-diagram", alt: "커서 삽입 테스트 다이어그램", caption: "", zoom: true, }); assert.equal( textarea.value, `${body.slice(0, cursor)}\n\n${expectedDirective}${body.slice(cursor)}`, ); await user.click(screen.getByRole("tab", { name: "즉시 미리보기" })); const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" }); assert.equal(within(panel).queryByRole("alert"), null); // `zoom: true` (DIAGRAM kind) renders the figure's image twice -- once as // the zoom trigger, once inside the (closed) zoom dialog -- both share the // same alt text, so assert on the first (the visible trigger figure). const [image] = within(panel).getAllByAltText("커서 삽입 테스트 다이어그램"); assert.equal(image!.getAttribute("src"), "/media/cursor-test-diagram.svg"); });