feat(ai-character): 시리즈 이미지 입력 순서 조정

This commit is contained in:
Yu Sung
2026-08-04 15:19:22 +09:00
parent b62c416fa1
commit d030d2eebd
4 changed files with 31 additions and 3 deletions

View File

@@ -216,6 +216,7 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
<p className="text-sm text-muted-foreground">, , , .</p> <p className="text-sm text-muted-foreground">, , , .</p>
</div> </div>
<form aria-label={mode === "create" ? "시리즈 생성 입력 화면" : "시리즈 수정 입력 화면"} className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" onSubmit={(event) => void submit(event)} ref={formRef}> <form aria-label={mode === "create" ? "시리즈 생성 입력 화면" : "시리즈 수정 입력 화면"} className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" onSubmit={(event) => void submit(event)} ref={formRef}>
<FileField accept="image/jpeg,image/png" acceptDescription="JPEG 또는 PNG, 10MB 이하, 210:297 crop 후 최대 폭 1,000px로 전송합니다." error={errors.image} label="시리즈 이미지" onChange={(file) => void selectImage(file)} value={image} />
<label className="flex flex-col gap-2 text-sm font-semibold"><input aria-describedby={errors.title === undefined ? undefined : errorIds.title} aria-invalid={errors.title === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setTitle(event.currentTarget.value)} value={title} /></label> <label className="flex flex-col gap-2 text-sm font-semibold"><input aria-describedby={errors.title === undefined ? undefined : errorIds.title} aria-invalid={errors.title === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setTitle(event.currentTarget.value)} value={title} /></label>
{errors.title === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.title} role="alert">{errors.title}</p>} {errors.title === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.title} role="alert">{errors.title}</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"><textarea aria-describedby={errors.introduction === undefined ? undefined : errorIds.introduction} aria-invalid={errors.introduction === undefined ? undefined : true} className="min-h-28 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setIntroduction(event.currentTarget.value)} value={introduction} /></label> <label className="flex flex-col gap-2 text-sm font-semibold"><textarea aria-describedby={errors.introduction === undefined ? undefined : errorIds.introduction} aria-invalid={errors.introduction === undefined ? undefined : true} className="min-h-28 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setIntroduction(event.currentTarget.value)} value={introduction} /></label>
@@ -228,7 +229,6 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={isAdult} onChange={(event) => setIsAdult(event.currentTarget.checked)} type="checkbox" /> </label> <label className="flex items-center gap-2 text-sm font-semibold"><input checked={isAdult} onChange={(event) => setIsAdult(event.currentTarget.checked)} type="checkbox" /> </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) => setWriter(event.currentTarget.value)} value={writer} /></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) => setWriter(event.currentTarget.value)} value={writer} /></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) => setStudio(event.currentTarget.value)} value={studio} /></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) => setStudio(event.currentTarget.value)} value={studio} /></label>
<FileField accept="image/jpeg,image/png" acceptDescription="JPEG 또는 PNG, 10MB 이하, 210:297 crop 후 최대 폭 1,000px로 전송합니다." 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>} {errors.form === undefined ? null : <p className="text-sm font-semibold text-destructive" role="alert">{errors.form}</p>}
{isSaving ? <p className="rounded-md border border-border bg-muted p-3 text-sm font-semibold" role="status"> </p> : null} {isSaving ? <p className="rounded-md border border-border bg-muted p-3 text-sm font-semibold" role="status"> </p> : null}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">

View File

@@ -111,6 +111,20 @@ async function renderCrop(request: CropRenderRequest): Promise<File> {
return new File([String(request.outputWidth)], "cropped-series.png", { type: "image/png" }); return new File([String(request.outputWidth)], "cropped-series.png", { type: "image/png" });
} }
test("SeriesForm renders the required image before the title when creating", () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/series/new");
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="create" renderCrop={renderCrop} />);
// When
const imageInput = screen.getByLabelText("시리즈 이미지");
const titleInput = screen.getByLabelText("제목");
// Then
expect(imageInput.compareDocumentPosition(titleInput)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
});
test("SeriesForm creates with required image request and navigates to list notification", async () => { test("SeriesForm creates with required image request and navigates to list notification", async () => {
// Given // Given
const requests: CapturedRequest[] = []; const requests: CapturedRequest[] = [];
@@ -261,6 +275,7 @@ test("SeriesForm links validation errors and focuses the first invalid control",
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="create" renderCrop={renderCrop} />); render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="create" renderCrop={renderCrop} />);
// When // When
const imageInput = screen.getByLabelText("시리즈 이미지");
const titleInput = screen.getByLabelText("제목"); const titleInput = screen.getByLabelText("제목");
fireEvent.submit(screen.getByRole("form", { name: "시리즈 생성 입력 화면" })); fireEvent.submit(screen.getByRole("form", { name: "시리즈 생성 입력 화면" }));
@@ -281,6 +296,6 @@ test("SeriesForm links validation errors and focuses the first invalid control",
expect(screen.getByLabelText("장르")).toHaveAttribute("aria-describedby", "series-genre-error"); expect(screen.getByLabelText("장르")).toHaveAttribute("aria-describedby", "series-genre-error");
expect(screen.getByRole("checkbox", { name: "월요일" })).toHaveAttribute("aria-describedby", "series-days-error"); expect(screen.getByRole("checkbox", { name: "월요일" })).toHaveAttribute("aria-describedby", "series-days-error");
expect(titleInput).toHaveAttribute("aria-invalid", "true"); expect(titleInput).toHaveAttribute("aria-invalid", "true");
await waitFor(() => expect(titleInput).toHaveFocus()); await waitFor(() => expect(imageInput).toHaveFocus());
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0); expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
}); });

View File

@@ -53,3 +53,16 @@ test("FileField gives same visible file buttons field-specific accessible names"
expect(screen.getByRole("button", { name: "오디오 파일 파일 선택" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "오디오 파일 파일 선택" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "커버 이미지 파일 선택" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "커버 이미지 파일 선택" })).toBeInTheDocument();
}); });
test("FileField visible container exposes the focus-within ring contract", () => {
// Given
const { container } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={null} />);
const input = screen.getByLabelText("대표 이미지");
// When
input.focus();
// Then
expect(input).toHaveFocus();
expect(container.firstElementChild).toHaveClass("focus-within:ring-2", "focus-within:ring-ring", "focus-within:ring-offset-2");
});

View File

@@ -31,7 +31,7 @@ export function FileField({ accept, acceptDescription, description, error, label
} }
return ( return (
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card p-4"> <div className="flex flex-col gap-2 rounded-lg border border-border bg-card p-4 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
<label className="text-sm font-semibold" htmlFor={inputId}>{label}</label> <label className="text-sm font-semibold" htmlFor={inputId}>{label}</label>
{description === undefined ? null : <p className="text-sm text-muted-foreground" id={descriptionId}>{description}</p>} {description === undefined ? null : <p className="text-sm text-muted-foreground" id={descriptionId}>{description}</p>}
<p className="text-sm text-muted-foreground" id={acceptId}>{acceptDescription}</p> <p className="text-sm text-muted-foreground" id={acceptId}>{acceptDescription}</p>