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:
co-authored by
Claude Opus 5
parent
f19be639a3
commit
6085af51b6
@@ -8,6 +8,7 @@ import { createLocalId } from "../../../domain/studio/local-id.ts";
|
|||||||
export type UploadState =
|
export type UploadState =
|
||||||
| { kind: "IDLE" }
|
| { kind: "IDLE" }
|
||||||
| { kind: "SELECTION_FAILED"; message: string }
|
| { kind: "SELECTION_FAILED"; message: string }
|
||||||
|
| { kind: "ALT_REQUIRED" }
|
||||||
| { kind: "UPLOADING" }
|
| { kind: "UPLOADING" }
|
||||||
| { kind: "TRANSPORT_FAILED"; message: string }
|
| { kind: "TRANSPORT_FAILED"; message: string }
|
||||||
| { kind: "TOO_LARGE" }
|
| { kind: "TOO_LARGE" }
|
||||||
@@ -41,6 +42,7 @@ export function stateForError(error: unknown): UploadState {
|
|||||||
const MESSAGES: Record<UploadState["kind"], string> = {
|
const MESSAGES: Record<UploadState["kind"], string> = {
|
||||||
IDLE: "",
|
IDLE: "",
|
||||||
SELECTION_FAILED: "파일을 선택하지 못했습니다.",
|
SELECTION_FAILED: "파일을 선택하지 못했습니다.",
|
||||||
|
ALT_REQUIRED: "대체 텍스트를 입력하거나 장식용으로 표시하세요.",
|
||||||
UPLOADING: "업로드 중입니다.",
|
UPLOADING: "업로드 중입니다.",
|
||||||
TRANSPORT_FAILED: "업로드를 전송하지 못했습니다.",
|
TRANSPORT_FAILED: "업로드를 전송하지 못했습니다.",
|
||||||
TOO_LARGE: "파일 크기가 허용 범위를 넘었습니다.",
|
TOO_LARGE: "파일 크기가 허용 범위를 넘었습니다.",
|
||||||
@@ -57,7 +59,11 @@ export function AssetUploadDialog(props: Readonly<{
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}>) {
|
}>) {
|
||||||
const [state, setState] = useState<UploadState>({ kind: "IDLE" });
|
const [state, setState] = useState<UploadState>({ kind: "IDLE" });
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [altText, setAltText] = useState("");
|
||||||
|
const [decorative, setDecorative] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const altRef = useRef<HTMLInputElement>(null);
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
const descriptionId = 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" });
|
setState({ kind: "UPLOADING" });
|
||||||
try {
|
try {
|
||||||
// Fix round 1 (I1). A fresh key per call, not one generated once when
|
// 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
|
// different payloads must never share one idempotency key -- the exact
|
||||||
// retry scenario idempotency keys exist for.
|
// retry scenario idempotency keys exist for.
|
||||||
const asset = await props.gateway.uploadAsset(
|
const asset = await props.gateway.uploadAsset(
|
||||||
{ file, kind: props.kind },
|
{
|
||||||
|
file,
|
||||||
|
kind: props.kind,
|
||||||
|
decorative,
|
||||||
|
...(decorative ? {} : { altText: trimmedAlt }),
|
||||||
|
},
|
||||||
{ idempotencyKey: createLocalId("studio-asset-upload") },
|
{ idempotencyKey: createLocalId("studio-asset-upload") },
|
||||||
);
|
);
|
||||||
const next = stateForUploaded(asset);
|
const next = stateForUploaded(asset);
|
||||||
@@ -132,7 +171,8 @@ export function AssetUploadDialog(props: Readonly<{
|
|||||||
<p className="studio-eyebrow">ASSET UPLOAD</p>
|
<p className="studio-eyebrow">ASSET UPLOAD</p>
|
||||||
<h2 id={titleId}>Asset 업로드</h2>
|
<h2 id={titleId}>Asset 업로드</h2>
|
||||||
<p id={descriptionId}>
|
<p id={descriptionId}>
|
||||||
업로드한 파일은 서버 검증을 거친 뒤에만 본문에 삽입할 수 있습니다.
|
업로드한 파일은 서버 검증을 거친 뒤에만 본문에 삽입할 수 있습니다. 장식용이
|
||||||
|
아니면 대체 텍스트가 필요합니다.
|
||||||
</p>
|
</p>
|
||||||
<label className="studio-field">
|
<label className="studio-field">
|
||||||
<span>Asset 파일</span>
|
<span>Asset 파일</span>
|
||||||
@@ -142,20 +182,60 @@ export function AssetUploadDialog(props: Readonly<{
|
|||||||
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml,application/pdf"
|
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml,application/pdf"
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const file = event.currentTarget.files?.[0];
|
const selected = event.currentTarget.files?.[0] ?? null;
|
||||||
if (!file) {
|
setFile(selected);
|
||||||
|
if (!selected) {
|
||||||
setState({ kind: "SELECTION_FAILED", message: MESSAGES.SELECTION_FAILED });
|
setState({ kind: "SELECTION_FAILED", message: MESSAGES.SELECTION_FAILED });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void submit(file);
|
// A new file clears whatever the previous attempt reported.
|
||||||
|
setState({ kind: "IDLE" });
|
||||||
|
if (!decorative) altRef.current?.focus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="studio-field studio-field--checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={decorative}
|
||||||
|
disabled={uploading}
|
||||||
|
onChange={(event) => {
|
||||||
|
setDecorative(event.currentTarget.checked);
|
||||||
|
if (state.kind === "ALT_REQUIRED") setState({ kind: "IDLE" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>장식용 이미지 (대체 텍스트 없음)</span>
|
||||||
|
</label>
|
||||||
|
<label className="studio-field">
|
||||||
|
<span>대체 텍스트</span>
|
||||||
|
<input
|
||||||
|
ref={altRef}
|
||||||
|
type="text"
|
||||||
|
value={altText}
|
||||||
|
disabled={uploading || decorative}
|
||||||
|
aria-invalid={state.kind === "ALT_REQUIRED"}
|
||||||
|
onChange={(event) => {
|
||||||
|
setAltText(event.currentTarget.value);
|
||||||
|
if (state.kind === "ALT_REQUIRED") setState({ kind: "IDLE" });
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Enter") return;
|
||||||
|
event.preventDefault();
|
||||||
|
void submit();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p className="studio-dialog-status" role="status" aria-live="polite" aria-label="업로드 상태">{MESSAGES[state.kind]}</p>
|
<p className="studio-dialog-status" role="status" aria-live="polite" aria-label="업로드 상태">{MESSAGES[state.kind]}</p>
|
||||||
|
{/* `.studio-dialog-actions button:last-child` is the primary style, so
|
||||||
|
the confirming action goes last -- same order as the other Studio
|
||||||
|
dialogs. */}
|
||||||
<div className="studio-dialog-actions">
|
<div className="studio-dialog-actions">
|
||||||
<button type="button" disabled={uploading} onClick={props.onClose}>
|
<button type="button" disabled={uploading} onClick={props.onClose}>
|
||||||
닫기
|
닫기
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" disabled={uploading} onClick={() => void submit()}>
|
||||||
|
업로드
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|||||||
@@ -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 { 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::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-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 { 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; }
|
.studio-app .studio-page-top .studio-page-heading { padding-bottom: 42px; }
|
||||||
|
|||||||
@@ -250,6 +250,19 @@ function chooseFile(file: File) {
|
|||||||
fireEvent.change(input, { target: { files: [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" });
|
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", () => {
|
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={() => {}} />);
|
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
|
||||||
|
|
||||||
chooseFile(SAMPLE_FILE);
|
chooseAndUpload(SAMPLE_FILE);
|
||||||
|
|
||||||
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드 중입니다."));
|
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드 중입니다."));
|
||||||
assert.equal((document.querySelector('input[type="file"]') as HTMLInputElement).disabled, true);
|
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, "업로드했습니다."));
|
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드했습니다."));
|
||||||
assert.equal(uploaded.length, 1);
|
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={() => {}} />,
|
<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, "보안 검사에서 격리되어 사용할 수 없습니다."));
|
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "보안 검사에서 격리되어 사용할 수 없습니다."));
|
||||||
assert.equal(uploaded.length, 0);
|
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={() => {}} />,
|
<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, "서버 검증에서 거절되어 사용할 수 없습니다."));
|
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "서버 검증에서 거절되어 사용할 수 없습니다."));
|
||||||
assert.equal(uploaded.length, 0);
|
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={() => {}} />);
|
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
|
||||||
|
|
||||||
chooseFile(SAMPLE_FILE);
|
chooseAndUpload(SAMPLE_FILE);
|
||||||
|
|
||||||
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "파일 크기가 허용 범위를 넘었습니다."));
|
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={() => {}} />);
|
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
|
||||||
|
|
||||||
chooseFile(SAMPLE_FILE);
|
chooseAndUpload(SAMPLE_FILE);
|
||||||
|
|
||||||
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "지원하지 않는 파일 형식입니다."));
|
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={() => {}} />);
|
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
|
||||||
|
|
||||||
chooseFile(SAMPLE_FILE);
|
chooseAndUpload(SAMPLE_FILE);
|
||||||
|
|
||||||
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드를 전송하지 못했습니다."));
|
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)} />);
|
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => (closed += 1)} />);
|
||||||
|
|
||||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
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: "닫기" });
|
const closeButton = screen.getByRole("button", { name: "닫기" });
|
||||||
assert.equal(document.activeElement, input);
|
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();
|
input.focus();
|
||||||
await user.tab();
|
for (const expected of [decorativeBox, altInput, closeButton, uploadButton, input]) {
|
||||||
assert.equal(document.activeElement, closeButton);
|
await user.tab();
|
||||||
await user.tab();
|
assert.equal(document.activeElement, expected);
|
||||||
assert.equal(document.activeElement, input);
|
}
|
||||||
|
|
||||||
await user.click(closeButton);
|
await user.click(closeButton);
|
||||||
assert.equal(closed, 1);
|
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={() => {}} />);
|
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, "파일 크기가 허용 범위를 넘었습니다."));
|
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, "업로드했습니다."));
|
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드했습니다."));
|
||||||
|
|
||||||
assert.equal(keys.length, 2);
|
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 screen.findByLabelText("본문 Markdown");
|
||||||
await user.click(screen.getByRole("button", { name: "Asset 업로드" }));
|
await user.click(screen.getByRole("button", { name: "Asset 업로드" }));
|
||||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
chooseAndUpload(
|
||||||
fireEvent.change(fileInput, {
|
new File(["<svg/>"], "boundary-check.svg", { type: "image/svg+xml" }),
|
||||||
target: { files: [new File(["<svg/>"], "boundary-check.svg", { type: "image/svg+xml" })] },
|
"업로드 확인용 대체 텍스트",
|
||||||
});
|
);
|
||||||
// case-fields.tsx's design decision: a successful upload auto-inserts the
|
// 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 --
|
// 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
|
// 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;
|
const textarea = screen.getByLabelText("본문 Markdown") as HTMLTextAreaElement;
|
||||||
|
|
||||||
// The upload form (asset-picker.tsx's AssetUploadDialog) has no altText
|
// Final fix wave, item 2: the upload dialog now collects the alt text, so
|
||||||
// field, so the auto-inserted directive carries `alt=""` -- fill it in
|
// the auto-inserted directive already carries it and no hand-edit of the
|
||||||
// here the way an author would by editing the textarea, so the only
|
// raw Markdown is needed. This test's own target stays the evidence key
|
||||||
// remaining validation question is the evidence key itself (I2's target),
|
// (I2); the alt-text loop has its own tests at the bottom of this file.
|
||||||
// not the unrelated EVIDENCE_ALT_REQUIRED rule.
|
const authoredBody = textarea.value;
|
||||||
const authoredBody = textarea.value.replace('alt=""', 'alt="업로드 확인용 대체 텍스트"');
|
assert.ok(authoredBody.includes('alt="업로드 확인용 대체 텍스트"'), authoredBody);
|
||||||
assert.notEqual(authoredBody, textarea.value);
|
|
||||||
|
|
||||||
const currentDraft = { ...created } as Record<string, unknown>;
|
const currentDraft = { ...created } as Record<string, unknown>;
|
||||||
delete currentDraft.id;
|
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);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user