import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { expect, test } from "vitest"; import { SeriesForm } from "@/features/series/components/SeriesForm"; import type { SeriesGenreItem, SeriesListItem } from "@/features/series/model/types"; import type { CropSourceImage } from "@/shared/ui/image-crop-dialog"; import type { ApiClient, ApiRequestOptions } from "@/shared/api/client"; type CapturedRequest = { readonly body?: BodyInit | null; readonly method?: string; readonly path: string }; const genres = [ { id: 77, genre: "로맨스", isAdult: false }, { id: 88, genre: "일상", isAdult: false }, ] as const satisfies readonly SeriesGenreItem[]; const existingSeries = { seriesId: 5001, title: "달빛 상담 시리즈", introduction: "밤마다 이어지는 상담 에피소드", coverImageUrl: "https://cdn.example.com/series/moon.png", publishedDaysOfWeek: ["SUN", "WED"], genreId: 77, isAdult: false, state: "PROCEEDING", isActive: true, writer: "스튜디오 루나", studio: "소다랩", } as const satisfies SeriesListItem; function createClient(requests: CapturedRequest[]): ApiClient { return { async request(options: ApiRequestOptions): Promise { requests.push({ body: options.body, method: options.method, path: options.path }); return options.responseSchema.parse(null); } }; } function createImage(name = "series.png"): File { return new File(["image"], name, { type: "image/png" }); } function renderEditForm(requests: CapturedRequest[], createCropSource: (file: File) => Promise) { window.history.pushState({}, "", "/ai-characters/101/series/5001/edit"); render( Promise.resolve(request.file)} series={existingSeries} />); } function requireFile(part: FormDataEntryValue | null): File { if (part instanceof File) { return part; } throw new TypeError("Expected File part"); } test("SeriesForm blocks edit save while replacement image crop source is preparing", async () => { const requests: CapturedRequest[] = []; renderEditForm(requests, () => new Promise(() => undefined)); fireEvent.change(screen.getByLabelText("시리즈 이미지"), { target: { files: [createImage("pending.png")] } }); await waitFor(() => expect(screen.getByRole("button", { name: "저장" })).toBeDisabled()); fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" })); expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0); }); test("SeriesForm ignores stale image crop sources and saves the latest applied replacement", async () => { const requests: CapturedRequest[] = []; const cropResolvers = new Map void>(); renderEditForm(requests, (file) => new Promise((resolve) => cropResolvers.set(file.name, resolve))); const staleFile = createImage("stale.png"); const freshFile = createImage("fresh.png"); fireEvent.change(screen.getByLabelText("시리즈 이미지"), { target: { files: [staleFile] } }); fireEvent.change(screen.getByLabelText("시리즈 이미지"), { target: { files: [freshFile] } }); cropResolvers.get("fresh.png")?.({ file: freshFile, height: 1414, previewUrl: "blob:fresh", width: 1000 }); fireEvent.click(await screen.findByRole("button", { name: "적용" })); await screen.findByText("fresh.png"); cropResolvers.get("stale.png")?.({ file: staleFile, height: 1414, previewUrl: "blob:stale", width: 1000 }); await waitFor(() => expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument()); fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" })); await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/series/5001")); const updateBody = requests.find((request) => request.method === "PUT")?.body as FormData; expect(requireFile(updateBody.get("image")).name).toBe("fresh.png"); }); test("SeriesForm keeps existing edit image when replacement crop is canceled", async () => { const requests: CapturedRequest[] = []; renderEditForm(requests, (file) => Promise.resolve({ file, height: 1414, previewUrl: "blob:series-preview", width: 1000 })); fireEvent.change(screen.getByLabelText("시리즈 이미지"), { target: { files: [createImage("replacement.png")] } }); const cropDialog = await screen.findByRole("dialog", { name: "이미지 crop" }); fireEvent.click(within(cropDialog).getByRole("button", { name: "취소" })); fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" })); await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/series/5001")); const updateBody = requests.find((request) => request.method === "PUT")?.body as FormData; expect(updateBody.get("image")).toBeNull(); }); test.each(["create", "edit"] as const)("SeriesForm shows an image preparation error when %s preview creation rejects", async (mode) => { const requests: CapturedRequest[] = []; window.history.pushState({}, "", mode === "create" ? "/ai-characters/101/series/new" : "/ai-characters/101/series/5001/edit"); render( Promise.reject(new Error("preview failed"))} genres={genres} mode={mode} renderCrop={(request) => Promise.resolve(request.file)} series={mode === "edit" ? existingSeries : undefined} />); fireEvent.change(screen.getByLabelText("시리즈 이미지"), { target: { files: [createImage("broken.png")] } }); expect(await screen.findByRole("alert")).toHaveTextContent("이미지 미리보기 준비에 실패했습니다."); expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument(); });