feat(ai-character): 오디오 콘텐츠 폼 입력 UX 정렬

This commit is contained in:
Yu Sung
2026-08-04 12:20:04 +09:00
parent a213479e8e
commit b62c416fa1
13 changed files with 608 additions and 173 deletions

View File

@@ -0,0 +1,38 @@
import { selectionCardClassName } from "@/features/audio-contents/components/audio-content-form-helpers";
import type { AudioContentCreateSettings } from "@/features/audio-contents/components/audio-content-form-helpers";
type AudioContentCreateOptionsProps = {
readonly isPaid: boolean;
readonly onChange: (value: AudioContentCreateSettings) => void;
readonly value: AudioContentCreateSettings;
};
export function AudioContentCreateOptions({ isPaid, onChange, value }: AudioContentCreateOptionsProps) {
function updatePurchaseOption(purchaseOption: string) {
if (purchaseOption === "BOTH" || purchaseOption === "BUY_ONLY" || purchaseOption === "RENT_ONLY") {
onChange({ ...value, purchaseOption });
}
}
return (
<fieldset className="grid gap-3 rounded-lg border border-border bg-card p-4 sm:grid-cols-2">
<legend className="text-sm font-semibold"> </legend>
<label className={selectionCardClassName}><input checked={value.isAdult} className="size-4 accent-primary" onChange={(event) => onChange({ ...value, isAdult: event.currentTarget.checked })} type="checkbox" /> </label>
<label className={selectionCardClassName}><input checked={value.isCommentAvailable} className="size-4 accent-primary" onChange={(event) => onChange({ ...value, isCommentAvailable: event.currentTarget.checked })} type="checkbox" /> </label>
<label className={selectionCardClassName}><input checked={value.isFullDetailVisible} className="size-4 accent-primary" onChange={(event) => onChange({ ...value, isFullDetailVisible: event.currentTarget.checked })} type="checkbox" /> </label>
{isPaid ? (
<>
<fieldset aria-label="구매 옵션" className="grid gap-2 sm:col-span-2 sm:grid-cols-3">
<legend className="mb-2 text-sm font-semibold"> </legend>
<label className={selectionCardClassName}><input checked={value.purchaseOption === "BOTH"} className="size-4 accent-primary" name="purchaseOption" onChange={(event) => updatePurchaseOption(event.currentTarget.value)} type="radio" value="BOTH" />/</label>
<label className={selectionCardClassName}><input checked={value.purchaseOption === "BUY_ONLY"} className="size-4 accent-primary" name="purchaseOption" onChange={(event) => updatePurchaseOption(event.currentTarget.value)} type="radio" value="BUY_ONLY" /> </label>
<label className={selectionCardClassName}><input checked={value.purchaseOption === "RENT_ONLY"} className="size-4 accent-primary" name="purchaseOption" onChange={(event) => updatePurchaseOption(event.currentTarget.value)} type="radio" value="RENT_ONLY" /> </label>
</fieldset>
<label className={selectionCardClassName}><input checked={value.isGeneratePreview} className="size-4 accent-primary" onChange={(event) => onChange({ ...value, isGeneratePreview: event.currentTarget.checked, previewStartTime: event.currentTarget.checked ? value.previewStartTime : null, previewEndTime: event.currentTarget.checked ? value.previewEndTime : null })} type="checkbox" /> </label>
<label className={selectionCardClassName}><input checked={value.isPointAvailable} className="size-4 accent-primary" onChange={(event) => onChange({ ...value, isPointAvailable: event.currentTarget.checked })} type="checkbox" /> </label>
{value.isGeneratePreview ? <><label className="flex flex-col gap-2 text-sm font-semibold"> <input className="min-h-11 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onChange={(event) => onChange({ ...value, previewStartTime: event.currentTarget.value.length === 0 ? null : event.currentTarget.value })} pattern="[0-9]{2}:[0-9]{2}:[0-9]{2}" placeholder="예: 00:00:30" type="text" value={value.previewStartTime ?? ""} /></label><label className="flex flex-col gap-2 text-sm font-semibold"> <input className="min-h-11 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onChange={(event) => onChange({ ...value, previewEndTime: event.currentTarget.value.length === 0 ? null : event.currentTarget.value })} pattern="[0-9]{2}:[0-9]{2}:[0-9]{2}" placeholder="예: 01:00:05" type="text" value={value.previewEndTime ?? ""} /></label></> : null}
</>
) : null}
</fieldset>
);
}

View File

