// @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 { 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"; // 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", { 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={() => {}} />); chooseFile(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={() => {}} />, ); chooseFile(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={() => {}} />, ); chooseFile(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={() => {}} />, ); chooseFile(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={() => {}} />); chooseFile(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={() => {}} />); chooseFile(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={() => {}} />); chooseFile(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 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"); }); // --- 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={() => {}} />); chooseFile(new File(["a"], "a.svg", { type: "image/svg+xml" })); await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "파일 크기가 허용 범위를 넘었습니다.")); chooseFile(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 업로드" })); const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; fireEvent.change(fileInput, { target: { files: [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; // The upload form (asset-picker.tsx's AssetUploadDialog) has no altText // field, so the auto-inserted directive carries `alt=""` -- fill it in // here the way an author would by editing the textarea, so the only // remaining validation question is the evidence key itself (I2's target), // not the unrelated EVIDENCE_ALT_REQUIRED rule. const authoredBody = textarea.value.replace('alt=""', 'alt="업로드 확인용 대체 텍스트"'); assert.notEqual(authoredBody, textarea.value); 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. test("idempotent() wraps a raw domain error into a StudioGatewayError, never lets it escape the port", async () => { const asset = assetFixture({ assetKey: "race-check" }); const assets = new Map([[asset.assetKey, asset]]); const gateway = createMockStudioGateway({ assets }); 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"); await assert.rejects( gateway.createPreview( created.id, { expectedVersion: created.version, validationId: report.validationId }, { idempotencyKey: "race-preview" }, ), (error: unknown) => { assert.ok( error instanceof StudioGatewayError, `expected a StudioGatewayError, got ${String(error)}`, ); assert.equal(error.code, "STUDIO_UNAVAILABLE"); return true; }, ); }); // 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), ); }); // Low note (reviewer): round 2's assets-only gate 1 in InstantPreview made a // CASE referencing a document-catalog-backed key blank the WHOLE preview // behind the error panel whenever that key's Asset was not (yet) in the // loaded `assets` array -- outside the Picker's 50-item page, or simply // because `assets` is still `[]` while `listAssets` is in flight. Fix round // 3 reverted InstantPreview's gate 1 to the merged catalog; this pins that // such a document now degrades the one unresolved figure to a placeholder // instead of failing the whole preview. test("Instant Preview degrades to a placeholder for a document-catalog-backed key with no loaded Asset, instead of blanking the whole preview", async () => { const user = userEvent.setup(); const gateway = createMockStudioGateway(); const assetGateway = gatewayOf([]); const created = await gateway.createDocument( { kind: "CASE", title: "카탈로그로만 뒷받침되는 근거 미리보기", slug: "catalog-only-evidence-preview-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:::evidence key="${FIXTURE_IDS.fetchJoinCase}" alt="근거" caption="근거" zoom="false"\n:::`, }, { idempotencyKey: "catalog-only-evidence-preview-create" }, ); render( gateway} createAssetGateway={() => assetGateway}> , ); await screen.findByLabelText("본문 Markdown"); await user.click(screen.getByRole("tab", { name: "즉시 미리보기" })); const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" }); assert.equal(within(panel).queryByRole("alert"), null); assert.ok( within(panel).getByRole("heading", { level: 1, name: "카탈로그로만 뒷받침되는 근거 미리보기" }), ); });