Files
DongHyeonkaandClaude Opus 5 60c8c82097 fix: 편집과 미리보기를 한 화면에서 보고, 저장·게시를 아래에 고정한다
편집기는 탭이었다. 고친 것이 어떻게 보이는지 확인하려면 편집하던 자리를 화면에서
치워야 했고, 돌아오면 스크롤 위치도 잃었다. 두 패널을 나란히 두면 그 왕복이 통째로
없어진다.

작업 상태와 저장·게시는 오른쪽 세로 rail 이었다. 그 자리는 편집기와 미리보기가 함께
쓸 가로 폭을 가져갔고, 편집 칸이 길어질수록 rail 은 위에 붙은 채 본문만 멀어졌다.
화면 아래에 고정하면 폭을 돌려주면서 스크롤 위치와 무관하게 손이 닿는다.

탭을 없앴으므로 각 패널이 스스로 이름을 가져야 한다. `aria-labelledby` 로 제목을
가리켜 landmark 로 만든다 — 탭 목록이 하던 "여기는 편집, 저기는 미리보기" 안내를
대신한다. `aside` 와 「작업 상태」라는 이름은 그대로 둔다. 자리가 바뀐 것이지 이
묶음이 무엇인지가 바뀐 것은 아니다.

미리보기가 늘 떠 있게 되면서 Picker 테스트의 단언도 함께 고쳤다. 「Picker 가
좁아졌는가」를 화면 전체에 묻고 있었는데, 이제는 미리보기 쪽의 같은 Asset 까지
세게 된다. 그 김에 `.studio-asset-panel` 의 `aria-labelledby` 가 role 없는 div 에서
아무 이름도 만들지 못하던 것도 `role="group"` 으로 실효화했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 15:49:18 +09:00

2009 lines
79 KiB
TypeScript

