feat(ai-character): 오디오 콘텐츠 폼 입력 UX 정렬
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />}
|
||||
|
||||
54
src/features/audio-contents/components/AudioTagInput.tsx
Normal file
54
src/features/audio-contents/components/AudioTagInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: "힐링,집중" });
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" })] } });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user