// @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 { 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), ); });