feat(ai-character): 캐릭터 관리 기능 구현
This commit is contained in:
197
src/features/characters/api/character-api.ts
Normal file
197
src/features/characters/api/character-api.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { CharacterDetail, CharacterListItem, OriginalWorkSearchItem } from "@/features/characters/model/types";
|
||||
import { characterDetailSchema, characterListResponseSchema, originalWorkSearchItemSchema } from "@/features/characters/model/types";
|
||||
import type { ApiClient } from "@/shared/api/client";
|
||||
import type { PageData } from "@/shared/api/pagination";
|
||||
import { z } from "zod";
|
||||
|
||||
export type GetCharactersParams = {
|
||||
readonly page?: number;
|
||||
readonly searchTerm?: string;
|
||||
readonly size?: number;
|
||||
};
|
||||
|
||||
type CharacterRelationshipRequest = {
|
||||
readonly personName: string;
|
||||
readonly relationshipName: string;
|
||||
readonly description: string;
|
||||
readonly importance: number;
|
||||
readonly relationshipType: string;
|
||||
readonly currentStatus: string;
|
||||
};
|
||||
|
||||
type CharacterTraitRequest = {
|
||||
readonly trait: string;
|
||||
readonly description: string;
|
||||
};
|
||||
|
||||
type CharacterBackgroundRequest = {
|
||||
readonly topic: string;
|
||||
readonly description: string;
|
||||
};
|
||||
|
||||
type CharacterMemoryRequest = {
|
||||
readonly title: string;
|
||||
readonly content: string;
|
||||
readonly emotion: string;
|
||||
};
|
||||
|
||||
type CharacterRepeatedRequest = {
|
||||
readonly tags?: readonly string[];
|
||||
readonly hobbies?: readonly string[];
|
||||
readonly values?: readonly string[];
|
||||
readonly goals?: readonly string[];
|
||||
readonly relationships?: readonly CharacterRelationshipRequest[];
|
||||
readonly personalities?: readonly CharacterTraitRequest[];
|
||||
readonly backgrounds?: readonly CharacterBackgroundRequest[];
|
||||
readonly memories?: readonly CharacterMemoryRequest[];
|
||||
};
|
||||
|
||||
type CharacterUpdateRepeatedRequest = {
|
||||
readonly tags?: readonly string[] | null;
|
||||
readonly hobbies?: readonly string[] | null;
|
||||
readonly values?: readonly string[] | null;
|
||||
readonly goals?: readonly string[] | null;
|
||||
readonly relationships?: readonly CharacterRelationshipRequest[] | null;
|
||||
readonly personalities?: readonly CharacterTraitRequest[] | null;
|
||||
readonly backgrounds?: readonly CharacterBackgroundRequest[] | null;
|
||||
readonly memories?: readonly CharacterMemoryRequest[] | null;
|
||||
};
|
||||
|
||||
type CharacterEditableRequest = {
|
||||
readonly name?: string;
|
||||
readonly systemPrompt?: string;
|
||||
readonly description?: string;
|
||||
readonly age?: string | null;
|
||||
readonly gender?: string | null;
|
||||
readonly mbti?: string | null;
|
||||
readonly speechPattern?: string | null;
|
||||
readonly speechStyle?: string | null;
|
||||
readonly appearance?: string | null;
|
||||
readonly originalTitle?: string | null;
|
||||
readonly originalLink?: string | null;
|
||||
readonly originalWorkId?: number | null;
|
||||
readonly characterType?: string | null;
|
||||
};
|
||||
|
||||
export type CreateCharacterParams = {
|
||||
readonly image: File;
|
||||
readonly request: CharacterEditableRequest & CharacterRepeatedRequest & {
|
||||
readonly name: string;
|
||||
readonly systemPrompt: string;
|
||||
readonly description: string;
|
||||
readonly region?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type UpdateCharacterParams = {
|
||||
readonly image?: File;
|
||||
readonly request: CharacterEditableRequest & CharacterUpdateRepeatedRequest;
|
||||
};
|
||||
|
||||
const nullSuccessSchema = z.null();
|
||||
const originalWorkSearchResponseSchema = z.array(originalWorkSearchItemSchema);
|
||||
|
||||
function hasClearedOriginalWork(request: object): request is { readonly originalWorkId: null } {
|
||||
return "originalWorkId" in request && request.originalWorkId === null;
|
||||
}
|
||||
|
||||
function serializeCharacterRequest(request: object): object {
|
||||
if (!hasClearedOriginalWork(request)) {
|
||||
return request;
|
||||
}
|
||||
|
||||
return Object.fromEntries(Object.entries(request).filter(([key]) => key !== "originalWorkId"));
|
||||
}
|
||||
|
||||
function createCharacterFormData(image: File | undefined, request: object): FormData {
|
||||
const body = new FormData();
|
||||
if (image !== undefined) {
|
||||
body.append("image", image);
|
||||
}
|
||||
body.append("request", new Blob([JSON.stringify(serializeCharacterRequest(request))], { type: "application/json" }));
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function normalizePage(value: number | undefined): number {
|
||||
return Math.max(0, Math.trunc(value ?? 0));
|
||||
}
|
||||
|
||||
function normalizeSize(value: number | undefined): number {
|
||||
return Math.max(1, Math.trunc(value ?? 20));
|
||||
}
|
||||
|
||||
export async function getCharacters(apiClient: ApiClient, params: GetCharactersParams): Promise<PageData<CharacterListItem>> {
|
||||
const page = normalizePage(params.page);
|
||||
const size = normalizeSize(params.size);
|
||||
const query = new URLSearchParams({ page: String(page), size: String(size) });
|
||||
const searchTerm = params.searchTerm?.trim();
|
||||
if (searchTerm !== undefined && searchTerm.length > 0) {
|
||||
query.set("searchTerm", searchTerm);
|
||||
}
|
||||
const response = await apiClient.request({
|
||||
path: `/api/v2/admin/ai-characters?${query.toString()}`,
|
||||
responseSchema: characterListResponseSchema,
|
||||
authentication: "required",
|
||||
});
|
||||
|
||||
return {
|
||||
totalCount: response.totalCount,
|
||||
page,
|
||||
size,
|
||||
hasNext: (page + 1) * size < response.totalCount,
|
||||
items: response.content,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCharacter(apiClient: ApiClient, characterId: string): Promise<CharacterDetail> {
|
||||
return apiClient.request({
|
||||
path: `/api/v2/admin/ai-characters/${encodeURIComponent(characterId)}`,
|
||||
responseSchema: characterDetailSchema,
|
||||
authentication: "required",
|
||||
});
|
||||
}
|
||||
|
||||
export function searchOriginalWorks(apiClient: ApiClient, searchTerm: string): Promise<OriginalWorkSearchItem[]> {
|
||||
const query = new URLSearchParams({ searchTerm: searchTerm.trim() });
|
||||
|
||||
return apiClient.request({
|
||||
path: `/api/v2/admin/ai-characters/original-works/search?${query.toString()}`,
|
||||
responseSchema: originalWorkSearchResponseSchema,
|
||||
authentication: "required",
|
||||
});
|
||||
}
|
||||
|
||||
export async function createCharacter(apiClient: ApiClient, params: CreateCharacterParams): Promise<void> {
|
||||
await apiClient.request({
|
||||
path: "/api/v2/admin/ai-characters",
|
||||
method: "POST",
|
||||
body: createCharacterFormData(params.image, params.request),
|
||||
responseSchema: nullSuccessSchema,
|
||||
authentication: "required",
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCharacter(
|
||||
apiClient: ApiClient,
|
||||
characterId: string,
|
||||
params: UpdateCharacterParams,
|
||||
): Promise<void> {
|
||||
await apiClient.request({
|
||||
path: `/api/v2/admin/ai-characters/${encodeURIComponent(characterId)}`,
|
||||
method: "PUT",
|
||||
body: createCharacterFormData(params.image, params.request),
|
||||
responseSchema: nullSuccessSchema,
|
||||
authentication: "required",
|
||||
});
|
||||
}
|
||||
|
||||
export async function deactivateCharacter(apiClient: ApiClient, characterId: string): Promise<void> {
|
||||
await apiClient.request({
|
||||
path: `/api/v2/admin/ai-characters/${encodeURIComponent(characterId)}`,
|
||||
method: "PUT",
|
||||
body: createCharacterFormData(undefined, { isActive: false }),
|
||||
responseSchema: nullSuccessSchema,
|
||||
authentication: "required",
|
||||
});
|
||||
}
|
||||
35
src/features/characters/components/CharacterList.tsx
Normal file
35
src/features/characters/components/CharacterList.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { CharacterListItem } from "@/features/characters/components/CharacterListItem";
|
||||
import type { CharacterListItem as CharacterListItemData } from "@/features/characters/model/types";
|
||||
import type { PageData } from "@/shared/api/pagination";
|
||||
import { ResponsiveResourceList } from "@/shared/ui/responsive-resource-list";
|
||||
|
||||
export function CharacterList({ data }: { readonly data: PageData<CharacterListItemData> }) {
|
||||
const desktop = (
|
||||
<table className="min-w-full border-separate border-spacing-0 text-left text-sm">
|
||||
<thead className="text-muted-foreground">
|
||||
<tr>
|
||||
<th className="border-b border-border px-4 py-3 font-semibold" scope="col">캐릭터</th>
|
||||
<th className="border-b border-border px-4 py-3 font-semibold" scope="col">지역</th>
|
||||
<th className="border-b border-border px-4 py-3 font-semibold" scope="col">태그</th>
|
||||
<th className="border-b border-border px-4 py-3 font-semibold" scope="col">선택</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((character) => (
|
||||
<tr key={character.id}>
|
||||
<td className="border-b border-border px-4 py-3">
|
||||
<p className="font-semibold">{character.name}</p>
|
||||
<p className="mt-1 max-w-xl text-muted-foreground">{character.description}</p>
|
||||
</td>
|
||||
<td className="border-b border-border px-4 py-3">{character.region}</td>
|
||||
<td className="border-b border-border px-4 py-3">{character.tags.join(", ")}</td>
|
||||
<td className="border-b border-border px-4 py-3"><CharacterListItem character={character} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
const mobile = <div className="grid gap-3 p-3">{data.items.map((character) => <CharacterListItem character={character} key={character.id} />)}</div>;
|
||||
|
||||
return <ResponsiveResourceList ariaLabel="AI 캐릭터 목록" desktop={desktop} mobile={mobile} />;
|
||||
}
|
||||
36
src/features/characters/components/CharacterListItem.tsx
Normal file
36
src/features/characters/components/CharacterListItem.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { navigateTo } from "@/app/browser-location";
|
||||
import { routePaths } from "@/app/route-paths";
|
||||
import type { CharacterListItem as CharacterListItemData } from "@/features/characters/model/types";
|
||||
|
||||
export function CharacterListItem({ character }: { readonly character: CharacterListItemData }) {
|
||||
const detailPath = routePaths.aiCharacterDetail(String(character.id));
|
||||
const description = character.description.length > 0 ? character.description : "설명이 없습니다.";
|
||||
|
||||
return (
|
||||
<a
|
||||
aria-label={`${character.name} 선택`}
|
||||
className="block rounded-lg border border-border bg-card p-4 text-foreground hover:bg-accent focus-visible:bg-accent"
|
||||
href={detailPath}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateTo(detailPath);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-start gap-3">
|
||||
{character.imageUrl === null ? (
|
||||
<span aria-hidden="true" className="grid size-12 shrink-0 place-items-center rounded-md bg-muted font-semibold text-muted-foreground">
|
||||
{character.name.slice(0, 1)}
|
||||
</span>
|
||||
) : (
|
||||
<img alt="" className="size-12 shrink-0 rounded-md object-cover" height="48" loading="lazy" src={character.imageUrl} width="48" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-semibold">{character.name}</span>
|
||||
<span className="mt-1 line-clamp-2 block text-sm text-muted-foreground">{description}</span>
|
||||
<span className="mt-2 block text-xs font-semibold text-info">ID {character.id} · {character.region}</span>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">{character.tags.join(", ")}</span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
145
src/features/characters/components/CharacterOptionalFields.tsx
Normal file
145
src/features/characters/components/CharacterOptionalFields.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
export type RelationshipDraft = {
|
||||
readonly personName: string;
|
||||
readonly relationshipName: string;
|
||||
readonly description: string;
|
||||
readonly importance: string;
|
||||
readonly relationshipType: string;
|
||||
readonly currentStatus: string;
|
||||
};
|
||||
|
||||
export type TraitDraft = {
|
||||
readonly trait: string;
|
||||
readonly description: string;
|
||||
};
|
||||
|
||||
export type BackgroundDraft = {
|
||||
readonly topic: string;
|
||||
readonly description: string;
|
||||
};
|
||||
|
||||
export type MemoryDraft = {
|
||||
readonly title: string;
|
||||
readonly content: string;
|
||||
readonly emotion: string;
|
||||
};
|
||||
|
||||
export type CharacterOptionalFieldsValue = {
|
||||
readonly age: string;
|
||||
readonly gender: string;
|
||||
readonly mbti: string;
|
||||
readonly speechPattern: string;
|
||||
readonly speechStyle: string;
|
||||
readonly appearance: string;
|
||||
readonly region: string;
|
||||
readonly originalTitle: string;
|
||||
readonly originalLink: string;
|
||||
readonly originalTitleTouched: boolean;
|
||||
readonly originalLinkTouched: boolean;
|
||||
readonly characterType: string;
|
||||
readonly tags: string;
|
||||
readonly hobbies: string;
|
||||
readonly values: string;
|
||||
readonly goals: string;
|
||||
readonly relationships: readonly RelationshipDraft[];
|
||||
readonly personalities: readonly TraitDraft[];
|
||||
readonly backgrounds: readonly BackgroundDraft[];
|
||||
readonly memories: readonly MemoryDraft[];
|
||||
};
|
||||
|
||||
type ScalarKey = "age" | "gender" | "mbti" | "speechPattern" | "speechStyle" | "appearance" | "region" | "originalTitle" | "originalLink" | "characterType";
|
||||
type StringArrayKey = "tags" | "hobbies" | "values" | "goals";
|
||||
|
||||
const scalarFields = [
|
||||
{ key: "age", label: "나이" },
|
||||
{ key: "gender", label: "성별" },
|
||||
{ key: "mbti", label: "MBTI" },
|
||||
{ key: "speechPattern", label: "말투 패턴" },
|
||||
{ key: "speechStyle", label: "말투 스타일" },
|
||||
{ key: "appearance", label: "외형" },
|
||||
{ key: "originalTitle", label: "원작 제목" },
|
||||
{ key: "originalLink", label: "원작 링크" },
|
||||
{ key: "characterType", label: "캐릭터 유형" },
|
||||
] as const satisfies readonly { readonly key: ScalarKey; readonly label: string }[];
|
||||
|
||||
const stringArrayFields = [
|
||||
{ key: "tags", label: "태그" },
|
||||
{ key: "hobbies", label: "취미" },
|
||||
{ key: "values", label: "가치관" },
|
||||
{ key: "goals", label: "목표" },
|
||||
] as const satisfies readonly { readonly key: StringArrayKey; readonly label: string }[];
|
||||
|
||||
function emptyRelationship(): RelationshipDraft {
|
||||
return { personName: "", relationshipName: "", description: "", importance: "", relationshipType: "", currentStatus: "" };
|
||||
}
|
||||
|
||||
function emptyTrait(): TraitDraft {
|
||||
return { trait: "", description: "" };
|
||||
}
|
||||
|
||||
function emptyBackground(): BackgroundDraft {
|
||||
return { topic: "", description: "" };
|
||||
}
|
||||
|
||||
function emptyMemory(): MemoryDraft {
|
||||
return { title: "", content: "", emotion: "" };
|
||||
}
|
||||
|
||||
function replaceAt<Row>(rows: readonly Row[], index: number, row: Row): readonly Row[] {
|
||||
return rows.map((current, currentIndex) => currentIndex === index ? row : current);
|
||||
}
|
||||
|
||||
function removeAt<Row>(rows: readonly Row[], index: number): readonly Row[] {
|
||||
return rows.filter((_, currentIndex) => currentIndex !== index);
|
||||
}
|
||||
|
||||
export function CharacterOptionalFields({ onChange, regionEditable, value }: { readonly onChange: (value: CharacterOptionalFieldsValue) => void; readonly regionEditable: boolean; readonly value: CharacterOptionalFieldsValue }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" aria-labelledby="character-optional-fields-title">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-sm font-semibold" id="character-optional-fields-title">선택 프로필</h2>
|
||||
<p className="text-sm text-muted-foreground">비워 두면 생성 요청에서는 제외하고, 수정 요청에서는 null로 저장합니다.</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{scalarFields.map((field) => (
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold" key={field.key}>
|
||||
{field.label}
|
||||
<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" name={field.key} onChange={(event) => onChange({ ...value, [field.key]: event.currentTarget.value, ...(field.key === "originalTitle" ? { originalTitleTouched: true } : {}), ...(field.key === "originalLink" ? { originalLinkTouched: true } : {}) })} value={value[field.key]} />
|
||||
</label>
|
||||
))}
|
||||
{regionEditable ? (
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
||||
지역
|
||||
<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" name="region" onChange={(event) => onChange({ ...value, region: event.currentTarget.value })} value={value.region} />
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{stringArrayFields.map((field) => (
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold" key={field.key}>
|
||||
{field.label}
|
||||
<textarea className="min-h-20 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" name={field.key} onChange={(event) => onChange({ ...value, [field.key]: event.currentTarget.value })} value={value[field.key]} />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<section className="flex flex-col gap-3 rounded-md border border-border bg-muted p-3" aria-label="관계">
|
||||
<div className="flex items-center justify-between gap-2"><h3 className="text-sm font-semibold">관계</h3><button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => onChange({ ...value, relationships: [...value.relationships, emptyRelationship()] })} type="button">관계 추가</button></div>
|
||||
{value.relationships.map((row, index) => (
|
||||
<div className="grid gap-2 sm:grid-cols-2" key={index}>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">관계 인물명<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => onChange({ ...value, relationships: replaceAt(value.relationships, index, { ...row, personName: event.currentTarget.value }) })} value={row.personName} /></label>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">관계명<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => onChange({ ...value, relationships: replaceAt(value.relationships, index, { ...row, relationshipName: event.currentTarget.value }) })} value={row.relationshipName} /></label>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">관계 설명<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => onChange({ ...value, relationships: replaceAt(value.relationships, index, { ...row, description: event.currentTarget.value }) })} value={row.description} /></label>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">관계 중요도<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => onChange({ ...value, relationships: replaceAt(value.relationships, index, { ...row, importance: event.currentTarget.value }) })} value={row.importance} /></label>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">관계 유형<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => onChange({ ...value, relationships: replaceAt(value.relationships, index, { ...row, relationshipType: event.currentTarget.value }) })} value={row.relationshipType} /></label>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">관계 현재 상태<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => onChange({ ...value, relationships: replaceAt(value.relationships, index, { ...row, currentStatus: event.currentTarget.value }) })} value={row.currentStatus} /></label>
|
||||
<button className="w-fit rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => onChange({ ...value, relationships: removeAt(value.relationships, index) })} type="button">관계 삭제</button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<section className="grid gap-3 sm:grid-cols-3" aria-label="성격 배경 기억">
|
||||
<div className="flex flex-col gap-2 rounded-md border border-border bg-muted p-3"><h3 className="text-sm font-semibold">성격</h3>{value.personalities.map((row, index) => <div className="flex flex-col gap-2" key={index}><label className="flex flex-col gap-2 text-sm font-semibold">성격 특성<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => onChange({ ...value, personalities: replaceAt(value.personalities, index, { ...row, trait: event.currentTarget.value }) })} value={row.trait} /></label><label className="flex flex-col gap-2 text-sm font-semibold">성격 설명<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => onChange({ ...value, personalities: replaceAt(value.personalities, index, { ...row, description: event.currentTarget.value }) })} value={row.description} /></label><button className="w-fit rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => onChange({ ...value, personalities: removeAt(value.personalities, index) })} type="button">성격 삭제</button></div>)}<button className="w-fit rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => onChange({ ...value, personalities: [...value.personalities, emptyTrait()] })} type="button">성격 추가</button></div>
|
||||
<div className="flex flex-col gap-2 rounded-md border border-border bg-muted p-3"><h3 className="text-sm font-semibold">배경</h3>{value.backgrounds.map((row, index) => <div className="flex flex-col gap-2" key={index}><label className="flex flex-col gap-2 text-sm font-semibold">배경 주제<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => onChange({ ...value, backgrounds: replaceAt(value.backgrounds, index, { ...row, topic: event.currentTarget.value }) })} value={row.topic} /></label><label className="flex flex-col gap-2 text-sm font-semibold">배경 설명<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => onChange({ ...value, backgrounds: replaceAt(value.backgrounds, index, { ...row, description: event.currentTarget.value }) })} value={row.description} /></label><button className="w-fit rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => onChange({ ...value, backgrounds: removeAt(value.backgrounds, index) })} type="button">배경 삭제</button></div>)}<button className="w-fit rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => onChange({ ...value, backgrounds: [...value.backgrounds, emptyBackground()] })} type="button">배경 추가</button></div>
|
||||
<div className="flex flex-col gap-2 rounded-md border border-border bg-muted p-3"><h3 className="text-sm font-semibold">기억</h3>{value.memories.map((row, index) => <div className="flex flex-col gap-2" key={index}><label className="flex flex-col gap-2 text-sm font-semibold">기억 제목<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => onChange({ ...value, memories: replaceAt(value.memories, index, { ...row, title: event.currentTarget.value }) })} value={row.title} /></label><label className="flex flex-col gap-2 text-sm font-semibold">기억 내용<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => onChange({ ...value, memories: replaceAt(value.memories, index, { ...row, content: event.currentTarget.value }) })} value={row.content} /></label><label className="flex flex-col gap-2 text-sm font-semibold">기억 감정<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => onChange({ ...value, memories: replaceAt(value.memories, index, { ...row, emotion: event.currentTarget.value }) })} value={row.emotion} /></label><button className="w-fit rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => onChange({ ...value, memories: removeAt(value.memories, index) })} type="button">기억 삭제</button></div>)}<button className="w-fit rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => onChange({ ...value, memories: [...value.memories, emptyMemory()] })} type="button">기억 추가</button></div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
28
src/features/characters/components/CharacterProfile.tsx
Normal file
28
src/features/characters/components/CharacterProfile.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { CharacterDetail } from "@/features/characters/model/types";
|
||||
|
||||
function OptionalText({ value }: { readonly value: string | number | null }) {
|
||||
return <>{value === null || value === "" ? "-" : value}</>;
|
||||
}
|
||||
|
||||
export function CharacterProfile({ character }: { readonly character: CharacterDetail }) {
|
||||
return (
|
||||
<section aria-labelledby="character-profile-title" className="rounded-lg border border-border bg-card p-4">
|
||||
<h2 className="text-xl font-semibold" id="character-profile-title">프로필</h2>
|
||||
<dl className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div><dt className="text-xs font-semibold text-muted-foreground">캐릭터 UUID</dt><dd className="mt-1">{character.characterUUID}</dd></div>
|
||||
<div><dt className="text-xs font-semibold text-muted-foreground">설명</dt><dd className="mt-1">{character.description}</dd></div>
|
||||
<div><dt className="text-xs font-semibold text-muted-foreground">원작</dt><dd className="mt-1">{character.originalWork?.title ?? "원작 없음"}</dd></div>
|
||||
<div><dt className="text-xs font-semibold text-muted-foreground">지역</dt><dd className="mt-1">{character.region}</dd></div>
|
||||
<div><dt className="text-xs font-semibold text-muted-foreground">나이</dt><dd className="mt-1"><OptionalText value={character.age} /></dd></div>
|
||||
<div><dt className="text-xs font-semibold text-muted-foreground">성별</dt><dd className="mt-1"><OptionalText value={character.gender} /></dd></div>
|
||||
<div><dt className="text-xs font-semibold text-muted-foreground">MBTI</dt><dd className="mt-1"><OptionalText value={character.mbti} /></dd></div>
|
||||
<div><dt className="text-xs font-semibold text-muted-foreground">말투</dt><dd className="mt-1"><OptionalText value={character.speechStyle} /></dd></div>
|
||||
<div><dt className="text-xs font-semibold text-muted-foreground">태그</dt><dd className="mt-1">{character.tags.length === 0 ? "-" : character.tags.join(", ")}</dd></div>
|
||||
</dl>
|
||||
<section className="mt-4 rounded-md border border-border bg-muted p-3" aria-label="시스템 프롬프트">
|
||||
<h3 className="text-sm font-semibold">시스템 프롬프트</h3>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">{character.systemPrompt}</p>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
105
src/features/characters/components/OriginalWorkSearchField.tsx
Normal file
105
src/features/characters/components/OriginalWorkSearchField.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useId, useState } from "react";
|
||||
|
||||
import { searchOriginalWorks } from "@/features/characters/api/character-api";
|
||||
import type { OriginalWorkSearchItem } from "@/features/characters/model/types";
|
||||
import { ApiError } from "@/shared/api/api-error";
|
||||
import type { ApiClient } from "@/shared/api/client";
|
||||
|
||||
export type OriginalWorkSelection = Pick<OriginalWorkSearchItem, "id" | "title">;
|
||||
|
||||
export function OriginalWorkSearchField({ apiClient, onChange, value }: { readonly apiClient: ApiClient; readonly onChange: (value: OriginalWorkSelection | null) => void; readonly value: OriginalWorkSelection | null }) {
|
||||
const searchId = useId();
|
||||
const [query, setQuery] = useState(value?.title ?? "");
|
||||
const [results, setResults] = useState<readonly OriginalWorkSearchItem[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const hasSearchTerm = query.trim().length > 0;
|
||||
const visibleError = hasSearchTerm ? error : null;
|
||||
const visibleResults = hasSearchTerm ? results : [];
|
||||
|
||||
useEffect(() => {
|
||||
const searchTerm = query.trim();
|
||||
if (searchTerm.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let isCurrent = true;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void searchOriginalWorks(apiClient, searchTerm)
|
||||
.then((items) => {
|
||||
if (isCurrent) {
|
||||
setResults(items);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((searchError: unknown) => {
|
||||
if (isCurrent) {
|
||||
setResults([]);
|
||||
setError(searchError instanceof ApiError ? searchError.message : "원작 검색에 실패했습니다.");
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [apiClient, query]);
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-2 rounded-lg border border-border bg-card p-4" aria-labelledby={`${searchId}-title`}>
|
||||
<h2 className="text-sm font-semibold" id={`${searchId}-title`}>원작 선택</h2>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold" htmlFor={searchId}>
|
||||
원작 검색어
|
||||
<input
|
||||
className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground"
|
||||
id={searchId}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
type="search"
|
||||
value={query}
|
||||
/>
|
||||
</label>
|
||||
{value === null ? <p className="text-sm text-muted-foreground">선택하지 않으면 원작 없이 저장합니다.</p> : (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-semibold text-info">선택된 원작: {value.title}</span>
|
||||
<button
|
||||
className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent"
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
원작 선택 해제
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{visibleError === null ? null : <p className="text-sm font-semibold text-destructive" role="alert">{visibleError}</p>}
|
||||
{visibleResults.length === 0 ? null : (
|
||||
<ul className="flex flex-col gap-2" aria-label="원작 검색 결과">
|
||||
{visibleResults.map((item) => (
|
||||
<li className="rounded-md border border-border bg-muted p-3" key={item.id}>
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-foreground">{item.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{item.contentType} · {item.category}</p>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent"
|
||||
onClick={() => {
|
||||
onChange({ id: item.id, title: item.title });
|
||||
setQuery(item.title);
|
||||
setResults([]);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{item.title} 선택
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { CreateCharacterParams, UpdateCharacterParams } from "@/features/characters/api/character-api";
|
||||
import type { CharacterDetail } from "@/features/characters/model/types";
|
||||
import type { BackgroundDraft, CharacterOptionalFieldsValue, MemoryDraft, RelationshipDraft, TraitDraft } from "@/features/characters/components/CharacterOptionalFields";
|
||||
|
||||
type RelationshipRequest = NonNullable<CreateCharacterParams["request"]["relationships"]>[number];
|
||||
type TraitRequest = NonNullable<CreateCharacterParams["request"]["personalities"]>[number];
|
||||
type BackgroundRequest = NonNullable<CreateCharacterParams["request"]["backgrounds"]>[number];
|
||||
type MemoryRequest = NonNullable<CreateCharacterParams["request"]["memories"]>[number];
|
||||
type CreateOptionalRequest = Partial<CreateCharacterParams["request"]>;
|
||||
type MutableCreateOptionalRequest = { -readonly [Key in keyof CreateOptionalRequest]: CreateOptionalRequest[Key] };
|
||||
|
||||
function emptyRelationship(): RelationshipDraft {
|
||||
return { personName: "", relationshipName: "", description: "", importance: "", relationshipType: "", currentStatus: "" };
|
||||
}
|
||||
|
||||
function emptyTrait(): TraitDraft {
|
||||
return { trait: "", description: "" };
|
||||
}
|
||||
|
||||
function emptyBackground(): BackgroundDraft {
|
||||
return { topic: "", description: "" };
|
||||
}
|
||||
|
||||
function emptyMemory(): MemoryDraft {
|
||||
return { title: "", content: "", emotion: "" };
|
||||
}
|
||||
|
||||
export function createEmptyCharacterOptionalFields(): CharacterOptionalFieldsValue {
|
||||
return { age: "", gender: "", mbti: "", speechPattern: "", speechStyle: "", appearance: "", region: "", originalTitle: "", originalLink: "", originalTitleTouched: false, originalLinkTouched: false, characterType: "", tags: "", hobbies: "", values: "", goals: "", relationships: [emptyRelationship()], personalities: [emptyTrait()], backgrounds: [emptyBackground()], memories: [emptyMemory()] };
|
||||
}
|
||||
|
||||
export function characterOptionalFieldsFromDetail(character: CharacterDetail): CharacterOptionalFieldsValue {
|
||||
return {
|
||||
age: character.age === null ? "" : String(character.age), gender: character.gender ?? "", mbti: character.mbti ?? "", speechPattern: character.speechPattern ?? "", speechStyle: character.speechStyle ?? "", appearance: character.appearance ?? "", region: character.region, originalTitle: "", originalLink: "", originalTitleTouched: false, originalLinkTouched: false, characterType: character.characterType,
|
||||
tags: character.tags.join("\n"), hobbies: character.hobbies.join("\n"), values: character.values.join("\n"), goals: character.goals.join("\n"),
|
||||
relationships: character.relationships.length === 0 ? [emptyRelationship()] : character.relationships.map((row) => ({ ...row, importance: String(row.importance) })), personalities: character.personalities.length === 0 ? [emptyTrait()] : character.personalities, backgrounds: character.backgrounds.length === 0 ? [emptyBackground()] : character.backgrounds, memories: character.memories.length === 0 ? [emptyMemory()] : character.memories,
|
||||
};
|
||||
}
|
||||
|
||||
function trimOptional(value: string): string | undefined {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length === 0 ? undefined : trimmed;
|
||||
}
|
||||
|
||||
function trimNullable(value: string): string | null {
|
||||
return trimOptional(value) ?? null;
|
||||
}
|
||||
|
||||
function splitList(value: string): readonly string[] {
|
||||
return value.split(/[\n,]/).map((item) => item.trim()).filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function hasRelationship(row: RelationshipDraft): boolean {
|
||||
return [row.personName, row.relationshipName, row.description, row.importance, row.relationshipType, row.currentStatus].some((value) => value.trim().length > 0);
|
||||
}
|
||||
|
||||
function relationships(rows: readonly RelationshipDraft[]): readonly RelationshipRequest[] {
|
||||
return rows.filter(hasRelationship).map((row) => {
|
||||
const importance = Number.parseInt(row.importance.trim(), 10);
|
||||
return {
|
||||
personName: row.personName.trim(),
|
||||
relationshipName: row.relationshipName.trim(),
|
||||
description: row.description.trim(),
|
||||
importance: Number.isNaN(importance) ? 0 : importance,
|
||||
relationshipType: row.relationshipType.trim(),
|
||||
currentStatus: row.currentStatus.trim(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function traits(rows: readonly TraitDraft[]): readonly TraitRequest[] {
|
||||
return rows.filter((row) => row.trait.trim().length > 0 || row.description.trim().length > 0).map((row) => ({ trait: row.trait.trim(), description: row.description.trim() }));
|
||||
}
|
||||
|
||||
function backgrounds(rows: readonly BackgroundDraft[]): readonly BackgroundRequest[] {
|
||||
return rows.filter((row) => row.topic.trim().length > 0 || row.description.trim().length > 0).map((row) => ({ topic: row.topic.trim(), description: row.description.trim() }));
|
||||
}
|
||||
|
||||
function memories(rows: readonly MemoryDraft[]): readonly MemoryRequest[] {
|
||||
return rows.filter((row) => row.title.trim().length > 0 || row.content.trim().length > 0 || row.emotion.trim().length > 0).map((row) => ({ title: row.title.trim(), content: row.content.trim(), emotion: row.emotion.trim() }));
|
||||
}
|
||||
|
||||
export function toCreateCharacterOptionalRequest(value: CharacterOptionalFieldsValue): CreateOptionalRequest {
|
||||
const request: MutableCreateOptionalRequest = {};
|
||||
const age = trimOptional(value.age);
|
||||
const gender = trimOptional(value.gender);
|
||||
const mbti = trimOptional(value.mbti);
|
||||
const speechPattern = trimOptional(value.speechPattern);
|
||||
const speechStyle = trimOptional(value.speechStyle);
|
||||
const appearance = trimOptional(value.appearance);
|
||||
const region = trimOptional(value.region);
|
||||
const originalTitle = trimOptional(value.originalTitle);
|
||||
const originalLink = trimOptional(value.originalLink);
|
||||
const characterType = trimOptional(value.characterType);
|
||||
const tags = splitList(value.tags);
|
||||
const hobbies = splitList(value.hobbies);
|
||||
const values = splitList(value.values);
|
||||
const goals = splitList(value.goals);
|
||||
const relationshipRows = relationships(value.relationships);
|
||||
const personalityRows = traits(value.personalities);
|
||||
const backgroundRows = backgrounds(value.backgrounds);
|
||||
const memoryRows = memories(value.memories);
|
||||
|
||||
if (age !== undefined) request.age = age;
|
||||
if (gender !== undefined) request.gender = gender;
|
||||
if (mbti !== undefined) request.mbti = mbti;
|
||||
if (speechPattern !== undefined) request.speechPattern = speechPattern;
|
||||
if (speechStyle !== undefined) request.speechStyle = speechStyle;
|
||||
if (appearance !== undefined) request.appearance = appearance;
|
||||
if (region !== undefined) request.region = region;
|
||||
if (originalTitle !== undefined) request.originalTitle = originalTitle;
|
||||
if (originalLink !== undefined) request.originalLink = originalLink;
|
||||
if (characterType !== undefined) request.characterType = characterType;
|
||||
if (tags.length > 0) request.tags = tags;
|
||||
if (hobbies.length > 0) request.hobbies = hobbies;
|
||||
if (values.length > 0) request.values = values;
|
||||
if (goals.length > 0) request.goals = goals;
|
||||
if (relationshipRows.length > 0) request.relationships = relationshipRows;
|
||||
if (personalityRows.length > 0) request.personalities = personalityRows;
|
||||
if (backgroundRows.length > 0) request.backgrounds = backgroundRows;
|
||||
if (memoryRows.length > 0) request.memories = memoryRows;
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
export function toUpdateCharacterOptionalRequest(value: CharacterOptionalFieldsValue): UpdateCharacterParams["request"] {
|
||||
const tags = splitList(value.tags);
|
||||
const hobbies = splitList(value.hobbies);
|
||||
const values = splitList(value.values);
|
||||
const goals = splitList(value.goals);
|
||||
const relationshipRows = relationships(value.relationships);
|
||||
const personalityRows = traits(value.personalities);
|
||||
const backgroundRows = backgrounds(value.backgrounds);
|
||||
const memoryRows = memories(value.memories);
|
||||
|
||||
return {
|
||||
age: trimNullable(value.age), gender: trimNullable(value.gender), mbti: trimNullable(value.mbti), speechPattern: trimNullable(value.speechPattern), speechStyle: trimNullable(value.speechStyle), appearance: trimNullable(value.appearance), ...(value.originalTitleTouched ? { originalTitle: trimNullable(value.originalTitle) } : {}), ...(value.originalLinkTouched ? { originalLink: trimNullable(value.originalLink) } : {}), characterType: trimNullable(value.characterType),
|
||||
tags: tags.length === 0 ? null : tags, hobbies: hobbies.length === 0 ? null : hobbies, values: values.length === 0 ? null : values, goals: goals.length === 0 ? null : goals,
|
||||
relationships: relationshipRows.length === 0 ? null : relationshipRows, personalities: personalityRows.length === 0 ? null : personalityRows, backgrounds: backgroundRows.length === 0 ? null : backgroundRows, memories: memoryRows.length === 0 ? null : memoryRows,
|
||||
};
|
||||
}
|
||||
91
src/features/characters/model/types.ts
Normal file
91
src/features/characters/model/types.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const nullableString = z.string().nullable();
|
||||
const nullableNumber = z.number().int().nullable();
|
||||
|
||||
export const characterListItemSchema = z.object({
|
||||
id: z.number().int(),
|
||||
name: z.string(),
|
||||
imageUrl: nullableString,
|
||||
description: z.string(),
|
||||
gender: nullableString,
|
||||
age: nullableNumber,
|
||||
mbti: nullableString,
|
||||
speechStyle: nullableString,
|
||||
speechPattern: nullableString,
|
||||
region: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
createdAt: nullableString,
|
||||
updatedAt: nullableString,
|
||||
});
|
||||
|
||||
const originalWorkSchema = z.object({
|
||||
id: z.number().int(),
|
||||
imageUrl: nullableString,
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
export const originalWorkSearchItemSchema = z.object({
|
||||
id: z.number().int(),
|
||||
title: z.string(),
|
||||
contentType: z.string(),
|
||||
category: z.string(),
|
||||
isAdult: z.boolean(),
|
||||
description: z.string(),
|
||||
originalWork: nullableString,
|
||||
originalLink: nullableString,
|
||||
writer: nullableString,
|
||||
studio: nullableString,
|
||||
originalLinks: z.array(z.string()),
|
||||
tags: z.array(z.string()),
|
||||
imageUrl: nullableString,
|
||||
});
|
||||
|
||||
const characterRelationshipSchema = z.object({
|
||||
personName: z.string(),
|
||||
relationshipName: z.string(),
|
||||
description: z.string(),
|
||||
importance: z.number().int(),
|
||||
relationshipType: z.string(),
|
||||
currentStatus: z.string(),
|
||||
});
|
||||
|
||||
const characterTraitSchema = z.object({ trait: z.string(), description: z.string() });
|
||||
const characterBackgroundSchema = z.object({ topic: z.string(), description: z.string() });
|
||||
const characterMemorySchema = z.object({ title: z.string(), content: z.string(), emotion: z.string() });
|
||||
|
||||
export const characterDetailSchema = z.object({
|
||||
id: z.number().int(),
|
||||
characterUUID: z.string(),
|
||||
name: z.string(),
|
||||
imageUrl: nullableString,
|
||||
description: z.string(),
|
||||
systemPrompt: z.string(),
|
||||
characterType: z.union([z.literal("Clone"), z.literal("Character")]),
|
||||
age: nullableNumber,
|
||||
gender: nullableString,
|
||||
mbti: nullableString,
|
||||
speechPattern: nullableString,
|
||||
speechStyle: nullableString,
|
||||
appearance: nullableString,
|
||||
region: z.string(),
|
||||
isActive: z.boolean(),
|
||||
tags: z.array(z.string()),
|
||||
hobbies: z.array(z.string()),
|
||||
values: z.array(z.string()),
|
||||
goals: z.array(z.string()),
|
||||
relationships: z.array(characterRelationshipSchema),
|
||||
personalities: z.array(characterTraitSchema),
|
||||
backgrounds: z.array(characterBackgroundSchema),
|
||||
memories: z.array(characterMemorySchema),
|
||||
originalWork: originalWorkSchema.nullable(),
|
||||
});
|
||||
|
||||
export const characterListResponseSchema = z.object({
|
||||
totalCount: z.number().int(),
|
||||
content: z.array(characterListItemSchema),
|
||||
});
|
||||
|
||||
export type CharacterDetail = z.infer<typeof characterDetailSchema>;
|
||||
export type CharacterListItem = z.infer<typeof characterListItemSchema>;
|
||||
export type OriginalWorkSearchItem = z.infer<typeof originalWorkSearchItemSchema>;
|
||||
213
src/features/characters/pages/CharacterCreatePage.tsx
Normal file
213
src/features/characters/pages/CharacterCreatePage.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { navigateTo } from "@/app/browser-location";
|
||||
import { routePaths } from "@/app/route-paths";
|
||||
import { createCharacter } from "@/features/characters/api/character-api";
|
||||
import { CharacterOptionalFields } from "@/features/characters/components/CharacterOptionalFields";
|
||||
import { createEmptyCharacterOptionalFields, toCreateCharacterOptionalRequest } from "@/features/characters/components/character-optional-field-serialization";
|
||||
import { OriginalWorkSearchField } from "@/features/characters/components/OriginalWorkSearchField";
|
||||
import type { OriginalWorkSelection } from "@/features/characters/components/OriginalWorkSearchField";
|
||||
import { validateCharacterImageFile } from "@/features/characters/validation/character-image-policy";
|
||||
import { ApiError } from "@/shared/api/api-error";
|
||||
import type { ApiClient } from "@/shared/api/client";
|
||||
import type { CropRenderRequest } from "@/shared/lib/crop-image";
|
||||
import { createImageCropSource } from "@/shared/lib/create-image-crop-source";
|
||||
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
|
||||
import { FileField } from "@/shared/ui/file-field";
|
||||
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
|
||||
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
|
||||
|
||||
type FieldErrors = {
|
||||
readonly description?: string;
|
||||
readonly form?: string;
|
||||
readonly image?: string;
|
||||
readonly name?: string;
|
||||
readonly systemPrompt?: string;
|
||||
};
|
||||
|
||||
const profileCropPolicy = { aspect: 1, maxWidth: 800, noUpscale: true } as const;
|
||||
const imagePreparationErrorMessage = "이미지 미리보기 준비에 실패했습니다.";
|
||||
|
||||
const errorIds = {
|
||||
description: "character-create-description-error",
|
||||
name: "character-create-name-error",
|
||||
systemPrompt: "character-create-system-prompt-error",
|
||||
};
|
||||
|
||||
function validateImage(image: File | null): string | undefined {
|
||||
if (image === null) {
|
||||
return "프로필 이미지를 선택하세요.";
|
||||
}
|
||||
const result = validateCharacterImageFile(image);
|
||||
if (result.ok) {
|
||||
return undefined;
|
||||
}
|
||||
if (result.reason === "size") {
|
||||
return "프로필 이미지는 10MB 이하만 업로드할 수 있습니다.";
|
||||
}
|
||||
|
||||
return "JPEG 또는 PNG 파일만 업로드하세요.";
|
||||
}
|
||||
|
||||
export function CharacterCreatePage({ apiClient, createCropSource = createImageCropSource, renderCrop }: { readonly apiClient: ApiClient; readonly createCropSource?: (file: File) => Promise<CropSourceImage>; readonly renderCrop?: (request: CropRenderRequest) => Promise<File> }) {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [description, setDescription] = useState("");
|
||||
const [image, setImage] = useState<File | null>(null);
|
||||
const [cropSource, setCropSource] = useState<CropSourceImage | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [optionalFields, setOptionalFields] = useState(createEmptyCharacterOptionalFields);
|
||||
const [originalWork, setOriginalWork] = useState<OriginalWorkSelection | null>(null);
|
||||
const [systemPrompt, setSystemPrompt] = useState("");
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [isImagePreparing, setIsImagePreparing] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const imageSelectionId = useRef(0);
|
||||
|
||||
useEffect(() => () => cropSource?.release?.(), [cropSource]);
|
||||
|
||||
async function selectImage(file: File | null) {
|
||||
imageSelectionId.current += 1;
|
||||
const currentSelectionId = imageSelectionId.current;
|
||||
if (file === null) {
|
||||
setImage(null);
|
||||
setCropSource(null);
|
||||
setIsImagePreparing(false);
|
||||
setErrors((current) => ({ ...current, image: undefined }));
|
||||
return;
|
||||
}
|
||||
|
||||
const imageError = validateImage(file);
|
||||
setImage(null);
|
||||
if (imageError !== undefined) {
|
||||
setCropSource(null);
|
||||
setIsImagePreparing(false);
|
||||
setErrors((current) => ({ ...current, image: imageError }));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsImagePreparing(true);
|
||||
try {
|
||||
const nextCropSource = await createCropSource(file);
|
||||
if (currentSelectionId !== imageSelectionId.current) {
|
||||
nextCropSource.release?.();
|
||||
return;
|
||||
}
|
||||
setCropSource(nextCropSource);
|
||||
setErrors((current) => ({ ...current, image: undefined }));
|
||||
} catch (error: unknown) {
|
||||
if (currentSelectionId !== imageSelectionId.current) {
|
||||
return;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
setCropSource(null);
|
||||
setErrors((current) => ({ ...current, image: imagePreparationErrorMessage }));
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (currentSelectionId === imageSelectionId.current) {
|
||||
setIsImagePreparing(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validate(): FieldErrors {
|
||||
return {
|
||||
description: description.trim().length === 0 ? "설명을 입력하세요." : undefined,
|
||||
image: isImagePreparing || cropSource !== null ? "이미지 처리가 끝난 뒤 저장하세요." : errors.image ?? validateImage(image),
|
||||
name: name.trim().length === 0 ? "이름을 입력하세요." : undefined,
|
||||
systemPrompt: systemPrompt.trim().length === 0 ? "시스템 프롬프트를 입력하세요." : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function submit(event: { readonly preventDefault: () => void }) {
|
||||
event.preventDefault();
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextErrors = validate();
|
||||
setErrors(nextErrors);
|
||||
if (nextErrors.description !== undefined || nextErrors.image !== undefined || nextErrors.name !== undefined || nextErrors.systemPrompt !== undefined || image === null) {
|
||||
queueMicrotask(() => focusFirstInvalidControl(formRef.current));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createCharacter(apiClient, {
|
||||
image,
|
||||
request: {
|
||||
...toCreateCharacterOptionalRequest(optionalFields),
|
||||
description: description.trim(),
|
||||
name: name.trim(),
|
||||
originalWorkId: originalWork?.id,
|
||||
systemPrompt: systemPrompt.trim(),
|
||||
},
|
||||
});
|
||||
navigateTo(routePaths.aiCharacters, { successNotification: "AI 캐릭터를 생성했습니다." });
|
||||
} catch (error: unknown) {
|
||||
setErrors({ form: error instanceof ApiError ? error.message : "AI 캐릭터를 생성하지 못했습니다." });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4" aria-labelledby="character-create-title">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-semibold text-info">AI CHARACTER ADMIN</p>
|
||||
<h1 className="text-2xl font-bold leading-tight" id="character-create-title">AI 캐릭터 생성</h1>
|
||||
<p className="text-sm text-muted-foreground">필수 프로필과 이미지를 입력해 새 AI 캐릭터를 생성합니다.</p>
|
||||
</div>
|
||||
<section className="rounded-lg border border-border bg-card p-4 md:hidden" aria-labelledby="character-create-mobile-guidance-title">
|
||||
<h2 className="text-xl font-semibold" id="character-create-mobile-guidance-title">데스크톱에서 생성해 주세요.</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">AI 캐릭터 생성과 파일 업로드 관리는 태블릿 이상 화면에서 이용할 수 있습니다.</p>
|
||||
</section>
|
||||
<form className="hidden flex-col gap-4 rounded-lg border border-border bg-card p-4 md:flex" aria-label="AI 캐릭터 생성 form" onSubmit={(event) => void submit(event)} ref={formRef}>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
||||
이름
|
||||
<input aria-describedby={errors.name === undefined ? undefined : errorIds.name} aria-invalid={errors.name === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" name="name" onChange={(event) => setName(event.currentTarget.value)} value={name} />
|
||||
</label>
|
||||
{errors.name === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.name} role="alert">{errors.name}</p>}
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
||||
시스템 프롬프트
|
||||
<textarea aria-describedby={errors.systemPrompt === undefined ? undefined : errorIds.systemPrompt} aria-invalid={errors.systemPrompt === undefined ? undefined : true} className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" name="systemPrompt" onChange={(event) => setSystemPrompt(event.currentTarget.value)} value={systemPrompt} />
|
||||
</label>
|
||||
{errors.systemPrompt === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.systemPrompt} role="alert">{errors.systemPrompt}</p>}
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
||||
설명
|
||||
<textarea aria-describedby={errors.description === undefined ? undefined : errorIds.description} aria-invalid={errors.description === undefined ? undefined : true} className="min-h-24 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" name="description" onChange={(event) => setDescription(event.currentTarget.value)} value={description} />
|
||||
</label>
|
||||
{errors.description === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.description} role="alert">{errors.description}</p>}
|
||||
<CharacterOptionalFields onChange={setOptionalFields} regionEditable value={optionalFields} />
|
||||
<OriginalWorkSearchField apiClient={apiClient} onChange={setOriginalWork} value={originalWork} />
|
||||
<FileField accept="image/jpeg,image/png" acceptDescription="JPEG 또는 PNG, 10MB 이하, 1:1 crop 후 최대 800×800px로 전송합니다." error={errors.image} label="프로필 이미지" onChange={(file) => void selectImage(file)} value={image} />
|
||||
{errors.form === undefined ? null : <p className="text-sm font-semibold text-destructive" role="alert">{errors.form}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSubmitting || isImagePreparing || cropSource !== null} type="submit">
|
||||
{isSubmitting ? "생성 중" : "생성"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{cropSource === null ? null : (
|
||||
<ImageCropDialog
|
||||
image={cropSource}
|
||||
onApply={(file) => {
|
||||
setImage(file);
|
||||
setCropSource(null);
|
||||
setErrors((current) => ({ ...current, image: undefined }));
|
||||
}}
|
||||
onCancel={() => {
|
||||
setImage(null);
|
||||
setCropSource(null);
|
||||
setErrors((current) => ({ ...current, image: undefined }));
|
||||
}}
|
||||
open
|
||||
policy={profileCropPolicy}
|
||||
renderCrop={renderCrop}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
102
src/features/characters/pages/CharacterDetailPage.tsx
Normal file
102
src/features/characters/pages/CharacterDetailPage.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { navigateTo } from "@/app/browser-location";
|
||||
import { routePaths } from "@/app/route-paths";
|
||||
import { deactivateCharacter, getCharacter } from "@/features/characters/api/character-api";
|
||||
import { CharacterProfile } from "@/features/characters/components/CharacterProfile";
|
||||
import type { CharacterDetail } from "@/features/characters/model/types";
|
||||
import { CharacterWorkspaceLayout } from "@/layouts/CharacterWorkspaceLayout";
|
||||
import { ApiError } from "@/shared/api/api-error";
|
||||
import type { ApiClient } from "@/shared/api/client";
|
||||
import { ConfirmDeactivateDialog } from "@/shared/ui/confirm-deactivate-dialog";
|
||||
import { PageState } from "@/shared/ui/page-state";
|
||||
|
||||
type DetailState =
|
||||
| { readonly requestKey: string; readonly status: "loading" }
|
||||
| { readonly character: CharacterDetail; readonly requestKey: string; readonly status: "content" }
|
||||
| { readonly message: string; readonly requestKey: string; readonly status: "error" };
|
||||
|
||||
export function CharacterDetailPage({ apiClient, characterId }: { readonly apiClient: ApiClient; readonly characterId: string }) {
|
||||
const [state, setState] = useState<DetailState>({ requestKey: "", status: "loading" });
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
const [isDeactivateDialogOpen, setIsDeactivateDialogOpen] = useState(false);
|
||||
const [isDeactivating, setIsDeactivating] = useState(false);
|
||||
const [deactivateError, setDeactivateError] = useState<string | undefined>();
|
||||
const isDeactivatingRef = useRef(false);
|
||||
const requestKey = `${characterId}:${retryKey}`;
|
||||
|
||||
useEffect(() => {
|
||||
let isCurrent = true;
|
||||
void getCharacter(apiClient, characterId)
|
||||
.then((character) => {
|
||||
if (isCurrent) {
|
||||
setState({ character, requestKey, status: "content" });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (isCurrent) {
|
||||
setState({ message: error instanceof ApiError ? error.message : "AI 캐릭터 상세를 불러오지 못했습니다.", requestKey, status: "error" });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [apiClient, characterId, requestKey]);
|
||||
|
||||
const visibleState: DetailState = state.requestKey === requestKey ? state : { requestKey, status: "loading" };
|
||||
|
||||
if (visibleState.status === "loading") {
|
||||
return <PageState state="loading" title="AI 캐릭터 상세를 불러오는 중" />;
|
||||
}
|
||||
if (visibleState.status === "error") {
|
||||
return <PageState description={visibleState.message} onRetry={() => setRetryKey((key) => key + 1)} state="error" title="AI 캐릭터 상세 조회 실패" />;
|
||||
}
|
||||
|
||||
async function confirmDeactivate() {
|
||||
if (isDeactivatingRef.current) {
|
||||
return;
|
||||
}
|
||||
isDeactivatingRef.current = true;
|
||||
setIsDeactivating(true);
|
||||
setDeactivateError(undefined);
|
||||
try {
|
||||
await deactivateCharacter(apiClient, characterId);
|
||||
navigateTo(routePaths.aiCharacters, { successNotification: "AI 캐릭터를 비활성화했습니다." });
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw error;
|
||||
}
|
||||
setDeactivateError("비활성화하지 못했습니다. 다시 시도하세요.");
|
||||
} finally {
|
||||
isDeactivatingRef.current = false;
|
||||
setIsDeactivating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CharacterWorkspaceLayout character={visibleState.character}>
|
||||
{visibleState.character.isActive ? (
|
||||
<div className="hidden justify-end gap-2 md:flex">
|
||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={() => navigateTo(routePaths.aiCharacterEdit(characterId))} type="button">
|
||||
수정
|
||||
</button>
|
||||
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={() => setIsDeactivateDialogOpen(true)} type="button">
|
||||
비활성화
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<CharacterProfile character={visibleState.character} />
|
||||
<ConfirmDeactivateDialog
|
||||
confirmLabel="비활성화 확인"
|
||||
errorMessage={deactivateError}
|
||||
impactDescription="비활성화하면 목록에서 쓰기 작업을 종료합니다. 복원은 지원하지 않습니다. 완전 삭제는 아니며 목록에서 제외됩니다."
|
||||
isPending={isDeactivating}
|
||||
onCancel={() => setIsDeactivateDialogOpen(false)}
|
||||
onConfirm={() => void confirmDeactivate()}
|
||||
open={isDeactivateDialogOpen}
|
||||
targetName={visibleState.character.name}
|
||||
/>
|
||||
</CharacterWorkspaceLayout>
|
||||
);
|
||||
}
|
||||
277
src/features/characters/pages/CharacterEditPage.tsx
Normal file
277
src/features/characters/pages/CharacterEditPage.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { navigateTo } from "@/app/browser-location";
|
||||
import { routePaths } from "@/app/route-paths";
|
||||
import { getCharacter, updateCharacter } from "@/features/characters/api/character-api";
|
||||
import { CharacterOptionalFields } from "@/features/characters/components/CharacterOptionalFields";
|
||||
import { characterOptionalFieldsFromDetail, toUpdateCharacterOptionalRequest } from "@/features/characters/components/character-optional-field-serialization";
|
||||
import { OriginalWorkSearchField } from "@/features/characters/components/OriginalWorkSearchField";
|
||||
import type { OriginalWorkSelection } from "@/features/characters/components/OriginalWorkSearchField";
|
||||
import type { CharacterDetail } from "@/features/characters/model/types";
|
||||
import { validateCharacterImageFile } from "@/features/characters/validation/character-image-policy";
|
||||
import { ApiError } from "@/shared/api/api-error";
|
||||
import type { ApiClient } from "@/shared/api/client";
|
||||
import type { CropRenderRequest } from "@/shared/lib/crop-image";
|
||||
import { createImageCropSource } from "@/shared/lib/create-image-crop-source";
|
||||
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
|
||||
import { FileField } from "@/shared/ui/file-field";
|
||||
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
|
||||
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
|
||||
import { PageState } from "@/shared/ui/page-state";
|
||||
import { UnsavedChangesGuard } from "@/shared/ui/unsaved-changes-guard";
|
||||
|
||||
type FieldErrors = {
|
||||
readonly description?: string;
|
||||
readonly form?: string;
|
||||
readonly image?: string;
|
||||
readonly name?: string;
|
||||
readonly systemPrompt?: string;
|
||||
};
|
||||
|
||||
type EditState =
|
||||
| { readonly requestKey: string; readonly status: "loading" }
|
||||
| { readonly character: CharacterDetail; readonly requestKey: string; readonly status: "content" }
|
||||
| { readonly message: string; readonly requestKey: string; readonly status: "error" };
|
||||
|
||||
const profileCropPolicy = { aspect: 1, maxWidth: 800, noUpscale: true } as const;
|
||||
const imagePreparationErrorMessage = "이미지 미리보기 준비에 실패했습니다.";
|
||||
|
||||
const errorIds = {
|
||||
description: "character-edit-description-error",
|
||||
name: "character-edit-name-error",
|
||||
systemPrompt: "character-edit-system-prompt-error",
|
||||
};
|
||||
|
||||
const mobileEditGuidance = (
|
||||
<section className="rounded-lg border border-border bg-card p-4 md:hidden" aria-labelledby="character-edit-mobile-guidance-title">
|
||||
<h2 className="text-xl font-semibold" id="character-edit-mobile-guidance-title">데스크톱에서 수정해 주세요.</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">AI 캐릭터 수정과 파일 업로드 관리는 태블릿 이상 화면에서 이용할 수 있습니다.</p>
|
||||
</section>
|
||||
);
|
||||
|
||||
function validateImage(image: File | null): string | undefined {
|
||||
if (image === null) {
|
||||
return undefined;
|
||||
}
|
||||
const result = validateCharacterImageFile(image);
|
||||
if (result.ok) {
|
||||
return undefined;
|
||||
}
|
||||
if (result.reason === "size") {
|
||||
return "프로필 이미지는 10MB 이하만 업로드할 수 있습니다.";
|
||||
}
|
||||
|
||||
return "JPEG 또는 PNG 파일만 업로드하세요.";
|
||||
}
|
||||
|
||||
function CharacterEditForm({ apiClient, character, createCropSource, renderCrop }: { readonly apiClient: ApiClient; readonly character: CharacterDetail; readonly createCropSource: (file: File) => Promise<CropSourceImage>; readonly renderCrop?: (request: CropRenderRequest) => Promise<File> }) {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [description, setDescription] = useState(character.description);
|
||||
const [image, setImage] = useState<File | null>(null);
|
||||
const [cropSource, setCropSource] = useState<CropSourceImage | null>(null);
|
||||
const [name, setName] = useState(character.name);
|
||||
const [optionalFields, setOptionalFields] = useState(() => characterOptionalFieldsFromDetail(character));
|
||||
const [originalWork, setOriginalWork] = useState<OriginalWorkSelection | null>(character.originalWork);
|
||||
const [systemPrompt, setSystemPrompt] = useState(character.systemPrompt);
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [isImagePreparing, setIsImagePreparing] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const imageSelectionId = useRef(0);
|
||||
const detailPath = routePaths.aiCharacterDetail(String(character.id));
|
||||
const dirty = image !== null || isImagePreparing || cropSource !== null || description !== character.description || name !== character.name || originalWork?.id !== character.originalWork?.id || systemPrompt !== character.systemPrompt || JSON.stringify(optionalFields) !== JSON.stringify(characterOptionalFieldsFromDetail(character));
|
||||
const imageSubmitBlocked = isImagePreparing || cropSource !== null;
|
||||
|
||||
useEffect(() => () => cropSource?.release?.(), [cropSource]);
|
||||
|
||||
async function selectImage(file: File | null) {
|
||||
imageSelectionId.current += 1;
|
||||
const currentSelectionId = imageSelectionId.current;
|
||||
if (file === null) {
|
||||
setImage(null);
|
||||
setCropSource(null);
|
||||
setIsImagePreparing(false);
|
||||
setErrors((current) => ({ ...current, image: undefined }));
|
||||
return;
|
||||
}
|
||||
|
||||
const imageError = validateImage(file);
|
||||
setImage(null);
|
||||
if (imageError !== undefined) {
|
||||
setCropSource(null);
|
||||
setIsImagePreparing(false);
|
||||
setErrors((current) => ({ ...current, image: imageError }));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsImagePreparing(true);
|
||||
try {
|
||||
const nextCropSource = await createCropSource(file);
|
||||
if (currentSelectionId !== imageSelectionId.current) {
|
||||
nextCropSource.release?.();
|
||||
return;
|
||||
}
|
||||
setCropSource(nextCropSource);
|
||||
setErrors((current) => ({ ...current, image: undefined }));
|
||||
} catch (error: unknown) {
|
||||
if (currentSelectionId !== imageSelectionId.current) {
|
||||
return;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
setCropSource(null);
|
||||
setErrors((current) => ({ ...current, image: imagePreparationErrorMessage }));
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (currentSelectionId === imageSelectionId.current) {
|
||||
setIsImagePreparing(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validate(): FieldErrors {
|
||||
return {
|
||||
description: description.trim().length === 0 ? "설명을 입력하세요." : undefined,
|
||||
image: imageSubmitBlocked ? "이미지 처리가 끝난 뒤 저장하세요." : errors.image ?? validateImage(image),
|
||||
name: name.trim().length === 0 ? "이름을 입력하세요." : undefined,
|
||||
systemPrompt: systemPrompt.trim().length === 0 ? "시스템 프롬프트를 입력하세요." : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function submit(event: { readonly preventDefault: () => void }) {
|
||||
event.preventDefault();
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextErrors = validate();
|
||||
setErrors(nextErrors);
|
||||
if (nextErrors.description !== undefined || nextErrors.image !== undefined || nextErrors.name !== undefined || nextErrors.systemPrompt !== undefined) {
|
||||
queueMicrotask(() => focusFirstInvalidControl(formRef.current));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await updateCharacter(apiClient, String(character.id), {
|
||||
image: image ?? undefined,
|
||||
request: {
|
||||
...toUpdateCharacterOptionalRequest(optionalFields),
|
||||
description: description.trim(),
|
||||
name: name.trim(),
|
||||
originalWorkId: originalWork?.id,
|
||||
systemPrompt: systemPrompt.trim(),
|
||||
},
|
||||
});
|
||||
navigateTo(routePaths.aiCharacterDetail(String(character.id)), { successNotification: "AI 캐릭터를 저장했습니다." });
|
||||
} catch (error: unknown) {
|
||||
setErrors({ form: error instanceof ApiError ? error.message : "AI 캐릭터를 저장하지 못했습니다." });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<UnsavedChangesGuard dirty={dirty} impactDescription="저장하지 않은 변경사항이 사라집니다." title="수정을 취소하시겠습니까?">
|
||||
{(requestRouteLeave) => (
|
||||
<section className="flex flex-col gap-4" aria-labelledby="character-edit-title">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-semibold text-info">AI CHARACTER ADMIN</p>
|
||||
<h1 className="text-2xl font-bold leading-tight" id="character-edit-title">AI 캐릭터 수정</h1>
|
||||
<p className="text-sm text-muted-foreground">프로필 텍스트와 이미지를 수정합니다. 지역은 생성 후 변경할 수 없습니다.</p>
|
||||
</div>
|
||||
<form className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" aria-label="AI 캐릭터 수정 form" onSubmit={(event) => void submit(event)} ref={formRef}>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
||||
이름
|
||||
<input aria-describedby={errors.name === undefined ? undefined : errorIds.name} aria-invalid={errors.name === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" name="name" onChange={(event) => setName(event.currentTarget.value)} value={name} />
|
||||
</label>
|
||||
{errors.name === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.name} role="alert">{errors.name}</p>}
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
||||
시스템 프롬프트
|
||||
<textarea aria-describedby={errors.systemPrompt === undefined ? undefined : errorIds.systemPrompt} aria-invalid={errors.systemPrompt === undefined ? undefined : true} className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" name="systemPrompt" onChange={(event) => setSystemPrompt(event.currentTarget.value)} value={systemPrompt} />
|
||||
</label>
|
||||
{errors.systemPrompt === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.systemPrompt} role="alert">{errors.systemPrompt}</p>}
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
||||
설명
|
||||
<textarea aria-describedby={errors.description === undefined ? undefined : errorIds.description} aria-invalid={errors.description === undefined ? undefined : true} className="min-h-24 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" name="description" onChange={(event) => setDescription(event.currentTarget.value)} value={description} />
|
||||
</label>
|
||||
{errors.description === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.description} role="alert">{errors.description}</p>}
|
||||
<CharacterOptionalFields onChange={setOptionalFields} regionEditable={false} value={optionalFields} />
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
||||
지역
|
||||
<input className="rounded-md border border-input bg-muted px-3 py-2 text-base font-normal text-muted-foreground" disabled name="region" value={character.region} />
|
||||
</label>
|
||||
<OriginalWorkSearchField apiClient={apiClient} onChange={setOriginalWork} value={originalWork} />
|
||||
<FileField accept="image/jpeg,image/png" acceptDescription="선택하지 않으면 기존 프로필 이미지를 유지합니다. JPEG 또는 PNG, 10MB 이하." error={errors.image} label="프로필 이미지" onChange={(file) => void selectImage(file)} value={image} />
|
||||
{errors.form === undefined ? null : <p className="text-sm font-semibold text-destructive" role="alert">{errors.form}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(detailPath))} type="button">
|
||||
상세로 돌아가기
|
||||
</button>
|
||||
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSubmitting || imageSubmitBlocked} type="submit">
|
||||
{isSubmitting ? "저장 중" : "저장"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{cropSource === null ? null : (
|
||||
<ImageCropDialog
|
||||
image={cropSource}
|
||||
onApply={(file) => {
|
||||
setImage(file);
|
||||
setCropSource(null);
|
||||
setErrors((current) => ({ ...current, image: undefined }));
|
||||
}}
|
||||
onCancel={() => {
|
||||
setImage(null);
|
||||
setCropSource(null);
|
||||
setErrors((current) => ({ ...current, image: undefined }));
|
||||
}}
|
||||
open
|
||||
policy={profileCropPolicy}
|
||||
renderCrop={renderCrop}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</UnsavedChangesGuard>
|
||||
);
|
||||
}
|
||||
|
||||
export function CharacterEditPage({ apiClient, characterId, createCropSource = createImageCropSource, renderCrop }: { readonly apiClient: ApiClient; readonly characterId: string; readonly createCropSource?: (file: File) => Promise<CropSourceImage>; readonly renderCrop?: (request: CropRenderRequest) => Promise<File> }) {
|
||||
const [state, setState] = useState<EditState>({ requestKey: "", status: "loading" });
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
const requestKey = `${characterId}:${retryKey}`;
|
||||
|
||||
useEffect(() => {
|
||||
let isCurrent = true;
|
||||
void getCharacter(apiClient, characterId)
|
||||
.then((character) => {
|
||||
if (isCurrent) {
|
||||
setState({ character, requestKey, status: "content" });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (isCurrent) {
|
||||
setState({ message: error instanceof ApiError ? error.message : "AI 캐릭터 상세를 불러오지 못했습니다.", requestKey, status: "error" });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [apiClient, characterId, requestKey]);
|
||||
|
||||
const visibleState: EditState = state.requestKey === requestKey ? state : { requestKey, status: "loading" };
|
||||
|
||||
if (visibleState.status === "loading") {
|
||||
return <>{mobileEditGuidance}<div className="hidden md:block"><PageState state="loading" title="AI 캐릭터 수정 정보를 불러오는 중" /></div></>;
|
||||
}
|
||||
if (visibleState.status === "error") {
|
||||
return <>{mobileEditGuidance}<div className="hidden md:block"><PageState description={visibleState.message} onRetry={() => setRetryKey((key) => key + 1)} state="error" title="AI 캐릭터 수정 정보 조회 실패" /></div></>;
|
||||
}
|
||||
if (!visibleState.character.isActive) {
|
||||
return <>{mobileEditGuidance}<div className="hidden md:block"><PageState description="상세 화면에서 조회만 가능합니다." state="empty" title="비활성화된 AI 캐릭터는 수정할 수 없습니다." /></div></>;
|
||||
}
|
||||
|
||||
return <>{mobileEditGuidance}<div className="hidden md:block"><CharacterEditForm apiClient={apiClient} character={visibleState.character} createCropSource={createCropSource} renderCrop={renderCrop} /></div></>;
|
||||
}
|
||||
109
src/features/characters/pages/CharacterListPage.tsx
Normal file
109
src/features/characters/pages/CharacterListPage.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { navigateTo, useBrowserLocation } from "@/app/browser-location";
|
||||
import { routePaths } from "@/app/route-paths";
|
||||
import { getCharacters } from "@/features/characters/api/character-api";
|
||||
import { CharacterList } from "@/features/characters/components/CharacterList";
|
||||
import type { CharacterListItem } from "@/features/characters/model/types";
|
||||
import { ApiError } from "@/shared/api/api-error";
|
||||
import type { ApiClient } from "@/shared/api/client";
|
||||
import type { PageData } from "@/shared/api/pagination";
|
||||
import { PageState } from "@/shared/ui/page-state";
|
||||
import { ResourcePagination } from "@/shared/ui/resource-pagination";
|
||||
import { SearchToolbar } from "@/shared/ui/search-toolbar";
|
||||
|
||||
type ListQuery = {
|
||||
readonly page: number;
|
||||
readonly search: string;
|
||||
readonly size: number;
|
||||
};
|
||||
|
||||
type ListState =
|
||||
| { readonly requestKey: string; readonly status: "loading" }
|
||||
| { readonly data: PageData<CharacterListItem>; readonly requestKey: string; readonly status: "content" }
|
||||
| { readonly message: string; readonly requestKey: string; readonly status: "error" };
|
||||
|
||||
function getListQuery(): ListQuery {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const page = Number(query.get("page") ?? "0");
|
||||
const size = Number(query.get("size") ?? "20");
|
||||
|
||||
return {
|
||||
page: Number.isFinite(page) ? Math.max(0, Math.trunc(page)) : 0,
|
||||
size: Number.isFinite(size) ? Math.max(1, Math.trunc(size)) : 20,
|
||||
search: query.get("searchTerm") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function navigateList(query: ListQuery): void {
|
||||
const nextQuery = new URLSearchParams({ page: String(query.page), size: String(query.size) });
|
||||
if (query.search.trim().length > 0) {
|
||||
nextQuery.set("searchTerm", query.search.trim());
|
||||
}
|
||||
navigateTo(`${routePaths.aiCharacters}?${nextQuery.toString()}`);
|
||||
}
|
||||
|
||||
export function CharacterListPage({ apiClient, routeError }: { readonly apiClient: ApiClient; readonly routeError: string | null }) {
|
||||
const location = useBrowserLocation();
|
||||
const query = getListQuery();
|
||||
const [state, setState] = useState<ListState>({ requestKey: "", status: "loading" });
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
const requestKey = `${location.visitKey}:${query.page}:${query.search}:${query.size}:${retryKey}`;
|
||||
|
||||
useEffect(() => {
|
||||
let isCurrent = true;
|
||||
void getCharacters(apiClient, { searchTerm: query.search, page: query.page, size: query.size })
|
||||
.then((data) => {
|
||||
if (isCurrent) {
|
||||
setState({ data, requestKey, status: "content" });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (isCurrent) {
|
||||
setState({ message: error instanceof ApiError ? error.message : "AI 캐릭터 목록을 불러오지 못했습니다.", requestKey, status: "error" });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [apiClient, query.page, query.search, query.size, requestKey]);
|
||||
|
||||
const visibleState: ListState = state.requestKey === requestKey ? state : { requestKey, status: "loading" };
|
||||
const onQueryChange = useCallback((nextSearch: string) => navigateList({ page: 0, search: nextSearch, size: query.size }), [query.size]);
|
||||
const onPageChange = useCallback((page: number) => navigateList({ page, search: query.search, size: query.size }), [query.search, query.size]);
|
||||
const onSizeChange = useCallback((size: number) => navigateList({ page: 0, search: query.search, size }), [query.search]);
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4" aria-labelledby="ai-characters-title">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-semibold text-info">AI CHARACTER ADMIN</p>
|
||||
<h1 className="text-2xl font-bold leading-tight" id="ai-characters-title">AI 캐릭터</h1>
|
||||
<p className="text-sm text-muted-foreground">목록에서 캐릭터를 검색하고 선택해 워크스페이스로 이동합니다.</p>
|
||||
</div>
|
||||
<a
|
||||
className="hidden rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] md:inline-flex"
|
||||
href={routePaths.aiCharacterCreate}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateTo(routePaths.aiCharacterCreate);
|
||||
}}
|
||||
>
|
||||
AI 캐릭터 생성
|
||||
</a>
|
||||
</div>
|
||||
{routeError === null ? null : <p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">{routeError}</p>}
|
||||
<SearchToolbar key={query.search} onQueryChange={onQueryChange} search={query.search} />
|
||||
{visibleState.status === "loading" ? <PageState state="loading" title="AI 캐릭터 목록을 불러오는 중" /> : null}
|
||||
{visibleState.status === "error" ? <PageState description={visibleState.message} onRetry={() => setRetryKey((key) => key + 1)} state="error" title="AI 캐릭터 목록 조회 실패" /> : null}
|
||||
{visibleState.status === "content" && visibleState.data.items.length === 0 ? <PageState description="검색어를 바꾸거나 나중에 다시 시도하세요." state="empty" title="검색 조건에 맞는 AI 캐릭터가 없습니다." /> : null}
|
||||
{visibleState.status === "content" && visibleState.data.items.length > 0 ? (
|
||||
<>
|
||||
<CharacterList data={visibleState.data} />
|
||||
<ResourcePagination data={visibleState.data} onPageChange={onPageChange} onSizeChange={onSizeChange} />
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
506
src/features/characters/tests/CharacterCreatePage.test.tsx
Normal file
506
src/features/characters/tests/CharacterCreatePage.test.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { CharacterCreatePage } from "@/features/characters/pages/CharacterCreatePage";
|
||||
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 Character create page tests keep form validation, payload, crop, and retry contract coverage together.
|
||||
|
||||
type CapturedRequest = {
|
||||
readonly body?: BodyInit | null;
|
||||
readonly method?: string;
|
||||
readonly path: string;
|
||||
};
|
||||
|
||||
function createPendingClient(requests: CapturedRequest[]) {
|
||||
let finishRequest: (() => void) | null = null;
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
finishRequest = resolve;
|
||||
});
|
||||
const apiClient: ApiClient = {
|
||||
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
|
||||
requests.push({ body: options.body, method: options.method, path: options.path });
|
||||
await finished;
|
||||
return options.responseSchema.parse(null);
|
||||
},
|
||||
};
|
||||
|
||||
return { apiClient, finishRequest: () => finishRequest?.() };
|
||||
}
|
||||
|
||||
function createOriginalWorkClient(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.startsWith("/api/v2/admin/ai-characters/original-works/search")) {
|
||||
return options.responseSchema.parse([
|
||||
{
|
||||
id: 7,
|
||||
title: "달빛 상담소",
|
||||
contentType: "WEBTOON",
|
||||
category: "힐링",
|
||||
isAdult: false,
|
||||
description: "차분한 상담 원작",
|
||||
originalWork: "Moonlight Office",
|
||||
originalLink: "https://example.com/moonlight",
|
||||
writer: "하린",
|
||||
studio: "소다스튜디오",
|
||||
originalLinks: ["https://example.com/moonlight"],
|
||||
tags: ["상담", "힐링"],
|
||||
imageUrl: "https://example.com/moonlight.png",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return options.responseSchema.parse(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createRejectOnceClient(requests: CapturedRequest[]): ApiClient {
|
||||
let submitCount = 0;
|
||||
|
||||
return {
|
||||
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
|
||||
requests.push({ body: options.body, method: options.method, path: options.path });
|
||||
submitCount += 1;
|
||||
if (submitCount === 1) {
|
||||
throw new ApiError({ status: 500, message: "서버에서 캐릭터 생성을 거부했습니다.", errorProperty: 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("CharacterCreatePage shows required field errors without submitting", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
const { apiClient } = createPendingClient(requests);
|
||||
render(<CharacterCreatePage apiClient={apiClient} />);
|
||||
|
||||
// When
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
expect(await screen.findByText("이름을 입력하세요.")).toBeInTheDocument();
|
||||
expect(screen.getByText("시스템 프롬프트를 입력하세요.")).toBeInTheDocument();
|
||||
expect(screen.getByText("설명을 입력하세요.")).toBeInTheDocument();
|
||||
expect(screen.getByText("프로필 이미지를 선택하세요.")).toBeInTheDocument();
|
||||
expect(requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("CharacterCreatePage links required errors and focuses the first invalid field", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
const { apiClient } = createPendingClient(requests);
|
||||
render(<CharacterCreatePage apiClient={apiClient} />);
|
||||
|
||||
// When
|
||||
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-create-name-error");
|
||||
expect(screen.getByText("시스템 프롬프트를 입력하세요.")).toHaveAttribute("id", "character-create-system-prompt-error");
|
||||
expect(screen.getByText("설명을 입력하세요.")).toHaveAttribute("id", "character-create-description-error");
|
||||
expect(nameInput).toHaveAttribute("aria-describedby", "character-create-name-error");
|
||||
expect(systemPromptInput).toHaveAttribute("aria-describedby", "character-create-system-prompt-error");
|
||||
expect(descriptionInput).toHaveAttribute("aria-describedby", "character-create-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).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("CharacterCreatePage submits multipart once and returns to the list", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
const { apiClient, finishRequest } = createPendingClient(requests);
|
||||
const image = new File(["image"], "luna.png", { type: "image/png" });
|
||||
const croppedImage = new File(["cropped"], "luna-cropped.png", { type: "image/png" });
|
||||
window.history.pushState({}, "", "/ai-characters/new");
|
||||
render(<CharacterCreatePage apiClient={apiClient} createCropSource={createCropSource} renderCrop={() => Promise.resolve(croppedImage)} />);
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("이름"), { target: { value: "루나" } });
|
||||
fireEvent.change(screen.getByLabelText("시스템 프롬프트"), { target: { value: "친절하게 답한다." } });
|
||||
fireEvent.change(screen.getByLabelText("설명"), { target: { value: "차분한 상담형 캐릭터" } });
|
||||
fireEvent.change(screen.getByLabelText("프로필 이미지"), { target: { files: [image] } });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
|
||||
await waitFor(() => expect(screen.getByText("luna-cropped.png")).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
await screen.findByRole("button", { name: "생성 중" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성 중" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(requests).toHaveLength(1));
|
||||
expect(screen.getByRole("button", { name: "생성 중" })).toBeDisabled();
|
||||
const body = requireFormData(requests[0]?.body);
|
||||
expect(requests[0]).toMatchObject({ method: "POST", path: "/api/v2/admin/ai-characters" });
|
||||
expect(body.get("image")).toBe(croppedImage);
|
||||
expect(await readJsonPart(body.get("request"))).toEqual({ name: "루나", systemPrompt: "친절하게 답한다.", description: "차분한 상담형 캐릭터" });
|
||||
|
||||
finishRequest();
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters"));
|
||||
});
|
||||
|
||||
test("CharacterCreatePage keeps entered values and retries after an ApiError rejection", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
const image = new File(["image"], "luna.png", { type: "image/png" });
|
||||
window.history.pushState({}, "", "/ai-characters/new");
|
||||
render(<CharacterCreatePage apiClient={createRejectOnceClient(requests)} createCropSource={createCropSource} renderCrop={renderOriginalCrop} />);
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("이름"), { target: { value: "루나" } });
|
||||
fireEvent.change(screen.getByLabelText("시스템 프롬프트"), { target: { value: "친절하게 답한다." } });
|
||||
fireEvent.change(screen.getByLabelText("설명"), { target: { value: "차분한 상담형 캐릭터" } });
|
||||
fireEvent.change(screen.getByLabelText("프로필 이미지"), { target: { files: [image] } });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
|
||||
await waitFor(() => expect(screen.getByText("luna.png")).toBeInTheDocument());
|
||||
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"));
|
||||
expect(requests).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("CharacterCreatePage submits entered optional scalar and array fields", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
const { apiClient } = createPendingClient(requests);
|
||||
const image = new File(["image"], "noa.png", { type: "image/png" });
|
||||
render(<CharacterCreatePage apiClient={apiClient} createCropSource={createCropSource} renderCrop={renderOriginalCrop} />);
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("이름"), { target: { value: " 노아 " } });
|
||||
fireEvent.change(screen.getByLabelText("시스템 프롬프트"), { target: { value: " 차분하게 답한다. " } });
|
||||
fireEvent.change(screen.getByLabelText("설명"), { target: { value: " 상담형 캐릭터 " } });
|
||||
fireEvent.change(screen.getByLabelText("프로필 이미지"), { target: { files: [image] } });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
|
||||
await waitFor(() => expect(screen.getByText("noa.png")).toBeInTheDocument());
|
||||
fireEvent.change(screen.getByLabelText("나이"), { target: { value: " 24 " } });
|
||||
fireEvent.change(screen.getByLabelText("성별"), { target: { value: " 여성 " } });
|
||||
fireEvent.change(screen.getByLabelText("MBTI"), { target: { value: " INFJ " } });
|
||||
fireEvent.change(screen.getByLabelText("말투 패턴"), { target: { value: " 존댓말 " } });
|
||||
fireEvent.change(screen.getByLabelText("말투 스타일"), { target: { value: " 부드러움 " } });
|
||||
fireEvent.change(screen.getByLabelText("외형"), { target: { value: " 긴 머리 " } });
|
||||
fireEvent.change(screen.getByLabelText("지역"), { target: { value: " KR " } });
|
||||
fireEvent.change(screen.getByLabelText("원작 제목"), { target: { value: " 달빛 상담소 " } });
|
||||
fireEvent.change(screen.getByLabelText("원작 링크"), { target: { value: " https://example.com/original " } });
|
||||
fireEvent.change(screen.getByLabelText("캐릭터 유형"), { target: { value: " Character " } });
|
||||
fireEvent.change(screen.getByLabelText("태그"), { target: { value: " 상담\n힐링 " } });
|
||||
fireEvent.change(screen.getByLabelText("취미"), { target: { value: " 독서\n산책 " } });
|
||||
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: "3" } });
|
||||
fireEvent.change(screen.getByLabelText("관계 유형"), { target: { value: " friend " } });
|
||||
fireEvent.change(screen.getByLabelText("관계 현재 상태"), { target: { value: " active " } });
|
||||
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: " calm " } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(requests).toHaveLength(1));
|
||||
expect(await readJsonPart(requireFormData(requests[0]?.body).get("request"))).toEqual({
|
||||
name: "노아",
|
||||
systemPrompt: "차분하게 답한다.",
|
||||
description: "상담형 캐릭터",
|
||||
age: "24",
|
||||
gender: "여성",
|
||||
mbti: "INFJ",
|
||||
speechPattern: "존댓말",
|
||||
speechStyle: "부드러움",
|
||||
appearance: "긴 머리",
|
||||
region: "KR",
|
||||
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("CharacterCreatePage rejects unsupported image type and images over 10MB", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
const { apiClient } = createPendingClient(requests);
|
||||
const unsupportedImage = new File(["image"], "luna.gif", { type: "image/gif" });
|
||||
const largeImage = new File([new Uint8Array(10 * 1024 * 1024 + 1)], "luna.png", { type: "image/png" });
|
||||
render(<CharacterCreatePage apiClient={apiClient} />);
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("이름"), { target: { value: "루나" } });
|
||||
fireEvent.change(screen.getByLabelText("시스템 프롬프트"), { target: { value: "친절하게 답한다." } });
|
||||
fireEvent.change(screen.getByLabelText("설명"), { target: { value: "차분한 상담형 캐릭터" } });
|
||||
fireEvent.change(screen.getByLabelText("프로필 이미지"), { target: { files: [unsupportedImage] } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
expect(await screen.findByText("JPEG 또는 PNG 파일만 업로드하세요.")).toBeInTheDocument();
|
||||
expect(requests).toHaveLength(0);
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("프로필 이미지"), { target: { files: [largeImage] } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
expect(await screen.findByText("프로필 이미지는 10MB 이하만 업로드할 수 있습니다.")).toBeInTheDocument();
|
||||
expect(requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
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" })],
|
||||
])("CharacterCreatePage rejects %s before crop or submit", async (_caseName, image) => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
const { apiClient } = createPendingClient(requests);
|
||||
const createCropSource = vi.fn((file: File) => Promise.resolve({ file, height: 1200, previewUrl: "blob:luna", width: 1200 }));
|
||||
render(<CharacterCreatePage apiClient={apiClient} createCropSource={createCropSource} />);
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("이름"), { target: { value: "루나" } });
|
||||
fireEvent.change(screen.getByLabelText("시스템 프롬프트"), { target: { value: "친절하게 답한다." } });
|
||||
fireEvent.change(screen.getByLabelText("설명"), { target: { value: "차분한 상담형 캐릭터" } });
|
||||
fireEvent.change(screen.getByLabelText("프로필 이미지"), { target: { files: [image] } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
expect(await screen.findByText("JPEG 또는 PNG 파일만 업로드하세요.")).toBeInTheDocument();
|
||||
expect(createCropSource).not.toHaveBeenCalled();
|
||||
expect(requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("CharacterCreatePage crops the selected image before submit", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
const { apiClient } = createPendingClient(requests);
|
||||
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);
|
||||
render(
|
||||
<CharacterCreatePage
|
||||
apiClient={apiClient}
|
||||
createCropSource={(file) => Promise.resolve({ file, height: 1200, previewUrl: "blob:luna", width: 1200 })}
|
||||
renderCrop={renderCrop}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("프로필 이미지"), { target: { files: [originalImage] } });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
|
||||
await waitFor(() => expect(screen.getByText("luna-cropped.png")).toBeInTheDocument());
|
||||
fireEvent.change(screen.getByLabelText("이름"), { target: { value: "루나" } });
|
||||
fireEvent.change(screen.getByLabelText("시스템 프롬프트"), { target: { value: "친절하게 답한다." } });
|
||||
fireEvent.change(screen.getByLabelText("설명"), { target: { value: "차분한 상담형 캐릭터" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(requests).toHaveLength(1));
|
||||
const submittedImage = requireFile(requireFormData(requests[0]?.body).get("image"));
|
||||
expect(submittedImage.name).toBe("luna-cropped.png");
|
||||
expect(await submittedImage.text()).toBe("cropped");
|
||||
});
|
||||
|
||||
test("CharacterCreatePage blocks submit while 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 { apiClient } = createPendingClient(requests);
|
||||
const image = new File(["raw"], "luna.png", { type: "image/png" });
|
||||
render(<CharacterCreatePage apiClient={apiClient} createCropSource={() => cropSourceReady} />);
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("이름"), { target: { value: "루나" } });
|
||||
fireEvent.change(screen.getByLabelText("시스템 프롬프트"), { target: { value: "친절하게 답한다." } });
|
||||
fireEvent.change(screen.getByLabelText("설명"), { target: { value: "차분한 상담형 캐릭터" } });
|
||||
fireEvent.change(screen.getByLabelText("프로필 이미지"), { target: { files: [image] } });
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("button", { name: "생성" })).toBeDisabled();
|
||||
|
||||
// When
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
expect(requests).toHaveLength(0);
|
||||
|
||||
// When
|
||||
resolveCropSource({ file: image, height: 1200, previewUrl: "blob:luna", width: 1200 });
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("dialog", { name: "이미지 crop" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("CharacterCreatePage clears the selected image when crop is cancelled", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
const { apiClient } = createPendingClient(requests);
|
||||
const image = new File(["raw"], "luna.png", { type: "image/png" });
|
||||
render(<CharacterCreatePage apiClient={apiClient} createCropSource={createCropSource} />);
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("이름"), { target: { value: "루나" } });
|
||||
fireEvent.change(screen.getByLabelText("시스템 프롬프트"), { target: { value: "친절하게 답한다." } });
|
||||
fireEvent.change(screen.getByLabelText("설명"), { target: { value: "차분한 상담형 캐릭터" } });
|
||||
fireEvent.change(screen.getByLabelText("프로필 이미지"), { target: { files: [image] } });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "취소" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
expect(await screen.findByText("프로필 이미지를 선택하세요.")).toBeInTheDocument();
|
||||
expect(screen.getByText("선택된 파일 없음")).toBeInTheDocument();
|
||||
expect(requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("CharacterCreatePage ignores a stale 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 { apiClient } = createPendingClient(requests);
|
||||
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);
|
||||
render(<CharacterCreatePage apiClient={apiClient} createCropSource={createRaceCropSource} renderCrop={renderOriginalCrop} />);
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("프로필 이미지"), { 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: "적용" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByText("fresh.png")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
test("CharacterCreatePage shows an image error when crop source preparation rejects", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
const { apiClient } = createPendingClient(requests);
|
||||
const image = new File(["raw"], "luna.png", { type: "image/png" });
|
||||
render(<CharacterCreatePage apiClient={apiClient} createCropSource={() => Promise.reject(new Error("preview failed"))} />);
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("이름"), { target: { value: "루나" } });
|
||||
fireEvent.change(screen.getByLabelText("시스템 프롬프트"), { target: { value: "친절하게 답한다." } });
|
||||
fireEvent.change(screen.getByLabelText("설명"), { target: { value: "차분한 상담형 캐릭터" } });
|
||||
fireEvent.change(screen.getByLabelText("프로필 이미지"), { target: { files: [image] } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
expect(await screen.findByText("이미지 미리보기 준비에 실패했습니다.")).toBeInTheDocument();
|
||||
expect(requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("P10-T1 CharacterCreatePage searches v2 original works after 300ms and submits selected originalWorkId", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
const image = new File(["image"], "luna.png", { type: "image/png" });
|
||||
render(<CharacterCreatePage apiClient={createOriginalWorkClient(requests)} createCropSource={createCropSource} renderCrop={renderOriginalCrop} />);
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("이름"), { target: { value: "루나" } });
|
||||
fireEvent.change(screen.getByLabelText("시스템 프롬프트"), { target: { value: "친절하게 답한다." } });
|
||||
fireEvent.change(screen.getByLabelText("설명"), { target: { value: "차분한 상담형 캐릭터" } });
|
||||
fireEvent.change(screen.getByLabelText("프로필 이미지"), { target: { files: [image] } });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
|
||||
await waitFor(() => expect(screen.getByText("luna.png")).toBeInTheDocument());
|
||||
vi.useFakeTimers();
|
||||
fireEvent.change(screen.getByLabelText("원작 검색어"), { target: { value: "달빛" } });
|
||||
act(() => vi.advanceTimersByTime(299));
|
||||
expect(requests).toHaveLength(0);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1);
|
||||
await Promise.resolve();
|
||||
});
|
||||
vi.useRealTimers();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "달빛 상담소 선택" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(requests).toHaveLength(2));
|
||||
expect(requests[0]?.path).toBe("/api/v2/admin/ai-characters/original-works/search?searchTerm=%EB%8B%AC%EB%B9%9B");
|
||||
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({
|
||||
name: "루나",
|
||||
systemPrompt: "친절하게 답한다.",
|
||||
description: "차분한 상담형 캐릭터",
|
||||
originalWorkId: 7,
|
||||
});
|
||||
});
|
||||
154
src/features/characters/tests/CharacterDetailPage.test.tsx
Normal file
154
src/features/characters/tests/CharacterDetailPage.test.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { CharacterDetailPage } from "@/features/characters/pages/CharacterDetailPage";
|
||||
import type { CharacterDetail } from "@/features/characters/model/types";
|
||||
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
function createDetailClient(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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
test("CharacterDetailPage soft deletes an active character after confirmation", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101");
|
||||
|
||||
// When
|
||||
render(<CharacterDetailPage apiClient={createDetailClient(requests)} characterId="101" />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "비활성화" }));
|
||||
|
||||
// Then
|
||||
const dialog = screen.getByRole("alertdialog", { name: "루나 비활성화 확인" });
|
||||
expect(dialog).toHaveTextContent("복원은 지원하지 않습니다.");
|
||||
expect(dialog).toHaveTextContent("완전 삭제는 아니며 목록에서 제외됩니다.");
|
||||
expect(dialog).not.toHaveTextContent("hard delete");
|
||||
|
||||
// When
|
||||
fireEvent.click(screen.getByRole("button", { name: "비활성화 확인" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters"));
|
||||
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({ isActive: false });
|
||||
});
|
||||
|
||||
test("CharacterDetailPage sends one deactivate request while pending and shows retry guidance on failure", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
let rejectDeactivate: () => void = () => { throw new Error("Deactivate request was not started"); };
|
||||
const client: ApiClient = {
|
||||
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
|
||||
requests.push({ body: options.body, method: options.method, path: options.path });
|
||||
if (options.method === undefined) {
|
||||
return options.responseSchema.parse(activeCharacter);
|
||||
}
|
||||
await new Promise<void>((_, reject) => { rejectDeactivate = () => reject(new Error("fail")); });
|
||||
return options.responseSchema.parse(null);
|
||||
},
|
||||
};
|
||||
window.history.pushState({}, "", "/ai-characters/101");
|
||||
render(<CharacterDetailPage apiClient={client} characterId="101" />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "비활성화" }));
|
||||
|
||||
// When
|
||||
fireEvent.click(screen.getByRole("button", { name: "비활성화 확인" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "처리 중" }));
|
||||
rejectDeactivate();
|
||||
|
||||
// Then
|
||||
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(1);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("비활성화하지 못했습니다. 다시 시도하세요.");
|
||||
});
|
||||
|
||||
test("CharacterDetailPage routes active characters to edit", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101");
|
||||
|
||||
// When
|
||||
render(<CharacterDetailPage apiClient={createDetailClient(requests)} characterId="101" />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "수정" }));
|
||||
|
||||
// Then
|
||||
expect(window.location.pathname).toBe("/ai-characters/101/edit");
|
||||
});
|
||||
|
||||
test("CharacterDetailPage shows characterUUID as read-only profile data", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101");
|
||||
|
||||
// When
|
||||
render(<CharacterDetailPage apiClient={createDetailClient(requests)} characterId="101" />);
|
||||
|
||||
// Then
|
||||
const profile = await screen.findByRole("region", { name: "프로필" });
|
||||
expect(within(profile).getByText("캐릭터 UUID")).toBeInTheDocument();
|
||||
expect(within(profile).getByText("character-101")).toBeInTheDocument();
|
||||
expect(screen.queryByText("externalCharacterId")).not.toBeInTheDocument();
|
||||
});
|
||||
577
src/features/characters/tests/CharacterEditPage.test.tsx
Normal file
577
src/features/characters/tests/CharacterEditPage.test.tsx
Normal file
@@ -0,0 +1,577 @@
|
||||
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: "차분한 상담형 캐릭터",
|
||||
});
|
||||
});
|
||||
272
src/features/characters/tests/character-api.test.ts
Normal file
272
src/features/characters/tests/character-api.test.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import * as characterApi from "@/features/characters/api/character-api";
|
||||
import {
|
||||
createCharacter,
|
||||
deactivateCharacter,
|
||||
updateCharacter,
|
||||
} from "@/features/characters/api/character-api";
|
||||
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
|
||||
|
||||
type CapturedRequest = {
|
||||
readonly path: string;
|
||||
readonly method?: string;
|
||||
readonly body?: BodyInit | null;
|
||||
};
|
||||
|
||||
function createCapturingClient(requests: CapturedRequest[]): ApiClient {
|
||||
return {
|
||||
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
|
||||
requests.push({ path: options.path, method: options.method, body: options.body });
|
||||
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;
|
||||
}
|
||||
|
||||
describe("Character API mutations", () => {
|
||||
test("P10-T1 searchOriginalWorks calls the v2 path with searchTerm and parses the full DTO", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
const originalWorks = [
|
||||
{
|
||||
id: 7,
|
||||
title: "달빛 상담소",
|
||||
contentType: "WEBTOON",
|
||||
category: "힐링",
|
||||
isAdult: false,
|
||||
description: "차분한 상담 원작",
|
||||
originalWork: "Moonlight Office",
|
||||
originalLink: "https://example.com/moonlight",
|
||||
writer: "하린",
|
||||
studio: "소다스튜디오",
|
||||
originalLinks: ["https://example.com/moonlight"],
|
||||
tags: ["상담", "힐링"],
|
||||
imageUrl: "https://example.com/moonlight.png",
|
||||
},
|
||||
];
|
||||
const client: ApiClient = {
|
||||
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
|
||||
requests.push({ path: options.path, method: options.method, body: options.body });
|
||||
return options.responseSchema.parse(originalWorks);
|
||||
},
|
||||
};
|
||||
const searchOriginalWorks = "searchOriginalWorks" in characterApi ? characterApi.searchOriginalWorks : undefined;
|
||||
|
||||
// When
|
||||
expect(searchOriginalWorks).toBeTypeOf("function");
|
||||
if (typeof searchOriginalWorks !== "function") {
|
||||
throw new TypeError("searchOriginalWorks is missing");
|
||||
}
|
||||
const result: unknown = await searchOriginalWorks(client, " 달빛 상담 ");
|
||||
|
||||
// Then
|
||||
expect(requests).toMatchObject([
|
||||
{ path: "/api/v2/admin/ai-characters/original-works/search?searchTerm=%EB%8B%AC%EB%B9%9B+%EC%83%81%EB%8B%B4", method: undefined },
|
||||
]);
|
||||
expect(result).toEqual(originalWorks);
|
||||
});
|
||||
|
||||
test("createCharacter sends required image and JSON request without server-owned fields", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
const client = createCapturingClient(requests);
|
||||
const image = new File(["image-bytes"], "luna.png", { type: "image/png" });
|
||||
|
||||
// When
|
||||
await createCharacter(client, {
|
||||
image,
|
||||
request: {
|
||||
name: "루나",
|
||||
systemPrompt: "친절하고 안전하게 답한다.",
|
||||
description: "차분한 상담형 AI 캐릭터",
|
||||
region: "KR",
|
||||
tags: ["상담", "힐링"],
|
||||
},
|
||||
});
|
||||
|
||||
// Then
|
||||
const body = requireFormData(requests[0]?.body);
|
||||
expect(requests[0]).toMatchObject({ path: "/api/v2/admin/ai-characters", method: "POST" });
|
||||
expect(body?.get("image")).toBe(image);
|
||||
expect(await readJsonPart(body?.get("request") ?? null)).toEqual({
|
||||
name: "루나",
|
||||
systemPrompt: "친절하고 안전하게 답한다.",
|
||||
description: "차분한 상담형 AI 캐릭터",
|
||||
region: "KR",
|
||||
tags: ["상담", "힐링"],
|
||||
});
|
||||
});
|
||||
|
||||
test("updateCharacter omits region and isActive while deactivateCharacter sends only isActive false", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
const client = createCapturingClient(requests);
|
||||
const image = new File(["new-image"], "luna-new.png", { type: "image/png" });
|
||||
|
||||
// When
|
||||
await updateCharacter(client, "101", {
|
||||
image,
|
||||
request: {
|
||||
name: "루나 수정",
|
||||
systemPrompt: "수정된 프롬프트",
|
||||
description: "수정된 설명",
|
||||
tags: ["수정"],
|
||||
},
|
||||
});
|
||||
await deactivateCharacter(client, "101");
|
||||
|
||||
// Then
|
||||
const updateBody = requireFormData(requests[0]?.body);
|
||||
const deactivateBody = requireFormData(requests[1]?.body);
|
||||
expect(requests).toMatchObject([
|
||||
{ path: "/api/v2/admin/ai-characters/101", method: "PUT" },
|
||||
{ path: "/api/v2/admin/ai-characters/101", method: "PUT" },
|
||||
]);
|
||||
expect(updateBody?.get("image")).toBe(image);
|
||||
expect(await readJsonPart(updateBody?.get("request") ?? null)).toEqual({
|
||||
name: "루나 수정",
|
||||
systemPrompt: "수정된 프롬프트",
|
||||
description: "수정된 설명",
|
||||
tags: ["수정"],
|
||||
});
|
||||
expect(deactivateBody?.has("image")).toBe(false);
|
||||
expect(await readJsonPart(deactivateBody?.get("request") ?? null)).toEqual({ isActive: false });
|
||||
});
|
||||
|
||||
test("createCharacter and updateCharacter serialize optional scalar and repeated fields", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
const client = createCapturingClient(requests);
|
||||
const image = new File(["image-bytes"], "noa.png", { type: "image/png" });
|
||||
|
||||
// When
|
||||
await createCharacter(client, {
|
||||
image,
|
||||
request: {
|
||||
name: "노아",
|
||||
systemPrompt: "차분하게 답한다.",
|
||||
description: "상담형 캐릭터",
|
||||
age: "24",
|
||||
gender: "FEMALE",
|
||||
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" }],
|
||||
},
|
||||
});
|
||||
await updateCharacter(client, "101", {
|
||||
request: {
|
||||
name: "노아 수정",
|
||||
age: null,
|
||||
tags: null,
|
||||
hobbies: null,
|
||||
values: null,
|
||||
goals: null,
|
||||
relationships: null,
|
||||
personalities: null,
|
||||
backgrounds: null,
|
||||
memories: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(await readJsonPart(requireFormData(requests[0]?.body).get("request"))).toEqual({
|
||||
name: "노아",
|
||||
systemPrompt: "차분하게 답한다.",
|
||||
description: "상담형 캐릭터",
|
||||
age: "24",
|
||||
gender: "FEMALE",
|
||||
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" }],
|
||||
});
|
||||
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({
|
||||
name: "노아 수정",
|
||||
age: null,
|
||||
tags: null,
|
||||
hobbies: null,
|
||||
values: null,
|
||||
goals: null,
|
||||
relationships: null,
|
||||
personalities: null,
|
||||
backgrounds: null,
|
||||
memories: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("P10-T1 createCharacter and updateCharacter omit originalWorkId when selection is cleared", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
const client = createCapturingClient(requests);
|
||||
const image = new File(["image-bytes"], "noa.png", { type: "image/png" });
|
||||
|
||||
// When
|
||||
await createCharacter(client, {
|
||||
image,
|
||||
request: {
|
||||
name: "노아",
|
||||
systemPrompt: "차분하게 답한다.",
|
||||
description: "상담형 캐릭터",
|
||||
originalWorkId: null,
|
||||
},
|
||||
});
|
||||
await updateCharacter(client, "101", {
|
||||
request: {
|
||||
name: "노아 수정",
|
||||
originalWorkId: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(await readJsonPart(requireFormData(requests[0]?.body).get("request"))).toEqual({
|
||||
name: "노아",
|
||||
systemPrompt: "차분하게 답한다.",
|
||||
description: "상담형 캐릭터",
|
||||
});
|
||||
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({
|
||||
name: "노아 수정",
|
||||
});
|
||||
});
|
||||
});
|
||||
127
src/features/characters/tests/character-list.test.tsx
Normal file
127
src/features/characters/tests/character-list.test.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { App } from "@/app/App";
|
||||
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
|
||||
import { server } from "@/shared/test/server";
|
||||
|
||||
const apiBaseUrl = "https://api.example.com";
|
||||
const activeCharacter = {
|
||||
id: 101,
|
||||
name: "루나",
|
||||
imageUrl: "https://cdn.example.com/luna.png",
|
||||
description: "차분한 상담형 AI 캐릭터",
|
||||
gender: "여성",
|
||||
age: 24,
|
||||
mbti: "INFJ",
|
||||
speechStyle: "다정함",
|
||||
speechPattern: "존댓말",
|
||||
region: "KR",
|
||||
tags: ["상담", "힐링"],
|
||||
createdAt: "2026-07-28 10:00:00",
|
||||
updatedAt: "2026-07-28 11:00:00",
|
||||
} as const;
|
||||
|
||||
function saveAdminSession() {
|
||||
authSessionStorage.save({ token: "admin-token", role: "ADMIN" });
|
||||
}
|
||||
|
||||
function useListResponse(onRequest: (request: Request) => void = () => undefined) {
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, ({ request }) => {
|
||||
onRequest(request);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
message: null,
|
||||
data: { totalCount: 1, content: [activeCharacter] },
|
||||
errorProperty: null,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
test("Character list serializes URL search to searchTerm without active filters and preserves pagination", async () => {
|
||||
// Given
|
||||
saveAdminSession();
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const requests: Request[] = [];
|
||||
useListResponse((request) => requests.push(request));
|
||||
window.history.pushState({}, "", "/ai-characters?searchTerm=루나&page=1&size=20");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
|
||||
await waitFor(() => expect(requests.length).toBeGreaterThanOrEqual(2));
|
||||
const url = new URL(requests.at(-1)?.url ?? "");
|
||||
expect(url.pathname).toBe("/api/v2/admin/ai-characters");
|
||||
expect(url.searchParams.get("searchTerm")).toBe("루나");
|
||||
expect(url.searchParams.get("page")).toBe("1");
|
||||
expect(url.searchParams.get("size")).toBe("20");
|
||||
expect(url.searchParams.has("isActive")).toBe(false);
|
||||
expect(url.searchParams.has("activeStatus")).toBe(false);
|
||||
expect(screen.getByRole("searchbox", { name: "검색어" })).toHaveValue("루나");
|
||||
expect(screen.getAllByRole("link", { name: /루나 선택/ })[0]).toHaveAttribute("href", "/ai-characters/101");
|
||||
});
|
||||
|
||||
test("Character list supports loading, empty, error retry, and selection navigation", async () => {
|
||||
// Given
|
||||
saveAdminSession();
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
let listRequestCount = 0;
|
||||
let resolveFirstListReady: (finishList: () => void) => void = () => undefined;
|
||||
const firstListReady = new Promise<() => void>((resolve) => {
|
||||
resolveFirstListReady = resolve;
|
||||
});
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.get("page") === "0" && url.searchParams.get("size") === "20" && !url.searchParams.has("searchTerm")) {
|
||||
return HttpResponse.json({ success: true, message: null, data: { totalCount: 0, content: [] }, errorProperty: null });
|
||||
}
|
||||
|
||||
listRequestCount += 1;
|
||||
if (listRequestCount === 1) {
|
||||
return new Promise((resolve) => {
|
||||
resolveFirstListReady(() => resolve(HttpResponse.json({ success: true, message: null, data: { totalCount: 0, content: [] }, errorProperty: null })));
|
||||
});
|
||||
}
|
||||
if (listRequestCount === 2) {
|
||||
return HttpResponse.json({ success: false, message: "목록 조회 실패", data: null, errorProperty: null }, { status: 500 });
|
||||
}
|
||||
|
||||
return HttpResponse.json({ success: true, message: null, data: { totalCount: 1, content: [activeCharacter] }, errorProperty: null });
|
||||
}),
|
||||
);
|
||||
window.history.pushState({}, "", "/ai-characters?searchTerm=없음");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
const finishFirstList = await firstListReady;
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("status")).toHaveTextContent("AI 캐릭터 목록을 불러오는 중");
|
||||
finishFirstList();
|
||||
expect(await screen.findByText("검색 조건에 맞는 AI 캐릭터가 없습니다.")).toBeInTheDocument();
|
||||
|
||||
vi.useFakeTimers();
|
||||
fireEvent.change(screen.getByRole("searchbox", { name: "검색어" }), { target: { value: "루나" } });
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
vi.useRealTimers();
|
||||
await waitFor(() => expect(window.location.search).toContain("searchTerm=%EB%A3%A8%EB%82%98"));
|
||||
expect(window.location.search).not.toContain("search=");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("목록 조회 실패");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||
const links = await screen.findAllByRole("link", { name: /루나 선택/ });
|
||||
fireEvent.click(links[0]);
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101"));
|
||||
});
|
||||
169
src/features/characters/tests/character-mutation-reload.test.tsx
Normal file
169
src/features/characters/tests/character-mutation-reload.test.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { App } from "@/app/App";
|
||||
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
|
||||
import { server } from "@/shared/test/server";
|
||||
|
||||
const apiBaseUrl = "https://api.example.com";
|
||||
|
||||
function saveAdminSession() {
|
||||
authSessionStorage.save({ token: "admin-token", role: "ADMIN" });
|
||||
}
|
||||
|
||||
function characterDetail(name = "루나") {
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
authSessionStorage.remove();
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
test("create success re-enters the list route and fetches the list", async () => {
|
||||
// Given
|
||||
saveAdminSession();
|
||||
const originalGetContext = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "getContext");
|
||||
const originalToBlob = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "toBlob");
|
||||
let listRequests = 0;
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => {
|
||||
listRequests += 1;
|
||||
return HttpResponse.json({ success: true, message: null, data: { totalCount: 0, content: [] }, errorProperty: null });
|
||||
}),
|
||||
http.post(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => HttpResponse.json({ success: true, message: null, data: null, errorProperty: null })),
|
||||
);
|
||||
window.history.pushState({}, "", "/ai-characters/new");
|
||||
|
||||
try {
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
|
||||
configurable: true,
|
||||
value: () => ({ drawImage: () => undefined }),
|
||||
});
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, "toBlob", {
|
||||
configurable: true,
|
||||
value: (callback: BlobCallback) => callback(new Blob(["cropped"], { type: "image/png" })),
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"Image",
|
||||
class FakeImage extends EventTarget {
|
||||
readonly naturalHeight = 1200;
|
||||
readonly naturalWidth = 1200;
|
||||
|
||||
set src(_value: string) {
|
||||
queueMicrotask(() => this.dispatchEvent(new Event("load")));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
fireEvent.change(await screen.findByLabelText("이름"), { target: { value: "노아" } });
|
||||
fireEvent.change(screen.getByLabelText("시스템 프롬프트"), { target: { value: "차분하게 답한다." } });
|
||||
fireEvent.change(screen.getByLabelText("설명"), { target: { value: "상담형 캐릭터" } });
|
||||
fireEvent.change(screen.getByLabelText("프로필 이미지"), { target: { files: [new File(["image"], "noa.png", { type: "image/png" })] } });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
|
||||
await waitFor(() => expect(screen.getByText("noa.png")).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters"));
|
||||
expect(await screen.findByRole("status", { name: "작업 성공" })).toHaveTextContent("AI 캐릭터를 생성했습니다.");
|
||||
expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
|
||||
await waitFor(() => expect(listRequests).toBeGreaterThanOrEqual(2));
|
||||
} finally {
|
||||
if (originalGetContext === undefined) {
|
||||
Reflect.deleteProperty(HTMLCanvasElement.prototype, "getContext");
|
||||
} else {
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, "getContext", originalGetContext);
|
||||
}
|
||||
if (originalToBlob === undefined) {
|
||||
Reflect.deleteProperty(HTMLCanvasElement.prototype, "toBlob");
|
||||
} else {
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, "toBlob", originalToBlob);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("update success re-enters the detail route and fetches the detail again", async () => {
|
||||
// Given
|
||||
saveAdminSession();
|
||||
let detailRequests = 0;
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => HttpResponse.json({ success: true, message: null, data: { totalCount: 0, content: [] }, errorProperty: null })),
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101`, () => {
|
||||
detailRequests += 1;
|
||||
return HttpResponse.json({ success: true, message: null, data: characterDetail(detailRequests === 1 ? "루나" : "루나 수정"), errorProperty: null });
|
||||
}),
|
||||
http.put(`${apiBaseUrl}/api/v2/admin/ai-characters/101`, () => HttpResponse.json({ success: true, message: null, data: null, errorProperty: null })),
|
||||
);
|
||||
window.history.pushState({}, "", "/ai-characters/101/edit");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
fireEvent.change(await screen.findByLabelText("이름"), { target: { value: "루나 수정" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101"));
|
||||
expect(await screen.findByRole("status", { name: "작업 성공" })).toHaveTextContent("AI 캐릭터를 저장했습니다.");
|
||||
expect(await screen.findByRole("heading", { name: "루나 수정" })).toBeInTheDocument();
|
||||
expect(detailRequests).toBe(2);
|
||||
});
|
||||
|
||||
test("soft delete success re-enters the list route and fetches the list", async () => {
|
||||
// Given
|
||||
saveAdminSession();
|
||||
let listRequests = 0;
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => {
|
||||
listRequests += 1;
|
||||
return HttpResponse.json({ success: true, message: null, data: { totalCount: 0, content: [] }, errorProperty: null });
|
||||
}),
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters/101`, () => HttpResponse.json({ success: true, message: null, data: characterDetail(), errorProperty: null })),
|
||||
http.put(`${apiBaseUrl}/api/v2/admin/ai-characters/101`, () => HttpResponse.json({ success: true, message: null, data: null, errorProperty: null })),
|
||||
);
|
||||
window.history.pushState({}, "", "/ai-characters/101");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "비활성화" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "비활성화 확인" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters"));
|
||||
expect(await screen.findByRole("status", { name: "작업 성공" })).toHaveTextContent("AI 캐릭터를 비활성화했습니다.");
|
||||
expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
|
||||
await waitFor(() => expect(listRequests).toBeGreaterThanOrEqual(3));
|
||||
});
|
||||
24
src/features/characters/validation/character-image-policy.ts
Normal file
24
src/features/characters/validation/character-image-policy.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { getFileExtension, validateFile } from "@/shared/validation/file-validation";
|
||||
import type { FileValidationResult } from "@/shared/validation/file-validation";
|
||||
|
||||
export const CHARACTER_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const characterImageFilePolicy = {
|
||||
allowedExtensions: [".jpg", ".jpeg", ".png"],
|
||||
allowedMimeTypes: ["image/jpeg", "image/png"],
|
||||
maxBytes: CHARACTER_IMAGE_MAX_BYTES,
|
||||
} as const;
|
||||
|
||||
export function validateCharacterImageFile(file: File): FileValidationResult {
|
||||
const result = validateFile(file, characterImageFilePolicy);
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const extension = getFileExtension(file.name);
|
||||
if (((extension === ".jpg" || extension === ".jpeg") && file.type !== "image/jpeg") || (extension === ".png" && file.type !== "image/png")) {
|
||||
return { ok: false, reason: "mime" };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
Reference in New Issue
Block a user