fix: collect alt text and a decorative flag when uploading a Studio Asset

The upload dialog sent `{ file, kind }` only, though `UploadAssetForm` and
`asset-upload-transport.ts` both carry `altText`/`decorative`. Every asset
uploaded through the flagship authoring flow therefore landed as
`altText: null, decorative: false`, and the directive `case-fields.tsx` and
`asset-picker.tsx` build from `asset.decorative ? "" : (asset.altText ?? "")`
could only ever be `alt=""`. The document parsed and previewed correctly and
then failed publish validation with EVIDENCE_ALT_REQUIRED, recoverable only
by hand-editing raw Markdown -- the exact thing the Picker exists to prevent.

The dialog now stages the file instead of uploading on selection, and carries
a decorative checkbox plus an alt-text field (focused as soon as a file is
chosen, submitting on Enter). A decorative asset never demands alt text; a
meaningful one is refused with a stated reason rather than a disabled button.
Insertion is unchanged: both call sites already read the Asset, so they now
insert what it actually carries.

Three new tests drive the whole loop -- upload through the real MOCK
composition, auto-insert, save, validate, preview -- and assert the result is
publishable. The pre-existing loop test no longer needs its hand-edit
workaround.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 09:07:38 +09:00
co-authored by Claude Opus 5
parent f19be639a3
commit 6085af51b6
3 changed files with 329 additions and 30 deletions
+240 -24
View File
@@ -250,6 +250,19 @@ function chooseFile(file: File) {
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", () => {
@@ -274,7 +287,7 @@ test("shows an uploading status and disables the file input while the transport
);
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
chooseFile(SAMPLE_FILE);
chooseAndUpload(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드 중입니다."));
assert.equal((document.querySelector('input[type="file"]') as HTMLInputElement).disabled, true);
@@ -295,7 +308,7 @@ test("only calls onUploaded and shows success once the server returns READY", as
/>,
);
chooseFile(SAMPLE_FILE);
chooseAndUpload(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드했습니다."));
assert.equal(uploaded.length, 1);
@@ -309,7 +322,7 @@ test("a QUARANTINED server outcome never calls onUploaded, even though the trans
<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={(asset) => uploaded.push(asset)} onClose={() => {}} />,
);
chooseFile(SAMPLE_FILE);
chooseAndUpload(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "보안 검사에서 격리되어 사용할 수 없습니다."));
assert.equal(uploaded.length, 0);
@@ -322,7 +335,7 @@ test("a REJECTED server outcome never calls onUploaded", async () => {
<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={(asset) => uploaded.push(asset)} onClose={() => {}} />,
);
chooseFile(SAMPLE_FILE);
chooseAndUpload(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "서버 검증에서 거절되어 사용할 수 없습니다."));
assert.equal(uploaded.length, 0);
@@ -340,7 +353,7 @@ test("a PAYLOAD_TOO_LARGE transport rejection shows the size-exceeded message",
});
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
chooseFile(SAMPLE_FILE);
chooseAndUpload(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "파일 크기가 허용 범위를 넘었습니다."));
});
@@ -357,7 +370,7 @@ test("an UNSUPPORTED_MEDIA_TYPE transport rejection shows the unsupported-type m
});
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
chooseFile(SAMPLE_FILE);
chooseAndUpload(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "지원하지 않는 파일 형식입니다."));
});
@@ -368,7 +381,7 @@ test("a plain network failure shows the generic transport-failed message", async
});
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
chooseFile(SAMPLE_FILE);
chooseAndUpload(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드를 전송하지 못했습니다."));
});
@@ -380,14 +393,19 @@ test("focuses the file input on open, traps Tab inside the dialog, and calls onC
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: "닫기" });
assert.equal(document.activeElement, input);
// 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();
await user.tab();
assert.equal(document.activeElement, closeButton);
await user.tab();
assert.equal(document.activeElement, input);
for (const expected of [decorativeBox, altInput, closeButton, uploadButton, input]) {
await user.tab();
assert.equal(document.activeElement, expected);
}
await user.click(closeButton);
assert.equal(closed, 1);
@@ -470,10 +488,10 @@ test("generates a fresh idempotency key for each upload attempt, even after a fa
});
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
chooseFile(new File(["a"], "a.svg", { type: "image/svg+xml" }));
chooseAndUpload(new File(["a"], "a.svg", { type: "image/svg+xml" }));
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "파일 크기가 허용 범위를 넘었습니다."));
chooseFile(new File(["b"], "b.svg", { type: "image/svg+xml" }));
chooseAndUpload(new File(["b"], "b.svg", { type: "image/svg+xml" }));
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드했습니다."));
assert.equal(keys.length, 2);
@@ -522,10 +540,10 @@ test("an asset uploaded through the mock composition previews live and passes mo
await screen.findByLabelText("본문 Markdown");
await user.click(screen.getByRole("button", { name: "Asset 업로드" }));
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(fileInput, {
target: { files: [new File(["<svg/>"], "boundary-check.svg", { type: "image/svg+xml" })] },
});
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
@@ -539,13 +557,12 @@ test("an asset uploaded through the mock composition previews live and passes mo
});
const textarea = screen.getByLabelText("본문 Markdown") as HTMLTextAreaElement;
// The upload form (asset-picker.tsx's AssetUploadDialog) has no altText
// field, so the auto-inserted directive carries `alt=""` -- fill it in
// here the way an author would by editing the textarea, so the only
// remaining validation question is the evidence key itself (I2's target),
// not the unrelated EVIDENCE_ALT_REQUIRED rule.
const authoredBody = textarea.value.replace('alt=""', 'alt="업로드 확인용 대체 텍스트"');
assert.notEqual(authoredBody, textarea.value);
// 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;
@@ -1345,3 +1362,202 @@ test("a same-key retry re-runs after an uncharacterized internal failure instead
);
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 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 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);
});