feat: insert evidence directives from the TechLog asset picker

Adds an Asset Picker and upload dialog to the CASE editor so authors can
insert `:::evidence` directives that reference backend assets, and opens
projectWorkingCopy's two evidence gates so Instant Preview accepts a key
backed by a freshly loaded READY asset instead of only the one hardcoded
legacy key. The editor screen now owns the loaded Asset list so the
Picker, the upload dialog, and Instant Preview all read the same array,
and a freshly uploaded asset appears in the preview without a refetch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 04:32:56 +09:00
co-authored by Claude Opus 5
parent e2c1d076f4
commit 54d9bf9120
11 changed files with 907 additions and 12 deletions
@@ -0,0 +1,443 @@
// @vitest-environment jsdom
import assert from "node:assert/strict";
import { afterAll, beforeAll, test } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import {
AssetPicker,
buildEvidenceDirective,
} from "../../../src/features/tech-log/presentation/studio/components/asset-picker.tsx";
import {
AssetUploadDialog,
stateForError,
stateForUploaded,
} from "../../../src/features/tech-log/presentation/studio/components/asset-upload-dialog.tsx";
import { DocumentEditorScreen } from "../../../src/features/tech-log/presentation/studio/components/document-editor-screen.tsx";
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
import { createMockStudioGateway } from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts";
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
import type { StudioAssetGateway } from "../../../src/features/tech-log/application/ports/studio-asset-gateway.ts";
import { StudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
import type { Asset } from "../../../src/features/tech-log/contracts/studio/contract.ts";
// jsdom does not implement `<dialog>` -- 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(<AssetPicker gateway={gatewayOf([READY])} onInsert={(value) => 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(<AssetPicker gateway={gatewayOf([READY, QUARANTINED])} onInsert={() => {}} />);
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(
<AssetPicker
gateway={gatewayOf([READY, QUARANTINED])}
onInsert={() => {}}
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(["<svg/>"], "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(<AssetUploadDialog gateway={gateway} kind="IMAGE" idempotencyKey="k1" onUploaded={() => {}} onClose={() => {}} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(input, { target: { files: [] } });
assert.equal(screen.getByRole("status").textContent, "파일을 선택하지 못했습니다.");
assert.equal(calls, 0);
});
test("shows an uploading status and disables the file input while the transport is pending", async () => {
let resolveUpload!: (asset: Asset) => void;
const gateway = uploadOnlyGateway(
() => new Promise<Asset>((resolve) => { resolveUpload = resolve; }),
);
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" idempotencyKey="k1" onUploaded={() => {}} onClose={() => {}} />);
chooseFile(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "업로드 중입니다."));
assert.equal((document.querySelector('input[type="file"]') as HTMLInputElement).disabled, true);
resolveUpload(READY as unknown as Asset);
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "업로드했습니다."));
});
test("only calls onUploaded and shows success once the server returns READY", async () => {
const uploaded: Asset[] = [];
const gateway = uploadOnlyGateway(async () => READY as never);
render(
<AssetUploadDialog
gateway={gateway}
kind="DIAGRAM"
idempotencyKey="k1"
onUploaded={(asset) => uploaded.push(asset)}
onClose={() => {}}
/>,
);
chooseFile(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "업로드했습니다."));
assert.equal(uploaded.length, 1);
assert.equal(uploaded[0]!.assetKey, "fetch-strategy-boundary");
});
test("a QUARANTINED server outcome never calls onUploaded, even though the transport succeeded", async () => {
const uploaded: Asset[] = [];
const gateway = uploadOnlyGateway(async () => ({ ...READY, managementStatus: "QUARANTINED" }) as never);
render(
<AssetUploadDialog gateway={gateway} kind="IMAGE" idempotencyKey="k1" onUploaded={(asset) => uploaded.push(asset)} onClose={() => {}} />,
);
chooseFile(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "보안 검사에서 격리되어 사용할 수 없습니다."));
assert.equal(uploaded.length, 0);
});
test("a REJECTED server outcome never calls onUploaded", async () => {
const uploaded: Asset[] = [];
const gateway = uploadOnlyGateway(async () => ({ ...READY, managementStatus: "REJECTED" }) as never);
render(
<AssetUploadDialog gateway={gateway} kind="IMAGE" idempotencyKey="k1" onUploaded={(asset) => uploaded.push(asset)} onClose={() => {}} />,
);
chooseFile(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "서버 검증에서 거절되어 사용할 수 없습니다."));
assert.equal(uploaded.length, 0);
});
test("a PAYLOAD_TOO_LARGE transport rejection shows the size-exceeded message", async () => {
const gateway = uploadOnlyGateway(async () => {
throw new StudioGatewayError({
type: "https://techlog.local/problems/payload-too-large",
title: "PAYLOAD_TOO_LARGE",
status: 413,
detail: "파일이 너무 큽니다.",
code: "PAYLOAD_TOO_LARGE",
});
});
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" idempotencyKey="k1" onUploaded={() => {}} onClose={() => {}} />);
chooseFile(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "파일 크기가 허용 범위를 넘었습니다."));
});
test("an UNSUPPORTED_MEDIA_TYPE transport rejection shows the unsupported-type message", async () => {
const gateway = uploadOnlyGateway(async () => {
throw new StudioGatewayError({
type: "https://techlog.local/problems/unsupported-media-type",
title: "UNSUPPORTED_MEDIA_TYPE",
status: 415,
detail: "지원하지 않는 형식입니다.",
code: "UNSUPPORTED_MEDIA_TYPE",
});
});
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" idempotencyKey="k1" onUploaded={() => {}} onClose={() => {}} />);
chooseFile(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "지원하지 않는 파일 형식입니다."));
});
test("a plain network failure shows the generic transport-failed message", async () => {
const gateway = uploadOnlyGateway(async () => {
throw new Error("offline");
});
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" idempotencyKey="k1" onUploaded={() => {}} onClose={() => {}} />);
chooseFile(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "업로드를 전송하지 못했습니다."));
});
test("focuses the file input on open, traps Tab inside the dialog, and calls onClose from the close button", async () => {
const user = userEvent.setup();
let closed = 0;
const gateway = uploadOnlyGateway(async () => READY as never);
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" idempotencyKey="k1" onUploaded={() => {}} 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(
<MemoryRouter initialEntries={[`/studio/documents/${FIXTURE_IDS.redisAdapterCase}/edit`]}>
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<DocumentEditorScreen documentId={FIXTURE_IDS.redisAdapterCase} />
</StudioProvider>
</MemoryRouter>,
);
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");
});
@@ -19,9 +19,14 @@ function renderEditor(
documentId: string,
gateway: StudioGateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(),
) {
// CASE editors mount the Asset Picker (Task 10), which uses the throwing
// `useStudioAssetGateway()` accessor -- a test harness that renders it must
// supply `createAssetGateway`, the same as `StudioShell` always does.
const assetGateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT)
.input.createStudioAssetGateway();
return render(
<MemoryRouter initialEntries={[`/studio/documents/${documentId}/edit`]}>
<StudioProvider createGateway={() => gateway}>
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<DocumentEditorScreen documentId={documentId} />
</StudioProvider>
</MemoryRouter>,
@@ -79,9 +79,14 @@ function Announcement() {
}
function renderEditor(documentId: string, gateway: StudioGateway) {
// CASE editors mount the Asset Picker (Task 10), which uses the throwing
// `useStudioAssetGateway()` accessor -- a test harness that renders it must
// supply `createAssetGateway`, the same as `StudioShell` always does.
const assetGateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT)
.input.createStudioAssetGateway();
return render(
<MemoryRouter initialEntries={[`/studio/documents/${documentId}/edit`]}>
<StudioProvider createGateway={() => gateway}>
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<DocumentEditorScreen documentId={documentId} />
<Announcement />
</StudioProvider>