From b01fda44004164adc9bac33a53170f32f056ba1a Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Mon, 3 Aug 2026 11:54:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(ai-character):=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EB=AA=A9=EB=A1=9D=20=EC=82=AC=EC=9A=A9=EC=84=B1=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../characters/components/CharacterList.tsx | 148 ++++++++++++++++-- .../characters/pages/CharacterListPage.tsx | 4 +- .../characters/tests/character-list.test.tsx | 62 +++++++- .../ui/__tests__/resource-pagination.test.tsx | 17 +- src/shared/ui/resource-pagination.tsx | 34 ++-- 5 files changed, 231 insertions(+), 34 deletions(-) diff --git a/src/features/characters/components/CharacterList.tsx b/src/features/characters/components/CharacterList.tsx index a8d6f7c..dfaa55f 100644 --- a/src/features/characters/components/CharacterList.tsx +++ b/src/features/characters/components/CharacterList.tsx @@ -1,35 +1,157 @@ +import { navigateTo } from "@/app/browser-location"; +import { routePaths } from "@/app/route-paths"; 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"; +import { useModalFocus } from "@/shared/ui/use-modal-focus"; +import type { KeyboardEvent, MouseEvent } from "react"; +import { useState } from "react"; + +const previewLength = 14; + +type SpeechDialogTarget = { + readonly name: string; + readonly speechPattern: string | null; + readonly speechStyle: string | null; +}; + +function optionalText(value: string | null): string { + return value === null || value.length === 0 ? "-" : value; +} + +function truncatePreview(value: string): string { + const characters = Array.from(value); + + return characters.length > previewLength ? `${characters.slice(0, previewLength).join("")}...` : value; +} + +function speechText(character: CharacterListItemData): string { + return `${optionalText(character.speechStyle)} · ${optionalText(character.speechPattern)}`; +} + +function padDatePart(value: number): string { + return String(value).padStart(2, "0"); +} + +function formatUtcTimestamp(value: string | null): string { + if (value === null || value.length === 0) { + return "-"; + } + const localDate = new Date(`${value.replace(" ", "T")}Z`); + + return `${localDate.getFullYear()}-${padDatePart(localDate.getMonth() + 1)}-${padDatePart(localDate.getDate())} ${padDatePart(localDate.getHours())}:${padDatePart(localDate.getMinutes())}`; +} + +function SpeechDialog({ onClose, target }: { readonly onClose: () => void; readonly target: SpeechDialogTarget | null }) { + const { dialogRef, trapFocus } = useModalFocus(target !== null); + + if (target === null) { + return null; + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.stopPropagation(); + onClose(); + return; + } + trapFocus(event); + } + + return ( +
+
+
+

{target.name} 말투

