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 6d5ee31..e4ffede 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 @@ -200,9 +200,22 @@ export function AssetUploadDialog(props: Readonly<{ // 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. + // + // `decorative` is reset for the same reason and a sharper one. + // It does not merely describe the previous image, it *exempts* + // it: `validate-working-copy.ts`'s EVIDENCE_ALT_REQUIRED reads + // `Asset.decorative`, so a flag inherited from a discarded + // divider lets a meaningful diagram publish with no accessible + // name at all -- the check passes rather than catching it. Alt + // text riding along produces a wrong description; this produces + // none. setState({ kind: "IDLE" }); setAltText(""); - if (!decorative) altRef.current?.focus(); + setDecorative(false); + // Unconditional now: the alt field is enabled again by the line + // above, so there is no longer a case where focusing it would + // land on a disabled control. + altRef.current?.focus(); }} /> diff --git a/tests/features/tech-log/asset-picker.test.tsx b/tests/features/tech-log/asset-picker.test.tsx index 609d0eb..db96d8c 100644 --- a/tests/features/tech-log/asset-picker.test.tsx +++ b/tests/features/tech-log/asset-picker.test.tsx @@ -1908,3 +1908,74 @@ test("the retry after a transport failure ships its own alt text, never the firs { 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( {}} 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", + }); +});