578 lines
24 KiB
TypeScript
578 lines
24 KiB
TypeScript
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { expect, test, vi } from "vitest";
|
|
|
|
import { CharacterEditPage } from "@/features/characters/pages/CharacterEditPage";
|
|
import type { CharacterDetail } from "@/features/characters/model/types";
|
|
import { ApiError } from "@/shared/api/api-error";
|
|
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
|
|
import type { CropRenderRequest } from "@/shared/lib/crop-image";
|
|
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
|
|
|
|
// allow: SIZE_OK — P3-R1 create/update optional contract matrix is intentionally kept in one page test.
|
|
|
|
type CapturedRequest = {
|
|
readonly body?: BodyInit | null;
|
|
readonly method?: string;
|
|
readonly path: string;
|
|
};
|
|
|
|
const activeCharacter: CharacterDetail = {
|
|
id: 101,
|
|
characterUUID: "character-101",
|
|
name: "루나",
|
|
imageUrl: null,
|
|
description: "차분한 상담형 캐릭터",
|
|
systemPrompt: "친절하게 답한다.",
|
|
characterType: "Character",
|
|
age: null,
|
|
gender: null,
|
|
mbti: null,
|
|
speechPattern: null,
|
|
speechStyle: null,
|
|
appearance: null,
|
|
region: "KR",
|
|
isActive: true,
|
|
tags: [],
|
|
hobbies: [],
|
|
values: [],
|
|
goals: [],
|
|
relationships: [],
|
|
personalities: [],
|
|
backgrounds: [],
|
|
memories: [],
|
|
originalWork: null,
|
|
};
|
|
|
|
const characterWithOptionalFields: CharacterDetail = {
|
|
...activeCharacter,
|
|
age: 24,
|
|
gender: "여성",
|
|
mbti: "INFJ",
|
|
speechPattern: "존댓말",
|
|
speechStyle: "부드러움",
|
|
appearance: "긴 머리",
|
|
characterType: "Character",
|
|
tags: ["상담", "힐링"],
|
|
hobbies: ["독서", "산책"],
|
|
values: ["안전"],
|
|
goals: ["도움"],
|
|
relationships: [{ personName: "테오", relationshipName: "친구", description: "오랜 친구", importance: 3, relationshipType: "friend", currentStatus: "active" }],
|
|
personalities: [{ trait: "차분함", description: "침착하게 응대" }],
|
|
backgrounds: [{ topic: "출신", description: "달빛 상담소" }],
|
|
memories: [{ title: "첫 상담", content: "따뜻한 기억", emotion: "calm" }],
|
|
};
|
|
|
|
const inactiveCharacter: CharacterDetail = {
|
|
...activeCharacter,
|
|
isActive: false,
|
|
};
|
|
|
|
const activeCharacterOptionalPayload = {
|
|
age: null,
|
|
gender: null,
|
|
mbti: null,
|
|
speechPattern: null,
|
|
speechStyle: null,
|
|
appearance: null,
|
|
characterType: "Character",
|
|
tags: null,
|
|
hobbies: null,
|
|
values: null,
|
|
goals: null,
|
|
relationships: null,
|
|
personalities: null,
|
|
backgrounds: null,
|
|
memories: null,
|
|
} as const;
|
|
|
|
function createEditClient(requests: CapturedRequest[], character: CharacterDetail = activeCharacter): ApiClient {
|
|
return {
|
|
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
|
|
requests.push({ body: options.body, method: options.method, path: options.path });
|
|
if (options.path === "/api/v2/admin/ai-characters/101" && options.method === undefined) {
|
|
return options.responseSchema.parse(character);
|
|
}
|
|
|
|
return options.responseSchema.parse(null);
|
|
},
|
|
};
|
|
}
|
|
|
|
function createRejectOnceEditClient(requests: CapturedRequest[], character: CharacterDetail = activeCharacter): ApiClient {
|
|
let submitCount = 0;
|
|
|
|
return {
|
|
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
|
|
requests.push({ body: options.body, method: options.method, path: options.path });
|
|
if (options.path === "/api/v2/admin/ai-characters/101" && options.method === undefined) {
|
|
return options.responseSchema.parse(character);
|
|
}
|
|
|
|
submitCount += 1;
|
|
if (submitCount === 1) {
|
|
throw new ApiError({ status: 500, message: "서버에서 캐릭터 저장을 거부했습니다.", errorProperty: null });
|
|
}
|
|
|
|
return options.responseSchema.parse(null);
|
|
},
|
|
};
|
|
}
|
|
|
|
function createOriginalWorkEditClient(requests: CapturedRequest[]): ApiClient {
|
|
return {
|
|
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
|
|
requests.push({ body: options.body, method: options.method, path: options.path });
|
|
if (options.path === "/api/v2/admin/ai-characters/101" && options.method === undefined) {
|
|
return options.responseSchema.parse(activeCharacter);
|
|
}
|
|
if (options.path.startsWith("/api/v2/admin/ai-characters/original-works/search")) {
|
|
return options.responseSchema.parse([
|
|
{
|
|
id: 8,
|
|
title: "별빛 기록실",
|
|
contentType: "NOVEL",
|
|
category: "드라마",
|
|
isAdult: false,
|
|
description: "기록형 원작",
|
|
originalWork: null,
|
|
originalLink: null,
|
|
writer: "이든",
|
|
studio: "소다스튜디오",
|
|
originalLinks: [],
|
|
tags: ["기록"],
|
|
imageUrl: null,
|
|
},
|
|
]);
|
|
}
|
|
|
|
return options.responseSchema.parse(null);
|
|
},
|
|
};
|
|
}
|
|
|
|
function requireFormData(body: BodyInit | null | undefined): FormData {
|
|
if (body instanceof FormData) {
|
|
return body;
|
|
}
|
|
|
|
throw new TypeError("Expected FormData body");
|
|
}
|
|
|
|
async function readJsonPart(part: FormDataEntryValue | null): Promise<unknown> {
|
|
if (part instanceof Blob) {
|
|
return JSON.parse(await part.text());
|
|
}
|
|
if (typeof part === "string") {
|
|
return JSON.parse(part);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function requireFile(part: FormDataEntryValue | null): File {
|
|
if (part instanceof File) {
|
|
return part;
|
|
}
|
|
|
|
throw new TypeError("Expected File part");
|
|
}
|
|
|
|
function createCropSource(file: File): Promise<CropSourceImage> {
|
|
return Promise.resolve({ file, height: 1200, previewUrl: `blob:${file.name}`, width: 1200 });
|
|
}
|
|
|
|
function renderOriginalCrop(request: CropRenderRequest): Promise<File> {
|
|
return Promise.resolve(request.file);
|
|
}
|
|
|
|
test("CharacterEditPage submits changed fields without region or active state", async () => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
window.history.pushState({}, "", "/ai-characters/101/edit");
|
|
render(<CharacterEditPage apiClient={createEditClient(requests)} characterId="101" />);
|
|
|
|
// When
|
|
fireEvent.change(await screen.findByLabelText("이름"), { target: { value: "루나 수정" } });
|
|
fireEvent.change(screen.getByLabelText("시스템 프롬프트"), { target: { value: "짧고 안전하게 답한다." } });
|
|
fireEvent.change(screen.getByLabelText("설명"), { target: { value: "업데이트된 상담형 캐릭터" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
|
|
// Then
|
|
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101"));
|
|
expect(requests).toMatchObject([
|
|
{ path: "/api/v2/admin/ai-characters/101", method: undefined },
|
|
{ path: "/api/v2/admin/ai-characters/101", method: "PUT" },
|
|
]);
|
|
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({
|
|
...activeCharacterOptionalPayload,
|
|
name: "루나 수정",
|
|
systemPrompt: "짧고 안전하게 답한다.",
|
|
description: "업데이트된 상담형 캐릭터",
|
|
});
|
|
});
|
|
|
|
test("CharacterEditPage blocks the direct edit route for inactive characters", async () => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
window.history.pushState({}, "", "/ai-characters/101/edit");
|
|
render(<CharacterEditPage apiClient={createEditClient(requests, inactiveCharacter)} characterId="101" />);
|
|
|
|
// When
|
|
await screen.findByText("비활성화된 AI 캐릭터는 수정할 수 없습니다.");
|
|
|
|
// Then
|
|
expect(screen.queryByRole("form", { name: "AI 캐릭터 수정 form" })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "저장" })).not.toBeInTheDocument();
|
|
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101", method: undefined }]);
|
|
});
|
|
|
|
test("CharacterEditPage links required errors and focuses the first invalid field", async () => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
window.history.pushState({}, "", "/ai-characters/101/edit");
|
|
render(<CharacterEditPage apiClient={createEditClient(requests)} characterId="101" />);
|
|
|
|
// When
|
|
fireEvent.change(await screen.findByLabelText("이름"), { target: { value: " " } });
|
|
fireEvent.change(screen.getByLabelText("시스템 프롬프트"), { target: { value: " " } });
|
|
fireEvent.change(screen.getByLabelText("설명"), { target: { value: " " } });
|
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
|
|
// Then
|
|
const nameInput = screen.getByLabelText("이름");
|
|
const systemPromptInput = screen.getByLabelText("시스템 프롬프트");
|
|
const descriptionInput = screen.getByLabelText("설명");
|
|
expect(await screen.findByText("이름을 입력하세요.")).toHaveAttribute("id", "character-edit-name-error");
|
|
expect(screen.getByText("시스템 프롬프트를 입력하세요.")).toHaveAttribute("id", "character-edit-system-prompt-error");
|
|
expect(screen.getByText("설명을 입력하세요.")).toHaveAttribute("id", "character-edit-description-error");
|
|
expect(nameInput).toHaveAttribute("aria-describedby", "character-edit-name-error");
|
|
expect(systemPromptInput).toHaveAttribute("aria-describedby", "character-edit-system-prompt-error");
|
|
expect(descriptionInput).toHaveAttribute("aria-describedby", "character-edit-description-error");
|
|
expect(nameInput).toHaveAttribute("aria-invalid", "true");
|
|
expect(systemPromptInput).toHaveAttribute("aria-invalid", "true");
|
|
expect(descriptionInput).toHaveAttribute("aria-invalid", "true");
|
|
expect(nameInput).toHaveFocus();
|
|
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101", method: undefined }]);
|
|
});
|
|
|
|
test("CharacterEditPage keeps entered values and retries after an ApiError rejection", async () => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
window.history.pushState({}, "", "/ai-characters/101/edit");
|
|
render(<CharacterEditPage apiClient={createRejectOnceEditClient(requests)} characterId="101" />);
|
|
|
|
// When
|
|
fireEvent.change(await screen.findByLabelText("이름"), { target: { value: "루나 수정" } });
|
|
fireEvent.change(screen.getByLabelText("시스템 프롬프트"), { target: { value: "짧고 안전하게 답한다." } });
|
|
fireEvent.change(screen.getByLabelText("설명"), { target: { value: "업데이트된 상담형 캐릭터" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
|
|
// Then
|
|
expect(await screen.findByText("서버에서 캐릭터 저장을 거부했습니다.")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "저장" })).toBeEnabled();
|
|
expect(screen.getByLabelText("이름")).toHaveValue("루나 수정");
|
|
expect(screen.getByLabelText("시스템 프롬프트")).toHaveValue("짧고 안전하게 답한다.");
|
|
expect(screen.getByLabelText("설명")).toHaveValue("업데이트된 상담형 캐릭터");
|
|
|
|
// When
|
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
|
|
// Then
|
|
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101"));
|
|
expect(requests).toHaveLength(3);
|
|
});
|
|
|
|
test("CharacterEditPage submits optional scalar and array fields without region", async () => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
window.history.pushState({}, "", "/ai-characters/101/edit");
|
|
render(<CharacterEditPage apiClient={createEditClient(requests, characterWithOptionalFields)} characterId="101" />);
|
|
|
|
// When
|
|
fireEvent.change(await screen.findByLabelText("원작 제목"), { target: { value: " 달빛 상담소 " } });
|
|
fireEvent.change(screen.getByLabelText("원작 링크"), { target: { value: " https://example.com/original " } });
|
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
|
|
// Then
|
|
await waitFor(() => expect(requests).toHaveLength(2));
|
|
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({
|
|
name: "루나",
|
|
systemPrompt: "친절하게 답한다.",
|
|
description: "차분한 상담형 캐릭터",
|
|
age: "24",
|
|
gender: "여성",
|
|
mbti: "INFJ",
|
|
speechPattern: "존댓말",
|
|
speechStyle: "부드러움",
|
|
appearance: "긴 머리",
|
|
originalTitle: "달빛 상담소",
|
|
originalLink: "https://example.com/original",
|
|
characterType: "Character",
|
|
tags: ["상담", "힐링"],
|
|
hobbies: ["독서", "산책"],
|
|
values: ["안전"],
|
|
goals: ["도움"],
|
|
relationships: [{ personName: "테오", relationshipName: "친구", description: "오랜 친구", importance: 3, relationshipType: "friend", currentStatus: "active" }],
|
|
personalities: [{ trait: "차분함", description: "침착하게 응대" }],
|
|
backgrounds: [{ topic: "출신", description: "달빛 상담소" }],
|
|
memories: [{ title: "첫 상담", content: "따뜻한 기억", emotion: "calm" }],
|
|
});
|
|
});
|
|
|
|
test("CharacterEditPage serializes cleared optional scalar and array fields as null", async () => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
window.history.pushState({}, "", "/ai-characters/101/edit");
|
|
render(<CharacterEditPage apiClient={createEditClient(requests, characterWithOptionalFields)} characterId="101" />);
|
|
|
|
// When
|
|
fireEvent.change(await screen.findByLabelText("나이"), { target: { value: "" } });
|
|
fireEvent.change(screen.getByLabelText("성별"), { target: { value: "" } });
|
|
fireEvent.change(screen.getByLabelText("MBTI"), { target: { value: "" } });
|
|
fireEvent.change(screen.getByLabelText("말투 패턴"), { target: { value: "" } });
|
|
fireEvent.change(screen.getByLabelText("말투 스타일"), { target: { value: "" } });
|
|
fireEvent.change(screen.getByLabelText("외형"), { target: { value: "" } });
|
|
fireEvent.change(screen.getByLabelText("원작 제목"), { target: { value: "임시 원작" } });
|
|
fireEvent.change(screen.getByLabelText("원작 링크"), { target: { value: "https://example.com/temp" } });
|
|
fireEvent.change(screen.getByLabelText("원작 제목"), { target: { value: "" } });
|
|
fireEvent.change(screen.getByLabelText("원작 링크"), { target: { value: "" } });
|
|
fireEvent.change(screen.getByLabelText("캐릭터 유형"), { target: { value: "" } });
|
|
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "" } });
|
|
fireEvent.change(screen.getByLabelText("취미"), { target: { value: "" } });
|
|
fireEvent.change(screen.getByLabelText("가치관"), { target: { value: "" } });
|
|
fireEvent.change(screen.getByLabelText("목표"), { target: { value: "" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "관계 삭제" }));
|
|
fireEvent.click(screen.getByRole("button", { name: "성격 삭제" }));
|
|
fireEvent.click(screen.getByRole("button", { name: "배경 삭제" }));
|
|
fireEvent.click(screen.getByRole("button", { name: "기억 삭제" }));
|
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
|
|
// Then
|
|
await waitFor(() => expect(requests).toHaveLength(2));
|
|
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({
|
|
name: "루나",
|
|
systemPrompt: "친절하게 답한다.",
|
|
description: "차분한 상담형 캐릭터",
|
|
age: null,
|
|
gender: null,
|
|
mbti: null,
|
|
speechPattern: null,
|
|
speechStyle: null,
|
|
appearance: null,
|
|
originalTitle: null,
|
|
originalLink: null,
|
|
characterType: null,
|
|
tags: null,
|
|
hobbies: null,
|
|
values: null,
|
|
goals: null,
|
|
relationships: null,
|
|
personalities: null,
|
|
backgrounds: null,
|
|
memories: null,
|
|
});
|
|
});
|
|
|
|
test("CharacterEditPage asks before leaving with dirty changes", async () => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
window.history.pushState({}, "", "/ai-characters/101/edit");
|
|
render(<CharacterEditPage apiClient={createEditClient(requests)} characterId="101" />);
|
|
|
|
// When
|
|
fireEvent.change(await screen.findByLabelText("이름"), { target: { value: "루나 수정" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "상세로 돌아가기" }));
|
|
|
|
// Then
|
|
const dialog = screen.getByRole("alertdialog", { name: "수정을 취소하시겠습니까?" });
|
|
expect(dialog).toHaveTextContent("저장하지 않은 변경사항이 사라집니다.");
|
|
expect(window.location.pathname).toBe("/ai-characters/101/edit");
|
|
|
|
// When
|
|
fireEvent.click(screen.getByRole("button", { name: "떠나기" }));
|
|
|
|
// Then
|
|
expect(window.location.pathname).toBe("/ai-characters/101");
|
|
});
|
|
|
|
test("CharacterEditPage crops a replacement image before update", async () => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
const originalImage = new File(["original"], "luna.png", { type: "image/png" });
|
|
const croppedImage = new File(["cropped"], "luna-cropped.png", { type: "image/png" });
|
|
const renderCrop = () => Promise.resolve(croppedImage);
|
|
window.history.pushState({}, "", "/ai-characters/101/edit");
|
|
render(
|
|
<CharacterEditPage
|
|
apiClient={createEditClient(requests)}
|
|
characterId="101"
|
|
createCropSource={(file: File) => Promise.resolve({ file, height: 1200, previewUrl: "blob:luna", width: 1200 })}
|
|
renderCrop={renderCrop}
|
|
/>,
|
|
);
|
|
|
|
// When
|
|
fireEvent.change(await screen.findByLabelText("프로필 이미지"), { target: { files: [originalImage] } });
|
|
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
|
|
await waitFor(() => expect(screen.getByText("luna-cropped.png")).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
|
|
// Then
|
|
await waitFor(() => expect(requests).toHaveLength(2));
|
|
const submittedImage = requireFile(requireFormData(requests[1]?.body).get("image"));
|
|
expect(submittedImage.name).toBe("luna-cropped.png");
|
|
expect(await submittedImage.text()).toBe("cropped");
|
|
});
|
|
|
|
test("CharacterEditPage blocks submit while replacement crop source preparation is pending", async () => {
|
|
// Given
|
|
let resolveCropSource: (source: CropSourceImage) => void = () => undefined;
|
|
const cropSourceReady = new Promise<CropSourceImage>((resolve) => {
|
|
resolveCropSource = resolve;
|
|
});
|
|
const requests: CapturedRequest[] = [];
|
|
const image = new File(["raw"], "luna.png", { type: "image/png" });
|
|
window.history.pushState({}, "", "/ai-characters/101/edit");
|
|
render(<CharacterEditPage apiClient={createEditClient(requests)} characterId="101" createCropSource={() => cropSourceReady} />);
|
|
|
|
// When
|
|
fireEvent.change(await screen.findByLabelText("프로필 이미지"), { target: { files: [image] } });
|
|
|
|
// Then
|
|
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
|
|
|
|
// When
|
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
|
|
// Then
|
|
expect(requests).toHaveLength(1);
|
|
|
|
// When
|
|
resolveCropSource({ file: image, height: 1200, previewUrl: "blob:luna", width: 1200 });
|
|
|
|
// Then
|
|
expect(await screen.findByRole("dialog", { name: "이미지 crop" })).toBeInTheDocument();
|
|
});
|
|
|
|
test("CharacterEditPage ignores a stale replacement crop source from an earlier selection", async () => {
|
|
// Given
|
|
let resolveStaleCropSource: (source: CropSourceImage) => void = () => undefined;
|
|
const staleCropSource = new Promise<CropSourceImage>((resolve) => {
|
|
resolveStaleCropSource = resolve;
|
|
});
|
|
const requests: CapturedRequest[] = [];
|
|
const staleImage = new File(["stale"], "stale.png", { type: "image/png" });
|
|
const freshImage = new File(["fresh"], "fresh.png", { type: "image/png" });
|
|
const createRaceCropSource = (file: File) => file.name === "stale.png" ? staleCropSource : createCropSource(file);
|
|
window.history.pushState({}, "", "/ai-characters/101/edit");
|
|
render(<CharacterEditPage apiClient={createEditClient(requests)} characterId="101" createCropSource={createRaceCropSource} renderCrop={renderOriginalCrop} />);
|
|
|
|
// When
|
|
fireEvent.change(await screen.findByLabelText("프로필 이미지"), { target: { files: [staleImage] } });
|
|
fireEvent.change(screen.getByLabelText("프로필 이미지"), { target: { files: [freshImage] } });
|
|
await screen.findByRole("dialog", { name: "이미지 crop" });
|
|
await act(async () => {
|
|
resolveStaleCropSource({ file: staleImage, height: 1200, previewUrl: "blob:stale", width: 1200 });
|
|
await staleCropSource;
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
|
await waitFor(() => expect(screen.getByText("fresh.png")).toBeInTheDocument());
|
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
|
|
// Then
|
|
await waitFor(() => expect(requests).toHaveLength(2));
|
|
const submittedImage = requireFile(requireFormData(requests[1]?.body).get("image"));
|
|
expect(submittedImage.name).toBe("fresh.png");
|
|
});
|
|
|
|
test("CharacterEditPage shows an image error when replacement crop source preparation rejects", async () => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
const image = new File(["raw"], "luna.png", { type: "image/png" });
|
|
window.history.pushState({}, "", "/ai-characters/101/edit");
|
|
render(<CharacterEditPage apiClient={createEditClient(requests)} characterId="101" createCropSource={() => Promise.reject(new Error("preview failed"))} />);
|
|
|
|
// When
|
|
fireEvent.change(await screen.findByLabelText("프로필 이미지"), { target: { files: [image] } });
|
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
|
|
// Then
|
|
expect(await screen.findByText("이미지 미리보기 준비에 실패했습니다.")).toBeInTheDocument();
|
|
expect(requests).toHaveLength(1);
|
|
});
|
|
|
|
test.each([
|
|
["jpg file with PNG MIME", new File(["image"], "luna.jpg", { type: "image/png" })],
|
|
["png file with JPEG MIME", new File(["image"], "luna.png", { type: "image/jpeg" })],
|
|
])("CharacterEditPage rejects %s before crop or submit", async (_caseName, image) => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
const createCropSource = vi.fn((file: File) => Promise.resolve({ file, height: 1200, previewUrl: "blob:luna", width: 1200 }));
|
|
window.history.pushState({}, "", "/ai-characters/101/edit");
|
|
render(<CharacterEditPage apiClient={createEditClient(requests)} characterId="101" createCropSource={createCropSource} />);
|
|
|
|
// When
|
|
fireEvent.change(await screen.findByLabelText("프로필 이미지"), { target: { files: [image] } });
|
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
|
|
// Then
|
|
expect(await screen.findByText("JPEG 또는 PNG 파일만 업로드하세요.")).toBeInTheDocument();
|
|
expect(createCropSource).not.toHaveBeenCalled();
|
|
expect(requests).toHaveLength(1);
|
|
});
|
|
|
|
test("CharacterEditPage keeps the existing image when replacement crop is cancelled", async () => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
const originalImage = new File(["original"], "luna.png", { type: "image/png" });
|
|
window.history.pushState({}, "", "/ai-characters/101/edit");
|
|
render(
|
|
<CharacterEditPage
|
|
apiClient={createEditClient(requests)}
|
|
characterId="101"
|
|
createCropSource={(file: File) => Promise.resolve({ file, height: 1200, previewUrl: "blob:luna", width: 1200 })}
|
|
/>,
|
|
);
|
|
|
|
// When
|
|
fireEvent.change(await screen.findByLabelText("프로필 이미지"), { target: { files: [originalImage] } });
|
|
fireEvent.click(await screen.findByRole("button", { name: "취소" }));
|
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
|
|
// Then
|
|
await waitFor(() => expect(requests).toHaveLength(2));
|
|
expect(requireFormData(requests[1]?.body).get("image")).toBeNull();
|
|
expect(screen.queryByRole("button", { name: /기존 이미지 삭제/ })).not.toBeInTheDocument();
|
|
});
|
|
|
|
test("P10-T1 CharacterEditPage searches v2 original works and omits originalWorkId after clearing selection", async () => {
|
|
// Given
|
|
const requests: CapturedRequest[] = [];
|
|
window.history.pushState({}, "", "/ai-characters/101/edit");
|
|
render(<CharacterEditPage apiClient={createOriginalWorkEditClient(requests)} characterId="101" />);
|
|
const originalWorkSearchInput = await screen.findByLabelText("원작 검색어");
|
|
vi.useFakeTimers();
|
|
|
|
// When
|
|
fireEvent.change(originalWorkSearchInput, { target: { value: "별빛" } });
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(300);
|
|
await Promise.resolve();
|
|
});
|
|
vi.useRealTimers();
|
|
fireEvent.click(await screen.findByRole("button", { name: "별빛 기록실 선택" }));
|
|
expect(screen.getByText("선택된 원작: 별빛 기록실")).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole("button", { name: "원작 선택 해제" }));
|
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
|
|
// Then
|
|
await waitFor(() => expect(requests).toHaveLength(3));
|
|
expect(requests[1]?.path).toBe("/api/v2/admin/ai-characters/original-works/search?searchTerm=%EB%B3%84%EB%B9%9B");
|
|
expect(await readJsonPart(requireFormData(requests[2]?.body).get("request"))).toEqual({
|
|
...activeCharacterOptionalPayload,
|
|
name: "루나",
|
|
systemPrompt: "친절하게 답한다.",
|
|
description: "차분한 상담형 캐릭터",
|
|
});
|
|
});
|