@@ -7,6 +7,8 @@ import { uploadAudioContent } from "@/features/audio-contents/api/upload-audio-c
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 type { AudioContentCreateSettings } from "@/features/audio-contents/components/audio-content-form-helpers";
import { AudioContentCreateOptions } from "@/features/audio-contents/components/AudioContentCreateOptions";
import { AudioTagInput } from "@/features/audio-contents/components/AudioTagInput";
import { AudioContentThemeSelect } from "@/features/audio-contents/components/AudioContentThemeSelect";
import { ReleaseScheduleField } from "@/features/audio-contents/components/ReleaseScheduleField";
import type { ReleaseScheduleValue } from "@/features/audio-contents/components/ReleaseScheduleField";
@@ -73,12 +75,15 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
const contentId = audio === undefined ? null : String(audio.contentId);
const isDirty = title !== (audio?.title ?? "") || detail !== (audio?.detail ?? "") || tags !== (audio?.tag ?? "") || price !== createInitialPrice(audio?.price) || audioFile !== null || coverImage !== null || themeId !== null || releaseSchedule.publishMode !== "immediate" || releaseSchedule.releaseDateTime !== "" || createSettings !== defaultAudioContentCreateSettings;
const isCoverSubmitBlocked = isCoverPreparing || cropSource !== null;
const isPaid = (parsePrice(price) ?? 0) > 0;
useEffect(() => () => cropSource?.release?.(), [cropSource]);
function updatePurchaseOption(value: string) {
if (value === "BOTH" || value === "BUY_ONLY" || value === "RENT_ONLY") {
setCreateSettings((current) => ({ ...current, purchaseOption: value }));
function changePrice(value: string) {
const nextPrice = formatPrice(value);
setPrice(nextPrice);
if (mode === "create" && nextPrice === "0") {
setCreateSettings((current) => ({ ...current, purchaseOption: "BOTH", isGeneratePreview: false, isPointAvailable: false, previewStartTime: null, previewEndTime: null }));
}
}
@@ -223,34 +228,18 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
<p className="text-sm text-muted-foreground">, , . .</p>
</div>
<form className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" aria-label={mode === "create" ? "오디오 콘텐츠 생성 입력 화면" : "오디오 콘텐츠 수정 입력 화면"} onSubmit={(event) => void submit(event)} ref={formRef}>
<FileField accept="image/jpeg,image/png" acceptDescription="JPEG 또는 PNG, 10MB 이하, 1:1 crop 후 최대 800×800px로 전송합니다." error={errors.cover} label="커버 이미지" onChange={(file) => void selectCoverImage(file)} value={coverImage} />
{mode === "create" ? <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} /> : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground"> .</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"><input aria-describedby={errors.title === undefined ? undefined : errorIds.title} aria-invalid={errors.title === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setTitle(event.currentTarget.value)} value={title} /></label>
{errors.title === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.title} role="alert">{errors.title}</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"> <textarea aria-describedby={errors.detail === undefined ? undefined : errorIds.detail} aria-invalid={errors.detail === undefined ? undefined : true} className="min-h-28 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setDetail(event.currentTarget.value)} value={detail} /></label>
{errors.detail === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.detail} role="alert">{errors.detail}</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"><input aria-describedby={errors.tags === undefined ? undefined : errorIds.tags} aria-invalid={errors.tags === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setTags(event.currentTarget.value)} value={tags} /></label>
{errors.tags === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.tags} role="alert">{errors.tags}</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(formatPrice(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>}
{mode === "create" ? (
<fieldset className="grid gap-3 rounded-lg border border-border bg-card p-4 sm:grid-cols-2">
<legend className="text-sm font-semibold"> </legend>
<label className="flex flex-col gap-2 text-sm font-semibold"> <select aria-label="구매 옵션" className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => updatePurchaseOption(event.currentTarget.value)} value={createSettings.purchaseOption}><option value="BOTH">/</option><option value="BUY_ONLY"> </option><option value="RENT_ONLY"> </option></select></label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.limited !== null} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, limited: checked ? 1 : null })); }} type="checkbox" /></label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isAdult} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isAdult: checked })); }} type="checkbox" /> </label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isGeneratePreview} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isGeneratePreview: checked })); }} type="checkbox" /> </label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isOnlyRental} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isOnlyRental: checked })); }} type="checkbox" /> </label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isPointAvailable} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isPointAvailable: checked })); }} type="checkbox" /> </label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isCommentAvailable} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isCommentAvailable: checked })); }} type="checkbox" /> </label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isFullDetailVisible} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isFullDetailVisible: checked })); }} type="checkbox" /> </label>
<label className="flex flex-col gap-2 text-sm font-semibold"> <input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => { const value = event.currentTarget.value; setCreateSettings((current) => ({ ...current, previewStartTime: value.length === 0 ? null : value })); }} value={createSettings.previewStartTime ?? ""} /></label>
<label className="flex flex-col gap-2 text-sm font-semibold"> <input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => { const value = event.currentTarget.value; setCreateSettings((current) => ({ ...current, previewEndTime: value.length === 0 ? null : value })); }} value={createSettings.previewEndTime ?? ""} /></label>
<label className="flex flex-col gap-2 text-sm font-semibold"> <input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => { const value = event.currentTarget.value; setCreateSettings((current) => ({ ...current, languageCode: value.length === 0 ? null : value })); }} value={createSettings.languageCode ?? ""} /></label>
</fieldset>
) : null}
<AudioTagInput error={errors.tags} errorId={errorIds.tags} 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>}
{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>}
{mode === "create" ? <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} /> : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground"> .</p>}
<FileField accept="image/jpeg,image/png" acceptDescription="JPEG 또는 PNG, 10MB 이하, 1:1 crop 후 최대 800×800px로 전송합니다." error={errors.cover} label="커버 이미지" onChange={(file) => void selectCoverImage(file)} value={coverImage} />
<p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">시리즈: 현재 .</p>
{errors.form === undefined ? null : <p className="text-sm font-semibold text-destructive" role="alert">{errors.form}</p>}
{uploadState.status === "idle" ? null : <UploadProgress fileName={audioFile?.name ?? coverImage?.name} onCancel={abortController === null ? undefined : () => abortController.abort()} onRetry={uploadState.status === "error" ? () => void submitWithCurrentState() : undefined} progress={uploadState.progress} status={uploadState.status} />}

