fix: clear alt text when the upload dialog is handed a different file

The file input's `onChange` reset `state` to `IDLE` and left `altText`
untouched. On success that is harmless -- the dialog unmounts. But
REJECTED, QUARANTINED and TRANSPORT_FAILED all leave it mounted with the
file input re-enabled, and that is precisely the retry path: upload
`db-schema.png` described as "DB 스키마", have it quarantined, pick
`sequence.png`, upload -- and `sequence.png` shipped described as
"DB 스키마", passing EVIDENCE_ALT_REQUIRED and publishing with a caption
about a different image.

Cleared on every file selection rather than only after a failure: alt
text describes one image, and "which file is this describing" has one
honest answer per selection. `submit()`'s existing ALT_REQUIRED refusal
turns the emptied field into a stop rather than a silent omission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 13:17:33 +09:00
co-authored by Claude Opus 5
parent f69edb633d
commit 1f2cba79e9
2 changed files with 86 additions and 1 deletions
@@ -188,8 +188,20 @@ export function AssetUploadDialog(props: Readonly<{
setState({ kind: "SELECTION_FAILED", message: MESSAGES.SELECTION_FAILED }); setState({ kind: "SELECTION_FAILED", message: MESSAGES.SELECTION_FAILED });
return; return;
} }
// A new file clears whatever the previous attempt reported. // A new file clears whatever the previous attempt reported --
// and its description with it. Alt text describes *this image*,
// and every terminal failure state (REJECTED, QUARANTINED,
// TRANSPORT_FAILED) leaves this dialog mounted with the file
// input re-enabled, so the retry path is exactly where a stale
// description survives: upload `db-schema.png` as "DB 스키마",
// get it quarantined, pick `sequence.png`, upload -- and
// `sequence.png` ships described as "DB 스키마" and validates as
// publishable. Cleared unconditionally rather than only after a
// failure: "which file is this text describing" has one honest
// answer per selection, and `submit()`'s ALT_REQUIRED refusal
// makes the emptied field impossible to ignore.
setState({ kind: "IDLE" }); setState({ kind: "IDLE" });
setAltText("");
if (!decorative) altRef.current?.focus(); if (!decorative) altRef.current?.focus();
}} }}
/> />
@@ -1835,3 +1835,76 @@ test("mergeAssetCatalog can only grow: an empty arrival keeps everything", () =>
assert.deepEqual(mergeAssetCatalog([known], []).map((asset) => asset.id), [known.id]); 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" },
]);
});