feat(ai-character): 커뮤니티 생성 미디어 흐름 개선

This commit is contained in:
Yu Sung
2026-08-05 00:33:49 +09:00
parent 766a06ad0a
commit fb9b550040
22 changed files with 700 additions and 141 deletions

View File

@@ -29,7 +29,7 @@ test("renders the protected admin shell for an existing ADMIN session", async ()
expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
expect(requests[0]?.url).toContain("size=20");
expect(screen.getByRole("link", { name: "본문으로 건너뛰기" })).toHaveAttribute("href", "#app-main");
expect(screen.getByRole("banner")).toBeInTheDocument();
expect(screen.getByRole("banner")).toHaveClass("flex-wrap", "py-2");
expect(screen.getByRole("button", { name: "모바일 메뉴 열기" })).toHaveClass("whitespace-nowrap");
expect(screen.getByRole("navigation", { name: "브레드크럼" })).toHaveClass("whitespace-nowrap");
expect(screen.getByRole("button", { name: "로그아웃" })).toHaveClass("whitespace-nowrap");

View File

@@ -167,7 +167,7 @@ export function ProtectedAdminShell({ apiClient, apiMode, routeError }: { readon
</nav>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex min-h-14 items-center justify-between gap-3 border-b border-border bg-card px-4" role="banner">
<header className="flex min-h-14 flex-wrap items-center justify-between gap-3 border-b border-border bg-card px-4 py-2" role="banner">
<div className="flex min-w-0 items-center gap-3">
<button
aria-expanded={isMobileMenuOpen}

View File

@@ -5,7 +5,7 @@ import { routePaths } from "@/app/route-paths";
import { createAudioContentCreateBody, deactivateAudioContent, updateAudioContent } from "@/features/audio-contents/api/audio-content-api";
import { uploadAudioContent } from "@/features/audio-contents/api/upload-audio-content";
import type { UploadAudioContentOptions, UploadAuthDependencies } from "@/features/audio-contents/api/upload-audio-content";
import { audioErrorMessage, coverErrorMessage, createInitialPrice, defaultAudioContentCreateSettings, formatPrice, isFutureLocalDateTime, parsePrice, toCreateRequest, toUpdateRequest } from "@/features/audio-contents/components/audio-content-form-helpers";
import { audioErrorMessage, coverErrorMessage, createInitialPrice, defaultAudioContentCreateSettings, isFutureLocalDateTime, parsePrice, toCreateRequest, toUpdateRequest } from "@/features/audio-contents/components/audio-content-form-helpers";
import type { AudioContentCreateSettings } from "@/features/audio-contents/components/audio-content-form-helpers";
import { AudioContentCreateOptions } from "@/features/audio-contents/components/AudioContentCreateOptions";
import { AudioContentThemeSelect } from "@/features/audio-contents/components/AudioContentThemeSelect";
@@ -21,6 +21,7 @@ import type { CropRenderRequest } from "@/shared/lib/crop-image";
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
import { formatSeoulDateTime } from "@/shared/lib/formatters";
import { ConfirmDeactivateDialog } from "@/shared/ui/confirm-deactivate-dialog";
import { CanPriceField } from "@/shared/ui/can-price-field";
import { FileField } from "@/shared/ui/file-field";
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
@@ -80,9 +81,8 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
useEffect(() => () => cropSource?.release?.(), [cropSource]);
function changePrice(value: string) {
const nextPrice = formatPrice(value);
setPrice(nextPrice);
if (mode === "create" && nextPrice === "0") {
setPrice(value);
if (mode === "create" && parsePrice(value) === 0) {
setCreateSettings((current) => ({ ...current, purchaseOption: "BOTH", isGeneratePreview: false, isPointAvailable: false, previewStartTime: null, previewEndTime: null }));
}
}
@@ -236,8 +236,7 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
{errors.detail === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.detail} role="alert">{errors.detail}</p>}
<TagInput error={errors.tags} errorId={errorIds.tags} label="태그" onChange={setTags} value={tags} />
{mode === "create" ? <AudioContentThemeSelect error={errors.theme} errorId={errorIds.theme} onChange={setThemeId} themes={themes} value={themeId} /> : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">: {audio?.themeStr ?? "-"} ( )</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"><input aria-describedby={errors.price === undefined ? undefined : errorIds.price} aria-invalid={errors.price === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" inputMode="numeric" min="0" onChange={(event) => changePrice(event.currentTarget.value)} step="1" type="number" value={price} /></label><p className="text-sm text-muted-foreground">단위: </p>
{errors.price === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.price} role="alert">{errors.price}</p>}
<CanPriceField error={errors.price} errorId={errorIds.price} onChange={changePrice} value={price} />
{mode === "create" ? <AudioContentCreateOptions isPaid={isPaid} onChange={setCreateSettings} value={createSettings} /> : null}
{mode === "create" ? <ReleaseScheduleField error={errors.releaseDate} errorId={errorIds.releaseDate} onChange={setReleaseSchedule} value={releaseSchedule} /> : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">: {audio?.releaseDate == null ? "즉시 공개" : formatSeoulDateTime(audio.releaseDate)} ( )</p>}
<p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">시리즈: 현재 .</p>

View File

@@ -13,14 +13,6 @@ export function parsePrice(value: string): number | null {
return parseCanPriceInput(value);
}
export function formatPrice(value: string): string {
if (value.startsWith("-")) {
return "0";
}
return value.replace(/\D/g, "");
}
export const selectionCardClassName = "flex min-h-11 cursor-pointer items-center gap-3 rounded-md border border-border bg-card px-3 py-2 text-sm font-semibold text-foreground hover:bg-accent hover:text-accent-foreground focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 has-[:checked]:border-input has-[:checked]:bg-accent has-[:checked]:text-accent-foreground";
const localDateTimePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;

View File

@@ -67,10 +67,10 @@ test("AudioContentFormPage commits tag chips with Enter and comma without submit
});
test.each([
{ value: "-1", expected: "0" },
{ value: "1.5", expected: "15" },
{ value: "100000", expected: "100000" },
])("AudioContentFormPage keeps only digits in the price input for $value", async ({ value, expected }) => {
"-1",
"1.5",
"100000",
])("AudioContentFormPage preserves the raw price input %s", async (value) => {
// Given
renderCreateForm();
const priceInput = await screen.findByLabelText("가격");
@@ -83,7 +83,7 @@ test.each([
fireEvent.change(priceInput, { target: { value } });
// Then
expect(priceInput).toHaveValue(Number(expected));
expect(priceInput).toHaveValue(Number(value));
});
test("AudioContentFormPage shows paid options only for a positive price", async () => {
@@ -110,7 +110,7 @@ test("AudioContentFormPage shows paid options only for a positive price", async
expect(screen.getByLabelText("포인트 사용")).toBeInTheDocument();
});
test("AudioContentFormPage resets paid settings and retains removed-control defaults when price returns to zero", async () => {
test("AudioContentFormPage resets paid settings and retains removed-control defaults when price is numeric zero", async () => {
// Given
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
renderCreateForm(uploadedBodies);
@@ -124,7 +124,7 @@ test("AudioContentFormPage resets paid settings and retains removed-control defa
fireEvent.change(screen.getByLabelText("미리듣기 종료"), { target: { value: "01:00:05" } });
// When
fireEvent.change(priceInput, { target: { value: "0" } });
fireEvent.change(priceInput, { target: { value: "00" } });
// Then
expect(screen.queryByRole("group", { name: "구매 옵션" })).not.toBeInTheDocument();

View File

@@ -26,6 +26,38 @@ function createInactiveFormClient(requests: CapturedRequest[]): ApiClient {
};
}
test.each(["-1", "1.5", "100000"])("AudioContentFormPage rejects invalid edit price %s before update", async (value) => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" />);
// When
const priceInput = await screen.findByLabelText("가격");
fireEvent.change(priceInput, { target: { value } });
fireEvent.submit(screen.getByRole("form", { name: "오디오 콘텐츠 수정 입력 화면" }));
// Then
expect(priceInput).toHaveValue(Number(value));
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
});
test("AudioContentFormPage allows the maximum edit price", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" />);
// When
fireEvent.change(await screen.findByLabelText("가격"), { target: { value: "99999" } });
fireEvent.click(screen.getByRole("button", { name: "저장" }));
// Then
await waitFor(() => expect(requests.filter((request) => request.method === "PUT")).toHaveLength(1));
expect(await readJsonPart(requireFormData(requests.at(-1)?.body).get("request"))).toMatchObject({ price: 99999 });
});
test("AudioContentFormPage update omits unsupported controls and soft delete navigates to the audio list", async () => {
// Given
const requests: CapturedRequest[] = [];

View File

@@ -161,9 +161,10 @@ test("AudioContentFormPage serializes supported create settings with removed-con
});
test.each([
{ label: "음수 기호", value: "-1", expected: "0" },
{ label: "소수점", value: "1.5", expected: "15" },
])("AudioContentFormPage removes non-digits from price input for %s", async ({ label, value, expected }) => {
"-1",
"1.5",
"100000",
])("AudioContentFormPage preserves and rejects invalid price input %s before upload", async (value) => {
// Given
const requests: CapturedRequest[] = [];
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
@@ -171,17 +172,21 @@ test.each([
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" createCropSource={(file) => Promise.resolve({ file, height: 800, previewUrl: "blob:cover", width: 800 })} renderCrop={(request) => Promise.resolve(request.file)} uploadAudioContentRequest={createSuccessfulUpload(uploadedBodies)} />);
// When
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: label } });
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "잘못된 가격" } });
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "설명" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "태그" } });
fireEvent.keyDown(screen.getByLabelText("태그"), { key: "Enter" });
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [fileWithSize("voice.mp3", "audio/mpeg", 10)] } });
fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "cover.png", { type: "image/png" })] } });
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
await waitFor(() => expect(screen.getByText("cover.png")).toBeInTheDocument());
fireEvent.change(screen.getByLabelText("가격"), { target: { value } });
fireEvent.submit(screen.getByRole("form", { name: "오디오 콘텐츠 생성 입력 화면" }));
// Then
expect(screen.getByLabelText("가격")).toHaveValue(Number(expected));
expect(screen.getByLabelText("가격")).toHaveValue(Number(value));
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(uploadedBodies).toHaveLength(0);
});
@@ -237,32 +242,6 @@ test("AudioContentFormPage validates audio boundary and serializes scheduled Asi
expect(request).not.toHaveProperty("timezone");
});
test("AudioContentFormPage blocks prices outside the CAN range before upload", async () => {
// Given
const requests: CapturedRequest[] = [];
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" createCropSource={(file) => Promise.resolve({ file, height: 800, previewUrl: "blob:cover", width: 800 })} renderCrop={(request) => Promise.resolve(request.file)} uploadAudioContentRequest={createSuccessfulUpload(uploadedBodies)} />);
// When
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "가격 경계" } });
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "설명" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "태그" } });
fireEvent.keyDown(screen.getByLabelText("태그"), { key: "Enter" });
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [fileWithSize("voice.mp3", "audio/mpeg", 10)] } });
fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "cover.png", { type: "image/png" })] } });
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
await waitFor(() => expect(screen.getByText("cover.png")).toBeInTheDocument());
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100000" } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(screen.getByLabelText("가격")).toHaveValue(100000);
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(uploadedBodies).toHaveLength(0);
});
test("AudioContentFormPage links validation errors and focuses the first invalid control", async () => {
// Given
const requests: CapturedRequest[] = [];
@@ -288,7 +267,7 @@ test("AudioContentFormPage links validation errors and focuses the first invalid
expect(screen.getByLabelText("상세 설명")).toHaveAttribute("aria-describedby", "audio-content-detail-error");
expect(screen.getByLabelText("태그").getAttribute("aria-describedby")?.split(" ")).toContain("audio-content-tags-error");
expect(screen.getByLabelText("태그")).toHaveAccessibleDescription("쉼표 또는 Enter로 태그를 추가하세요. 태그를 입력하세요.");
expect(screen.getByLabelText("가격")).not.toHaveAttribute("aria-describedby");
expect(screen.getByLabelText("가격")).toHaveAccessibleDescription("단위: 캔");
expect(screen.getByLabelText("오디오 테마")).toHaveAttribute("aria-describedby", "audio-content-theme-error");
expect(titleInput).toHaveAttribute("aria-invalid", "true");
expect(coverInput).toHaveAttribute("aria-invalid", "true");

View File

@@ -1,13 +1,14 @@
import { useEffect, useRef, useState } from "react";
import { createCommunityPost } from "@/features/community-posts/api/community-post-api";
import { communityPostAudioErrorMessage, formatCommunityPostPrice, hasCommunityPostFormErrors, parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
import { communityPostAudioErrorMessage, hasCommunityPostFormErrors, parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
import type { CommunityPostFormErrors } from "@/features/community-posts/components/community-post-form-helpers";
import { COMMUNITY_POST_IMAGE_POLICY, prepareCommunityPostImage } from "@/features/community-posts/validation/community-post-media-policy";
import { ApiError } from "@/shared/api/api-error";
import type { ApiClient } from "@/shared/api/client";
import type { CropRenderRequest } from "@/shared/lib/crop-image";
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
import { CanPriceField } from "@/shared/ui/can-price-field";
import { FileField } from "@/shared/ui/file-field";
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
@@ -27,12 +28,6 @@ const errorIds = {
price: "community-post-price-error",
} as const;
function buildCreateRequest(content: string, isAdult: boolean, isCommentAvailable: boolean, price: number | null) {
const base = { content, isAdult, isCommentAvailable };
return price === null ? base : { ...base, price };
}
export function CommunityPostForm({ apiClient, characterId, createCropSource, onCreated, renderCrop }: CommunityPostFormProps) {
const [audioFile, setAudioFile] = useState<File | null>(null);
const [content, setContent] = useState("");
@@ -43,7 +38,7 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
const [isImagePreparing, setIsImagePreparing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [postImage, setPostImage] = useState<File | null>(null);
const [price, setPrice] = useState("");
const [price, setPrice] = useState("0");
const formRef = useRef<HTMLFormElement>(null);
const imageSelectionId = useRef(0);
const isSavingRef = useRef(false);
@@ -53,9 +48,11 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
async function selectPostImage(file: File | null) {
imageSelectionId.current += 1;
const currentSelectionId = imageSelectionId.current;
setAudioFile(null);
setErrors((current) => ({ ...current, audio: undefined }));
setPostImage(null);
setCropSource(null);
if (file === null) {
setPostImage(null);
setCropSource(null);
setIsImagePreparing(false);
setErrors((current) => ({ ...current, image: undefined }));
return;
@@ -110,13 +107,12 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
function validateForm(): CommunityPostFormErrors {
const parsedPrice = parseCommunityPostPrice(price);
const hasPriceInput = price.trim().length > 0;
return {
audio: communityPostAudioErrorMessage(audioFile),
content: content.trim().length === 0 ? "내용을 입력하세요." : undefined,
image: isImagePreparing || cropSource !== null ? "이미지 처리가 끝난 뒤 저장하세요." : errors.image,
price: (hasPriceInput && parsedPrice === null) || (parsedPrice !== null && (!Number.isInteger(parsedPrice) || parsedPrice < 0 || parsedPrice > CAN_PRICE_MAX)) ? "가격은 0 이상 99,999 이하 정수 캔으로 입력하세요." : undefined,
price: parsedPrice === null || !Number.isInteger(parsedPrice) || parsedPrice < 0 || parsedPrice > CAN_PRICE_MAX ? "가격은 0 이상 99,999 이하 정수 캔으로 입력하세요." : undefined,
};
}
@@ -131,6 +127,10 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
queueMicrotask(() => focusFirstInvalidControl(formRef.current));
return;
}
const parsedPrice = parseCommunityPostPrice(price);
if (parsedPrice === null) {
return;
}
isSavingRef.current = true;
setIsSaving(true);
@@ -138,7 +138,7 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
await createCommunityPost(apiClient, characterId, {
audioFile: audioFile ?? undefined,
postImage: postImage ?? undefined,
request: buildCreateRequest(content.trim(), isAdult, isCommentAvailable, parseCommunityPostPrice(price)),
request: { content: content.trim(), isAdult, isCommentAvailable, price: parsedPrice },
});
onCreated();
} catch (error: unknown) {
@@ -154,17 +154,16 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
<div className="flex flex-col gap-2">
<p className="text-xs font-semibold text-info">COMMUNITY</p>
<h2 className="text-2xl font-bold leading-tight" id="community-post-form-title"> </h2>
<p className="text-sm text-muted-foreground"> . GIF는 crop .</p>
<p className="break-keep text-sm text-muted-foreground"> . GIF는 crop .</p>
</div>
<form aria-label="커뮤니티 게시글 생성 입력 화면" className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" onSubmit={(event) => void submit(event)} ref={formRef}>
<FileField accept="image/jpeg,image/png,image/gif" acceptDescription="JPEG 또는 PNG는 자유 ratio crop 후 최대 800px로 전송합니다. GIF는 원본 width 800px 이하만 crop 없이 전송합니다." error={errors.image} label="게시글 이미지" onChange={(file) => void selectPostImage(file)} value={postImage} />
{postImage === null ? null : <FileField accept={AUDIO_FILE_POLICY.allowedMimeTypes.join(",")} acceptDescription="MP3, AAC, M4A, 최대 1,024,000,000 bytes. WAV는 지원하지 않습니다." error={errors.audio} label="오디오 파일" onChange={setAudioFile} value={audioFile} />}
<label className="flex flex-col gap-2 text-sm font-semibold"><textarea aria-describedby={errors.content === undefined ? undefined : errorIds.content} aria-invalid={errors.content === undefined ? undefined : true} className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setContent(event.currentTarget.value)} value={content} /></label>
{errors.content === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.content} role="alert">{errors.content}</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"><input aria-describedby={errors.price === undefined ? undefined : errorIds.price} aria-invalid={errors.price === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" inputMode="numeric" onChange={(event) => setPrice(formatCommunityPostPrice(event.currentTarget.value))} value={price} /></label>
{errors.price === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.price} role="alert">{errors.price}</p>}
<CanPriceField error={errors.price} errorId={errorIds.price} onChange={setPrice} value={price} />
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={isCommentAvailable} onChange={(event) => setIsCommentAvailable(event.currentTarget.checked)} type="checkbox" /> </label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={isAdult} onChange={(event) => setIsAdult(event.currentTarget.checked)} type="checkbox" /> </label>
<FileField accept={AUDIO_FILE_POLICY.allowedMimeTypes.join(",")} acceptDescription="MP3, AAC, M4A, 최대 1,024,000,000 bytes. WAV는 지원하지 않습니다." error={errors.audio} label="오디오 파일" onChange={setAudioFile} value={audioFile} />
<FileField accept="image/jpeg,image/png,image/gif" acceptDescription="JPEG 또는 PNG는 자유 ratio crop 후 최대 800px로 전송합니다. GIF는 원본 width 800px 이하만 crop 없이 전송합니다." error={errors.image} label="게시글 이미지" onChange={(file) => void selectPostImage(file)} value={postImage} />
{errors.form === undefined ? null : <p className="text-sm font-semibold text-destructive" role="alert">{errors.form}</p>}
{isSaving ? <p className="rounded-md border border-border bg-muted p-3 text-sm font-semibold" role="status"> </p> : null}
<div className="flex justify-end gap-2">

View File

@@ -1,5 +1,5 @@
import { validateAudioFile } from "@/shared/validation/audio-file-policy";
import { formatCanPriceInput, parseCanPriceInput } from "@/shared/validation/can-price";
import { parseCanPriceInput } from "@/shared/validation/can-price";
export type CommunityPostFormErrors = {
readonly audio?: string;
@@ -13,10 +13,6 @@ export function parseCommunityPostPrice(value: string): number | null {
return parseCanPriceInput(value);
}
export function formatCommunityPostPrice(value: string): string {
return formatCanPriceInput(value);
}
export function communityPostAudioErrorMessage(file: File | null): string | undefined {
if (file === null) {
return undefined;

View File

@@ -68,6 +68,7 @@ describe("Community post contract", () => {
// Then
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101/community-posts", method: "POST", body: requests[0]?.body }]);
const body = requireFormData(requests[0]?.body);
expect([...body.keys()]).toEqual(["audioFile", "postImage", "request"]);
expect(body.get("audioFile")).toBe(audioFile);
expect(body.get("postImage")).toBe(postImage);
const request = await readJsonPart(body.get("request"));

View File

@@ -1,6 +1,6 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { afterEach, expect, test, vi } from "vitest";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { App } from "@/app/App";
import { apiBaseUrl, saveAdminSession, useAiCharacterDetailResponse, useAiCharactersResponse } from "@/app/app-test-support";
@@ -71,6 +71,18 @@ function createCropSource(file: File): Promise<CropSourceImage> {
return Promise.resolve({ file, height: 600, previewUrl: "blob:gif", width: 800 });
}
beforeEach(() => {
class TestUrl extends URL {
static createObjectURL(blob: Blob): string {
return `blob:community-${blob.size}`;
}
static revokeObjectURL(): void {}
}
vi.stubGlobal("URL", TestUrl);
});
afterEach(() => {
vi.unstubAllEnvs();
window.history.replaceState({}, "", "/");
@@ -91,6 +103,7 @@ test("Community create route opens from the existing list workspace", async () =
// Then
expect(await screen.findByRole("heading", { name: "커뮤니티 게시글 생성" })).toBeInTheDocument();
expect(screen.getByText("내용과 첨부 파일은 저장 전 운영 기준에 맞게 검토하세요. GIF는 crop 없이 원본을 보존합니다.")).toHaveClass("break-keep");
expect(window.location.pathname).toBe("/ai-characters/101/community-posts/new");
});
@@ -138,6 +151,8 @@ test("Community create form validates content audio policy and GIF width before
// When
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "GIF와 오디오가 있는 게시글" } });
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [animatedGif] } });
await screen.findByLabelText("오디오 파일");
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [fileWithSize("voice.wav", "audio/wav", 10)] } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
@@ -145,7 +160,6 @@ test("Community create form validates content audio policy and GIF width before
expect(await screen.findByText("MP3, AAC, M4A 파일만 업로드하세요.")).toBeInTheDocument();
// When
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [audioFile] } });
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "big.gif", { type: "image/gif" })] } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
@@ -155,6 +169,9 @@ test("Community create form validates content audio policy and GIF width before
// When
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [animatedGif] } });
const revealedAudioInput = await screen.findByLabelText("오디오 파일");
expect(screen.queryByText("MP3, AAC, M4A 파일만 업로드하세요.")).not.toBeInTheDocument();
fireEvent.change(revealedAudioInput, { target: { files: [audioFile] } });
await waitFor(() => expect(screen.getByRole("button", { name: "생성" })).not.toBeDisabled());
fireEvent.click(screen.getByRole("button", { name: "생성" }));
@@ -164,7 +181,98 @@ test("Community create form validates content audio policy and GIF width before
const body = requireFormData(requests.at(-1)?.body);
expect(body.get("audioFile")).toBe(audioFile);
expect(body.get("postImage")).toBe(animatedGif);
expect(await readJsonPart(body.get("request"))).toEqual({ content: "GIF와 오디오가 있는 게시글", isAdult: false, isCommentAvailable: true });
expect(await readJsonPart(body.get("request"))).toEqual({ content: "GIF와 오디오가 있는 게시글", isAdult: false, isCommentAvailable: true, price: 0 });
});
test("Community create form renders final image preview first and reveals audio before content", async () => {
// Given
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
render(<CommunityPostFormPage apiClient={createCommunityFormClient([])} characterId="101" createCropSource={createCropSource} />);
const form = await screen.findByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" });
if (!(form instanceof HTMLFormElement)) {
throw new Error("expected community create form");
}
const imageInput = screen.getByLabelText("게시글 이미지");
const contentInput = screen.getByLabelText("내용");
// Then
expect(screen.queryByLabelText("오디오 파일")).not.toBeInTheDocument();
expect(Array.from(form.elements).indexOf(imageInput)).toBeLessThan(Array.from(form.elements).indexOf(contentInput));
// When
fireEvent.change(imageInput, { target: { files: [new File(["gif"], "post.gif", { type: "image/gif" })] } });
// Then
const preview = await screen.findByRole("img", { name: "게시글 이미지 업로드 미리보기" });
const audioInput = screen.getByLabelText("오디오 파일");
expect(preview).toHaveAttribute("src", "blob:community-3");
expect(Array.from(form.elements).indexOf(imageInput)).toBeLessThan(Array.from(form.elements).indexOf(audioInput));
expect(Array.from(form.elements).indexOf(audioInput)).toBeLessThan(Array.from(form.elements).indexOf(contentInput));
});
test("Community create form clears final image and audio as soon as image replacement starts", async () => {
// Given
let resolveReplacement: (source: CropSourceImage) => void = () => undefined;
const replacementReady = new Promise<CropSourceImage>((resolve) => {
resolveReplacement = resolve;
});
const createReplacementCropSource = (file: File) => file.name === "replacement.png" ? replacementReady : createCropSource(file);
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
render(<CommunityPostFormPage apiClient={createCommunityFormClient([])} characterId="101" createCropSource={createReplacementCropSource} />);
fireEvent.change(await screen.findByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "ready.gif", { type: "image/gif" })] } });
const audioInput = await screen.findByLabelText("오디오 파일");
fireEvent.change(audioInput, { target: { files: [new File(["audio"], "voice.m4a", { type: "audio/x-m4a" })] } });
// When
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["image"], "replacement.png", { type: "image/png" })] } });
// Then
expect(screen.queryByLabelText("오디오 파일")).not.toBeInTheDocument();
expect(screen.queryByRole("img", { name: "게시글 이미지 업로드 미리보기" })).not.toBeInTheDocument();
await act(async () => {
resolveReplacement({ file: new File(["image"], "replacement.png", { type: "image/png" }), height: 500, previewUrl: "blob:replacement", width: 1000 });
await replacementReady;
});
expect(await screen.findByRole("dialog", { name: "이미지 crop" })).toBeInTheDocument();
});
test("Community create form hides and clears audio when the final image is removed", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
render(<CommunityPostFormPage apiClient={createCommunityFormClient(requests)} characterId="101" createCropSource={createCropSource} />);
fireEvent.change(await screen.findByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "ready.gif", { type: "image/gif" })] } });
fireEvent.change(await screen.findByLabelText("오디오 파일"), { target: { files: [new File(["audio"], "voice.m4a", { type: "audio/x-m4a" })] } });
// When
fireEvent.click(screen.getByRole("button", { name: "게시글 이미지 선택 취소" }));
// Then
await waitFor(() => expect(screen.queryByLabelText("오디오 파일")).not.toBeInTheDocument());
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "이미지 제거 게시글" } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
await waitFor(() => expect(requests.filter((request) => request.method === "POST")).toHaveLength(1));
const body = requireFormData(requests.at(-1)?.body);
expect(body.has("audioFile")).toBe(false);
expect(body.has("postImage")).toBe(false);
});
test("Community create form defaults price to zero and rejects blank price", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
render(<CommunityPostFormPage apiClient={createCommunityFormClient(requests)} characterId="101" createCropSource={createCropSource} />);
const priceInput = await screen.findByLabelText("가격");
expect(priceInput).toHaveValue(0);
// When
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "빈 가격 게시글" } });
fireEvent.change(priceInput, { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
});
test("Community create form crops JPEG PNG with free ratio and no upscale", async () => {
@@ -177,7 +285,7 @@ test("Community create form crops JPEG PNG with free ratio and no upscale", asyn
// When
fireEvent.change(await screen.findByLabelText("내용"), { target: { value: "PNG crop 게시글" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "1,000" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "1000" } });
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["image"], "wide.png", { type: "image/png" })] } });
expect(await screen.findByRole("dialog", { name: "이미지 crop" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "생성" })).toBeDisabled();
@@ -185,6 +293,7 @@ test("Community create form crops JPEG PNG with free ratio and no upscale", asyn
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
await waitFor(() => expect(renderCrop).toHaveBeenCalled());
expect(await screen.findByRole("img", { name: "게시글 이미지 업로드 미리보기" })).toHaveAttribute("src", "blob:community-7");
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
@@ -203,7 +312,7 @@ test("Community create form blocks prices outside the CAN range before submit",
// When
fireEvent.change(await screen.findByLabelText("내용"), { target: { value: "가격 경계 게시글" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100,000" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100000" } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
@@ -219,7 +328,7 @@ test("Community create form links validation errors and focuses the first invali
// When
const contentInput = await screen.findByLabelText("내용");
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100,000" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100000" } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
@@ -228,7 +337,7 @@ test("Community create form links validation errors and focuses the first invali
expect(contentError).toHaveAttribute("id", "community-post-content-error");
expect(priceError).toHaveAttribute("id", "community-post-price-error");
expect(contentInput).toHaveAttribute("aria-describedby", "community-post-content-error");
expect(screen.getByLabelText("가격")).toHaveAttribute("aria-describedby", "community-post-price-error");
expect(screen.getByLabelText("가격").getAttribute("aria-describedby")?.split(" ")).toContain("community-post-price-error");
expect(contentInput).toHaveAttribute("aria-invalid", "true");
expect(screen.getByLabelText("가격")).toHaveAttribute("aria-invalid", "true");
await waitFor(() => expect(contentInput).toHaveFocus());
@@ -295,7 +404,7 @@ test("Community create form ignores stale image preparation results", async () =
expect(submittedImage.name).toBe("fresh.gif");
});
test("Community create form clears pending image preparation when selection is canceled", async () => {
test("Community create form clears image replacement when crop is canceled", async () => {
let resolveCropSource: (source: CropSourceImage) => void = () => undefined;
const cropSourceReady = new Promise<CropSourceImage>((resolve) => {
resolveCropSource = resolve;
@@ -309,11 +418,11 @@ test("Community create form clears pending image preparation when selection is c
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "ready.gif", { type: "image/gif" })] } });
await waitFor(() => expect(screen.getByRole("button", { name: "생성" })).not.toBeDisabled());
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["image"], "slow.png", { type: "image/png" })] } });
fireEvent.click(screen.getByRole("button", { name: "선택 취소" }));
await act(async () => {
resolveCropSource({ file: new File(["image"], "slow.png", { type: "image/png" }), height: 500, previewUrl: "blob:slow", width: 1000 });
await cropSourceReady;
});
fireEvent.click(await screen.findByRole("button", { name: "취소" }));
expect(screen.getByRole("button", { name: "생성" })).not.toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "생성" }));

