import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { expect, test } from "vitest"; 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"; function createSuccessfulUpload(uploadedBodies: XMLHttpRequestBodyInit[]): UploadAudioContentRequest { return async (options) => { uploadedBodies.push(options.body); return options.responseSchema.parse({ contentId: 9301 }); }; } function renderCreateForm(uploadedBodies: XMLHttpRequestBodyInit[] = []) { const requests: CapturedRequest[] = []; window.history.pushState({}, "", "/ai-characters/101/audio-contents/new"); render( Promise.resolve({ file, height: 800, previewUrl: "blob:cover", width: 800 })} renderCrop={(request) => Promise.resolve(request.file)} uploadAudioContentRequest={createSuccessfulUpload(uploadedBodies)} />, ); } async function fillRequiredCreateFields() { 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.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([ "-1", "1.5", "100000", ])("AudioContentFormPage preserves the raw price input %s", async (value) => { // 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(value)); }); 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 is numeric 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: "00" } }); // 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 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 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(); 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(); // 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: "힐링,집중" }); });