fix(ai-character): 폼 도메인 검증 복구
This commit is contained in:
@@ -4,10 +4,12 @@ import type { AudioContentCreateSettings } from "@/features/audio-contents/compo
|
||||
type AudioContentCreateOptionsProps = {
|
||||
readonly isPaid: boolean;
|
||||
readonly onChange: (value: AudioContentCreateSettings) => void;
|
||||
readonly previewEndError?: string;
|
||||
readonly previewStartError?: string;
|
||||
readonly value: AudioContentCreateSettings;
|
||||
};
|
||||
|
||||
export function AudioContentCreateOptions({ isPaid, onChange, value }: AudioContentCreateOptionsProps) {
|
||||
export function AudioContentCreateOptions({ isPaid, onChange, previewEndError, previewStartError, value }: AudioContentCreateOptionsProps) {
|
||||
function updatePurchaseOption(purchaseOption: string) {
|
||||
if (purchaseOption === "BOTH" || purchaseOption === "BUY_ONLY" || purchaseOption === "RENT_ONLY") {
|
||||
onChange({ ...value, purchaseOption });
|
||||
@@ -30,7 +32,7 @@ export function AudioContentCreateOptions({ isPaid, onChange, value }: AudioCont
|
||||
</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}
|
||||
{value.isGeneratePreview ? <><label className="flex flex-col gap-2 text-sm font-semibold">미리듣기 시작<input aria-describedby={previewStartError === undefined ? undefined : "audio-content-preview-start-error"} aria-invalid={previewStartError === 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({ ...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>{previewStartError === undefined ? null : <p className="text-sm font-semibold text-destructive" id="audio-content-preview-start-error" role="alert">{previewStartError}</p>}<label className="flex flex-col gap-2 text-sm font-semibold">미리듣기 종료<input aria-describedby={previewEndError === undefined ? undefined : "audio-content-preview-end-error"} aria-invalid={previewEndError === 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({ ...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>{previewEndError === undefined ? null : <p className="text-sm font-semibold text-destructive" id="audio-content-preview-end-error" role="alert">{previewEndError}</p>}</> : null}
|
||||
</>
|
||||
) : null}
|
||||
</fieldset>
|
||||
|
||||
@@ -34,7 +34,7 @@ import type { UploadProgressStatus } from "@/shared/ui/upload-progress";
|
||||
|
||||
export type UploadAudioContentRequest = <Data>(options: UploadAudioContentOptions<Data>) => Promise<Data>;
|
||||
|
||||
type FieldName = "audio" | "cover" | "detail" | "form" | "price" | "releaseDate" | "tags" | "theme" | "title";
|
||||
type FieldName = "audio" | "cover" | "detail" | "form" | "previewEnd" | "previewStart" | "price" | "releaseDate" | "tags" | "theme" | "title";
|
||||
type FieldErrors = Partial<Record<FieldName, string>>;
|
||||
|
||||
type UploadState = {
|
||||
@@ -51,6 +51,8 @@ const errorIds = {
|
||||
title: "audio-content-title-error",
|
||||
} as const;
|
||||
|
||||
const previewTimePattern = /^\d{2}:\d{2}:\d{2}$/;
|
||||
|
||||
export function AudioContentForm({ apiClient, audio, characterId, createCropSource, mode, renderCrop, themes, uploadAuth, uploadAudioContentRequest = uploadAudioContent }: { readonly apiClient: ApiClient; readonly audio?: AudioContentDetail; readonly characterId: string; readonly createCropSource: (file: File) => Promise<CropSourceImage>; readonly mode: "create" | "edit"; readonly renderCrop?: (request: CropRenderRequest) => Promise<File>; readonly themes: readonly AudioContentTheme[]; readonly uploadAuth?: UploadAuthDependencies; readonly uploadAudioContentRequest?: UploadAudioContentRequest }) {
|
||||
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||
const [createSettings, setCreateSettings] = useState<AudioContentCreateSettings>(defaultAudioContentCreateSettings);
|
||||
@@ -126,6 +128,8 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
|
||||
audio: audioErrorMessage(audioFile, mode),
|
||||
cover: isCoverSubmitBlocked ? "이미지 처리가 끝난 뒤 저장하세요." : coverErrorMessage(coverImage, mode),
|
||||
detail: detail.trim().length === 0 ? "상세 설명을 입력하세요." : undefined,
|
||||
previewEnd: mode === "create" && createSettings.isGeneratePreview && createSettings.previewEndTime !== null && !previewTimePattern.test(createSettings.previewEndTime) ? "미리듣기 시간은 HH:MM:SS 형식으로 입력하세요." : undefined,
|
||||
previewStart: mode === "create" && createSettings.isGeneratePreview && createSettings.previewStartTime !== null && !previewTimePattern.test(createSettings.previewStartTime) ? "미리듣기 시간은 HH:MM:SS 형식으로 입력하세요." : undefined,
|
||||
price: parsedPrice === null || parsedPrice < 0 || parsedPrice > CAN_PRICE_MAX || !Number.isInteger(parsedPrice) ? "가격은 0 이상 99,999 이하 정수 캔으로 입력하세요." : undefined,
|
||||
releaseDate: releaseSchedule.publishMode === "scheduled" && !isFutureLocalDateTime(releaseSchedule.releaseDateTime) ? "미래 Asia/Seoul 시각을 입력하세요." : undefined,
|
||||
tags: tags.trim().length === 0 ? "태그를 입력하세요." : undefined,
|
||||
@@ -227,7 +231,7 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
|
||||
<h2 className="text-2xl font-bold leading-tight" id="audio-form-title">{mode === "create" ? "오디오 콘텐츠 생성" : "오디오 콘텐츠 수정"}</h2>
|
||||
<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}>
|
||||
<form className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" aria-label={mode === "create" ? "오디오 콘텐츠 생성 입력 화면" : "오디오 콘텐츠 수정 입력 화면"} noValidate 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>
|
||||
@@ -237,7 +241,7 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
|
||||
<TagInput error={errors.tags} errorId={errorIds.tags} label="태그" onChange={setTags} value={tags} />
|
||||
{mode === "create" ? <AudioContentThemeSelect error={errors.theme} errorId={errorIds.theme} onChange={setThemeId} themes={themes} value={themeId} /> : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">테마: {audio?.themeStr ?? "-"} (수정 불가)</p>}
|
||||
<CanPriceField error={errors.price} errorId={errorIds.price} onChange={changePrice} value={price} />
|
||||
{mode === "create" ? <AudioContentCreateOptions isPaid={isPaid} onChange={setCreateSettings} value={createSettings} /> : null}
|
||||
{mode === "create" ? <AudioContentCreateOptions isPaid={isPaid} onChange={setCreateSettings} previewEndError={errors.previewEnd} previewStartError={errors.previewStart} value={createSettings} /> : null}
|
||||
{mode === "create" ? <ReleaseScheduleField error={errors.releaseDate} errorId={errorIds.releaseDate} onChange={setReleaseSchedule} value={releaseSchedule} /> : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">공개일: {audio?.releaseDate == null ? "즉시 공개" : formatSeoulDateTime(audio.releaseDate)} (수정 불가)</p>}
|
||||
<p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">시리즈: 현재 수정 화면에서는 변경할 수 없습니다.</p>
|
||||
{errors.form === undefined ? null : <p className="text-sm font-semibold text-destructive" role="alert">{errors.form}</p>}
|
||||
|
||||
@@ -191,6 +191,29 @@ test("AudioContentFormPage reveals preview duration offset inputs only when prev
|
||||
});
|
||||
});
|
||||
|
||||
test("AudioContentFormPage rejects malformed preview offsets through the submit button", async () => {
|
||||
// Given
|
||||
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
|
||||
renderCreateForm(uploadedBodies);
|
||||
await fillRequiredCreateFields();
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "1" } });
|
||||
fireEvent.click(screen.getByLabelText("미리듣기 생성"));
|
||||
const previewStart = screen.getByLabelText("미리듣기 시작");
|
||||
const previewEnd = screen.getByLabelText("미리듣기 종료");
|
||||
|
||||
// When
|
||||
fireEvent.change(previewStart, { target: { value: "30" } });
|
||||
fireEvent.change(previewEnd, { target: { value: "01:00" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
expect(await screen.findAllByText("미리듣기 시간은 HH:MM:SS 형식으로 입력하세요.")).toHaveLength(2);
|
||||
expect(previewStart).toHaveAttribute("aria-invalid", "true");
|
||||
expect(previewEnd).toHaveAttribute("aria-invalid", "true");
|
||||
await waitFor(() => expect(previewStart).toHaveFocus());
|
||||
expect(uploadedBodies).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("AudioContentFormPage conditionally renders and clears the native scheduled release datetime", async () => {
|
||||
// Given
|
||||
renderCreateForm();
|
||||
|
||||
@@ -26,7 +26,7 @@ function createInactiveFormClient(requests: CapturedRequest[]): ApiClient {
|
||||
};
|
||||
}
|
||||
|
||||
test.each(["-1", "1.5", "100000"])("AudioContentFormPage rejects invalid edit price %s before update", async (value) => {
|
||||
test.each(["-1", "1.5", "100000"])("AudioContentFormPage rejects invalid edit price %s through the submit button", async (value) => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
||||
@@ -35,7 +35,7 @@ test.each(["-1", "1.5", "100000"])("AudioContentFormPage rejects invalid edit pr
|
||||
// When
|
||||
const priceInput = await screen.findByLabelText("가격");
|
||||
fireEvent.change(priceInput, { target: { value } });
|
||||
fireEvent.submit(screen.getByRole("form", { name: "오디오 콘텐츠 수정 입력 화면" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
||||
|
||||
// Then
|
||||
expect(priceInput).toHaveValue(Number(value));
|
||||
|
||||
@@ -164,7 +164,7 @@ test.each([
|
||||
"-1",
|
||||
"1.5",
|
||||
"100000",
|
||||
])("AudioContentFormPage preserves and rejects invalid price input %s before upload", async (value) => {
|
||||
])("AudioContentFormPage preserves and rejects invalid price input %s through the submit button", async (value) => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
|
||||
@@ -182,7 +182,7 @@ test.each([
|
||||
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
|
||||
await waitFor(() => expect(screen.getByText("cover.png")).toBeInTheDocument());
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value } });
|
||||
fireEvent.submit(screen.getByRole("form", { name: "오디오 콘텐츠 생성 입력 화면" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("가격")).toHaveValue(Number(value));
|
||||
|
||||
@@ -156,7 +156,7 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
|
||||
<h2 className="text-2xl font-bold leading-tight" id="community-post-form-title">커뮤니티 게시글 생성</h2>
|
||||
<p className="break-keep text-sm text-muted-foreground">내용과 첨부 파일은 저장 전 운영 기준에 맞게 검토하세요. GIF는 crop 없이 원본을 보존합니다.</p>
|
||||
</div>
|
||||
<form aria-label="커뮤니티 게시글 생성 입력 화면" className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" onSubmit={(event) => void submit(event)} ref={formRef}>
|
||||
<form aria-label="커뮤니티 게시글 생성 입력 화면" className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" noValidate onSubmit={(event) => void submit(event)} ref={formRef}>
|
||||
<FileField accept="image/jpeg,image/png,image/gif" acceptDescription="JPEG 또는 PNG는 자유 ratio crop 후 최대 800px로 전송합니다. GIF는 원본 width 800px 이하만 crop 없이 전송합니다." error={errors.image} label="게시글 이미지" onChange={(file) => void selectPostImage(file)} value={postImage} />
|
||||
{postImage === null ? null : <FileField accept={AUDIO_FILE_POLICY.allowedMimeTypes.join(",")} acceptDescription="MP3, AAC, M4A, 최대 1,024,000,000 bytes. WAV는 지원하지 않습니다." error={errors.audio} label="오디오 파일" onChange={setAudioFile} value={audioFile} />}
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">내용<textarea aria-describedby={errors.content === undefined ? undefined : errorIds.content} aria-invalid={errors.content === undefined ? undefined : true} className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setContent(event.currentTarget.value)} value={content} /></label>
|
||||
|
||||
@@ -31,13 +31,13 @@ test.each([
|
||||
"-1",
|
||||
"1.5",
|
||||
"100000",
|
||||
])("Community price preserves and rejects invalid raw input %s", async (rawValue) => {
|
||||
])("Community price preserves and rejects invalid raw input %s through the submit button", async (rawValue) => {
|
||||
const requests: CapturedRequest[] = [];
|
||||
const onCreated = renderForm(requests);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "가격 검증 게시글" } });
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value: rawValue } });
|
||||
fireEvent.submit(screen.getByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
expect(screen.getByLabelText("가격")).toHaveValue(Number(rawValue));
|
||||
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
|
||||
|
||||
Reference in New Issue
Block a user