View File

@@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { CommunityPostForm } from "@/features/community-posts/components/CommunityPostForm";
import { formatCommunityPostPrice, parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
import { parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
type CapturedRequest = {
@@ -27,25 +27,25 @@ function renderForm(requests: CapturedRequest[]) {
return onCreated;
}
async function submitWithPrice(value: string, requests: CapturedRequest[]) {
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "가격 검증 게시글" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.");
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
}
test.each(["-1", "1.5"])("Community price keeps invalid raw input %s and blocks submit", async (value) => {
test.each([
"-1",
"1.5",
"100000",
])("Community price preserves and rejects invalid raw input %s", async (rawValue) => {
const requests: CapturedRequest[] = [];
const onCreated = renderForm(requests);
await submitWithPrice(value, requests);
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "가격 검증 게시글" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: rawValue } });
fireEvent.submit(screen.getByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" }));
expect(screen.getByLabelText("가격")).toHaveValue(value);
expect(screen.getByLabelText("가격")).toHaveValue(Number(rawValue));
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
expect(onCreated).not.toHaveBeenCalled();
});
test.each(["0", "99,999"])("Community price allows boundary input %s", async (value) => {
test.each(["0", "99999"])("Community price allows boundary input %s", async (value) => {
const requests: CapturedRequest[] = [];
const onCreated = renderForm(requests);
@@ -57,9 +57,8 @@ test.each(["0", "99,999캔"])("Community price allows boundary input %s", async
expect(requests.filter((request) => request.method === "POST")).toHaveLength(1);
});
test("Community price parser rejects negative and decimal raw input", () => {
test("Community price parser rejects blank, negative, and decimal raw input", () => {
expect(parseCommunityPostPrice("")).toBeNull();
expect(parseCommunityPostPrice("-1")).toBeNull();
expect(parseCommunityPostPrice("1.5")).toBeNull();
expect(formatCommunityPostPrice("-1")).toBe("-1");
expect(formatCommunityPostPrice("1.5")).toBe("1.5");
});

View File

@@ -0,0 +1,37 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { CanPriceField } from "@/shared/ui/can-price-field";
test("CanPriceField renders the native numeric CAN price contract", () => {
render(<CanPriceField error={undefined} errorId="price-error" onChange={() => undefined} value="0" />);
const input = screen.getByLabelText("가격");
expect(input).toHaveAttribute("type", "number");
expect(input).toHaveAttribute("inputmode", "numeric");
expect(input).toHaveAttribute("min", "0");
expect(input).toHaveAttribute("step", "1");
expect(input).toHaveAccessibleDescription("단위: 캔");
});
test.each([
"-1",
"1.5",
])("CanPriceField emits the raw input value %s unchanged", (rawValue) => {
const onChange = vi.fn<(value: string) => void>();
render(<CanPriceField error={undefined} errorId="price-error" onChange={onChange} value="0" />);
fireEvent.change(screen.getByLabelText("가격"), { target: { value: rawValue } });
expect(onChange).toHaveBeenCalledWith(rawValue);
});
test("CanPriceField links the visible error and unit description to the invalid input", () => {
render(<CanPriceField error="가격 오류" errorId="price-error" onChange={() => undefined} value="100000" />);
const input = screen.getByLabelText("가격");
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input.getAttribute("aria-describedby")?.split(" ")).toContain("price-error");
expect(input).toHaveAccessibleDescription("단위: 캔 가격 오류");
expect(screen.getByRole("alert")).toHaveAttribute("id", "price-error");
});

View File

@@ -1,8 +1,36 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { StrictMode } from "react";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { FileField } from "@/shared/ui/file-field";
const createObjectUrl = vi.fn<(blob: Blob) => string>();
const revokeObjectUrl = vi.fn<(url: string) => void>();
let originalCreateObjectUrl: PropertyDescriptor | undefined;
let originalRevokeObjectUrl: PropertyDescriptor | undefined;
beforeEach(() => {
createObjectUrl.mockReset().mockImplementation(() => `blob:preview-${createObjectUrl.mock.calls.length}`);
revokeObjectUrl.mockReset();
originalCreateObjectUrl = Object.getOwnPropertyDescriptor(URL, "createObjectURL");
originalRevokeObjectUrl = Object.getOwnPropertyDescriptor(URL, "revokeObjectURL");
Object.defineProperty(URL, "createObjectURL", { configurable: true, value: createObjectUrl });
Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: revokeObjectUrl });
});
afterEach(() => {
if (originalCreateObjectUrl === undefined) {
Reflect.deleteProperty(URL, "createObjectURL");
} else {
Object.defineProperty(URL, "createObjectURL", originalCreateObjectUrl);
}
if (originalRevokeObjectUrl === undefined) {
Reflect.deleteProperty(URL, "revokeObjectURL");
} else {
Object.defineProperty(URL, "revokeObjectURL", originalRevokeObjectUrl);
}
});
test("FileField exposes label, description, error, accept guidance, keyboard file input, and controlled value", () => {
const onChange = vi.fn();
const value = new File(["image"], "profile.png", { type: "image/png" });
@@ -23,9 +51,11 @@ test("FileField exposes label, description, error, accept guidance, keyboard fil
expect(input).toHaveAttribute("accept", "image/png");
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input).toHaveAccessibleDescription("프로필 이미지를 선택하세요. PNG만 업로드할 수 있습니다. 파일이 너무 큽니다.");
expect(screen.getByText("프로필 이미지를 선택하세요.")).toHaveClass("break-keep");
expect(screen.getByText("PNG만 업로드할 수 있습니다.")).toHaveClass("break-keep");
expect(screen.getByRole("button", { name: "대표 이미지 파일 선택" })).toBeInTheDocument();
expect(screen.getByText("profile.png")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "선택 취소" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "대표 이미지 선택 취소" })).toHaveTextContent("선택 취소");
});
test("FileField emits File or null and clear selection without owning upload policy", () => {
@@ -37,10 +67,62 @@ test("FileField emits File or null and clear selection without owning upload pol
expect(onChange).toHaveBeenCalledWith(selected);
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={onChange} value={selected} />);
fireEvent.click(screen.getByRole("button", { name: "선택 취소" }));
fireEvent.click(screen.getByRole("button", { name: "오디오 선택 취소" }));
expect(onChange).toHaveBeenCalledWith(null);
});
test("FileField clears the native selection when the controlled value is cleared externally", () => {
// Given
const selected = new File(["audio"], "voice.mp3", { type: "audio/mpeg" });
const { rerender } = render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={null} />);
const input = screen.getByLabelText<HTMLInputElement>("오디오");
fireEvent.change(input, { target: { files: [selected] } });
let nativeValue = "C:\\fakepath\\voice.mp3";
Object.defineProperty(input, "value", { configurable: true, get: () => nativeValue, set: (value: string) => { nativeValue = value; } });
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={selected} />);
expect(input.files?.[0]).toBe(selected);
expect(input.value).toBe("C:\\fakepath\\voice.mp3");
// When
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={null} />);
// Then
expect(input.value).toBe("");
});
test("FileField clears a rejected native selection when only the error changes", () => {
// Given
const rejected = new File(["audio"], "voice.wav", { type: "audio/wav" });
const { rerender } = render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={null} />);
const input = screen.getByLabelText<HTMLInputElement>("오디오");
fireEvent.change(input, { target: { files: [rejected] } });
let nativeValue = "C:\\fakepath\\voice.wav";
Object.defineProperty(input, "value", { configurable: true, get: () => nativeValue, set: (value: string) => { nativeValue = value; } });
// When
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" error="지원하지 않는 파일입니다." label="오디오" onChange={vi.fn()} value={null} />);
// Then
expect(input.value).toBe("");
});
test("FileField clears the native selection when the controlled value is a different File", () => {
// Given
const selected = new File(["source"], "profile.png", { type: "image/png" });
const cropped = new File(["cropped"], "profile.png", { type: "image/png" });
const { rerender } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={null} />);
const input = screen.getByLabelText<HTMLInputElement>("대표 이미지");
fireEvent.change(input, { target: { files: [selected] } });
let nativeValue = "C:\\fakepath\\profile.png";
Object.defineProperty(input, "value", { configurable: true, get: () => nativeValue, set: (value: string) => { nativeValue = value; } });
// When
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={cropped} />);
// Then
expect(input.value).toBe("");
});
test("FileField gives same visible file buttons field-specific accessible names", () => {
render(
<>
@@ -66,3 +148,102 @@ test("FileField visible container exposes the focus-within ring contract", () =>
expect(input).toHaveFocus();
expect(container.firstElementChild).toHaveClass("focus-within:ring-2", "focus-within:ring-ring", "focus-within:ring-offset-2");
});
test("FileField reserves a bounded preview frame for the current image value", async () => {
// Given
const firstImage = new File(["first"], "first.png", { type: "image/png" });
const secondImage = new File(["second"], "second.png", { type: "image/png" });
createObjectUrl.mockImplementation((blob) => blob === firstImage ? "blob:first" : "blob:second");
const { rerender } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={firstImage} />);
// When
const frame = screen.getByRole("figure", { name: "대표 이미지 업로드 미리보기 영역" });
// Then
expect(frame).toHaveClass("aspect-video", "max-h-64", "w-full", "max-w-md");
expect(await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" })).toHaveAttribute("src", "blob:first");
// When
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={secondImage} />);
// Then
expect(screen.getByRole("figure", { name: "대표 이미지 업로드 미리보기 영역" })).toBeInTheDocument();
await waitFor(() => expect(screen.getByRole("img", { name: "대표 이미지 업로드 미리보기" })).toHaveAttribute("src", "blob:second"));
});
test("FileField revokes every object URL created under StrictMode", async () => {
// Given
const image = new File(["image"], "profile.png", { type: "image/png" });
// When
const { unmount } = render(
<StrictMode>
<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={image} />
</StrictMode>,
);
await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" });
unmount();
// Then
const createdUrls = createObjectUrl.mock.results.map(({ value }) => value);
const revokedUrls = revokeObjectUrl.mock.calls.map(([url]) => url);
expect(new Set(revokedUrls)).toEqual(new Set(createdUrls));
});
test("FileField previews an image and revokes each owned object URL when its controlled value changes", async () => {
// Given
const firstImage = new File(["first"], "first.png", { type: "image/png" });
const secondImage = new File(["second"], "second.png", { type: "image/png" });
const onChange = vi.fn();
const { rerender, unmount } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={firstImage} />);
// When
const preview = await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" });
const firstUrl = preview.getAttribute("src");
// Then
expect(firstUrl).toMatch(/^blob:preview-/);
expect(createObjectUrl).toHaveBeenCalledWith(firstImage);
expect(preview).toHaveClass("h-full", "w-full", "object-contain");
// When
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={secondImage} />);
// Then
await waitFor(() => expect(screen.getByRole("img", { name: "대표 이미지 업로드 미리보기" }).getAttribute("src")).not.toBe(firstUrl));
const secondUrl = screen.getByRole("img", { name: "대표 이미지 업로드 미리보기" }).getAttribute("src");
expect(createObjectUrl).toHaveBeenCalledWith(secondImage);
expect(revokeObjectUrl).toHaveBeenCalledWith(firstUrl);
// When
fireEvent.click(screen.getByRole("button", { name: "대표 이미지 선택 취소" }));
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={null} />);
// Then
expect(onChange).toHaveBeenCalledWith(null);
expect(screen.queryByRole("img", { name: "대표 이미지 업로드 미리보기" })).not.toBeInTheDocument();
await waitFor(() => expect(revokeObjectUrl).toHaveBeenCalledWith(secondUrl));
// When
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={firstImage} />);
const finalPreview = await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" });
const finalUrl = finalPreview.getAttribute("src");
unmount();
// Then
expect(revokeObjectUrl).toHaveBeenCalledWith(finalUrl);
});
test("FileField keeps non-image files filename-only without creating object URLs", () => {
// Given
const audio = new File(["audio"], "voice.mp3", { type: "audio/mpeg" });
// When
render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={audio} />);
// Then
expect(screen.getByText("voice.mp3")).toBeInTheDocument();
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(createObjectUrl).not.toHaveBeenCalled();
expect(revokeObjectUrl).not.toHaveBeenCalled();
});

