Files
tech-log-frontend/tests/features/tech-log/asset-picker.test.tsx
T
DongHyeonkaandClaude Opus 5 073fda87eb fix: collapse the evidence-key gate and resolver into one decision
Three fix rounds each rebuilt the gate as a separate expression that merely
agreed with the resolver on the inputs that round's tests used. Different
expressions cannot agree in general, so the defect class stayed open while
each reported instance closed.

`findResolvableAsset(assets, key)` is now the single place that decides which
Asset an evidence key resolves to. Every gate is
`Boolean(findResolvableAsset(...)) || legacyKey(key)` via one shared
composition, and every resolver returns what it returns:

- validate-working-copy: the key gate and the decorative lookup (a last-wins
  Map against the resolver's first-wins find, so alt could be judged against a
  different Asset than the one rendered)
- adapters/mock/project-public-render-model: gate and resolver
- instant-preview: gate and descriptor resolver
- createAssetCatalogResolver: the pixels, a fourth expression nobody had
  listed -- one Asset's caption could sit over another Asset's image

Duplicate assetKeys are a contract violation but reachable through a paged
list, so the choice is total and order-independent: newest updatedAt wins,
tie-broken by id.

InstantPreview's gate is no longer looser than the others. The un-loaded-asset
case it was loosened for blanks either way; all the looseness bought was
catalog-only keys rendering an empty gap with no message while validation said
EVIDENCE_UNSUPPORTED. The test that pinned that divergence now asserts the
consistent behaviour, and the false comment claiming a fix that did not exist
is gone.

idempotent() now maps a deterministic content failure to VALIDATION_STALE/409
instead of offering a retry that fails identically, and no longer caches
uncharacterized internal failures -- reporting one as retryable while freezing
it in the ledger meant the retry could never re-run.

Adds tests/features/tech-log/evidence-key-agreement.test.ts: 144 adversarial
(asset list, key) combinations asserting the agreement itself rather than
examples. It reported 53 disagreements against the previous code.

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