+
+
+
말투 스타일
+
{optionalText(target.speechStyle)}
+
+
+
말투 패턴
+
{optionalText(target.speechPattern)}
+
+
+
+ +
+
+ ); +} export function CharacterList({ data }: { readonly data: PageData }) { + const [speechDialogTarget, setSpeechDialogTarget] = useState(null); + const desktop = ( + + - + - {data.items.map((character) => ( - - - - - - - ))} + {data.items.map((character) => { + const detailPath = routePaths.aiCharacterDetail(String(character.id)); + + function openDetail() { + navigateTo(detailPath); + } + + function handleDetailLinkClick(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + openDetail(); + } + + return ( + + + + + + + + + ); + })}
캐릭터 지역프로필말투 태그선택수정일
-

{character.name}

-

{character.description}

-
{character.region}{character.tags.join(", ")}
+
+ {character.imageUrl === null ? ( + + ) : ( + + )} +
+ {character.name} +

{character.description.length > 0 ? character.description : "-"}

+

ID {character.id}

+
+
+
{character.region}{optionalText(character.gender)} · {character.age === null ? "-" : `${character.age}세`} · {optionalText(character.mbti)} + + {character.tags.length > 0 ? character.tags.join(", ") : "-"}{formatUtcTimestamp(character.updatedAt)}
); const mobile =
{data.items.map((character) => )}
; - return ; + return <> setSpeechDialogTarget(null)} target={speechDialogTarget} />; } diff --git a/src/features/characters/pages/CharacterListPage.tsx b/src/features/characters/pages/CharacterListPage.tsx index 29614b4..f8671b3 100644 --- a/src/features/characters/pages/CharacterListPage.tsx +++ b/src/features/characters/pages/CharacterListPage.tsx @@ -80,10 +80,10 @@ export function CharacterListPage({ apiClient, routeError }: { readonly apiClien

AI CHARACTER ADMIN

AI 캐릭터

-

목록에서 캐릭터를 검색하고 선택해 워크스페이스로 이동합니다.

+

목록에서 캐릭터를 검색하고 선택해 워크스페이스로 이동합니다.

{ event.preventDefault(); diff --git a/src/features/characters/tests/character-list.test.tsx b/src/features/characters/tests/character-list.test.tsx index 0f41c0b..958954d 100644 --- a/src/features/characters/tests/character-list.test.tsx +++ b/src/features/characters/tests/character-list.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { afterEach, expect, test, vi } from "vitest"; @@ -15,8 +15,8 @@ const activeCharacter = { gender: "여성", age: 24, mbti: "INFJ", - speechStyle: "다정함", - speechPattern: "존댓말", + speechStyle: "부드럽고 공감하는 말투", + speechPattern: "천천히 설명하는 존댓말", region: "KR", tags: ["상담", "힐링"], createdAt: "2026-07-28 10:00:00", @@ -72,6 +72,62 @@ test("Character list serializes URL search to searchTerm without active filters expect(screen.getAllByRole("link", { name: /루나 선택/ })[0]).toHaveAttribute("href", "/ai-characters/101"); }); +test("Character list renders desktop comparison metadata without duplicating the mobile card", async () => { + // Given + saveAdminSession(); + vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl); + useListResponse(); + window.history.pushState({}, "", "/ai-characters"); + + // When + render(); + const table = await screen.findByRole("table"); + const desktop = within(table); + const createLink = screen.getByRole("link", { name: "AI 캐릭터 생성" }); + const description = screen.getByText("목록에서 캐릭터를 검색하고 선택해 워크스페이스로 이동합니다."); + + // Then + expect(createLink).not.toHaveClass("hidden"); + expect(createLink).toHaveClass("min-h-11"); + expect(description).toHaveClass("break-keep"); + expect(desktop.queryByRole("columnheader", { name: "선택" })).not.toBeInTheDocument(); + expect(desktop.getByRole("columnheader", { name: "프로필" })).toBeInTheDocument(); + expect(desktop.getByRole("columnheader", { name: "말투" })).toBeInTheDocument(); + expect(desktop.getByRole("columnheader", { name: "수정일" })).toBeInTheDocument(); + expect(desktop.getByText("ID 101")).toBeInTheDocument(); + expect(desktop.getByText("여성 · 24세 · INFJ")).toBeInTheDocument(); + expect(desktop.queryByText("부드럽고 공감하는 말투 · 천천히 설명하는 존댓말")).not.toBeInTheDocument(); + expect(desktop.getByRole("button", { name: "루나 말투 전체 보기" })).toBeInTheDocument(); + expect(desktop.getByText("2026-07-28 20:00")).toBeInTheDocument(); + expect(desktop.queryByText("2026-07-28 11:00:00")).not.toBeInTheDocument(); + expect(desktop.queryByText("2026-07-28 10:00:00")).not.toBeInTheDocument(); + expect(desktop.getByRole("link", { name: "루나" })).toHaveAttribute("href", "/ai-characters/101"); + expect(screen.getAllByRole("link", { name: "루나 선택" })).toHaveLength(1); + + fireEvent.click(desktop.getByRole("button", { name: "루나 말투 전체 보기" })); + const dialog = screen.getByRole("dialog", { name: "루나 말투" }); + expect(dialog).toHaveTextContent("부드럽고 공감하는 말투"); + expect(dialog).toHaveTextContent("천천히 설명하는 존댓말"); + fireEvent.click(within(dialog).getByRole("button", { name: "닫기" })); + expect(screen.queryByRole("dialog", { name: "루나 말투" })).not.toBeInTheDocument(); +}); + +test("Character list opens detail when the desktop row is clicked", async () => { + // Given + saveAdminSession(); + vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl); + useListResponse(); + window.history.pushState({}, "", "/ai-characters"); + + // When + render(); + const row = await screen.findByRole("row", { name: /루나/ }); + + // Then + fireEvent.click(row); + await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101")); +}); + test("Character list supports loading, empty, error retry, and selection navigation", async () => { // Given saveAdminSession(); diff --git a/src/shared/ui/__tests__/resource-pagination.test.tsx b/src/shared/ui/__tests__/resource-pagination.test.tsx index 14e444b..2351f77 100644 --- a/src/shared/ui/__tests__/resource-pagination.test.tsx +++ b/src/shared/ui/__tests__/resource-pagination.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { expect, test, vi } from "vitest"; import type { PageData } from "@/shared/api/pagination"; @@ -56,3 +56,18 @@ test("ResourcePagination connects each page size label to a unique select", () = expect(new Set(selectIds).size).toBe(selectIds.length); expect(labels.map((label) => label.control)).toEqual(selects); }); + +test("ResourcePagination separates page size from an equal-width mobile movement row", () => { + render(); + + const sizeControls = screen.getByRole("group", { name: "페이지 크기 설정" }); + const movementControls = screen.getByRole("group", { name: "페이지 이동" }); + const previous = within(movementControls).getByRole("button", { name: "이전 페이지" }); + const next = within(movementControls).getByRole("button", { name: "다음 페이지" }); + + expect(within(sizeControls).getByLabelText("페이지 크기")).toBeInTheDocument(); + expect(sizeControls).not.toContainElement(previous); + expect(movementControls).toHaveClass("grid-cols-2"); + expect(previous).toHaveClass("min-h-11", "w-full"); + expect(next).toHaveClass("min-h-11", "w-full"); +}); diff --git a/src/shared/ui/resource-pagination.tsx b/src/shared/ui/resource-pagination.tsx index db976af..5563976 100644 --- a/src/shared/ui/resource-pagination.tsx +++ b/src/shared/ui/resource-pagination.tsx @@ -14,21 +14,25 @@ export function ResourcePagination({ data, onPageChange, onSizeChange, sizeOptio return ( );