View File

@@ -8,6 +8,8 @@ test("PageState exposes accessible loading, empty, error, retry, and content sta
const { rerender } = render(<PageState description="자료를 불러오는 중입니다." state="loading" title="불러오는 중" />);
expect(screen.getByRole("status")).toHaveTextContent("불러오는 중");
expect(screen.getByRole("heading", { name: "불러오는 중" })).toHaveClass("break-keep");
expect(screen.getByText("자료를 불러오는 중입니다.")).toHaveClass("break-keep");
rerender(<PageState description="조건에 맞는 자료가 없습니다." state="empty" title="자료 없음" />);

View File

@@ -0,0 +1,33 @@
import { useId } from "react";
export type CanPriceFieldProps = {
readonly error: string | undefined;
readonly errorId: string;
readonly onChange: (value: string) => void;
readonly value: string;
};
export function CanPriceField({ error, errorId, onChange, value }: CanPriceFieldProps) {
const inputId = useId();
const unitId = `${inputId}-unit`;
return (
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold" htmlFor={inputId}></label>
<input
aria-describedby={`${unitId}${error === undefined ? "" : ` ${errorId}`}`}
aria-invalid={error === undefined ? undefined : true}
className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground"
id={inputId}
inputMode="numeric"
min="0"
onChange={(event) => onChange(event.currentTarget.value)}
step="1"
type="number"
value={value}
/>
<p className="text-sm text-muted-foreground" id={unitId}>단위: </p>
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useId, useRef } from "react";
import { useId, useLayoutEffect, useRef } from "react";
export type FileFieldProps = {
readonly accept: string;
@@ -16,8 +16,33 @@ export function FileField({ accept, acceptDescription, description, error, label
const acceptId = useId();
const errorId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const previewRef = useRef<HTMLImageElement>(null);
const isImage = value !== null && value.type.startsWith("image/");
const describedBy = [description === undefined ? null : descriptionId, acceptId, error === undefined ? null : errorId].filter((id): id is string => id !== null).join(" ");
useLayoutEffect(() => {
if (value === null || !value.type.startsWith("image/")) {
return undefined;
}
const objectUrl = URL.createObjectURL(value);
const previewElement = previewRef.current;
previewElement?.setAttribute("src", objectUrl);
return () => {
URL.revokeObjectURL(objectUrl);
previewElement?.removeAttribute("src");
};
}, [value]);
useLayoutEffect(() => {
const input = inputRef.current;
const selectedFile = input?.files?.[0];
if (input !== null && selectedFile !== undefined && selectedFile !== value) {
input.value = "";
}
});
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const files = event.currentTarget.files;
onChange(files === null ? null : files[0] ?? null);
@@ -33,16 +58,21 @@ export function FileField({ accept, acceptDescription, description, error, label
return (
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card p-4 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
<label className="text-sm font-semibold" htmlFor={inputId}>{label}</label>
{description === undefined ? null : <p className="text-sm text-muted-foreground" id={descriptionId}>{description}</p>}
<p className="text-sm text-muted-foreground" id={acceptId}>{acceptDescription}</p>
{description === undefined ? null : <p className="break-keep text-sm text-muted-foreground" id={descriptionId}>{description}</p>}
<p className="break-keep text-sm text-muted-foreground" id={acceptId}>{acceptDescription}</p>
<input accept={accept} aria-describedby={describedBy} aria-invalid={error === undefined ? undefined : true} className="sr-only" id={inputId} onChange={handleChange} ref={inputRef} type="file" />
<button aria-label={`${label} 파일 선택`} className="w-fit rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => inputRef.current?.click()} type="button"> </button>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">{value === null ? "선택된 파일 없음" : value.name}</span>
{value === null ? null : (
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={clearSelection} type="button"> </button>
<button aria-label={`${label} 선택 취소`} className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={clearSelection} type="button"> </button>
)}
</div>
{isImage ? (
<figure aria-label={`${label} 업로드 미리보기 영역`} className="aspect-video max-h-64 w-full max-w-md overflow-hidden rounded-md border border-border bg-muted p-2">
<img alt={`${label} 업로드 미리보기`} className="h-full w-full object-contain" ref={previewRef} />
</figure>
) : null}
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
</div>
);

View File

@@ -37,22 +37,22 @@ export function PageState(props: PageStateProps) {
case "loading":
return (
<section className="rounded-lg border border-border bg-card p-6" role="status">
<h2 className="text-xl font-semibold">{props.title}</h2>
{props.description === undefined ? null : <p className="mt-2 text-sm text-muted-foreground">{props.description}</p>}
<h2 className="break-keep text-xl font-semibold">{props.title}</h2>
{props.description === undefined ? null : <p className="mt-2 break-keep text-sm text-muted-foreground">{props.description}</p>}
</section>
);
case "empty":
return (
<section className="rounded-lg border border-border bg-card p-6" role="status">
<h2 className="text-xl font-semibold">{props.title}</h2>
{props.description === undefined ? null : <p className="mt-2 text-sm text-muted-foreground">{props.description}</p>}
<h2 className="break-keep text-xl font-semibold">{props.title}</h2>
{props.description === undefined ? null : <p className="mt-2 break-keep text-sm text-muted-foreground">{props.description}</p>}
</section>
);
case "error":
return (
<section className="rounded-lg border border-destructive bg-card p-6 text-destructive" role="alert">
<h2 className="text-xl font-semibold">{props.title}</h2>
{props.description === undefined ? null : <p className="mt-2 text-sm">{props.description}</p>}
<h2 className="break-keep text-xl font-semibold">{props.title}</h2>
{props.description === undefined ? null : <p className="mt-2 break-keep text-sm">{props.description}</p>}
{props.onRetry === undefined ? null : (
<button className="mt-4 rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={props.onRetry} type="button">