From 1f2cba79e9f5d8ec1b6d1d881a637d626cd26797 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Tue, 18 Aug 2026 13:17:33 +0900 Subject: [PATCH] fix: clear alt text when the upload dialog is handed a different file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../studio/components/asset-upload-dialog.tsx | 14 +++- tests/features/tech-log/asset-picker.test.tsx | 73 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/features/tech-log/presentation/studio/components/asset-upload-dialog.tsx b/src/features/tech-log/presentation/studio/components/asset-upload-dialog.tsx index ee58738..6d5ee31 100644 --- a/src/features/tech-log/presentation/studio/components/asset-upload-dialog.tsx +++ b/src/features/tech-log/presentation/studio/components/asset-upload-dialog.tsx @@ -188,8 +188,20 @@ export function AssetUploadDialog(props: Readonly<{ setState({ kind: "SELECTION_FAILED", message: MESSAGES.SELECTION_FAILED }); 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" }); + setAltText(""); if (!decorative) altRef.current?.focus(); }} /> diff --git a/tests/features/tech-log/asset-picker.test.tsx b/tests/features/tech-log/asset-picker.test.tsx index df34b8e..609d0eb 100644 --- a/tests/features/tech-log/asset-picker.test.tsx +++ b/tests/features/tech-log/asset-picker.test.tsx @@ -1835,3 +1835,76 @@ test("mergeAssetCatalog can only grow: an empty arrival keeps everything", () => 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( {}} 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( {}} 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" }, + ]); +});