feat(ai-character): 캐릭터 관리 기능 구현

This commit is contained in:
Yu Sung
2026-08-01 01:30:28 +09:00
parent a1cae336d9
commit 3550a2dd03
19 changed files with 3308 additions and 0 deletions

View 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",
});
}

View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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,
};
}

View 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>;

View 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>
);
}

View 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>
);
}

View 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></>;
}

View 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>
);
}

View 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,
});
});

View 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();
});

View 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: "차분한 상담형 캐릭터",
});
});

View 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: "노아 수정",
});
});
});

View 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"));
});

View 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));
});

View 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 };
}