// @vitest-environment jsdom
import assert from "node:assert/strict";
import { afterAll, beforeAll, expect, test } from "vitest";
import { act, 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 { mergeAssetCatalog } from "../../../src/features/tech-log/domain/content-format/asset-evidence-catalog.ts";
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";
import { createManagementGatewayStub } from "../../helpers/management-gateway-stub.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/ });
expect(screen.queryByRole("button", { name: /unsafe/ })).not.toBeInTheDocument();
});
test("reports the loaded Asset list once listAssets resolves", async () => {
const loaded: readonly Asset[][] = [];
render(
<AssetPicker
gateway={gatewayOf([READY, QUARANTINED])}
onInsert={() => {}}
onAssetsObserved={(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] } });
}
/**
* Final fix wave, item 2. Selecting a file no longer uploads on its own: the
* dialog now collects the Asset's alt text (or a decorative flag) first, so
* the directive it auto-inserts can carry a real alt instead of `alt=""`.
* These state-machine tests care about the outcome of an upload, not about
* how the form was filled in, so they go through this one helper.
*/
function chooseAndUpload(file: File, altText = "샘플 대체 텍스트") {
chooseFile(file);
fireEvent.change(screen.getByLabelText("대체 텍스트"), { target: { value: altText } });
fireEvent.click(screen.getByRole("button", { name: "업로드" }));
}
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={() => {}} />);
chooseAndUpload(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={() => {}}
/>,
);
chooseAndUpload(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={() => {}} />,
);
chooseAndUpload(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={() => {}} />,
);
chooseAndUpload(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={() => {}} />);
chooseAndUpload(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={() => {}} />);
chooseAndUpload(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={() => {}} />);
chooseAndUpload(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 decorativeBox = screen.getByLabelText("장식용 이미지 (대체 텍스트 없음)");
const altInput = screen.getByLabelText("대체 텍스트");
const uploadButton = screen.getByRole("button", { name: "업로드" });
const closeButton = screen.getByRole("button", { name: "닫기" });
// `toHaveFocus`, never `assert.equal(document.activeElement, input)`:
// node:assert inspects both operands at `depth: 1000` to build its failure
// message, and a React-rendered element's `__reactFiber$*` graph re-expands
// per traversal path, so inspecting one exhausts the heap and kills the
// worker instead of reporting the regression.
expect(input).toHaveFocus();
// Final fix wave, item 2: the trap now has to hold across the two metadata
// controls the dialog gained, and still wrap from the last back to the first.
input.focus();
for (const expected of [decorativeBox, altInput, closeButton, uploadButton, input]) {
await user.tab();
expect(expected).toHaveFocus();
}
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
createManagementGateway={createManagementGatewayStub} 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)}`,
);
const panel = screen.getByRole("region", { name: "즉시 미리보기" });
expect(within(panel).queryByRole("alert")).not.toBeInTheDocument();
// `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={() => {}} />);
chooseAndUpload(new File(["a"], "a.svg", { type: "image/svg+xml" }));
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "파일 크기가 허용 범위를 넘었습니다."));
chooseAndUpload(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
createManagementGateway={createManagementGatewayStub} createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<DocumentEditorScreen documentId={created.id} />
</StudioProvider>
</MemoryRouter>,
);
await screen.findByLabelText("본문 Markdown");
await user.click(screen.getByRole("button", { name: "Asset 업로드" }));
chooseAndUpload(
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;
// Final fix wave, item 2: the upload dialog now collects the alt text, so
// the auto-inserted directive already carries it and no hand-edit of the
// raw Markdown is needed. This test's own target stays the evidence key
// (I2); the alt-text loop has its own tests at the bottom of this file.
const authoredBody = textarea.value;
assert.ok(authoredBody.includes('alt="업로드 확인용 대체 텍스트"'), authoredBody);
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.
//
// The alignment follow-up that taught the *default* dependency revision to
// read the Asset store closed exactly the gap this test used to reach the
// projection through: deleting the Asset now moves the revision, so the guard
// fires first and `failureOf`'s ContentFormatError branch would never run
// again. That branch still has to hold -- a projection can refuse for reasons
// the revision cannot see (a directive the parser rejects against a catalog
// row) -- so this test pins the revision itself and keeps reaching the
// projection, and asserts the *detail* to prove which of the two paths
// produced the 409. `mock-dependency-revision.test.ts` covers the guard path.
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,
dependencyRevision: { current: () => "pinned-so-the-guard-cannot-fire" },
});
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);
// The projection's failure, wrapped by `failureOf` -- not the guard's
// own "Current validation without errors is required."
assert.match(error.problem.detail ?? "", /no longer renders against current dependencies/);
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
createManagementGateway={createManagementGatewayStub} 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],
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
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);
});
// --- Final fix wave, item 2: the authoring loop must produce a publishable document ---
//
// The upload dialog used to send `{ file, kind }` only. `UploadAssetForm` and
// `asset-upload-transport.ts` both carry `altText`/`decorative`, so every
// asset uploaded through the flagship flow landed as
// `altText: null, decorative: false` -- and the directive the Picker then
// auto-inserted carried `alt=""`. The document parsed, previewed correctly,
// and failed at validate with EVIDENCE_ALT_REQUIRED, recoverable only by
// hand-editing raw Markdown. These three tests close the loop
// (upload -> insert -> validate) that nothing asserted before.
async function uploadThroughDialog(
user: ReturnType<typeof userEvent.setup>,
file: File,
options: Readonly<{ altText?: string; decorative?: boolean }> = {},
) {
chooseFile(file);
if (options.decorative) {
await user.click(screen.getByLabelText("장식용 이미지 (대체 텍스트 없음)"));
}
if (options.altText !== undefined) {
await user.type(screen.getByLabelText("대체 텍스트"), options.altText);
}
await user.click(screen.getByRole("button", { name: "업로드" }));
}
async function createUploadCase(
gateway: ReturnType<
ReturnType<typeof createTechLogFeatureInstalledInput>["input"]["createStudioGateway"]
>,
slug: string,
title: string,
) {
return gateway.createDocument(
{
kind: "CASE",
title,
slug,
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: `${slug}-create` },
);
}
async function saveValidateAndPreview(
gateway: ReturnType<
ReturnType<typeof createTechLogFeatureInstalledInput>["input"]["createStudioGateway"]
>,
created: { id: string; version: number },
slug: string,
bodyMarkdown: string,
) {
const currentDraft = { ...(created as unknown 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 } as never },
{ idempotencyKey: `${slug}-save` },
);
const report = await gateway.validateDocument(
created.id,
{ expectedVersion: saved.document.version },
{ idempotencyKey: `${slug}-validate` },
);
const preview = report.status === "INVALID"
? null
: await gateway.createPreview(
created.id,
{ expectedVersion: saved.document.version, validationId: report.validationId },
{ idempotencyKey: `${slug}-preview` },
);
return { report, preview };
}
test("an asset uploaded with alt text inserts that alt and the document validates as publishable, with no hand-editing", async () => {
const user = userEvent.setup();
const installed = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
const gateway = installed.createStudioGateway();
const assetGateway = installed.createStudioAssetGateway();
const created = await createUploadCase(gateway, "alt-text-publishable", "대체 텍스트 게시 가능");
render(
<MemoryRouter initialEntries={[`/studio/documents/${created.id}/edit`]}>
<StudioProvider
createManagementGateway={createManagementGatewayStub} createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<DocumentEditorScreen documentId={created.id} />
</StudioProvider>
</MemoryRouter>,
);
await screen.findByLabelText("본문 Markdown");
await user.click(screen.getByRole("button", { name: "Asset 업로드" }));
await uploadThroughDialog(
user,
new File(["<svg/>"], "alt-text-check.svg", { type: "image/svg+xml" }),
{ altText: "업로드한 다이어그램 설명" },
);
await waitFor(() => {
const value = (screen.getByLabelText("본문 Markdown") as HTMLTextAreaElement).value;
assert.ok(value.includes('key="alt-text-check"'), value);
});
const body = (screen.getByLabelText("본문 Markdown") as HTMLTextAreaElement).value;
assert.ok(body.includes('alt="업로드한 다이어그램 설명"'), body);
assert.ok(!body.includes('alt=""'), body);
const { report, preview } = await saveValidateAndPreview(
gateway,
created,
"alt-text-publishable",
body,
);
assert.ok(
!report.issues.some((issue) => issue.code === "EVIDENCE_ALT_REQUIRED"),
JSON.stringify(report.issues),
);
assert.equal(report.status, "VALID", JSON.stringify(report.issues));
assert.equal(preview?.renderModel.kind, "CASE");
});
test("a decorative upload inserts an empty alt and the document is still publishable", async () => {
const user = userEvent.setup();
const installed = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
const gateway = installed.createStudioGateway();
const assetGateway = installed.createStudioAssetGateway();
const created = await createUploadCase(gateway, "decorative-publishable", "장식용 게시 가능");
render(
<MemoryRouter initialEntries={[`/studio/documents/${created.id}/edit`]}>
<StudioProvider
createManagementGateway={createManagementGatewayStub} createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<DocumentEditorScreen documentId={created.id} />
</StudioProvider>
</MemoryRouter>,
);
await screen.findByLabelText("본문 Markdown");
await user.click(screen.getByRole("button", { name: "Asset 업로드" }));
await uploadThroughDialog(
user,
new File(["<svg/>"], "decorative-check.svg", { type: "image/svg+xml" }),
{ decorative: true },
);
await waitFor(() => {
const value = (screen.getByLabelText("본문 Markdown") as HTMLTextAreaElement).value;
assert.ok(value.includes('key="decorative-check"'), value);
});
const body = (screen.getByLabelText("본문 Markdown") as HTMLTextAreaElement).value;
assert.ok(body.includes('alt=""'), body);
const { report, preview } = await saveValidateAndPreview(
gateway,
created,
"decorative-publishable",
body,
);
assert.ok(
!report.issues.some((issue) => issue.code === "EVIDENCE_ALT_REQUIRED"),
JSON.stringify(report.issues),
);
assert.equal(report.status, "VALID", JSON.stringify(report.issues));
assert.equal(preview?.renderModel.kind, "CASE");
});
test("the dialog refuses to upload a non-decorative asset with no alt text", async () => {
const user = userEvent.setup();
let calls = 0;
const gateway = uploadOnlyGateway(async () => {
calls += 1;
return READY as never;
});
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
chooseFile(SAMPLE_FILE);
await user.click(screen.getByRole("button", { name: "업로드" }));
assert.equal(
screen.getByRole("status", { name: "업로드 상태" }).textContent,
"대체 텍스트를 입력하거나 장식용으로 표시하세요.",
);
assert.equal(calls, 0);
// Ticking `decorative` is the escape hatch, and it must not demand alt text.
await user.click(screen.getByLabelText("장식용 이미지 (대체 텍스트 없음)"));
await user.click(screen.getByRole("button", { name: "업로드" }));
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드했습니다."));
assert.equal(calls, 1);
});
// --- Alignment follow-up, item 2: the Picker searches the server ---
//
// The Picker asked for `{ managementStatus: "READY", limit: 50 }` and ignored
// `nextCursor`, so the 51st-oldest READY asset onward could not be inserted at
// all. The Picker sits inside the editing flow, where scrolling a long list is
// the wrong interaction, so it gets search rather than a "더 보기" control --
// but it still loads a first page, because an empty panel until you type is
// hostile to an author who just wants the asset they uploaded a minute ago.
function searchableGateway(items: ReadonlyArray<Record<string, unknown>>) {
const calls: Array<Record<string, unknown>> = [];
const gateway: StudioAssetGateway = {
async listAssets(query) {
calls.push({ ...query });
const q = (query.q ?? "").trim().toLocaleLowerCase("ko-KR");
const matched = items.filter(
(item) =>
(!query.managementStatus ||
item.managementStatus === query.managementStatus) &&
(!q ||
String(item.assetKey).toLocaleLowerCase("ko-KR").includes(q)),
);
return { items: matched, 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() {},
};
return { gateway, calls };
}
const PICKER_A = {
...READY,
id: "44444444-4444-4444-8444-444444444441",
assetKey: "inserted-diagram",
altText: "삽입한 다이어그램",
publicPath: "/media/inserted-diagram.svg",
};
const PICKER_B = {
...READY,
id: "44444444-4444-4444-8444-444444444442",
assetKey: "other-diagram",
altText: "다른 다이어그램",
publicPath: "/media/other-diagram.svg",
};
test("the Picker shows a first page before anything is typed, then narrows to the submitted query", async () => {
const user = userEvent.setup();
const { gateway, calls } = searchableGateway([PICKER_A, PICKER_B]);
render(<AssetPicker gateway={gateway} onInsert={() => {}} />);
// Useful before the author types: both assets are offered.
await screen.findByRole("button", { name: /inserted-diagram/ });
assert.ok(screen.getByRole("button", { name: /other-diagram/ }));
await user.type(screen.getByLabelText("Asset 검색"), "other");
await user.click(screen.getByRole("button", { name: "검색" }));
await waitFor(() =>
expect(screen.queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(),
);
assert.ok(screen.getByRole("button", { name: /other-diagram/ }));
assert.equal(calls.at(-1)?.q, "other");
// The server filter must survive search: an asset under review is never
// insertable, whatever the query.
assert.equal(calls.at(-1)?.managementStatus, "READY");
});
test("typing in the Picker's search box never reaches the gateway on its own", async () => {
const user = userEvent.setup();
const { gateway, calls } = searchableGateway([PICKER_A]);
render(<AssetPicker gateway={gateway} onInsert={() => {}} />);
await screen.findByRole("button", { name: /inserted-diagram/ });
assert.equal(calls.length, 1);
await user.type(screen.getByLabelText("Asset 검색"), "inserted");
assert.equal(calls.length, 1);
});
test("a QUARANTINED asset the server wrongly returns for a query is still not offered", async () => {
const user = userEvent.setup();
const unsafe = { ...QUARANTINED, assetKey: "unsafe-diagram" };
const gateway: StudioAssetGateway = {
async listAssets() {
// Deliberately ignores `managementStatus`: the screen's own filter is
// the thing under test.
return { items: [PICKER_A, unsafe], 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() {},
};
render(<AssetPicker gateway={gateway} onInsert={() => {}} />);
await screen.findByRole("button", { name: /inserted-diagram/ });
await user.type(screen.getByLabelText("Asset 검색"), "diagram");
await user.click(screen.getByRole("button", { name: "검색" }));
await waitFor(() =>
assert.ok(screen.getByRole("button", { name: /inserted-diagram/ })),
);
expect(screen.queryByRole("button", { name: /unsafe-diagram/ })).not.toBeInTheDocument();
});
test("the Picker reports search results too, so the catalog grows with every query", async () => {
const user = userEvent.setup();
const observed: Asset[][] = [];
const { gateway } = searchableGateway([PICKER_A, PICKER_B]);
render(
<AssetPicker
gateway={gateway}
onInsert={() => {}}
onAssetsObserved={(assets) => observed.push([...assets])}
/>,
);
await screen.findByRole("button", { name: /inserted-diagram/ });
await user.type(screen.getByLabelText("Asset 검색"), "other");
await user.click(screen.getByRole("button", { name: "검색" }));
await waitFor(() => assert.equal(observed.length, 2));
assert.deepEqual(
observed[1]!.map((asset) => asset.assetKey),
["other-diagram"],
);
});
test("a slow earlier Picker search never overwrites the newer query's results", async () => {
const user = userEvent.setup();
const pending: Array<{ settle: (page: unknown) => void }> = [];
const gateway: StudioAssetGateway = {
async listAssets() {
return new Promise((resolve) => {
pending.push({ settle: resolve });
}) 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() {},
};
render(<AssetPicker gateway={gateway} onInsert={() => {}} />);
await waitFor(() => assert.equal(pending.length, 1));
await act(async () => {
pending[0]!.settle({ items: [PICKER_A], nextCursor: null });
});
await screen.findByRole("button", { name: /inserted-diagram/ });
const input = screen.getByLabelText("Asset 검색");
await user.type(input, "slow");
await user.click(screen.getByRole("button", { name: "검색" }));
await waitFor(() => assert.equal(pending.length, 2));
await user.clear(input);
await user.type(input, "other");
await user.click(screen.getByRole("button", { name: "검색" }));
await waitFor(() => assert.equal(pending.length, 3));
await act(async () => {
pending[2]!.settle({ items: [PICKER_B], nextCursor: null });
});
await screen.findByRole("button", { name: /other-diagram/ });
await act(async () => {
pending[1]!.settle({ items: [PICKER_A], nextCursor: null });
});
expect(screen.queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument();
assert.ok(screen.getByRole("button", { name: /other-diagram/ }));
});
// --- The trap this item creates ---
//
// The editor screen's Asset array feeds two consumers with opposite needs: the
// Picker's *displayed* list (which a search must narrow) and Instant Preview's
// *resolution catalog* (which a search must never shrink -- its gate rejects a
// key no loaded asset backs). Wiring the Picker's results straight back into
// the array the screen owns makes typing a search term blank out evidence
// figures that rendered a moment earlier: a worse bug than the one being
// fixed. The two are kept apart by making the screen's callback additive --
// the Picker reports what it *observed*, the screen merges it into a catalog
// that only ever grows.
test("a Picker search never drops an already-inserted asset out of Instant Preview's resolution", async () => {
const user = userEvent.setup();
const gateway = createMockStudioGateway();
const { gateway: assetGateway } = searchableGateway([PICKER_A, PICKER_B]);
render(
<MemoryRouter initialEntries={[`/studio/documents/${FIXTURE_IDS.redisAdapterCase}/edit`]}>
<StudioProvider
createManagementGateway={createManagementGatewayStub} createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<DocumentEditorScreen documentId={FIXTURE_IDS.redisAdapterCase} />
</StudioProvider>
</MemoryRouter>,
);
await screen.findByLabelText("본문 Markdown");
await user.click(await screen.findByRole("button", { name: /inserted-diagram/ }));
const body = (screen.getByLabelText("본문 Markdown") as HTMLTextAreaElement).value;
assert.ok(body.includes('key="inserted-diagram"'), body);
// Now search for something that excludes the asset just inserted. The Picker
// must narrow; the preview must not.
await user.type(screen.getByLabelText("Asset 검색"), "other");
await user.click(screen.getByRole("button", { name: "검색" }));
const picker = screen.getByRole("group", { name: "본문에 Asset 삽입" });
await waitFor(() =>
expect(within(picker).queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(),
);
const panel = screen.getByRole("region", { name: "즉시 미리보기" });
assert.equal(
within(panel).queryByRole("alert")?.textContent ?? null,
null,
"the preview refused a directive it resolved before the search",
);
const [image] = within(panel).getAllByAltText("삽입한 다이어그램");
assert.equal(image!.getAttribute("src"), "/media/inserted-diagram.svg");
});
test("mergeAssetCatalog keeps every previously known asset and lets a fresher copy win", () => {
const known = assetFixture({ id: "50505050-5050-4050-8050-505050505051", assetKey: "known" });
const staleCopy = assetFixture({
id: "50505050-5050-4050-8050-505050505052",
assetKey: "replaced",
publicPath: "/media/replaced-old.svg",
updatedAt: "2026-08-14T00:00:00.000Z",
});
const freshCopy = { ...staleCopy, publicPath: "/media/replaced-new.svg", version: 2 };
const arrival = assetFixture({ id: "50505050-5050-4050-8050-505050505053", assetKey: "arrival" });
const merged = mergeAssetCatalog([known, staleCopy], [freshCopy, arrival]);
assert.deepEqual(
[...merged].map((asset) => asset.assetKey).sort(),
["arrival", "known", "replaced"],
);
assert.equal(
merged.find((asset) => asset.id === staleCopy.id)?.publicPath,
"/media/replaced-new.svg",
);
});
test("mergeAssetCatalog can only grow: an empty arrival keeps everything", () => {
const known = assetFixture({ id: "60606060-6060-4060-8060-606060606061", assetKey: "kept" });
assert.deepEqual(mergeAssetCatalog([known], []).map((asset) => asset.id), [known.id]);
});
// --- Alignment follow-up, item 3: alt text must not outlive its file ---
//
// The file input's `onChange` reset `state` to `IDLE` and left `altText`
// alone. On success that is harmless -- the dialog unmounts. But REJECTED,
// QUARANTINED and TRANSPORT_FAILED all leave it mounted with the file input
// re-enabled, which is exactly the retry path: upload `db-schema.png`
// described as "DB 스키마", get it quarantined, pick `sequence.png`, upload --
// and `sequence.png` shipped described as "DB 스키마", validating as
// publishable the whole way.
test("picking a different file after a quarantined upload clears the previous image's alt text", async () => {
const user = userEvent.setup();
const forms: Array<{ altText?: string; filename: string }> = [];
const gateway = uploadOnlyGateway(async (form) => {
forms.push({ altText: form.altText, filename: form.file.name });
return (forms.length === 1
? { ...READY, managementStatus: "QUARANTINED" }
: READY) as never;
});
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
chooseAndUpload(new File(["a"], "db-schema.png", { type: "image/png" }), "DB 스키마");
await waitFor(() =>
assert.equal(
screen.getByRole("status", { name: "업로드 상태" }).textContent,
"보안 검사에서 격리되어 사용할 수 없습니다.",
),
);
chooseFile(new File(["b"], "sequence.png", { type: "image/png" }));
assert.equal((screen.getByLabelText("대체 텍스트") as HTMLInputElement).value, "");
// And the emptied field is enforced, not merely displayed: uploading now
// refuses instead of shipping an undescribed image.
await user.click(screen.getByRole("button", { name: "업로드" }));
assert.equal(
screen.getByRole("status", { name: "업로드 상태" }).textContent,
"대체 텍스트를 입력하거나 장식용으로 표시하세요.",
);
assert.equal(forms.length, 1);
});
test("the retry after a transport failure ships its own alt text, never the first attempt's", async () => {
const forms: Array<{ altText?: string; filename: string }> = [];
const gateway = uploadOnlyGateway(async (form) => {
forms.push({ altText: form.altText, filename: form.file.name });
if (forms.length === 1) throw new Error("offline");
return READY as never;
});
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
chooseAndUpload(new File(["a"], "db-schema.png", { type: "image/png" }), "DB 스키마");
await waitFor(() =>
assert.equal(
screen.getByRole("status", { name: "업로드 상태" }).textContent,
"업로드를 전송하지 못했습니다.",
),
);
chooseAndUpload(new File(["b"], "sequence.png", { type: "image/png" }), "시퀀스 다이어그램");
await waitFor(() =>
assert.equal(
screen.getByRole("status", { name: "업로드 상태" }).textContent,
"업로드했습니다.",
),
);
assert.deepEqual(forms, [
{ altText: "DB 스키마", filename: "db-schema.png" },
{ altText: "시퀀스 다이어그램", filename: "sequence.png" },
]);
});
// The same defect as the alt-text one above, in the flag that *exempts* an
// asset from needing alt text at all. Alt text surviving a file swap ships a
// wrong description; `decorative` surviving one ships a meaningful image
// marked as decorative -- `validate-working-copy.ts`'s EVIDENCE_ALT_REQUIRED
// reads `Asset.decorative` and so passes it, and the image reaches publication
// with no accessible name whatsoever. Worse than the bug it rides beside.
test("picking a different file after a rejected upload clears the decorative flag too", async () => {
const user = userEvent.setup();
const forms: Array<{ decorative?: boolean; altText?: string; filename: string }> = [];
const gateway = uploadOnlyGateway(async (form) => {
forms.push({
decorative: form.decorative,
altText: form.altText,
filename: form.file.name,
});
return (forms.length === 1
? { ...READY, managementStatus: "REJECTED" }
: READY) as never;
});
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
chooseFile(new File(["a"], "divider.png", { type: "image/png" }));
await user.click(screen.getByLabelText("장식용 이미지 (대체 텍스트 없음)"));
await user.click(screen.getByRole("button", { name: "업로드" }));
await waitFor(() =>
assert.equal(
screen.getByRole("status", { name: "업로드 상태" }).textContent,
"서버 검증에서 거절되어 사용할 수 없습니다.",
),
);
assert.deepEqual(forms, [
{ decorative: true, altText: undefined, filename: "divider.png" },
]);
// A meaningful diagram is chosen next. It must not inherit the divider's
// "no alt text needed" exemption.
chooseFile(new File(["b"], "sequence.png", { type: "image/png" }));
assert.equal(
(screen.getByLabelText("장식용 이미지 (대체 텍스트 없음)") as HTMLInputElement).checked,
false,
);
// The alt field is re-enabled by the same reset -- while `decorative` was
// still set it was `disabled`, so a cleared-but-disabled field would be a
// dead end.
assert.equal((screen.getByLabelText("대체 텍스트") as HTMLInputElement).disabled, false);
// And it is enforced: uploading now refuses instead of publishing an
// unnamed image.
await user.click(screen.getByRole("button", { name: "업로드" }));
assert.equal(
screen.getByRole("status", { name: "업로드 상태" }).textContent,
"대체 텍스트를 입력하거나 장식용으로 표시하세요.",
);
assert.equal(forms.length, 1);
await user.type(screen.getByLabelText("대체 텍스트"), "시퀀스 다이어그램");
await user.click(screen.getByRole("button", { name: "업로드" }));
await waitFor(() =>
assert.equal(
screen.getByRole("status", { name: "업로드 상태" }).textContent,
"업로드했습니다.",
),
);
assert.deepEqual(forms[1], {
decorative: false,
altText: "시퀀스 다이어그램",
filename: "sequence.png",
});
});