106 lines
5.8 KiB
TypeScript
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();
|
|
});
|