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 8004421..ee58738 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 @@ -8,6 +8,7 @@ import { createLocalId } from "../../../domain/studio/local-id.ts"; export type UploadState = | { kind: "IDLE" } | { kind: "SELECTION_FAILED"; message: string } + | { kind: "ALT_REQUIRED" } | { kind: "UPLOADING" } | { kind: "TRANSPORT_FAILED"; message: string } | { kind: "TOO_LARGE" } @@ -41,6 +42,7 @@ export function stateForError(error: unknown): UploadState { const MESSAGES: Record = { IDLE: "", SELECTION_FAILED: "파일을 선택하지 못했습니다.", + ALT_REQUIRED: "대체 텍스트를 입력하거나 장식용으로 표시하세요.", UPLOADING: "업로드 중입니다.", TRANSPORT_FAILED: "업로드를 전송하지 못했습니다.", TOO_LARGE: "파일 크기가 허용 범위를 넘었습니다.", @@ -57,7 +59,11 @@ export function AssetUploadDialog(props: Readonly<{ onClose: () => void; }>) { const [state, setState] = useState({ kind: "IDLE" }); + const [file, setFile] = useState(null); + const [altText, setAltText] = useState(""); + const [decorative, setDecorative] = useState(false); const inputRef = useRef(null); + const altRef = useRef(null); const dialogRef = useRef(null); const titleId = useId(); const descriptionId = useId(); @@ -77,7 +83,35 @@ export function AssetUploadDialog(props: Readonly<{ }; }, []); - async function submit(file: File) { + /** + * Final fix wave, item 2. Alt text is collected here because it is a + * property of the *Asset*, not of one directive: `case-fields.tsx` and + * `asset-picker.tsx` both build the inserted directive from + * `asset.decorative ? "" : (asset.altText ?? "")`, so an Asset that carries + * neither can only ever produce `alt=""`. Publish validation + * (`validate-working-copy.ts`'s EVIDENCE_ALT_REQUIRED) then rejects the + * document, and hand-editing raw Markdown was the only recovery — the exact + * thing the Picker exists to prevent. + * + * A decorative Asset is exempt (that is what `decorative` means in the + * canonical contract, and what the validation rule already honours), so the + * fast path for a purely ornamental image stays one tick of a checkbox. + */ + const trimmedAlt = altText.trim(); + const missingAlt = !decorative && trimmedAlt.length === 0; + + async function submit() { + if (!file) { + setState({ kind: "SELECTION_FAILED", message: MESSAGES.SELECTION_FAILED }); + return; + } + if (missingAlt) { + // Refused here rather than by disabling the button: a disabled control + // states no reason, and this refusal has one worth reading. + setState({ kind: "ALT_REQUIRED" }); + altRef.current?.focus(); + return; + } setState({ kind: "UPLOADING" }); try { // Fix round 1 (I1). A fresh key per call, not one generated once when @@ -86,7 +120,12 @@ export function AssetUploadDialog(props: Readonly<{ // different payloads must never share one idempotency key -- the exact // retry scenario idempotency keys exist for. const asset = await props.gateway.uploadAsset( - { file, kind: props.kind }, + { + file, + kind: props.kind, + decorative, + ...(decorative ? {} : { altText: trimmedAlt }), + }, { idempotencyKey: createLocalId("studio-asset-upload") }, ); const next = stateForUploaded(asset); @@ -132,7 +171,8 @@ export function AssetUploadDialog(props: Readonly<{

ASSET UPLOAD

Asset 업로드

- 업로드한 파일은 서버 검증을 거친 뒤에만 본문에 삽입할 수 있습니다. + 업로드한 파일은 서버 검증을 거친 뒤에만 본문에 삽입할 수 있습니다. 장식용이 + 아니면 대체 텍스트가 필요합니다.

+ +

{MESSAGES[state.kind]}

+ {/* `.studio-dialog-actions button:last-child` is the primary style, so + the confirming action goes last -- same order as the other Studio + dialogs. */}
+
diff --git a/src/features/tech-log/presentation/styles/studio.css b/src/features/tech-log/presentation/styles/studio.css index cc7a4a9..31d0d9e 100644 --- a/src/features/tech-log/presentation/styles/studio.css +++ b/src/features/tech-log/presentation/styles/studio.css @@ -41,6 +41,9 @@ .studio-app .studio-asset-upload-dialog { width: min(560px, calc(100% - 32px)); padding: 0; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--paper); color: var(--ink); } .studio-app .studio-asset-upload-dialog::backdrop { background: rgba(23, 24, 27, 0.48); } .studio-app .studio-asset-upload-dialog .studio-dialog-status { min-height: 20px; margin: 16px 0 0; color: var(--muted); font-size: 13px; } +.studio-app .studio-asset-upload-dialog .studio-field + .studio-field { margin-top: 18px; } +.studio-app .studio-asset-upload-dialog .studio-field--checkbox { display: flex; align-items: center; gap: 10px; } +.studio-app .studio-asset-upload-dialog .studio-field--checkbox input { width: 18px; min-width: 18px; min-height: 18px; padding: 0; } .studio-app .studio-page-top { display: flex; align-items: flex-end; justify-content: space-between; gap: 36px; border-bottom: 1px solid var(--line-strong); } .studio-app .studio-page-top .studio-page-heading { padding-bottom: 42px; } diff --git a/tests/features/tech-log/asset-picker.test.tsx b/tests/features/tech-log/asset-picker.test.tsx index 420f458..5dc92d6 100644 --- a/tests/features/tech-log/asset-picker.test.tsx +++ b/tests/features/tech-log/asset-picker.test.tsx @@ -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([""], "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( {}} 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 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 () => { 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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([""], "boundary-check.svg", { type: "image/svg+xml" })] }, - }); + chooseAndUpload( + new File([""], "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; 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, + 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["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["input"]["createStudioGateway"] + >, + created: { id: string; version: number }, + slug: string, + bodyMarkdown: string, +) { + const currentDraft = { ...(created as unknown as Record) }; + 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( + + gateway} createAssetGateway={() => assetGateway}> + + + , + ); + + await screen.findByLabelText("본문 Markdown"); + await user.click(screen.getByRole("button", { name: "Asset 업로드" })); + await uploadThroughDialog( + user, + new File([""], "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( + + gateway} createAssetGateway={() => assetGateway}> + + + , + ); + + await screen.findByLabelText("본문 Markdown"); + await user.click(screen.getByRole("button", { name: "Asset 업로드" })); + await uploadThroughDialog( + user, + new File([""], "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( {}} 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); +});