View File

@@ -0,0 +1,54 @@
import { useId, useState } from "react";
type AudioTagInputProps = {
readonly error: string | undefined;
readonly errorId: string;
readonly onChange: (value: string) => void;
readonly value: string;
};
export function AudioTagInput({ error, errorId, onChange, value }: AudioTagInputProps) {
const [draft, setDraft] = useState("");
const inputId = useId();
const helpId = `${inputId}-help`;
const tags = value.split(",").map((tag) => tag.trim()).filter((tag) => tag.length > 0);
function commitDraft() {
const nextTag = draft.trim();
if (nextTag.length === 0) {
return;
}
onChange([...tags, nextTag].join(","));
setDraft("");
}
function changeDraft(nextDraft: string) {
const parts = nextDraft.split(",");
if (parts.length === 1) {
setDraft(nextDraft);
return;
}
const committedTags = parts.slice(0, -1).map((tag) => tag.trim()).filter((tag) => tag.length > 0);
if (committedTags.length > 0) {
onChange([...tags, ...committedTags].join(","));
}
setDraft(parts[parts.length - 1] ?? "");
}
return (
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold" htmlFor={inputId}></label>
<div className={`flex min-h-11 flex-wrap items-center gap-2 rounded-md border bg-card px-2 py-1 text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 ${error === undefined ? "border-input" : "border-destructive"}`}>
{tags.map((tag, index) => (
<span className="flex min-h-11 items-center rounded-md bg-muted pl-3 text-sm font-semibold" key={`${tag}-${index}`}>
{tag}
<button aria-label={`태그 ${tag} 삭제`} className="min-h-11 rounded-md px-3 text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={() => onChange(tags.filter((_, tagIndex) => tagIndex !== index).join(","))} type="button"></button>
</span>
))}
<input aria-describedby={`${helpId}${error === undefined ? "" : ` ${errorId}`}`} aria-invalid={error === undefined ? undefined : true} className="min-h-11 min-w-32 flex-1 bg-transparent px-2 text-base font-normal text-foreground outline-none" id={inputId} onChange={(event) => changeDraft(event.currentTarget.value)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); commitDraft(); } }} value={draft} />
</div>
<p className="text-sm text-muted-foreground" id={helpId}> Enter로 .</p>
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
</div>
);
}

View File

