feat(ai-character): 오디오 댓글 첫 답글 작성 지원

This commit is contained in:
Yu Sung
2026-08-05 18:53:41 +09:00
parent 9e2c910f0a
commit e82e209300
8 changed files with 822 additions and 3 deletions

View File

@@ -3,7 +3,7 @@ import { useState } from "react";
import type { CommentRecord } from "@/features/comments/model/types";
import { formatSeoulDateTime } from "@/shared/lib/formatters";
export function CommentItem({ canDelete = true, canEdit, comment, isSaving, onDelete, onEdit, onShowReplies, showRepliesButton }: { readonly canDelete?: boolean; readonly canEdit: boolean; readonly comment: CommentRecord; readonly isSaving: boolean; readonly onDelete: () => void; readonly onEdit: (comment: string) => void; readonly onShowReplies?: () => void; readonly showRepliesButton?: boolean }) {
export function CommentItem({ canDelete = true, canEdit, comment, isSaving, onDelete, onEdit, onShowReplies, replyActionLabel }: { readonly canDelete?: boolean; readonly canEdit: boolean; readonly comment: CommentRecord; readonly isSaving: boolean; readonly onDelete: () => void; readonly onEdit: (comment: string) => void; readonly onShowReplies?: () => void; readonly replyActionLabel?: "답글 보기" | "답글 작성" }) {
const [draft, setDraft] = useState(comment.comment);
const [isEditing, setIsEditing] = useState(false);
const label = `${comment.comment}`;
@@ -25,7 +25,7 @@ export function CommentItem({ canDelete = true, canEdit, comment, isSaving, onDe
<p className="text-xs text-muted-foreground">{formatSeoulDateTime(comment.date)}{comment.isSecret ? " · 비밀" : ""}</p>
</div>
<div className="flex flex-wrap gap-2">
{showRepliesButton === true && onShowReplies !== undefined ? <button className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={onShowReplies} type="button">{label} </button> : null}
{replyActionLabel !== undefined && onShowReplies !== undefined ? <button className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={onShowReplies} type="button">{label} {replyActionLabel}</button> : null}
{canEdit ? <button className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={() => setIsEditing(true)} type="button">{label} </button> : null}
{canDelete ? <button className="rounded-md border border-destructive bg-card px-3 py-2 text-sm font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={onDelete} type="button">{label} </button> : null}
</div>

View File

@@ -167,7 +167,7 @@ export function CommentThread({ apiClient, canMutate = true, target }: { readonl
<div className="flex flex-col gap-3">
{roots.data.items.map((comment) => (
<div className="flex flex-col gap-3" key={comment.id}>
<CommentItem canDelete={canMutate} canEdit={canMutate && canEdit(comment, requestTarget)} comment={comment} isSaving={isSaving} onDelete={() => void runMutation(() => deleteComment(apiClient, requestTarget, { commentId: comment.id }))} onEdit={(nextComment) => void runMutation(() => updateComment(apiClient, requestTarget, { commentId: comment.id, request: { comment: nextComment } }))} onShowReplies={() => toggleReplies(comment.id)} showRepliesButton={comment.replyCount > 0 || expandedRootIds.includes(comment.id)} />
<CommentItem canDelete={canMutate} canEdit={canMutate && canEdit(comment, requestTarget)} comment={comment} isSaving={isSaving} onDelete={() => void runMutation(() => deleteComment(apiClient, requestTarget, { commentId: comment.id }))} onEdit={(nextComment) => void runMutation(() => updateComment(apiClient, requestTarget, { commentId: comment.id, request: { comment: nextComment } }))} onShowReplies={() => toggleReplies(comment.id)} replyActionLabel={comment.replyCount > 0 || expandedRootIds.includes(comment.id) ? "답글 보기" : requestTarget.kind === "audio" && canMutate ? "답글 작성" : undefined} />
{renderReplies(comment)}
</div>
))}

View File

@@ -12,6 +12,7 @@ type CapturedRequest = {
};
const target = { kind: "audio", characterId: "101", contentId: "9001", creatorId: 101 } satisfies CommentTarget;
const communityTarget = { kind: "community", characterId: "101", postId: "7001", creatorId: 101 } satisfies CommentTarget;
const fanRoot = {
id: 1101,
@@ -59,6 +60,9 @@ function createThreadClient(requests: CapturedRequest[]): ApiClient {
if (options.path.includes("/1101/replies")) {
return options.responseSchema.parse({ totalCount: 2, items: [fanReply, aiReply] });
}
if (options.path.includes("/1102/replies")) {
return options.responseSchema.parse({ totalCount: 0, items: [] });
}
return options.responseSchema.parse({ totalCount: 2, items: [fanRoot, aiRoot] });
},
@@ -131,6 +135,46 @@ function getFormForControl(control: HTMLElement): HTMLFormElement {
throw new Error("expected parent form");
}
test("CommentThread opens the existing reply form for a first Audio reply only", async () => {
// Given
const requests: CapturedRequest[] = [];
render(<CommentThread apiClient={createThreadClient(requests)} target={target} />);
expect(await screen.findByText("AI 루트 댓글")).toBeInTheDocument();
// When
fireEvent.click(screen.getByRole("button", { name: "AI 루트 댓글 답글 작성" }));
const repliesRegion = await screen.findByRole("region", { name: "AI 루트 댓글 답글" });
const replyInput = within(repliesRegion).getByLabelText("AI 루트 댓글에 답글");
fireEvent.change(replyInput, { target: { value: "첫 답글" } });
fireEvent.click(within(repliesRegion).getByRole("button", { name: "답글 등록" }));
// Then
await waitFor(() => expect(requests.filter((request) => request.method === "POST" && request.body === JSON.stringify({ comment: "첫 답글", parentId: 1102, isSecret: false, languageCode: null }))).toHaveLength(1));
expect(requests.some((request) => request.method === undefined && request.path.includes("/1102/replies?page=0&size=20"))).toBe(true);
expect(within(repliesRegion).queryByRole("button", { name: /답글 작성/ })).not.toBeInTheDocument();
});
test("CommentThread keeps first-reply entry out of Community and read-only Audio roots", async () => {
// Given
const communityRequests: CapturedRequest[] = [];
const readOnlyRequests: CapturedRequest[] = [];
// When
const { unmount } = render(<CommentThread apiClient={createThreadClient(communityRequests)} target={communityTarget} />);
// Then
expect(await screen.findByText("AI 루트 댓글")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "AI 루트 댓글 답글 작성" })).not.toBeInTheDocument();
unmount();
// When
render(<CommentThread apiClient={createThreadClient(readOnlyRequests)} canMutate={false} target={target} />);
// Then
expect(await screen.findByText("AI 루트 댓글")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "AI 루트 댓글 답글 작성" })).not.toBeInTheDocument();
});
test("CommentThread links and focuses empty root and reply comment errors without sending mutations", async () => {
// Given
const requests: CapturedRequest[] = [];