// @vitest-environment jsdom import assert from "node:assert/strict"; import { afterAll, beforeAll, expect, test } from "vitest"; import { act, 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 { mergeAssetCatalog } from "../../../src/features/tech-log/domain/content-format/asset-evidence-catalog.ts"; 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 { InstantPreview } from "../../../src/features/tech-log/presentation/studio/components/instant-preview.tsx"; import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx"; import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts"; import { createMockStudioGateway } from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts"; import { projectWorkingCopy as mockProjectWorkingCopy } from "../../../src/features/tech-log/adapters/mock/project-public-render-model.ts"; import { validateWorkingCopy } from "../../../src/features/tech-log/adapters/mock/validate-working-copy.ts"; import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts"; import { MOCK_STUDIO_INSTALL_CONTEXT } from "../../helpers/studio-install-context.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"; import { createManagementGatewayStub } from "../../helpers/management-gateway-stub.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/ }); expect(screen.queryByRole("button", { name: /unsafe/ })).not.toBeInTheDocument(); }); test("reports the loaded Asset list once listAssets resolves", async () => { const loaded: readonly Asset[][] = []; render( {}} onAssetsObserved={(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] } }); } /** * Final fix wave, item 2. Selecting a file no longer uploads on its own: the * dialog now collects the Asset's alt text (or a decorative flag) first, so * the directive it auto-inserts can carry a real alt instead of `alt=""`. * These state-machine tests care about the outcome of an upload, not about * how the form was filled in, so they go through this one helper. */ function chooseAndUpload(file: File, altText = "샘플 대체 텍스트") { chooseFile(file); fireEvent.change(screen.getByLabelText("대체 텍스트"), { target: { value: altText } }); fireEvent.click(screen.getByRole("button", { name: "업로드" })); } 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", { name: "업로드 상태" }).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={() => {}} />); chooseAndUpload(SAMPLE_FILE); await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).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", { name: "업로드 상태" }).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={() => {}} />, ); chooseAndUpload(SAMPLE_FILE); await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).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={() => {}} />, ); chooseAndUpload(SAMPLE_FILE); await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).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={() => {}} />, ); chooseAndUpload(SAMPLE_FILE); await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).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={() => {}} />); chooseAndUpload(SAMPLE_FILE); await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).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={() => {}} />); chooseAndUpload(SAMPLE_FILE); await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "지원하지 않는 파일 형식입니다.")); }); test("a plain network failure shows the generic transport-failed message", async () => { const gateway = uploadOnlyGateway(async () => { throw new Error("offline"); }); render( {}} onClose={() => {}} />); chooseAndUpload(SAMPLE_FILE); await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).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 decorativeBox = screen.getByLabelText("장식용 이미지 (대체 텍스트 없음)"); const altInput = screen.getByLabelText("대체 텍스트"); const uploadButton = screen.getByRole("button", { name: "업로드" }); const closeButton = screen.getByRole("button", { name: "닫기" }); // `toHaveFocus`, never `assert.equal(document.activeElement, input)`: // node:assert inspects both operands at `depth: 1000` to build its failure // message, and a React-rendered element's `__reactFiber$*` graph re-expands // per traversal path, so inspecting one exhausts the heap and kills the // worker instead of reporting the regression. expect(input).toHaveFocus(); // Final fix wave, item 2: the trap now has to hold across the two metadata // controls the dialog gained, and still wrap from the last back to the first. input.focus(); for (const expected of [decorativeBox, altInput, closeButton, uploadButton, input]) { await user.tab(); expect(expected).toHaveFocus(); } 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: "즉시 미리보기" }); expect(within(panel).queryByRole("alert")).not.toBeInTheDocument(); // `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"); }); // --- Fix round 1 --- // I1. `uploadKey`/`idempotencyKey` used to be generated once when the dialog // opened and reused for every subsequent `submit()` call. Every terminal // state re-enables the file input, so a user can pick file A, hit // TOO_LARGE, then pick a *different* file B -- both attempts must not carry // the same idempotency key. test("generates a fresh idempotency key for each upload attempt, even after a failure", async () => { const keys: string[] = []; const gateway = uploadOnlyGateway(async (_form, options) => { keys.push(options.idempotencyKey); if (keys.length === 1) { throw new StudioGatewayError({ type: "https://techlog.local/problems/payload-too-large", title: "PAYLOAD_TOO_LARGE", status: 413, detail: "파일이 너무 큽니다.", code: "PAYLOAD_TOO_LARGE", }); } return READY as never; }); render( {}} onClose={() => {}} />); chooseAndUpload(new File(["a"], "a.svg", { type: "image/svg+xml" })); await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "파일 크기가 허용 범위를 넘었습니다.")); chooseAndUpload(new File(["b"], "b.svg", { type: "image/svg+xml" })); await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드했습니다.")); assert.equal(keys.length, 2); assert.notEqual(keys[0], keys[1]); }); // I2. `validate-working-copy.ts`'s evidence gate used to be driven entirely // by the one hardcoded legacy key, disconnected from the Asset system -- // so every directive the Picker/upload dialog inserted previewed live and // then failed mock validation with EVIDENCE_UNSUPPORTED/EVIDENCE_NOT_FOUND. // This exercises the real MOCK composition end to end (not a hand-rolled // gateway): upload through the UI, then validate through the SAME // composition's document gateway. test("an asset uploaded through the mock composition previews live and passes mock validation for the same key", async () => { const user = userEvent.setup(); const installed = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input; const gateway = installed.createStudioGateway(); const assetGateway = installed.createStudioAssetGateway(); const created = await gateway.createDocument( { kind: "CASE", title: "업로드 검증", slug: "upload-validation-check", summary: "업로드한 Asset이 검증도 통과하는지 확인합니다.", topicId: FIXTURE_IDS.topicJpa, projectId: FIXTURE_IDS.projectBackend, relations: [], problem: "문제", conclusion: "결론", environment: "env", reproduction: "repro", lastVerifiedOn: "2026-08-14", bodyMarkdown: "## 제목\n\n본문입니다.", }, { idempotencyKey: "upload-validation-create" }, ); render( gateway} createAssetGateway={() => assetGateway}> , ); await screen.findByLabelText("본문 Markdown"); await user.click(screen.getByRole("button", { name: "Asset 업로드" })); chooseAndUpload( new File([""], "boundary-check.svg", { type: "image/svg+xml" }), "업로드 확인용 대체 텍스트", ); // case-fields.tsx's design decision: a successful upload auto-inserts the // directive at the cursor and closes the dialog, all in the same update -- // so the reliable thing to await is the body's own final content, not a // transient dialog status (the dialog itself may already be gone here; // `StudioProvider` also always renders its own permanently-mounted // `UnsavedLeaveDialog`, which carries the same native `role="dialog"`, so // asserting on dialog presence/absence is not a safe signal either way). await waitFor(() => { const value = (screen.getByLabelText("본문 Markdown") as HTMLTextAreaElement).value; assert.ok(value.includes('key="boundary-check"'), value); }); const textarea = screen.getByLabelText("본문 Markdown") as HTMLTextAreaElement; // Final fix wave, item 2: the upload dialog now collects the alt text, so // the auto-inserted directive already carries it and no hand-edit of the // raw Markdown is needed. This test's own target stays the evidence key // (I2); the alt-text loop has its own tests at the bottom of this file. const authoredBody = textarea.value; assert.ok(authoredBody.includes('alt="업로드 확인용 대체 텍스트"'), authoredBody); const currentDraft = { ...created } as Record; delete currentDraft.id; delete currentDraft.version; delete currentDraft.updatedAt; const saved = await gateway.saveDocument( created.id, { expectedVersion: created.version, document: { ...currentDraft, bodyMarkdown: authoredBody } as never, }, { idempotencyKey: "upload-validation-save" }, ); const report = await gateway.validateDocument( created.id, { expectedVersion: saved.document.version }, { idempotencyKey: "upload-validation-validate" }, ); assert.ok( !report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED" || issue.code === "EVIDENCE_NOT_FOUND"), JSON.stringify(report.issues), ); assert.equal(report.status, "VALID", JSON.stringify(report.issues)); const preview = await gateway.createPreview( created.id, { expectedVersion: saved.document.version, validationId: report.validationId }, { idempotencyKey: "upload-validation-preview" }, ); assert.equal(preview.renderModel.kind, "CASE"); }); // I2. An unknown key (backed by no loaded Asset and no catalog row) must // still fail on both paths -- the reconciliation must not accidentally open // the gate for a genuinely dangling reference. test("a key backed by no asset and no catalog entry still fails mock validation", async () => { const installed = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input; const gateway = installed.createStudioGateway(); const created = await gateway.createDocument( { kind: "CASE", title: "미지원 근거", slug: "dangling-evidence-check", summary: "근거 없는 키는 여전히 거부됩니다.", topicId: FIXTURE_IDS.topicJpa, projectId: FIXTURE_IDS.projectBackend, relations: [], problem: "문제", conclusion: "결론", environment: "env", reproduction: "repro", lastVerifiedOn: "2026-08-14", bodyMarkdown: ':::evidence key="never-uploaded" alt="근거" caption="근거" zoom="false"\n:::', }, { idempotencyKey: "dangling-evidence-create" }, ); const report = await gateway.validateDocument( created.id, { expectedVersion: created.version }, { idempotencyKey: "dangling-evidence-validate" }, ); assert.ok(report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED")); }); // --- Fix round 2 --- // Round 1's `supportsEvidenceKeyIn(mergedCatalog)` made gate 1 ask the exact // question gate 2 asks, so a real `EVIDENCE` catalog row backing something // other than media (fixtures.ts:37 -- a row that exists for QUESTION // resolution targets, not an Asset) passed gate 1 with no Asset and no // legacy key behind it. `validateDocument` said VALID; `createPreview`'s // resolver, which only ever knew the legacy key, then threw a raw // `Error` -- I2's disagreement in the opposite direction, plus a bare // `Error` escaping a port whose contract is `StudioGatewayError` only. test("a directive key equal to a non-asset EVIDENCE catalog row's id is rejected on both mock paths, never as a raw Error", async () => { const installed = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input; const gateway = installed.createStudioGateway(); const created = await gateway.createDocument( { kind: "CASE", title: "카탈로그 행 오용", slug: "catalog-row-misuse-check", summary: "카탈로그에 EVIDENCE 행이 있다고 해서 Asset이 있는 건 아닙니다.", topicId: FIXTURE_IDS.topicJpa, projectId: FIXTURE_IDS.projectBackend, relations: [], problem: "문제", conclusion: "결론", environment: "env", reproduction: "repro", lastVerifiedOn: "2026-08-14", // FIXTURE_IDS.fetchJoinCase backs an EVIDENCE catalog row too // (fixtures.ts:37, label "Fetch Join Case") -- that row is for // QUESTION resolution targets, not media. bodyMarkdown: `:::evidence key="${FIXTURE_IDS.fetchJoinCase}" alt="근거" caption="근거" zoom="false"\n:::`, }, { idempotencyKey: "catalog-row-misuse-create" }, ); const report = await gateway.validateDocument( created.id, { expectedVersion: created.version }, { idempotencyKey: "catalog-row-misuse-validate" }, ); assert.equal(report.status, "INVALID", JSON.stringify(report.issues)); assert.ok( report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED"), JSON.stringify(report.issues), ); // createPreview requires a current, error-free validation -- an INVALID // one must reject with a StudioGatewayError (VALIDATION_STALE), never let // a raw Error escape the port. await assert.rejects( gateway.createPreview( created.id, { expectedVersion: created.version, validationId: report.validationId }, { idempotencyKey: "catalog-row-misuse-preview" }, ), (error: unknown) => { assert.ok( error instanceof StudioGatewayError, `expected a StudioGatewayError, got ${String(error)}`, ); assert.equal(error.code, "VALIDATION_STALE"); return true; }, ); }); // The same case pinned at the lower level: the mock's own `projectWorkingCopy` // wrapper (adapters/mock/project-public-render-model.ts) must reject at the // gate -- a `ContentFormatError`-flavored "supported local evidence key not // found" -- rather than ever reaching the resolver's `isSupportedEvidenceKey` // fallback and its raw `Error("Unknown local evidence asset: ...")`. This // pins the fix even if some future change lets `createPreview`'s own // staleness guard stop being the thing that prevents the call in practice. test("the mock's projectWorkingCopy wrapper rejects a non-asset EVIDENCE catalog row at the gate, not at the resolver", () => { const catalog = [ { id: FIXTURE_IDS.topicJpa, type: "TOPIC" as const, label: "JPA", dependencyRevision: "r1" }, { id: FIXTURE_IDS.fetchJoinCase, type: "EVIDENCE" as const, label: "Fetch Join Case", publicPath: "/cases/collection-fetch-join-pagination", dependencyRevision: "r1", }, ]; const input = { kind: "CASE" as const, title: "t", slug: "wrapper-gate-check", summary: "s", topicId: FIXTURE_IDS.topicJpa, projectId: null, relations: [], problem: "p", conclusion: "c", environment: "e", reproduction: "r", lastVerifiedOn: "2026-08-14", bodyMarkdown: `:::evidence key="${FIXTURE_IDS.fetchJoinCase}" alt="a" caption="c" zoom="false"\n:::`, }; assert.throws( () => mockProjectWorkingCopy( input as never, catalog as never, { mode: "PREVIEW", publishedAt: null }, [], ), /supported local evidence key not found/, ); }); // The rode-along fix (validate-working-copy.ts's EVIDENCE_ALT_REQUIRED check // now reads Asset.decorative): a decorative Asset's empty alt must pass, a // non-decorative one's must still fail. No repo test covered this pair // before round 2. test("EVIDENCE_ALT_REQUIRED respects Asset.decorative -- empty alt passes for a decorative Asset, fails for a non-decorative one", async () => { const installed = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input; const gateway = installed.createStudioGateway(); const assetGateway = installed.createStudioAssetGateway(); const decorative = await assetGateway.uploadAsset( { file: new File([""], "deco.svg", { type: "image/svg+xml" }), kind: "IMAGE", decorative: true }, { idempotencyKey: "decorative-alt-upload" }, ); const informative = await assetGateway.uploadAsset( { file: new File([""], "info.svg", { type: "image/svg+xml" }), kind: "IMAGE" }, { idempotencyKey: "informative-alt-upload" }, ); assert.equal(decorative.decorative, true); assert.equal(informative.decorative, false); async function validateBody(bodyMarkdown: string, slugSuffix: string) { const created = await gateway.createDocument( { kind: "CASE", title: "대체 텍스트 규칙", slug: `alt-rule-check-${slugSuffix}`, summary: "decorative 여부에 따라 대체 텍스트 요구가 달라집니다.", topicId: FIXTURE_IDS.topicJpa, projectId: FIXTURE_IDS.projectBackend, relations: [], problem: "문제", conclusion: "결론", environment: "env", reproduction: "repro", lastVerifiedOn: "2026-08-14", bodyMarkdown, }, { idempotencyKey: `alt-rule-create-${slugSuffix}` }, ); return gateway.validateDocument( created.id, { expectedVersion: created.version }, { idempotencyKey: `alt-rule-validate-${slugSuffix}` }, ); } const decorativeReport = await validateBody( `:::evidence key="${decorative.assetKey}" alt="" caption="" zoom="false"\n:::`, "decorative", ); assert.ok( !decorativeReport.issues.some((issue) => issue.code === "EVIDENCE_ALT_REQUIRED"), JSON.stringify(decorativeReport.issues), ); const informativeReport = await validateBody( `:::evidence key="${informative.assetKey}" alt="" caption="" zoom="false"\n:::`, "informative", ); assert.ok( informativeReport.issues.some((issue) => issue.code === "EVIDENCE_ALT_REQUIRED"), JSON.stringify(informativeReport.issues), ); }); // --- Fix round 3 --- // Round 2's gate 1 (`supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets))`) // still routed through `evidenceCatalogEntryFor` (id || label || publicPath), // which is gate 2's question over fewer rows -- not the resolver's actual // success condition (`assetKey === key && READY && publicPath truthy`). Two // Asset shapes make the two predicates disagree; both are exercised through // `MockStudioDependencies.assets` injection directly, the same way a test or // harness (or a future adapter) could construct one, bypassing the mock // uploader (which always sets a real, matching `publicPath` and so cannot // produce either shape itself). function assetFixture(overrides: Partial): Asset { return { id: "77777777-7777-4777-8777-777777777771", assetKey: "shape-check", kind: "IMAGE", mediaType: "image/svg+xml", originalFilename: "shape.svg", byteSize: 10, width: null, height: null, altText: null, decorative: false, managementStatus: "READY", publicPath: "/media/shape-check.svg", usageCount: 0, version: 1, createdAt: "2026-08-14T00:00:00.000Z", updatedAt: "2026-08-14T00:00:00.000Z", ...overrides, }; } async function expectBothPathsRejectSlipThrough( assets: Map, directiveKey: string, label: string, ) { const gateway = createMockStudioGateway({ assets }); const created = await gateway.createDocument( { kind: "CASE", title: `slip-through 확인 (${label})`, slug: `slip-through-${label}`, summary: "gate 1과 resolver가 다른 조건을 쓰면 일어나는 문제를 확인합니다.", topicId: FIXTURE_IDS.topicJpa, projectId: FIXTURE_IDS.projectBackend, relations: [], problem: "문제", conclusion: "결론", environment: "env", reproduction: "repro", lastVerifiedOn: "2026-08-14", bodyMarkdown: `:::evidence key="${directiveKey}" alt="근거" caption="근거" zoom="false"\n:::`, }, { idempotencyKey: `slip-through-create-${label}` }, ); const report = await gateway.validateDocument( created.id, { expectedVersion: created.version }, { idempotencyKey: `slip-through-validate-${label}` }, ); assert.equal(report.status, "INVALID", JSON.stringify(report.issues)); assert.ok( report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED"), JSON.stringify(report.issues), ); await assert.rejects( gateway.createPreview( created.id, { expectedVersion: created.version, validationId: report.validationId }, { idempotencyKey: `slip-through-preview-${label}` }, ), (error: unknown) => { assert.ok( error instanceof StudioGatewayError, `expected a StudioGatewayError, got ${String(error)}`, ); assert.equal(error.code, "VALIDATION_STALE"); return true; }, ); } for (const [publicPath, label] of [ [null, "null-path"], ["", "empty-path"], ] as const) { test(`a READY asset with a ${label === "null-path" ? "null" : "empty-string"} publicPath fails both mock paths, never as a raw Error`, async () => { const key = `${label}-check`; const asset = assetFixture({ assetKey: key, publicPath }); await expectBothPathsRejectSlipThrough(new Map([[asset.assetKey, asset]]), key, label); }); } test("a READY asset whose publicPath satisfies a different key's legacy convention fails both mock paths, never as a raw Error", async () => { // assetKey "real-owner" legitimately owns "/media/real-owner.svg", but its // publicPath is set to "/media/decoy-key.svg" instead -- which happens to // satisfy `evidenceCatalogEntryFor`'s legacy `/media/${key}.svg` match for // the UNRELATED key "decoy-key", a key this asset does not own // (`assetKey !== "decoy-key"`). The resolver requires `assetKey === key`, // so it can never resolve "decoy-key" from this asset. const asset = assetFixture({ assetKey: "real-owner", publicPath: "/media/decoy-key.svg" }); await expectBothPathsRejectSlipThrough( new Map([[asset.assetKey, asset]]), "decoy-key", "mismatched-path", ); }); // idempotent()'s new wrapping (part 3 of the ruling) is not exercised by the // shapes above -- those are caught by validateDocument's now-correct gate, // so createPreview only ever reaches its pre-existing VALIDATION_STALE // guard, itself already a StudioGatewayError. To exercise the *new* // wrapping, force a genuine internal disagreement: validate while the Asset // still backs the key (VALID), then remove it before createPreview, without // bumping dependencyRevision -- a narrow but real staleness gap the mock's // existing guard does not close on its own. projectWorkingCopy's gate, // re-evaluated fresh inside createPreview against the now-mutated assets, // correctly rejects -- and that internal ContentFormatError must not leave // the port unwrapped. // // Fix round 4 amends the expected code. Round 3 mapped this to // STUDIO_UNAVAILABLE/500 with `retryable: true`, which invites a retry that // fails identically -- the Asset is gone, the projection will refuse again. // A deterministic content failure between validate and preview is exactly // what VALIDATION_STALE/409 means, and it is not retryable without // re-validating first. // // The alignment follow-up that taught the *default* dependency revision to // read the Asset store closed exactly the gap this test used to reach the // projection through: deleting the Asset now moves the revision, so the guard // fires first and `failureOf`'s ContentFormatError branch would never run // again. That branch still has to hold -- a projection can refuse for reasons // the revision cannot see (a directive the parser rejects against a catalog // row) -- so this test pins the revision itself and keeps reaching the // projection, and asserts the *detail* to prove which of the two paths // produced the 409. `mock-dependency-revision.test.ts` covers the guard path. test("idempotent() maps a deterministic content failure to VALIDATION_STALE, never lets a raw error escape the port", async () => { const asset = assetFixture({ assetKey: "race-check" }); const assets = new Map([[asset.assetKey, asset]]); const gateway = createMockStudioGateway({ assets, dependencyRevision: { current: () => "pinned-so-the-guard-cannot-fire" }, }); const created = await gateway.createDocument( { kind: "CASE", title: "검증과 미리보기 사이의 경쟁 상태", slug: "validate-preview-race-check", summary: "검증 이후 Asset이 사라지면 gate가 새로 평가되어 거부해야 합니다.", topicId: FIXTURE_IDS.topicJpa, projectId: FIXTURE_IDS.projectBackend, relations: [], problem: "문제", conclusion: "결론", environment: "env", reproduction: "repro", lastVerifiedOn: "2026-08-14", bodyMarkdown: `:::evidence key="race-check" alt="근거" caption="근거" zoom="false"\n:::`, }, { idempotencyKey: "race-create" }, ); const report = await gateway.validateDocument( created.id, { expectedVersion: created.version }, { idempotencyKey: "race-validate" }, ); assert.equal(report.status, "VALID", JSON.stringify(report.issues)); assets.delete("race-check"); const expectStale = (error: unknown) => { assert.ok( error instanceof StudioGatewayError, `expected a StudioGatewayError, got ${String(error)}`, ); assert.equal(error.code, "VALIDATION_STALE"); assert.equal(error.status, 409); assert.equal(error.retryable, false); // The projection's failure, wrapped by `failureOf` -- not the guard's // own "Current validation without errors is required." assert.match(error.problem.detail ?? "", /no longer renders against current dependencies/); return true; }; await assert.rejects( gateway.createPreview( created.id, { expectedVersion: created.version, validationId: report.validationId }, { idempotencyKey: "race-preview" }, ), expectStale, ); // Deterministic outcomes stay in the ledger: a same-key retry replays the // same 409 rather than re-running work that cannot succeed. await assert.rejects( gateway.createPreview( created.id, { expectedVersion: created.version, validationId: report.validationId }, { idempotencyKey: "race-preview" }, ), expectStale, ); }); // EVIDENCE_NOT_FOUND has been unreachable through `createMockStudioGateway()` // on its own: the fixture catalog it always loads already carries a row for // the one legacy key, so gate 1 passing (via the legacy key) always implied // gate 2 passing too. Pinned directly against `validateWorkingCopy` instead, // with a hand-built catalog that omits the row on purpose. test("EVIDENCE_NOT_FOUND is reachable through validateWorkingCopy directly: the legacy key with no catalog EVIDENCE row", () => { const document = { id: "88888888-8888-4888-8888-888888888881", version: 1, updatedAt: "2026-08-14T00:00:00.000Z", kind: "CASE", title: "레거시 키인데 카탈로그에 없음", slug: "legacy-key-no-catalog-row", summary: "레거시 키는 gate 1을 통과하지만 카탈로그에 행이 없으면 gate 2가 거부해야 합니다.", topicId: FIXTURE_IDS.topicJpa, projectId: FIXTURE_IDS.projectBackend, relations: [], problem: "문제", conclusion: "결론", environment: "env", reproduction: "repro", lastVerifiedOn: "2026-08-14", bodyMarkdown: ':::evidence key="fetch-strategy-boundary" alt="근거" caption="근거" zoom="false"\n:::', }; const report = validateWorkingCopy(document as never, { now: new Date("2026-08-14T01:00:00.000Z"), validationId: "evidence-not-found-check", dependencyRevision: "r1", catalog: [ { id: FIXTURE_IDS.topicJpa, type: "TOPIC", label: "JPA", dependencyRevision: "r1" }, { id: FIXTURE_IDS.projectBackend, type: "PROJECT", label: "Backend", dependencyRevision: "r1" }, // Deliberately no EVIDENCE row for "fetch-strategy-boundary" -- gate 1 // (the legacy key) passes, gate 2 (this catalog) must not. ] as never, documents: [document as never], assets: [], }); assert.equal(report.status, "INVALID", JSON.stringify(report.issues)); assert.ok( report.issues.some((issue) => issue.code === "EVIDENCE_NOT_FOUND"), JSON.stringify(report.issues), ); }); // --- Fix round 4 --- // // Rounds 1-3 each rebuilt gate 1 as a *separate expression* that happened to // agree with the resolver on the inputs the round's tests used. They cannot // agree in general, because they were never the same function. Round 4 // collapses "can this key be rendered" and "which Asset does it render" into // one exported decision, `findResolvableAsset`, and puts every caller // (validation's gate, validation's decorative lookup, the mock projection's // gate and resolver, InstantPreview's gate, resolver, and pixel resolver) on // it. The tests below are the shapes that broke the round-3 pair. // `MockStudioDependencies.assets` is keyed by whatever the caller chooses -- // the gateway only ever reads `.values()`. Keying by `id` here is what lets a // test express the shape the whole round is about: two Assets that share one // `assetKey`. function duplicateKeyAssets(...assets: Asset[]): Map { return new Map(assets.map((asset) => [asset.id, asset])); } async function previewFor( assets: Map, bodyMarkdown: string, slugSuffix: string, ) { const gateway = createMockStudioGateway({ assets }); const created = await gateway.createDocument( { kind: "CASE", title: `중복 키 확인 (${slugSuffix})`, slug: `duplicate-key-${slugSuffix}`, summary: "하나의 assetKey를 공유하는 Asset이 둘일 때의 동작을 확인합니다.", topicId: FIXTURE_IDS.topicJpa, projectId: FIXTURE_IDS.projectBackend, relations: [], problem: "문제", conclusion: "결론", environment: "env", reproduction: "repro", lastVerifiedOn: "2026-08-14", bodyMarkdown, }, { idempotencyKey: `duplicate-key-create-${slugSuffix}` }, ); const report = await gateway.validateDocument( created.id, { expectedVersion: created.version }, { idempotencyKey: `duplicate-key-validate-${slugSuffix}` }, ); const preview = await gateway.createPreview( created.id, { expectedVersion: created.version, validationId: report.validationId }, { idempotencyKey: `duplicate-key-preview-${slugSuffix}` }, ); const model = preview.renderModel; assert.equal(model.kind, "CASE"); const figure = model.kind === "CASE" ? model.bodyBlocks.find((block) => block.type === "EVIDENCE_FIGURE") : undefined; assert.ok(figure && figure.type === "EVIDENCE_FIGURE", "expected an EVIDENCE_FIGURE block"); return { report, asset: figure.asset }; } // The third live instance the reviewer found: two READY Assets share one // `assetKey`, the first carries `publicPath: null`, the second a real path. // `∃a. P(a) ∧ Q(a)` says yes (the second one). `Q(first a satisfying P)` says // no, and the resolver falls through to the generic placeholder -- so // `validateDocument` returns VALID with zero issues, `createPreview` succeeds, // and `publishDocument` would snapshot a render model carrying // `publicPath: ""` with nothing anywhere reporting an error. test("two READY assets sharing one assetKey: the gate and the resolver agree, and the preview never carries an empty publicPath", async () => { const withoutPath = assetFixture({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", assetKey: "dup-null-first", publicPath: null, updatedAt: "2026-08-14T00:00:00.000Z", }); const withPath = assetFixture({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2", assetKey: "dup-null-first", publicPath: "/media/dup-null-first.svg", updatedAt: "2026-08-14T00:00:01.000Z", }); const { report, asset } = await previewFor( duplicateKeyAssets(withoutPath, withPath), ':::evidence key="dup-null-first" alt="근거" caption="근거" zoom="false"\n:::', "null-first", ); assert.equal(report.status, "VALID", JSON.stringify(report.issues)); assert.equal(asset.publicPath, "/media/dup-null-first.svg"); assert.equal(asset.assetId, withPath.id); }); // Order-independence. The same two resolvable Assets in either array order // must resolve to the same one; `find`-based first-wins picks whichever the // caller happened to list first. test("duplicate resolvable assets resolve to the same asset in either array order", async () => { const older = assetFixture({ id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1", assetKey: "dup-order", publicPath: "/media/dup-order-older.svg", updatedAt: "2026-08-14T00:00:00.000Z", }); const newer = assetFixture({ id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2", assetKey: "dup-order", publicPath: "/media/dup-order-newer.svg", updatedAt: "2026-08-14T00:00:01.000Z", }); const body = ':::evidence key="dup-order" alt="근거" caption="근거" zoom="false"\n:::'; const forward = await previewFor(duplicateKeyAssets(older, newer), body, "order-forward"); const reverse = await previewFor(duplicateKeyAssets(newer, older), body, "order-reverse"); assert.deepEqual(forward.asset, reverse.asset); assert.equal(forward.asset.assetId, newer.id); assert.equal(forward.asset.publicPath, "/media/dup-order-newer.svg"); }); // The third rule with the same disease: `readyAssetsByKey` was built with // `new Map(...)` (last-wins) while the resolver used `find` (first-wins), so // EVIDENCE_ALT_REQUIRED could be judged against a different Asset than the // one actually rendered. Here the rendered Asset is `decorative` (empty alt // is legitimate for it) but the last-wins map judges the other one. test("EVIDENCE_ALT_REQUIRED is judged against the asset that actually renders, not a different one sharing the key", async () => { const rendered = assetFixture({ id: "cccccccc-cccc-4ccc-8ccc-ccccccccccc1", assetKey: "alt-mismatch", publicPath: "/media/alt-mismatch-rendered.svg", decorative: true, updatedAt: "2026-08-14T00:00:02.000Z", }); const shadow = assetFixture({ id: "cccccccc-cccc-4ccc-8ccc-ccccccccccc2", assetKey: "alt-mismatch", publicPath: "/media/alt-mismatch-shadow.svg", decorative: false, updatedAt: "2026-08-14T00:00:01.000Z", }); const { report, asset } = await previewFor( duplicateKeyAssets(rendered, shadow), ':::evidence key="alt-mismatch" alt="" caption="근거" zoom="false"\n:::', "alt-mismatch", ); assert.ok( !report.issues.some((issue) => issue.code === "EVIDENCE_ALT_REQUIRED"), JSON.stringify(report.issues), ); assert.equal(asset.assetId, rendered.id); assert.equal(asset.decorative, true); }); const PREVIEW_CATALOG = [ { id: "topic-jpa", type: "TOPIC", label: "JPA", publicPath: "/topics/jpa", dependencyRevision: "r1" }, { id: "resolution-evidence", type: "EVIDENCE", label: "결론 근거", publicPath: "/cases/some-case", dependencyRevision: "r1", }, ] as never[]; function caseDraft(bodyMarkdown: string) { return { kind: "CASE", title: "미리보기 게이트 확인", slug: "preview-gate-check", summary: "요약", topicId: "topic-jpa", projectId: null, relations: [], problem: "문제", conclusion: "결론", environment: "env", reproduction: "repro", lastVerifiedOn: "2026-08-14", bodyMarkdown, } as never; } function renderInstantPreview(bodyMarkdown: string, assets: readonly Asset[]) { render( createMockStudioGateway()}> , ); } // Round 3 loosened InstantPreview's gate back to the merged catalog, arguing // the un-loaded-asset case would otherwise blank the preview. It blanks // either way -- an un-loaded Asset contributes no catalog row either. All the // looseness bought was keys the fetched EVIDENCE catalog carries: those // rendered a caption with an empty gap and no message while validation said // EVIDENCE_UNSUPPORTED. Every caller is now on the same expression, so the // preview refuses exactly what validation refuses -- and says so. test("Instant Preview refuses a key only the document catalog carries, exactly as mock validation does", () => { const body = ':::evidence key="resolution-evidence" alt="근거" caption="근거" zoom="false"\n:::'; renderInstantPreview(body, []); const alert = screen.getByRole("alert"); assert.match(alert.textContent ?? "", /resolution-evidence/); const report = validateWorkingCopy( { ...(caseDraft(body) as object), id: "88888888-8888-4888-8888-888888888882", version: 1, updatedAt: "2026-08-14T00:00:00.000Z" } as never, { now: new Date("2026-08-14T01:00:00.000Z"), validationId: "preview-gate-consistency", dependencyRevision: "r1", catalog: PREVIEW_CATALOG, documents: [], assets: [], }, ); assert.ok( report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED"), JSON.stringify(report.issues), ); }); // The pixels come from `createAssetCatalogResolver` (a last-wins `Map`) while // the block descriptor came from `resolveAssetDescriptor` (a first-wins // `find`) -- a fourth expression in the same path, disagreeing with the third // whenever two READY Assets share a key. test("Instant Preview renders the same asset the gate and the descriptor picked when two assets share a key", () => { const older = assetFixture({ id: "dddddddd-dddd-4ddd-8ddd-ddddddddddd1", assetKey: "dup-pixels", publicPath: "/media/dup-pixels-older.svg", updatedAt: "2026-08-14T00:00:00.000Z", }); const newer = assetFixture({ id: "dddddddd-dddd-4ddd-8ddd-ddddddddddd2", assetKey: "dup-pixels", publicPath: "/media/dup-pixels-newer.svg", updatedAt: "2026-08-14T00:00:01.000Z", }); renderInstantPreview( ':::evidence key="dup-pixels" alt="중복 키 그림" caption="근거" zoom="false"\n:::', [newer, older], ); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); assert.equal( screen.getByAltText("중복 키 그림").getAttribute("src"), "/media/dup-pixels-newer.svg", ); }); // The idempotency ledger must not freeze an *uncharacterized* internal // failure: the port reports it as retryable, so a same-key retry has to // actually re-run the work instead of replaying the cached 500. test("a same-key retry re-runs after an uncharacterized internal failure instead of replaying a cached 500", async () => { let remainingFailures = 1; const gateway = createMockStudioGateway({ dependencyRevision: { current: () => { if (remainingFailures > 0) { remainingFailures -= 1; throw new Error("의존성 리비전을 읽지 못했습니다."); } return "r1"; }, }, }); const created = await gateway.createDocument( { kind: "CASE", title: "일시적 내부 실패 재시도", slug: "transient-internal-failure-retry", summary: "성격이 규명되지 않은 내부 실패는 원장에 얼려두면 안 됩니다.", topicId: FIXTURE_IDS.topicJpa, projectId: FIXTURE_IDS.projectBackend, relations: [], problem: "문제", conclusion: "결론", environment: "env", reproduction: "repro", lastVerifiedOn: "2026-08-14", bodyMarkdown: "## 본문\n\n내용입니다.", }, { idempotencyKey: "transient-create" }, ); await assert.rejects( gateway.validateDocument( created.id, { expectedVersion: created.version }, { idempotencyKey: "transient-validate" }, ), (error: unknown) => { assert.ok(error instanceof StudioGatewayError, `expected a StudioGatewayError, got ${String(error)}`); assert.equal(error.code, "STUDIO_UNAVAILABLE"); assert.equal(error.retryable, true); return true; }, ); const report = await gateway.validateDocument( created.id, { expectedVersion: created.version }, { idempotencyKey: "transient-validate" }, ); assert.equal(report.documentId, created.id); }); // --- Final fix wave, item 2: the authoring loop must produce a publishable document --- // // The upload dialog used to send `{ file, kind }` only. `UploadAssetForm` and // `asset-upload-transport.ts` both carry `altText`/`decorative`, so every // asset uploaded through the flagship flow landed as // `altText: null, decorative: false` -- and the directive the Picker then // auto-inserted carried `alt=""`. The document parsed, previewed correctly, // and failed at validate with EVIDENCE_ALT_REQUIRED, recoverable only by // hand-editing raw Markdown. These three tests close the loop // (upload -> insert -> validate) that nothing asserted before. async function uploadThroughDialog( user: ReturnType, file: File, options: Readonly<{ altText?: string; decorative?: boolean }> = {}, ) { chooseFile(file); if (options.decorative) { await user.click(screen.getByLabelText("장식용 이미지 (대체 텍스트 없음)")); } if (options.altText !== undefined) { await user.type(screen.getByLabelText("대체 텍스트"), options.altText); } await user.click(screen.getByRole("button", { name: "업로드" })); } async function createUploadCase( gateway: ReturnType< ReturnType["input"]["createStudioGateway"] >, slug: string, title: string, ) { return gateway.createDocument( { kind: "CASE", title, slug, summary: "업로드한 Asset이 손질 없이 게시 가능한지 확인합니다.", topicId: FIXTURE_IDS.topicJpa, projectId: FIXTURE_IDS.projectBackend, relations: [], problem: "문제", conclusion: "결론", environment: "env", reproduction: "repro", lastVerifiedOn: "2026-08-14", bodyMarkdown: "## 제목\n\n본문입니다.", }, { idempotencyKey: `${slug}-create` }, ); } async function saveValidateAndPreview( gateway: ReturnType< ReturnType["input"]["createStudioGateway"] >, created: { id: string; version: number }, slug: string, bodyMarkdown: string, ) { const currentDraft = { ...(created as unknown as Record) }; delete currentDraft.id; delete currentDraft.version; delete currentDraft.updatedAt; const saved = await gateway.saveDocument( created.id, { expectedVersion: created.version, document: { ...currentDraft, bodyMarkdown } as never }, { idempotencyKey: `${slug}-save` }, ); const report = await gateway.validateDocument( created.id, { expectedVersion: saved.document.version }, { idempotencyKey: `${slug}-validate` }, ); const preview = report.status === "INVALID" ? null : await gateway.createPreview( created.id, { expectedVersion: saved.document.version, validationId: report.validationId }, { idempotencyKey: `${slug}-preview` }, ); return { report, preview }; } test("an asset uploaded with alt text inserts that alt and the document validates as publishable, with no hand-editing", async () => { const user = userEvent.setup(); const installed = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input; const gateway = installed.createStudioGateway(); const assetGateway = installed.createStudioAssetGateway(); const created = await createUploadCase(gateway, "alt-text-publishable", "대체 텍스트 게시 가능"); render( gateway} createAssetGateway={() => assetGateway}> , ); await screen.findByLabelText("본문 Markdown"); await user.click(screen.getByRole("button", { name: "Asset 업로드" })); await uploadThroughDialog( user, new File([""], "alt-text-check.svg", { type: "image/svg+xml" }), { altText: "업로드한 다이어그램 설명" }, ); await waitFor(() => { const value = (screen.getByLabelText("본문 Markdown") as HTMLTextAreaElement).value; assert.ok(value.includes('key="alt-text-check"'), value); }); const body = (screen.getByLabelText("본문 Markdown") as HTMLTextAreaElement).value; assert.ok(body.includes('alt="업로드한 다이어그램 설명"'), body); assert.ok(!body.includes('alt=""'), body); const { report, preview } = await saveValidateAndPreview( gateway, created, "alt-text-publishable", body, ); assert.ok( !report.issues.some((issue) => issue.code === "EVIDENCE_ALT_REQUIRED"), JSON.stringify(report.issues), ); assert.equal(report.status, "VALID", JSON.stringify(report.issues)); assert.equal(preview?.renderModel.kind, "CASE"); }); test("a decorative upload inserts an empty alt and the document is still publishable", async () => { const user = userEvent.setup(); const installed = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input; const gateway = installed.createStudioGateway(); const assetGateway = installed.createStudioAssetGateway(); const created = await createUploadCase(gateway, "decorative-publishable", "장식용 게시 가능"); render( gateway} createAssetGateway={() => assetGateway}> , ); await screen.findByLabelText("본문 Markdown"); await user.click(screen.getByRole("button", { name: "Asset 업로드" })); await uploadThroughDialog( user, new File([""], "decorative-check.svg", { type: "image/svg+xml" }), { decorative: true }, ); await waitFor(() => { const value = (screen.getByLabelText("본문 Markdown") as HTMLTextAreaElement).value; assert.ok(value.includes('key="decorative-check"'), value); }); const body = (screen.getByLabelText("본문 Markdown") as HTMLTextAreaElement).value; assert.ok(body.includes('alt=""'), body); const { report, preview } = await saveValidateAndPreview( gateway, created, "decorative-publishable", body, ); assert.ok( !report.issues.some((issue) => issue.code === "EVIDENCE_ALT_REQUIRED"), JSON.stringify(report.issues), ); assert.equal(report.status, "VALID", JSON.stringify(report.issues)); assert.equal(preview?.renderModel.kind, "CASE"); }); test("the dialog refuses to upload a non-decorative asset with no alt text", async () => { const user = userEvent.setup(); let calls = 0; const gateway = uploadOnlyGateway(async () => { calls += 1; return READY as never; }); render( {}} onClose={() => {}} />); chooseFile(SAMPLE_FILE); await user.click(screen.getByRole("button", { name: "업로드" })); assert.equal( screen.getByRole("status", { name: "업로드 상태" }).textContent, "대체 텍스트를 입력하거나 장식용으로 표시하세요.", ); assert.equal(calls, 0); // Ticking `decorative` is the escape hatch, and it must not demand alt text. await user.click(screen.getByLabelText("장식용 이미지 (대체 텍스트 없음)")); await user.click(screen.getByRole("button", { name: "업로드" })); await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드했습니다.")); assert.equal(calls, 1); }); // --- Alignment follow-up, item 2: the Picker searches the server --- // // The Picker asked for `{ managementStatus: "READY", limit: 50 }` and ignored // `nextCursor`, so the 51st-oldest READY asset onward could not be inserted at // all. The Picker sits inside the editing flow, where scrolling a long list is // the wrong interaction, so it gets search rather than a "더 보기" control -- // but it still loads a first page, because an empty panel until you type is // hostile to an author who just wants the asset they uploaded a minute ago. function searchableGateway(items: ReadonlyArray>) { const calls: Array> = []; const gateway: StudioAssetGateway = { async listAssets(query) { calls.push({ ...query }); const q = (query.q ?? "").trim().toLocaleLowerCase("ko-KR"); const matched = items.filter( (item) => (!query.managementStatus || item.managementStatus === query.managementStatus) && (!q || String(item.assetKey).toLocaleLowerCase("ko-KR").includes(q)), ); return { items: matched, 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() {}, }; return { gateway, calls }; } const PICKER_A = { ...READY, id: "44444444-4444-4444-8444-444444444441", assetKey: "inserted-diagram", altText: "삽입한 다이어그램", publicPath: "/media/inserted-diagram.svg", }; const PICKER_B = { ...READY, id: "44444444-4444-4444-8444-444444444442", assetKey: "other-diagram", altText: "다른 다이어그램", publicPath: "/media/other-diagram.svg", }; test("the Picker shows a first page before anything is typed, then narrows to the submitted query", async () => { const user = userEvent.setup(); const { gateway, calls } = searchableGateway([PICKER_A, PICKER_B]); render( {}} />); // Useful before the author types: both assets are offered. await screen.findByRole("button", { name: /inserted-diagram/ }); assert.ok(screen.getByRole("button", { name: /other-diagram/ })); await user.type(screen.getByLabelText("Asset 검색"), "other"); await user.click(screen.getByRole("button", { name: "검색" })); await waitFor(() => expect(screen.queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(), ); assert.ok(screen.getByRole("button", { name: /other-diagram/ })); assert.equal(calls.at(-1)?.q, "other"); // The server filter must survive search: an asset under review is never // insertable, whatever the query. assert.equal(calls.at(-1)?.managementStatus, "READY"); }); test("typing in the Picker's search box never reaches the gateway on its own", async () => { const user = userEvent.setup(); const { gateway, calls } = searchableGateway([PICKER_A]); render( {}} />); await screen.findByRole("button", { name: /inserted-diagram/ }); assert.equal(calls.length, 1); await user.type(screen.getByLabelText("Asset 검색"), "inserted"); assert.equal(calls.length, 1); }); test("a QUARANTINED asset the server wrongly returns for a query is still not offered", async () => { const user = userEvent.setup(); const unsafe = { ...QUARANTINED, assetKey: "unsafe-diagram" }; const gateway: StudioAssetGateway = { async listAssets() { // Deliberately ignores `managementStatus`: the screen's own filter is // the thing under test. return { items: [PICKER_A, unsafe], 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() {}, }; render( {}} />); await screen.findByRole("button", { name: /inserted-diagram/ }); await user.type(screen.getByLabelText("Asset 검색"), "diagram"); await user.click(screen.getByRole("button", { name: "검색" })); await waitFor(() => assert.ok(screen.getByRole("button", { name: /inserted-diagram/ })), ); expect(screen.queryByRole("button", { name: /unsafe-diagram/ })).not.toBeInTheDocument(); }); test("the Picker reports search results too, so the catalog grows with every query", async () => { const user = userEvent.setup(); const observed: Asset[][] = []; const { gateway } = searchableGateway([PICKER_A, PICKER_B]); render( {}} onAssetsObserved={(assets) => observed.push([...assets])} />, ); await screen.findByRole("button", { name: /inserted-diagram/ }); await user.type(screen.getByLabelText("Asset 검색"), "other"); await user.click(screen.getByRole("button", { name: "검색" })); await waitFor(() => assert.equal(observed.length, 2)); assert.deepEqual( observed[1]!.map((asset) => asset.assetKey), ["other-diagram"], ); }); test("a slow earlier Picker search never overwrites the newer query's results", async () => { const user = userEvent.setup(); const pending: Array<{ settle: (page: unknown) => void }> = []; const gateway: StudioAssetGateway = { async listAssets() { return new Promise((resolve) => { pending.push({ settle: resolve }); }) 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() {}, }; render( {}} />); await waitFor(() => assert.equal(pending.length, 1)); await act(async () => { pending[0]!.settle({ items: [PICKER_A], nextCursor: null }); }); await screen.findByRole("button", { name: /inserted-diagram/ }); const input = screen.getByLabelText("Asset 검색"); await user.type(input, "slow"); await user.click(screen.getByRole("button", { name: "검색" })); await waitFor(() => assert.equal(pending.length, 2)); await user.clear(input); await user.type(input, "other"); await user.click(screen.getByRole("button", { name: "검색" })); await waitFor(() => assert.equal(pending.length, 3)); await act(async () => { pending[2]!.settle({ items: [PICKER_B], nextCursor: null }); }); await screen.findByRole("button", { name: /other-diagram/ }); await act(async () => { pending[1]!.settle({ items: [PICKER_A], nextCursor: null }); }); expect(screen.queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(); assert.ok(screen.getByRole("button", { name: /other-diagram/ })); }); // --- The trap this item creates --- // // The editor screen's Asset array feeds two consumers with opposite needs: the // Picker's *displayed* list (which a search must narrow) and Instant Preview's // *resolution catalog* (which a search must never shrink -- its gate rejects a // key no loaded asset backs). Wiring the Picker's results straight back into // the array the screen owns makes typing a search term blank out evidence // figures that rendered a moment earlier: a worse bug than the one being // fixed. The two are kept apart by making the screen's callback additive -- // the Picker reports what it *observed*, the screen merges it into a catalog // that only ever grows. test("a Picker search never drops an already-inserted asset out of Instant Preview's resolution", async () => { const user = userEvent.setup(); const gateway = createMockStudioGateway(); const { gateway: assetGateway } = searchableGateway([PICKER_A, PICKER_B]); render( gateway} createAssetGateway={() => assetGateway}> , ); await screen.findByLabelText("본문 Markdown"); await user.click(await screen.findByRole("button", { name: /inserted-diagram/ })); const body = (screen.getByLabelText("본문 Markdown") as HTMLTextAreaElement).value; assert.ok(body.includes('key="inserted-diagram"'), body); // Now search for something that excludes the asset just inserted. The Picker // must narrow; the preview must not. await user.type(screen.getByLabelText("Asset 검색"), "other"); await user.click(screen.getByRole("button", { name: "검색" })); await waitFor(() => expect(screen.queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(), ); await user.click(screen.getByRole("tab", { name: "즉시 미리보기" })); const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" }); assert.equal( within(panel).queryByRole("alert")?.textContent ?? null, null, "the preview refused a directive it resolved before the search", ); const [image] = within(panel).getAllByAltText("삽입한 다이어그램"); assert.equal(image!.getAttribute("src"), "/media/inserted-diagram.svg"); }); test("mergeAssetCatalog keeps every previously known asset and lets a fresher copy win", () => { const known = assetFixture({ id: "50505050-5050-4050-8050-505050505051", assetKey: "known" }); const staleCopy = assetFixture({ id: "50505050-5050-4050-8050-505050505052", assetKey: "replaced", publicPath: "/media/replaced-old.svg", updatedAt: "2026-08-14T00:00:00.000Z", }); const freshCopy = { ...staleCopy, publicPath: "/media/replaced-new.svg", version: 2 }; const arrival = assetFixture({ id: "50505050-5050-4050-8050-505050505053", assetKey: "arrival" }); const merged = mergeAssetCatalog([known, staleCopy], [freshCopy, arrival]); assert.deepEqual( [...merged].map((asset) => asset.assetKey).sort(), ["arrival", "known", "replaced"], ); assert.equal( merged.find((asset) => asset.id === staleCopy.id)?.publicPath, "/media/replaced-new.svg", ); }); test("mergeAssetCatalog can only grow: an empty arrival keeps everything", () => { const known = assetFixture({ id: "60606060-6060-4060-8060-606060606061", assetKey: "kept" }); assert.deepEqual(mergeAssetCatalog([known], []).map((asset) => asset.id), [known.id]); }); // --- Alignment follow-up, item 3: alt text must not outlive its file --- // // The file input's `onChange` reset `state` to `IDLE` and left `altText` // alone. On success that is harmless -- the dialog unmounts. But REJECTED, // QUARANTINED and TRANSPORT_FAILED all leave it mounted with the file input // re-enabled, which is exactly the retry path: upload `db-schema.png` // described as "DB 스키마", get it quarantined, pick `sequence.png`, upload -- // and `sequence.png` shipped described as "DB 스키마", validating as // publishable the whole way. test("picking a different file after a quarantined upload clears the previous image's alt text", async () => { const user = userEvent.setup(); const forms: Array<{ altText?: string; filename: string }> = []; const gateway = uploadOnlyGateway(async (form) => { forms.push({ altText: form.altText, filename: form.file.name }); return (forms.length === 1 ? { ...READY, managementStatus: "QUARANTINED" } : READY) as never; }); render( {}} onClose={() => {}} />); chooseAndUpload(new File(["a"], "db-schema.png", { type: "image/png" }), "DB 스키마"); await waitFor(() => assert.equal( screen.getByRole("status", { name: "업로드 상태" }).textContent, "보안 검사에서 격리되어 사용할 수 없습니다.", ), ); chooseFile(new File(["b"], "sequence.png", { type: "image/png" })); assert.equal((screen.getByLabelText("대체 텍스트") as HTMLInputElement).value, ""); // And the emptied field is enforced, not merely displayed: uploading now // refuses instead of shipping an undescribed image. await user.click(screen.getByRole("button", { name: "업로드" })); assert.equal( screen.getByRole("status", { name: "업로드 상태" }).textContent, "대체 텍스트를 입력하거나 장식용으로 표시하세요.", ); assert.equal(forms.length, 1); }); test("the retry after a transport failure ships its own alt text, never the first attempt's", async () => { const forms: Array<{ altText?: string; filename: string }> = []; const gateway = uploadOnlyGateway(async (form) => { forms.push({ altText: form.altText, filename: form.file.name }); if (forms.length === 1) throw new Error("offline"); return READY as never; }); render( {}} onClose={() => {}} />); chooseAndUpload(new File(["a"], "db-schema.png", { type: "image/png" }), "DB 스키마"); await waitFor(() => assert.equal( screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드를 전송하지 못했습니다.", ), ); chooseAndUpload(new File(["b"], "sequence.png", { type: "image/png" }), "시퀀스 다이어그램"); await waitFor(() => assert.equal( screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드했습니다.", ), ); assert.deepEqual(forms, [ { altText: "DB 스키마", filename: "db-schema.png" }, { altText: "시퀀스 다이어그램", filename: "sequence.png" }, ]); }); // The same defect as the alt-text one above, in the flag that *exempts* an // asset from needing alt text at all. Alt text surviving a file swap ships a // wrong description; `decorative` surviving one ships a meaningful image // marked as decorative -- `validate-working-copy.ts`'s EVIDENCE_ALT_REQUIRED // reads `Asset.decorative` and so passes it, and the image reaches publication // with no accessible name whatsoever. Worse than the bug it rides beside. test("picking a different file after a rejected upload clears the decorative flag too", async () => { const user = userEvent.setup(); const forms: Array<{ decorative?: boolean; altText?: string; filename: string }> = []; const gateway = uploadOnlyGateway(async (form) => { forms.push({ decorative: form.decorative, altText: form.altText, filename: form.file.name, }); return (forms.length === 1 ? { ...READY, managementStatus: "REJECTED" } : READY) as never; }); render( {}} onClose={() => {}} />); chooseFile(new File(["a"], "divider.png", { type: "image/png" })); await user.click(screen.getByLabelText("장식용 이미지 (대체 텍스트 없음)")); await user.click(screen.getByRole("button", { name: "업로드" })); await waitFor(() => assert.equal( screen.getByRole("status", { name: "업로드 상태" }).textContent, "서버 검증에서 거절되어 사용할 수 없습니다.", ), ); assert.deepEqual(forms, [ { decorative: true, altText: undefined, filename: "divider.png" }, ]); // A meaningful diagram is chosen next. It must not inherit the divider's // "no alt text needed" exemption. chooseFile(new File(["b"], "sequence.png", { type: "image/png" })); assert.equal( (screen.getByLabelText("장식용 이미지 (대체 텍스트 없음)") as HTMLInputElement).checked, false, ); // The alt field is re-enabled by the same reset -- while `decorative` was // still set it was `disabled`, so a cleared-but-disabled field would be a // dead end. assert.equal((screen.getByLabelText("대체 텍스트") as HTMLInputElement).disabled, false); // And it is enforced: uploading now refuses instead of publishing an // unnamed image. await user.click(screen.getByRole("button", { name: "업로드" })); assert.equal( screen.getByRole("status", { name: "업로드 상태" }).textContent, "대체 텍스트를 입력하거나 장식용으로 표시하세요.", ); assert.equal(forms.length, 1); await user.type(screen.getByLabelText("대체 텍스트"), "시퀀스 다이어그램"); await user.click(screen.getByRole("button", { name: "업로드" })); await waitFor(() => assert.equal( screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드했습니다.", ), ); assert.deepEqual(forms[1], { decorative: false, altText: "시퀀스 다이어그램", filename: "sequence.png", }); });