@@ -1,3 +1,5 @@
import { selectionCardClassName } from "@/features/audio-contents/components/audio-content-form-helpers";
export type ReleaseScheduleValue = {
readonly publishMode: "immediate" | "scheduled";
readonly releaseDateTime: string;
@@ -11,19 +13,18 @@ export function ReleaseScheduleField({ error, errorId, onChange, value }: { read
return (
<fieldset className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4">
<legend className="text-sm font-semibold"> </legend>
<label className="flex items-center gap-2 text-sm font-semibold">
<input checked={value.publishMode === "immediate"} name="publishMode" onChange={() => setMode("immediate")} type="radio" />
<label className={selectionCardClassName}>
<input checked={value.publishMode === "immediate"} className="size-4 accent-primary" name="publishMode" onChange={() => setMode("immediate")} type="radio" />
</label>
<label className="flex items-center gap-2 text-sm font-semibold">
<input checked={value.publishMode === "scheduled"} name="publishMode" onChange={() => setMode("scheduled")} type="radio" />
<label className={selectionCardClassName}>
<input checked={value.publishMode === "scheduled"} className="size-4 accent-primary" name="publishMode" onChange={() => setMode("scheduled")} type="radio" />
</label>
<label className="flex flex-col gap-2 text-sm font-semibold">
{value.publishMode === "scheduled" ? <label className="flex flex-col gap-2 text-sm font-semibold">
<input aria-describedby={error === undefined ? 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 disabled:bg-muted disabled:text-muted-foreground" disabled={value.publishMode === "immediate"} onChange={(event) => onChange({ publishMode: value.publishMode, releaseDateTime: event.currentTarget.value })} type="datetime-local" value={value.releaseDateTime} />
</label>
<p className="text-sm text-muted-foreground">Asia/Seoul .</p>
<input aria-describedby={error === undefined ? undefined : errorId} aria-invalid={error === undefined ? undefined : true} className="min-h-11 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onChange={(event) => onChange({ publishMode: value.publishMode, releaseDateTime: event.currentTarget.value })} type="datetime-local" value={value.releaseDateTime} />
</label> : null}
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
</fieldset>
);

View File

@@ -3,10 +3,10 @@ import type { AudioContentDetail } from "@/features/audio-contents/model/types";
import type { AudioContentCreateRequest, AudioContentUpdateRequest } from "@/features/audio-contents/schemas/audio-content-schema";
import { validateAudioCoverFile } from "@/features/audio-contents/validation/audio-cover-policy";
import { validateAudioFile } from "@/shared/validation/audio-file-policy";
import { formatCanPriceInput, parseCanPriceInput } from "@/shared/validation/can-price";
import { parseCanPriceInput } from "@/shared/validation/can-price";
export function createInitialPrice(price: number | undefined): string {
return price === undefined ? "" : `${price.toLocaleString("ko-KR")}`;
return String(price ?? 0);
}
export function parsePrice(value: string): number | null {
@@ -14,9 +14,15 @@ export function parsePrice(value: string): number | null {
}
export function formatPrice(value: string): string {
return formatCanPriceInput(value);
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}$/;
function toUtcMilliseconds(value: string, offsetHours: number): number {

View File

@@ -1,8 +1,8 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { expect, test } from "vitest";
import { AudioContentFormPage } from "@/features/audio-contents/pages/AudioContentFormPage";
import type { UploadAudioContentRequest } from "@/features/audio-contents/components/AudioContentForm";
import { AudioContentFormPage } from "@/features/audio-contents/pages/AudioContentFormPage";
import type { CapturedRequest } from "@/features/audio-contents/tests/audio-form-test-support";
import { createFormClient, fileWithSize, readJsonPart, requireFormData } from "@/features/audio-contents/tests/audio-form-test-support";
@@ -13,97 +13,228 @@ function createSuccessfulUpload(uploadedBodies: XMLHttpRequestBodyInit[]): Uploa
};
}
test("AudioContentFormPage serializes editable create settings and excludes unsupported create fields", async () => {
// Given
function renderCreateForm(uploadedBodies: XMLHttpRequestBodyInit[] = []) {
const requests: CapturedRequest[] = [];
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
const contentFile = fileWithSize("voice.m4a", "audio/x-m4a", 1_024_000_000);
const coverImage = new File(["cover"], "cover.png", { type: "image/png" });
window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
render(
<AudioContentFormPage
apiClient={createFormClient(requests)}
characterId="101"
createCropSource={(file) => Promise.resolve({ file, height: 1200, previewUrl: "blob:cover", width: 1200 })}
createCropSource={(file) => Promise.resolve({ file, height: 800, previewUrl: "blob:cover", width: 800 })}
renderCrop={(request) => Promise.resolve(request.file)}
uploadAudioContentRequest={createSuccessfulUpload(uploadedBodies)}
/>,
);
}
// When
async function fillRequiredCreateFields() {
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "옵션 오디오" } });
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "옵션 설명" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "옵션" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "99999캔" } });
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("구매 옵션"), { target: { value: "RENT_ONLY" } });
fireEvent.click(screen.getByLabelText("기간제"));
fireEvent.click(screen.getByLabelText("성인 콘텐츠"));
fireEvent.click(screen.getByLabelText("미리듣기 생성"));
fireEvent.click(screen.getByLabelText("대여 전용"));
fireEvent.click(screen.getByLabelText("포인트 사용"));
fireEvent.click(screen.getByLabelText("댓글 허용"));
fireEvent.click(screen.getByLabelText("상세 정보 전체 공개"));
fireEvent.change(screen.getByLabelText("미리듣기 시작"), { target: { value: "00:30" } });
fireEvent.change(screen.getByLabelText("미리듣기 종료"), { target: { value: "01:00" } });
fireEvent.change(screen.getByLabelText("언어 코드"), { target: { value: "ko" } });
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [contentFile] } });
fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [coverImage] } });
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
await waitFor(() => expect(screen.queryByRole("button", { name: "적용" })).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(screen.getByLabelText("구매 옵션")).toBeInTheDocument();
const body = requireFormData(uploadedBodies.at(-1));
expect(await readJsonPart(body.get("request"))).toEqual({
title: "옵션 오디오",
detail: "옵션 설명",
tags: "옵션",
price: 99999,
purchaseOption: "RENT_ONLY",
limited: 1,
releaseDate: null,
themeId: 7,
isAdult: true,
isGeneratePreview: true,
isOnlyRental: true,
isPointAvailable: true,
isCommentAvailable: true,
isFullDetailVisible: false,
previewStartTime: "00:30",
previewEndTime: "01:00",
languageCode: "ko",
});
expect(await readJsonPart(body.get("request"))).not.toHaveProperty("isActive");
expect(await readJsonPart(body.get("request"))).not.toHaveProperty("seriesIds");
expect(await readJsonPart(body.get("request"))).not.toHaveProperty("timezone");
});
test.each([
{ label: "음수 가격", value: "-1", expected: "-1" },
{ label: "소수 가격", value: "1.5", expected: "1.5" },
])("AudioContentFormPage keeps raw price input and blocks upload for %s", async ({ label, value, expected }) => {
// 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: label } });
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 } });
await waitFor(() => expect(screen.queryByRole("button", { name: "적용" })).not.toBeInTheDocument());
}
test("AudioContentFormPage commits tag chips with Enter and comma without submitting, then removes one with a native button", async () => {
// Given
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
renderCreateForm(uploadedBodies);
const tagInput = await screen.findByLabelText("태그");
// When
fireEvent.change(tagInput, { target: { value: "상담" } });
fireEvent.keyDown(tagInput, { key: "Enter" });
fireEvent.change(tagInput, { target: { value: "힐링" } });
fireEvent.keyDown(tagInput, { key: "," });
// Then
expect(tagInput).toHaveValue("");
expect(uploadedBodies).toHaveLength(0);
const removeConsulting = screen.getByRole("button", { name: "태그 상담 삭제" });
expect(removeConsulting.tagName).toBe("BUTTON");
expect(screen.getByRole("button", { name: "태그 힐링 삭제" })).toBeInTheDocument();
// When
fireEvent.click(removeConsulting);
// Then
expect(screen.queryByRole("button", { name: "태그 상담 삭제" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "태그 힐링 삭제" })).toBeInTheDocument();
});
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 }) => {
// Given
renderCreateForm();
const priceInput = await screen.findByLabelText("가격");
expect(priceInput).toHaveValue(0);
expect(priceInput).toHaveAttribute("type", "number");
expect(priceInput).toHaveAttribute("min", "0");
expect(priceInput).toHaveAttribute("step", "1");
// When
fireEvent.change(priceInput, { target: { value } });
// Then
expect(priceInput).toHaveValue(Number(expected));
});
test("AudioContentFormPage shows paid options only for a positive price", async () => {
// Given
renderCreateForm();
const priceInput = await screen.findByLabelText("가격");
// When
fireEvent.change(priceInput, { target: { value: "0" } });
// Then
expect(screen.queryByRole("group", { name: "구매 옵션" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("미리듣기 생성")).not.toBeInTheDocument();
expect(screen.queryByLabelText("포인트 사용")).not.toBeInTheDocument();
expect(screen.queryByLabelText("미리듣기 시작")).not.toBeInTheDocument();
expect(screen.queryByLabelText("미리듣기 종료")).not.toBeInTheDocument();
// When
fireEvent.change(priceInput, { target: { value: "1" } });
// Then
expect(screen.getByRole("group", { name: "구매 옵션" })).toBeInTheDocument();
expect(screen.getByLabelText("미리듣기 생성")).toBeInTheDocument();
expect(screen.getByLabelText("포인트 사용")).toBeInTheDocument();
});
test("AudioContentFormPage resets paid settings and retains removed-control defaults when price returns to zero", async () => {
// Given
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
renderCreateForm(uploadedBodies);
await fillRequiredCreateFields();
const priceInput = screen.getByLabelText("가격");
fireEvent.change(priceInput, { target: { value: "1" } });
fireEvent.click(screen.getByRole("radio", { name: "대여 전용" }));
fireEvent.click(screen.getByLabelText("미리듣기 생성"));
fireEvent.click(screen.getByLabelText("포인트 사용"));
fireEvent.change(screen.getByLabelText("미리듣기 시작"), { target: { value: "00:00:30" } });
fireEvent.change(screen.getByLabelText("미리듣기 종료"), { target: { value: "01:00:05" } });
// When
fireEvent.change(priceInput, { target: { value: "0" } });
// Then
expect(screen.queryByRole("group", { name: "구매 옵션" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("미리듣기 생성")).not.toBeInTheDocument();
expect(screen.queryByLabelText("포인트 사용")).not.toBeInTheDocument();
expect(screen.queryByLabelText("미리듣기 시작")).not.toBeInTheDocument();
expect(screen.queryByLabelText("미리듣기 종료")).not.toBeInTheDocument();
expect(screen.queryByLabelText("기간제")).not.toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: "대여 전용" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("언어 코드")).not.toBeInTheDocument();
// When
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(screen.getByLabelText("가격")).toHaveValue(expected);
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(uploadedBodies).toHaveLength(0);
await waitFor(() => expect(uploadedBodies).toHaveLength(1));
expect(await readJsonPart(requireFormData(uploadedBodies.at(-1)).get("request"))).toMatchObject({
price: 0,
purchaseOption: "BOTH",
limited: null,
isGeneratePreview: false,
isOnlyRental: false,
isPointAvailable: false,
previewStartTime: null,
previewEndTime: null,
languageCode: null,
});
});
test("AudioContentFormPage reveals preview duration offset inputs only when preview generation is enabled and submits full HH:MM:SS values", async () => {
// Given
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
renderCreateForm(uploadedBodies);
await fillRequiredCreateFields();
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "1" } });
expect(screen.queryByLabelText("미리듣기 시작")).not.toBeInTheDocument();
expect(screen.queryByLabelText("미리듣기 종료")).not.toBeInTheDocument();
// When
fireEvent.click(screen.getByLabelText("미리듣기 생성"));
// Then
const previewStart = screen.getByLabelText("미리듣기 시작");
const previewEnd = screen.getByLabelText("미리듣기 종료");
expect(previewStart).toHaveAttribute("type", "text");
expect(previewStart).toHaveAttribute("placeholder", "예: 00:00:30");
expect(previewStart).toHaveAttribute("pattern", "[0-9]{2}:[0-9]{2}:[0-9]{2}");
expect(previewStart).not.toHaveAttribute("step");
expect(previewEnd).toHaveAttribute("type", "text");
expect(previewEnd).toHaveAttribute("placeholder", "예: 01:00:05");
expect(previewEnd).toHaveAttribute("pattern", "[0-9]{2}:[0-9]{2}:[0-9]{2}");
expect(previewEnd).not.toHaveAttribute("step");
// When
fireEvent.change(previewStart, { target: { value: "00:00:30" } });
fireEvent.change(previewEnd, { target: { value: "01:00:05" } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
await waitFor(() => expect(uploadedBodies).toHaveLength(1));
expect(await readJsonPart(requireFormData(uploadedBodies.at(-1)).get("request"))).toMatchObject({
previewStartTime: "00:00:30",
previewEndTime: "01:00:05",
});
});
test("AudioContentFormPage conditionally renders and clears the native scheduled release datetime", async () => {
// Given
renderCreateForm();
await screen.findByLabelText("즉시 공개");
expect(screen.queryByLabelText("예약 공개일")).not.toBeInTheDocument();
// When
fireEvent.click(screen.getByLabelText("예약 공개"));
// Then
const scheduledInput = screen.getByLabelText("예약 공개일");
expect(scheduledInput).toHaveAttribute("type", "datetime-local");
// When
fireEvent.change(scheduledInput, { target: { value: "2026-08-05T12:00" } });
fireEvent.click(screen.getByLabelText("즉시 공개"));
// Then
expect(screen.queryByLabelText("예약 공개일")).not.toBeInTheDocument();
// When
fireEvent.click(screen.getByLabelText("예약 공개"));
// Then
expect(screen.getByLabelText("예약 공개일")).toHaveValue("");
});
test("AudioContentFormPage turns edit tags into removable chips and serializes the remaining chips as a comma string", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" />);
// Then
await waitFor(() => expect(screen.queryByRole("button", { name: "태그 상담 삭제" })).toBeInTheDocument());
expect(screen.getByRole("button", { name: "태그 힐링 삭제" })).toBeInTheDocument();
// When
fireEvent.click(screen.getByRole("button", { name: "태그 상담 삭제" }));
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "집중" } });
fireEvent.keyDown(screen.getByLabelText("태그"), { key: "Enter" });
fireEvent.click(screen.getByRole("button", { name: "저장" }));
// Then
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9001"));
expect(await readJsonPart(requireFormData(requests.at(-1)?.body).get("request"))).toMatchObject({ tags: "힐링,집중" });
});

