편집기는 탭이었다. 고친 것이 어떻게 보이는지 확인하려면 편집하던 자리를 화면에서 치워야 했고, 돌아오면 스크롤 위치도 잃었다. 두 패널을 나란히 두면 그 왕복이 통째로 없어진다. 작업 상태와 저장·게시는 오른쪽 세로 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
2009 lines
79 KiB
TypeScript
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",
|
|
});
|
|
});
|