Files
voiceon-character-admin/src/features/series/tests/series-form-crop.test.tsx

106 lines
5.8 KiB
TypeScript

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<Data>(options: ApiRequestOptions<Data>): Promise<Data> { 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<CropSourceImage>) {
window.history.pushState({}, "", "/ai-characters/101/series/5001/edit");
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="edit" renderCrop={(request) => 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<string, (source: CropSourceImage) => 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(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={() => 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();
});