View File

@@ -42,7 +42,6 @@ test("AudioContentFormPage update omits unsupported controls and soft delete nav
// When
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "수정 오디오" } });
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "수정 설명" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "수정" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "0" } });
fireEvent.click(screen.getByRole("button", { name: "저장" }));
@@ -57,7 +56,7 @@ test("AudioContentFormPage update omits unsupported controls and soft delete nav
expect(await readJsonPart(updateBody.get("request"))).toEqual({
title: "수정 오디오",
detail: "수정 설명",
tags: "수정",
tags: "상담,힐링",
price: 0,
isAdult: false,
isPointAvailable: true,

View File

@@ -60,7 +60,7 @@ afterEach(() => {
async function fillValidCreateForm(): Promise<void> {
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "재시도 오디오" } });
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "재시도 설명" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "재시도" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "재시도," } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100" } });
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [new File(["audio"], "voice.aac", { type: "audio/aac" })] } });
@@ -293,7 +293,7 @@ test("AudioContentFormPage ignores stale cover crop sources and uploads the late
});
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "최신 커버 오디오" } });
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "최신 커버 설명" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "최신" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "최신," } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100" } });
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [new File(["audio"], "voice.aac", { type: "audio/aac" })] } });

View File

@@ -16,7 +16,21 @@ function createSuccessfulUpload(uploadedBodies: XMLHttpRequestBodyInit[]): Uploa
};
}
test("AudioContentFormPage creates immediate audio with required theme, crop policy, 캔 price, and no unsupported upload limits", async () => {
test("AudioContentFormPage renders upload fields in the requested order", async () => {
const expectedOrder = ["커버 이미지", "오디오 파일", "제목", "상세 설명", "태그", "오디오 테마", "가격", "생성 옵션", "공개 일정"];
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" />);
const form = await screen.findByRole("form", { name: "오디오 콘텐츠 생성 입력 화면" });
const actualOrder = Array.from(form.querySelectorAll("label, legend"))
.map((element) => element.childNodes[0]?.textContent?.trim())
.filter((label): label is string => label !== undefined && expectedOrder.includes(label));
expect(actualOrder).toEqual(expectedOrder);
});
test("AudioContentFormPage creates immediate audio with required theme, crop policy, numeric price, and no unsupported upload limits", async () => {
// Given
const requests: CapturedRequest[] = [];
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
@@ -38,9 +52,12 @@ test("AudioContentFormPage creates immediate audio with required theme, crop pol
// When
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "달빛 상담 오디오" } });
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "잠들기 전 듣는 상담 오디오" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "상담,힐링" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "1,000캔" } });
expect(screen.getByLabelText("가격")).toHaveValue("1,000캔");
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "상담" } });
fireEvent.keyDown(screen.getByLabelText("태그"), { key: "Enter" });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "힐링" } });
fireEvent.keyDown(screen.getByLabelText("태그"), { key: "," });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "1000" } });
expect(screen.getByLabelText("가격")).toHaveValue(1000);
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [contentFile] } });
fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [coverImage] } });
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
@@ -50,7 +67,7 @@ test("AudioContentFormPage creates immediate audio with required theme, crop pol
// Then
expect(await screen.findByText("테마를 선택하세요.")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
expect(screen.getByLabelText("예약 공개일")).toBeDisabled();
expect(screen.queryByLabelText("예약 공개일")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "생성" }));
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9301"));
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ aspect: 1, outputWidth: 800, outputHeight: 800 }));
@@ -80,7 +97,7 @@ test("AudioContentFormPage creates immediate audio with required theme, crop pol
expect(screen.getByText("제목, 상세 설명, 태그는 저장 전 운영 기준에 맞게 검토하세요. 업로드 제한은 안내된 파일 정책을 따릅니다.")).toBeInTheDocument();
});
test("AudioContentFormPage serializes editable create settings and excludes unsupported create fields", async () => {
test("AudioContentFormPage serializes supported create settings with removed-control defaults and excludes unsupported create fields", async () => {
// Given
const requests: CapturedRequest[] = [];
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
@@ -101,19 +118,12 @@ test("AudioContentFormPage serializes editable create settings and excludes unsu
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "옵션 오디오" } });
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "옵션 설명" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "옵션" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "99999캔" } });
fireEvent.keyDown(screen.getByLabelText("태그"), { key: "Enter" });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "99999" } });
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("구매 옵션"), { target: { value: "RENT_ONLY" } });
fireEvent.click(screen.getByLabelText("기간제"));
fireEvent.click(screen.getByLabelText("성인 콘텐츠"));
fireEvent.click(screen.getByLabelText("미리듣기 생성"));
fireEvent.click(screen.getByLabelText("대여 전용"));
fireEvent.click(screen.getByLabelText("포인트 사용"));
fireEvent.click(screen.getByLabelText("댓글 허용"));
fireEvent.click(screen.getByLabelText("상세 정보 전체 공개"));
fireEvent.change(screen.getByLabelText("미리듣기 시작"), { target: { value: "00:30" } });
fireEvent.change(screen.getByLabelText("미리듣기 종료"), { target: { value: "01:00" } });
fireEvent.change(screen.getByLabelText("언어 코드"), { target: { value: "ko" } });
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [contentFile] } });
fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [coverImage] } });
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
@@ -121,26 +131,29 @@ test("AudioContentFormPage serializes editable create settings and excludes unsu
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(screen.getByLabelText("구매 옵션")).toBeInTheDocument();
await waitFor(() => expect(uploadedBodies).toHaveLength(1));
expect(screen.queryByLabelText("기간제")).not.toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: "대여 전용" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("언어 코드")).not.toBeInTheDocument();
const body = requireFormData(uploadedBodies.at(-1));
expect(await readJsonPart(body.get("request"))).toEqual({
title: "옵션 오디오",
detail: "옵션 설명",
tags: "옵션",
price: 99999,
purchaseOption: "RENT_ONLY",
limited: 1,
purchaseOption: "BOTH",
limited: null,
releaseDate: null,
themeId: 7,
isAdult: true,
isGeneratePreview: true,
isOnlyRental: true,
isPointAvailable: true,
isGeneratePreview: false,
isOnlyRental: false,
isPointAvailable: false,
isCommentAvailable: true,
isFullDetailVisible: false,
previewStartTime: "00:30",
previewEndTime: "01:00",
languageCode: "ko",
previewStartTime: null,
previewEndTime: null,
languageCode: null,
});
expect(await readJsonPart(body.get("request"))).not.toHaveProperty("isActive");
expect(await readJsonPart(body.get("request"))).not.toHaveProperty("seriesIds");
@@ -148,9 +161,9 @@ test("AudioContentFormPage serializes editable create settings and excludes unsu
});
test.each([
{ label: "음수 가격", value: "-1", expected: "-1" },
{ label: "소수 가격", value: "1.5", expected: "1.5" },
])("AudioContentFormPage keeps raw price input and blocks upload for %s", async ({ label, value, expected }) => {
{ label: "음수 기호", value: "-1", expected: "0" },
{ label: "소수", value: "1.5", expected: "15" },
])("AudioContentFormPage removes non-digits from price input for %s", async ({ label, value, expected }) => {
// Given
const requests: CapturedRequest[] = [];
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
@@ -167,11 +180,8 @@ test.each([
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
await waitFor(() => expect(screen.getByText("cover.png")).toBeInTheDocument());
fireEvent.change(screen.getByLabelText("가격"), { target: { value } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(screen.getByLabelText("가격")).toHaveValue(expected);
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(screen.getByLabelText("가격")).toHaveValue(Number(expected));
expect(uploadedBodies).toHaveLength(0);
});
@@ -188,6 +198,7 @@ test("AudioContentFormPage validates audio boundary and serializes scheduled Asi
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: "0" } });
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [fileWithSize("voice.wav", "audio/wav", 10)] } });
@@ -237,15 +248,17 @@ test("AudioContentFormPage blocks prices outside the CAN range before upload", a
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: "100,000" } });
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);
});
@@ -259,26 +272,27 @@ test("AudioContentFormPage links validation errors and focuses the first invalid
// When
const titleInput = await screen.findByLabelText("제목");
const coverInput = screen.getByLabelText("커버 이미지");
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
const titleError = await screen.findByText("제목을 입력하세요.");
const detailError = screen.getByText("상세 설명을 입력하세요.");
const tagsError = screen.getByText("태그를 입력하세요.");
const priceError = screen.getByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.");
const themeError = screen.getByText("테마를 선택하세요.");
expect(titleError).toHaveAttribute("id", "audio-content-title-error");
expect(detailError).toHaveAttribute("id", "audio-content-detail-error");
expect(tagsError).toHaveAttribute("id", "audio-content-tags-error");
expect(priceError).toHaveAttribute("id", "audio-content-price-error");
expect(themeError).toHaveAttribute("id", "audio-content-theme-error");
expect(titleInput).toHaveAttribute("aria-describedby", "audio-content-title-error");
expect(screen.getByLabelText("상세 설명")).toHaveAttribute("aria-describedby", "audio-content-detail-error");
expect(screen.getByLabelText("태그")).toHaveAttribute("aria-describedby", "audio-content-tags-error");
expect(screen.getByLabelText("가격")).toHaveAttribute("aria-describedby", "audio-content-price-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("오디오 테마")).toHaveAttribute("aria-describedby", "audio-content-theme-error");
expect(titleInput).toHaveAttribute("aria-invalid", "true");
await waitFor(() => expect(titleInput).toHaveFocus());
expect(coverInput).toHaveAttribute("aria-invalid", "true");
await waitFor(() => expect(coverInput).toHaveFocus());
expect(uploadedBodies).toHaveLength(0);
});