Files
tech-log-frontend/tests/features/tech-log/asset-picker.test.tsx
T
DongHyeonka 11c2713139 feat: let Studio create the topics and projects publishing requires
Publishing needs a topic and nothing could create one. The backend now owns
that surface; this is its consumer — the management contract vendored, a
gateway over its nine operations, and one Studio screen that lists, creates,
and deletes topics and projects.

The screen adds no CSS. It reuses the classes the working-copy list already
uses, so it inherits Studio's spacing, type, and colour rather than growing a
second visual vocabulary beside them. Scope stops at list/create/delete:
renaming, phase changes, and visibility are implemented in the backend and
declared in the contract, but their screens are a separate design.

Two real defects surfaced while making the public port async, and both would
have shipped:

The search page and the header search dialog shared a query key. With an empty
query, `["tech-log","search",""]` was identical for both, so react-query
handed one surface the other's cache — different shapes — and the page died
reading a field that was not there. Keys now name the surface.

The explore filter's selects are uncontrolled and read `defaultValue`, which
React applies once. Their options arrive later now, so the first render had
nothing to match and the value stayed empty: a topic in the URL no longer
showed as selected. The form key includes whether the catalog has arrived, so
it remounts with the options present. Controlled inputs would be the other
answer, but this form submits to build a URL — the URL owns the value.

The route brought its own bookkeeping: a build chunk, a manual accessibility
evidence file, and the CI artifact baseline that counts them. The gate pins a
digest of its own shape precisely so a new route cannot slip in without that
count being reviewed.

Test harnesses that render public screens now assemble the query providers and
await the settled paint, because the screens they render became async.
2026-08-20 23:40:15 +09:00

2010 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)}`,
);
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
const panel = screen.getByRole("tabpanel", { 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: "검색" }));
await waitFor(() =>
expect(screen.queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(),
);
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
const panel = screen.getByRole("tabpanel", { 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",
});
});