1348 lines
52 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 { InstantPreview } from "../../../src/features/tech-log/presentation/studio/components/instant-preview.tsx";
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
import { createMockStudioGateway } from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts";
import { projectWorkingCopy as mockProjectWorkingCopy } from "../../../src/features/tech-log/adapters/mock/project-public-render-model.ts";
import { validateWorkingCopy } from "../../../src/features/tech-log/adapters/mock/validate-working-copy.ts";
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
import { MOCK_STUDIO_INSTALL_CONTEXT } from "../../helpers/studio-install-context.ts";
import type { StudioAssetGateway } from "../../../src/features/tech-log/application/ports/studio-asset-gateway.ts";
import { StudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
import type { Asset } from "../../../src/features/tech-log/contracts/studio/contract.ts";
// 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.
//
// Fix round 4 amends the expected code. Round 3 mapped this to
// STUDIO_UNAVAILABLE/500 with `retryable: true`, which invites a retry that
// fails identically -- the Asset is gone, the projection will refuse again.
// A deterministic content failure between validate and preview is exactly
// what VALIDATION_STALE/409 means, and it is not retryable without
// re-validating first.
test("idempotent() maps a deterministic content failure to VALIDATION_STALE, never lets a raw error escape the port", async () => {
const asset = assetFixture({ assetKey: "race-check" });
const assets = new Map<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");
const expectStale = (error: unknown) => {
assert.ok(
error instanceof StudioGatewayError,
`expected a StudioGatewayError, got ${String(error)}`,
);
assert.equal(error.code, "VALIDATION_STALE");
assert.equal(error.status, 409);
assert.equal(error.retryable, false);
return true;
};
await assert.rejects(
gateway.createPreview(
created.id,
{ expectedVersion: created.version, validationId: report.validationId },
{ idempotencyKey: "race-preview" },
),
expectStale,
);
// Deterministic outcomes stay in the ledger: a same-key retry replays the
// same 409 rather than re-running work that cannot succeed.
await assert.rejects(
gateway.createPreview(
created.id,
{ expectedVersion: created.version, validationId: report.validationId },
{ idempotencyKey: "race-preview" },
),
expectStale,
);
});
// EVIDENCE_NOT_FOUND has been unreachable through `createMockStudioGateway()`
// on its own: the fixture catalog it always loads already carries a row for
// the one legacy key, so gate 1 passing (via the legacy key) always implied
// gate 2 passing too. Pinned directly against `validateWorkingCopy` instead,
// with a hand-built catalog that omits the row on purpose.
test("EVIDENCE_NOT_FOUND is reachable through validateWorkingCopy directly: the legacy key with no catalog EVIDENCE row", () => {
const document = {
id: "88888888-8888-4888-8888-888888888881",
version: 1,
updatedAt: "2026-08-14T00:00:00.000Z",
kind: "CASE",
title: "레거시 키인데 카탈로그에 없음",
slug: "legacy-key-no-catalog-row",
summary: "레거시 키는 gate 1을 통과하지만 카탈로그에 행이 없으면 gate 2가 거부해야 합니다.",
topicId: FIXTURE_IDS.topicJpa,
projectId: FIXTURE_IDS.projectBackend,
relations: [],
problem: "문제",
conclusion: "결론",
environment: "env",
reproduction: "repro",
lastVerifiedOn: "2026-08-14",
bodyMarkdown: ':::evidence key="fetch-strategy-boundary" alt="근거" caption="근거" zoom="false"\n:::',
};
const report = validateWorkingCopy(document as never, {
now: new Date("2026-08-14T01:00:00.000Z"),
validationId: "evidence-not-found-check",
dependencyRevision: "r1",
catalog: [
{ id: FIXTURE_IDS.topicJpa, type: "TOPIC", label: "JPA", dependencyRevision: "r1" },
{ id: FIXTURE_IDS.projectBackend, type: "PROJECT", label: "Backend", dependencyRevision: "r1" },
// Deliberately no EVIDENCE row for "fetch-strategy-boundary" -- gate 1
// (the legacy key) passes, gate 2 (this catalog) must not.
] as never,
documents: [document as never],
assets: [],
});
assert.equal(report.status, "INVALID", JSON.stringify(report.issues));
assert.ok(
report.issues.some((issue) => issue.code === "EVIDENCE_NOT_FOUND"),
JSON.stringify(report.issues),
);
});
// --- Fix round 4 ---
//
// Rounds 1-3 each rebuilt gate 1 as a *separate expression* that happened to
// agree with the resolver on the inputs the round's tests used. They cannot
// agree in general, because they were never the same function. Round 4
// collapses "can this key be rendered" and "which Asset does it render" into
// one exported decision, `findResolvableAsset`, and puts every caller
// (validation's gate, validation's decorative lookup, the mock projection's
// gate and resolver, InstantPreview's gate, resolver, and pixel resolver) on
// it. The tests below are the shapes that broke the round-3 pair.
// `MockStudioDependencies.assets` is keyed by whatever the caller chooses --
// the gateway only ever reads `.values()`. Keying by `id` here is what lets a
// test express the shape the whole round is about: two Assets that share one
// `assetKey`.
function duplicateKeyAssets(...assets: Asset[]): Map<string, Asset> {
return new Map(assets.map((asset) => [asset.id, asset]));
}
async function previewFor(
assets: Map<string, Asset>,
bodyMarkdown: string,
slugSuffix: string,
) {
const gateway = createMockStudioGateway({ assets });
const created = await gateway.createDocument(
{
kind: "CASE",
title: `중복 키 확인 (${slugSuffix})`,
slug: `duplicate-key-${slugSuffix}`,
summary: "하나의 assetKey를 공유하는 Asset이 둘일 때의 동작을 확인합니다.",
topicId: FIXTURE_IDS.topicJpa,
projectId: FIXTURE_IDS.projectBackend,
relations: [],
problem: "문제",
conclusion: "결론",
environment: "env",
reproduction: "repro",
lastVerifiedOn: "2026-08-14",
bodyMarkdown,
},
{ idempotencyKey: `duplicate-key-create-${slugSuffix}` },
);
const report = await gateway.validateDocument(
created.id,
{ expectedVersion: created.version },
{ idempotencyKey: `duplicate-key-validate-${slugSuffix}` },
);
const preview = await gateway.createPreview(
created.id,
{ expectedVersion: created.version, validationId: report.validationId },
{ idempotencyKey: `duplicate-key-preview-${slugSuffix}` },
);
const model = preview.renderModel;
assert.equal(model.kind, "CASE");
const figure = model.kind === "CASE"
? model.bodyBlocks.find((block) => block.type === "EVIDENCE_FIGURE")
: undefined;
assert.ok(figure && figure.type === "EVIDENCE_FIGURE", "expected an EVIDENCE_FIGURE block");
return { report, asset: figure.asset };
}
// The third live instance the reviewer found: two READY Assets share one
// `assetKey`, the first carries `publicPath: null`, the second a real path.
// `∃a. P(a) ∧ Q(a)` says yes (the second one). `Q(first a satisfying P)` says
// no, and the resolver falls through to the generic placeholder -- so
// `validateDocument` returns VALID with zero issues, `createPreview` succeeds,
// and `publishDocument` would snapshot a render model carrying
// `publicPath: ""` with nothing anywhere reporting an error.
test("two READY assets sharing one assetKey: the gate and the resolver agree, and the preview never carries an empty publicPath", async () => {
const withoutPath = assetFixture({
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
assetKey: "dup-null-first",
publicPath: null,
updatedAt: "2026-08-14T00:00:00.000Z",
});
const withPath = assetFixture({
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2",
assetKey: "dup-null-first",
publicPath: "/media/dup-null-first.svg",
updatedAt: "2026-08-14T00:00:01.000Z",
});
const { report, asset } = await previewFor(
duplicateKeyAssets(withoutPath, withPath),
':::evidence key="dup-null-first" alt="근거" caption="근거" zoom="false"\n:::',
"null-first",
);
assert.equal(report.status, "VALID", JSON.stringify(report.issues));
assert.equal(asset.publicPath, "/media/dup-null-first.svg");
assert.equal(asset.assetId, withPath.id);
});
// Order-independence. The same two resolvable Assets in either array order
// must resolve to the same one; `find`-based first-wins picks whichever the
// caller happened to list first.
test("duplicate resolvable assets resolve to the same asset in either array order", async () => {
const older = assetFixture({
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1",
assetKey: "dup-order",
publicPath: "/media/dup-order-older.svg",
updatedAt: "2026-08-14T00:00:00.000Z",
});
const newer = assetFixture({
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2",
assetKey: "dup-order",
publicPath: "/media/dup-order-newer.svg",
updatedAt: "2026-08-14T00:00:01.000Z",
});
const body = ':::evidence key="dup-order" alt="근거" caption="근거" zoom="false"\n:::';
const forward = await previewFor(duplicateKeyAssets(older, newer), body, "order-forward");
const reverse = await previewFor(duplicateKeyAssets(newer, older), body, "order-reverse");
assert.deepEqual(forward.asset, reverse.asset);
assert.equal(forward.asset.assetId, newer.id);
assert.equal(forward.asset.publicPath, "/media/dup-order-newer.svg");
});
// The third rule with the same disease: `readyAssetsByKey` was built with
// `new Map(...)` (last-wins) while the resolver used `find` (first-wins), so
// EVIDENCE_ALT_REQUIRED could be judged against a different Asset than the
// one actually rendered. Here the rendered Asset is `decorative` (empty alt
// is legitimate for it) but the last-wins map judges the other one.
test("EVIDENCE_ALT_REQUIRED is judged against the asset that actually renders, not a different one sharing the key", async () => {
const rendered = assetFixture({
id: "cccccccc-cccc-4ccc-8ccc-ccccccccccc1",
assetKey: "alt-mismatch",
publicPath: "/media/alt-mismatch-rendered.svg",
decorative: true,
updatedAt: "2026-08-14T00:00:02.000Z",
});
const shadow = assetFixture({
id: "cccccccc-cccc-4ccc-8ccc-ccccccccccc2",
assetKey: "alt-mismatch",
publicPath: "/media/alt-mismatch-shadow.svg",
decorative: false,
updatedAt: "2026-08-14T00:00:01.000Z",
});
const { report, asset } = await previewFor(
duplicateKeyAssets(rendered, shadow),
':::evidence key="alt-mismatch" alt="" caption="근거" zoom="false"\n:::',
"alt-mismatch",
);
assert.ok(
!report.issues.some((issue) => issue.code === "EVIDENCE_ALT_REQUIRED"),
JSON.stringify(report.issues),
);
assert.equal(asset.assetId, rendered.id);
assert.equal(asset.decorative, true);
});
const PREVIEW_CATALOG = [
{ id: "topic-jpa", type: "TOPIC", label: "JPA", publicPath: "/topics/jpa", dependencyRevision: "r1" },
{
id: "resolution-evidence",
type: "EVIDENCE",
label: "결론 근거",
publicPath: "/cases/some-case",
dependencyRevision: "r1",
},
] as never[];
function caseDraft(bodyMarkdown: string) {
return {
kind: "CASE",
title: "미리보기 게이트 확인",
slug: "preview-gate-check",
summary: "요약",
topicId: "topic-jpa",
projectId: null,
relations: [],
problem: "문제",
conclusion: "결론",
environment: "env",
reproduction: "repro",
lastVerifiedOn: "2026-08-14",
bodyMarkdown,
} as never;
}
function renderInstantPreview(bodyMarkdown: string, assets: readonly Asset[]) {
render(
<MemoryRouter>
<StudioProvider createGateway={() => createMockStudioGateway()}>
<InstantPreview draft={caseDraft(bodyMarkdown)} catalog={PREVIEW_CATALOG} assets={assets} />
</StudioProvider>
</MemoryRouter>,
);
}
// Round 3 loosened InstantPreview's gate back to the merged catalog, arguing
// the un-loaded-asset case would otherwise blank the preview. It blanks
// either way -- an un-loaded Asset contributes no catalog row either. All the
// looseness bought was keys the fetched EVIDENCE catalog carries: those
// rendered a caption with an empty gap and no message while validation said
// EVIDENCE_UNSUPPORTED. Every caller is now on the same expression, so the
// preview refuses exactly what validation refuses -- and says so.
test("Instant Preview refuses a key only the document catalog carries, exactly as mock validation does", () => {
const body = ':::evidence key="resolution-evidence" alt="근거" caption="근거" zoom="false"\n:::';
renderInstantPreview(body, []);
const alert = screen.getByRole("alert");
assert.match(alert.textContent ?? "", /resolution-evidence/);
const report = validateWorkingCopy(
{ ...(caseDraft(body) as object), id: "88888888-8888-4888-8888-888888888882", version: 1, updatedAt: "2026-08-14T00:00:00.000Z" } as never,
{
now: new Date("2026-08-14T01:00:00.000Z"),
validationId: "preview-gate-consistency",
dependencyRevision: "r1",
catalog: PREVIEW_CATALOG,
documents: [],
assets: [],
},
);
assert.ok(
report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED"),
JSON.stringify(report.issues),
);
});
// The pixels come from `createAssetCatalogResolver` (a last-wins `Map`) while
// the block descriptor came from `resolveAssetDescriptor` (a first-wins
// `find`) -- a fourth expression in the same path, disagreeing with the third
// whenever two READY Assets share a key.
test("Instant Preview renders the same asset the gate and the descriptor picked when two assets share a key", () => {
const older = assetFixture({
id: "dddddddd-dddd-4ddd-8ddd-ddddddddddd1",
assetKey: "dup-pixels",
publicPath: "/media/dup-pixels-older.svg",
updatedAt: "2026-08-14T00:00:00.000Z",
});
const newer = assetFixture({
id: "dddddddd-dddd-4ddd-8ddd-ddddddddddd2",
assetKey: "dup-pixels",
publicPath: "/media/dup-pixels-newer.svg",
updatedAt: "2026-08-14T00:00:01.000Z",
});
renderInstantPreview(
':::evidence key="dup-pixels" alt="중복 키 그림" caption="근거" zoom="false"\n:::',
[newer, older],
);
assert.equal(screen.queryByRole("alert"), null);
assert.equal(
screen.getByAltText("중복 키 그림").getAttribute("src"),
"/media/dup-pixels-newer.svg",
);
});
// The idempotency ledger must not freeze an *uncharacterized* internal
// failure: the port reports it as retryable, so a same-key retry has to
// actually re-run the work instead of replaying the cached 500.
test("a same-key retry re-runs after an uncharacterized internal failure instead of replaying a cached 500", async () => {
let remainingFailures = 1;
const gateway = createMockStudioGateway({
dependencyRevision: {
current: () => {
if (remainingFailures > 0) {
remainingFailures -= 1;
throw new Error("의존성 리비전을 읽지 못했습니다.");
}
return "r1";
},
},
});
const created = await gateway.createDocument(
{
kind: "CASE",
title: "일시적 내부 실패 재시도",
slug: "transient-internal-failure-retry",
summary: "성격이 규명되지 않은 내부 실패는 원장에 얼려두면 안 됩니다.",
topicId: FIXTURE_IDS.topicJpa,
projectId: FIXTURE_IDS.projectBackend,
relations: [],
problem: "문제",
conclusion: "결론",
environment: "env",
reproduction: "repro",
lastVerifiedOn: "2026-08-14",
bodyMarkdown: "## 본문\n\n내용입니다.",
},
{ idempotencyKey: "transient-create" },
);
await assert.rejects(
gateway.validateDocument(
created.id,
{ expectedVersion: created.version },
{ idempotencyKey: "transient-validate" },
),
(error: unknown) => {
assert.ok(error instanceof StudioGatewayError, `expected a StudioGatewayError, got ${String(error)}`);
assert.equal(error.code, "STUDIO_UNAVAILABLE");
assert.equal(error.retryable, true);
return true;
},
);
const report = await gateway.validateDocument(
created.id,
{ expectedVersion: created.version },
{ idempotencyKey: "transient-validate" },
);
assert.equal(report.documentId, created.id);
});