Files
tech-log-frontend/tests/features/tech-log/asset-picker.test.tsx
T
DongHyeonkaandClaude Opus 5 783e9b2cf1 fix: rebuild gate 1 from the resolver's own predicate, not a catalog lookup
Fix round 3 (review of 7ff9728):

Round 2's gate 1 (supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets)))
still routed through evidenceCatalogEntryFor, matching on id || label ||
publicPath -- gate 2's question, asked over fewer rows, not the resolver's
actual success condition (assetKey === key && managementStatus === "READY"
&& Boolean(publicPath)). Two Asset shapes made the two predicates disagree:
a READY asset with publicPath null/"", and a READY asset whose publicPath
happens to satisfy the legacy /media/${someOtherKey}.svg convention for a
key that isn't its own assetKey. Both passed gate 1 while the resolver
could not produce real pixels, reproducing the validateDocument-VALID /
createPreview-throws-raw-Error disagreement a second time.

Rebuilt gate 1 in validate-working-copy.ts and
adapters/mock/project-public-render-model.ts directly from a new domain
function, supportsEvidenceKeyFromReadyAssets, that mirrors the resolver's
exact condition -- never through evidenceCatalogEntryFor again. Gate 2
keeps reading the merged catalog. Made the mock resolver total (returns a
placeholder instead of throwing, matching instant-preview.tsx's resolver),
removing a comment that asserted an invariant the code did not hold.
Wrapped mock-studio-gateway.ts's idempotent() so any non-StudioGatewayError
that reaches its catch is normalized before crossing the port -- closing
the class generally, not just this instance.

Reverted instant-preview.tsx's own gate 1 to the merged catalog (unlike
the mock adapters, its resolver is provably total, so a loose gate there
only ever degrades to a placeholder) -- round 2's narrowing there was a
separate regression: a CASE referencing a document-catalog-backed key
outside the editor's currently-loaded Asset list blanked the entire
preview instead of degrading one figure.

Pinned both slip-through shapes failing on both mock paths with the
thrown/rejected error's type asserted (StudioGatewayError, never a raw
Error), pinned idempotent()'s new wrapping via a validate/preview race,
pinned EVIDENCE_NOT_FOUND as reachable through validateWorkingCopy
directly, and pinned Instant Preview's graceful degradation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 06:24:46 +09:00

1062 lines
42 KiB
TypeScript

// @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 `<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" onUploaded={() => {}} 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<Asset>((resolve) => { resolveUpload = resolve; }),
);
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} 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(
<AssetUploadDialog
gateway={gateway}
kind="DIAGRAM"
onUploaded={(asset) => 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(
<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={(asset) => 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(
<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={(asset) => 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(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} 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(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} 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(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} 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(<AssetUploadDialog gateway={gateway} kind="IMAGE" 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");
});
// --- 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(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} 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(
<MemoryRouter initialEntries={[`/studio/documents/${created.id}/edit`]}>
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<DocumentEditorScreen documentId={created.id} />
</StudioProvider>
</MemoryRouter>,
);
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(["<svg/>"], "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<string, unknown>;
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(["<svg/>"], "deco.svg", { type: "image/svg+xml" }), kind: "IMAGE", decorative: true },
{ idempotencyKey: "decorative-alt-upload" },
);
const informative = await assetGateway.uploadAsset(
{ file: new File(["<svg/>"], "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>): 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<string, Asset>,
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<string, Asset>([[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(
<MemoryRouter initialEntries={[`/studio/documents/${created.id}/edit`]}>
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<DocumentEditorScreen documentId={created.id} />
</StudioProvider>
</MemoryRouter>,
);
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: "카탈로그로만 뒷받침되는 근거 미리보기" }),
);
});