feat(ai-character): 오디오 콘텐츠 폼 입력 UX 정렬
This commit is contained in:
@@ -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: "힐링,집중" });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user