feat(ai-character): 커뮤니티 생성 미디어 흐름 개선

This commit is contained in:
Yu Sung
2026-08-05 00:33:49 +09:00
parent 766a06ad0a
commit fb9b550040
22 changed files with 700 additions and 141 deletions

View File

@@ -146,9 +146,17 @@ Primary font stack: `Pretendard`, `Noto Sans KR`, `Apple SD Gothic Neo`, `system
### FileField
- Structure: controlled `File | null` field with visible label, native file input, accept guidance, selected filename, and clear button.
- Variants: domain-neutral only; allowed extensions, MIME, and max bytes are injected by caller policy.
- Accessibility: label targets the input; description, accept guidance, and error are connected with `aria-describedby`; clear is a native button.
- Structure: controlled `File | null` field with visible label, native file input, accept guidance, selected filename, clear button, and an actual upload preview when the controlled value is an image File.
- Variants: image values render an object-contain preview inside a bounded tokenized surface; non-image values retain the filename-only layout. Allowed extensions, MIME, and max bytes are injected by caller policy.
- Lifecycle: the field owns only its preview Blob URL and revokes it on value replacement, clear, and unmount; the caller continues to own validation, crop state, and the File itself.
- Accessibility: label targets the input; description, accept guidance, and error are connected with `aria-describedby`; the image preview uses the field label in its alt text; clear is a native button.
- Motion: none.
### CanPriceField
- Structure: controlled numeric CAN price input with a visible label, native number input, visible `단위: 캔` guidance, and an inline error slot.
- Contract: callers provide the controlled string and error state; the field preserves the native number input value and renders `min=0`, `step=1`. Domain submit logic rejects blank, negative, decimal, and out-of-range values against the shared `0..99,999` integer schema.
- Accessibility: the visible label targets the input, the unit guidance and error are connected with `aria-describedby`, invalid state uses `aria-invalid`, and mobile input text remains 16px.
- Motion: none.
### TagInput

View File

@@ -10,12 +10,12 @@
| 문서 항목 | 내용 |
|---|---|
| 상태 | 2026-08-04 `P5-R6` Series form 이미지 순서·keyword chip UX 자동 검증 완료. `P4-R10`, `P9-R18`~`P9-R19`, `P10-R16`~`P10-R17` 자동 보완 완료, 실제 crop pixel·stale ADMIN·Series/FanTalk/Comments/file policy 수동 QA 대기 |
| 상태 | 2026-08-04 `P6-R5` FileField 이미지 preview·Community 생성 media flow·공용 CAN 가격 계약 구현, 표준 배율 browser QA와 최종 review 완료. `P4-R10`, `P9-R18`~`P9-R19`, `P10-R16`~`P10-R17` 자동 보완 완료, 실제 crop pixel·stale ADMIN·Series/FanTalk/Comments/file policy 수동 QA 대기 |
| 최초 작성일 | 2026-07-25 |
| 재작성일 | 2026-07-26 |
| 요구사항 기준 | [prd.md](./prd.md) |
| API 기준 | [api-contract.openapi.json](./api-contract.openapi.json) |
| 현재 활성 Goal | `P5-R6` 자동 검증 완료 (`P5-R5` 완료) |
| 현재 활성 Goal | `P6-R5` 완료 |
## 목표
@@ -3118,6 +3118,35 @@ focus가 Sheet trigger로 복귀하는지 본다.
- 리뷰어 게이트: 1차 review에서 width 판정 전 release 순서 blocker를 지적받아 보완했고, delta review에서 `APPROVED` 판정을 받았다.
- E2E/수동: 개발 중 E2E와 반복 GIF browser 수동 확인은 사용자 지시에 따라 전체 Task 구현 후 필요 시 수행한다.
### Task R6.5 — 업로드 이미지 미리보기와 Community 생성 media flow 정렬
**Goal 실행 `P6-R5`:** 공통 `FileField`에 실제 업로드 이미지 미리보기를 제공하고 Community 생성 화면을 이미지 → 조건부 오디오 → 내용 순서와 Audio 동일 가격 계약으로 정렬한다.
- **시작 조건:** PRD `COMMUNITY-003`, `COMMUNITY-007`, `COMMUNITY-013`, `COMMUNITY-016`, `FILE-016`; 사용자 승인 설계.
- **완료 증거:** 이미지 preview URL 생성·교체·해제, 비이미지 filename-only, Community media 순서·audio 조건부 표시/제거, 기본 가격 0·blank 거부·항상 price 전송의 RED/GREEN, Audio 가격 회귀, Chromium mock browser와 visual QA.
- **Files:** Create: `src/shared/ui/can-price-field.tsx`, `src/shared/ui/__tests__/can-price-field.test.tsx`; Modify: `src/app/protected-admin-shell.tsx`, `src/app/App.protected-shell.test.tsx`, `src/shared/ui/file-field.tsx`, `src/shared/ui/page-state.tsx`, `src/shared/ui/__tests__/file-field.test.tsx`, `src/shared/ui/__tests__/page-state.test.tsx`, `src/features/audio-contents/components/AudioContentForm.tsx`, `src/features/audio-contents/components/audio-content-form-helpers.ts`, `src/features/audio-contents/tests/audio-form-create-red.test.tsx`, `src/features/audio-contents/tests/audio-form-update.test.tsx`, `src/features/audio-contents/tests/audio-form.test.tsx`, `src/features/community-posts/components/CommunityPostForm.tsx`, `src/features/community-posts/components/community-post-form-helpers.ts`, `src/features/community-posts/tests/community-form.test.tsx`, `src/features/community-posts/tests/community-price-validation.test.tsx`, `src/features/community-posts/tests/community-contract.test.ts`, `tests/e2e/community.spec.ts`, `DESIGN.md`, `docs/20260725_AI캐릭터관리자웹/prd.md`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`.
- **Interfaces:** `FileField`는 image File일 때만 object URL preview를 소유하고 교체·제거·unmount에서 revoke한다. `CanPriceField({value,onChange,error,errorId})`는 Audio와 Community의 native number/min=0/step=1/단위·오류 연결을 공유하고 range validation은 domain submit이 소유한다. Community는 최종 `postImage`가 있을 때만 audio input을 렌더하며 image 제거·교체 시작 시 `audioFile`을 제거한다.
- **범위 밖:** Community 수정 Sheet, multipart append 순서, API schema의 optional price, 비이미지 preview, 새 dependency, GIF resize·crop·re-encode.
- [x] **RED:** `FileField previews image values and revokes object URLs on replacement, clear, and unmount`; `FileField keeps non-image values filename-only without creating an object URL`을 실패시킨다.
- [x] **GREEN:** image value에만 bounded object-contain preview를 렌더하고 URL lifecycle을 `FileField` 한 곳에서 소유한다.
- [x] **RED:** `CanPriceField renders the Audio numeric CAN price contract`; `CanPriceField connects its inline error and emits Audio-compatible values`를 import 부재로 실패시킨다.
- [x] **GREEN:** 공통 numeric CAN field를 추가하고 Audio inline price markup을 교체하되 paid option reset과 request를 보존한다.
- [x] **RED:** Community 최종 preview/media-first order, image 전 audio 부재·image 변경 시 stale audio 제거, 기본 0·blank 거부·항상 price 전송을 실패시킨다.
- [x] **GREEN:** Community form state·render order·request를 최소 수정하고 multipart serializer 순서는 변경하지 않는다.
- [x] **REFACTOR:** 불필요해진 domain price formatter wrapper를 제거하고 shared/domain focused test, full Gate, 375/768/1280 browser·visual QA와 독립 review를 통과한다.
- **실행 명령:** `npm run test:run -- src/shared/ui/__tests__/file-field.test.tsx src/shared/ui/__tests__/can-price-field.test.tsx`; `npm run test:run -- src/features/audio-contents src/features/community-posts`; `npm run test:run`; `npm run typecheck`; `npm run lint`; `npm run build:dev`; `npm run build:prod`; `npm run e2e:mock -- tests/e2e/community.spec.ts`; `git diff --check`.
- **기대 결과:** 모든 명령 exit 0, crop/GIF 적용 전 최종 preview 0건, image 없는 audio input·stale audio multipart 0건, Community valid request의 price 누락 0건, Audio 가격 회귀 0건, object URL 미해제 0건.
- **수동 확인:** mock mode 375px read-only 정책, 768px·1280px Community 생성의 initial/crop/applied/replacement/error 상태를 keyboard·가로 overflow·Korean text 기준으로 확인하고 QA browser/server/temp fixture를 모두 정리한다. 내부 관리자 화면이므로 별도 browser zoom 검증은 제외한다.
**P6-R5 진행 기록 (2026-08-04):**
- RED/GREEN: FileField preview·Blob URL·native input 동기화 11개, 공용 CAN field와 Audio/Community 음수·소수·상한·blank·`00` zero reset, Community media 순서·조건부 audio·stale state·request 회귀를 실패 확인 후 최소 수정했다.
- 자동 Gate: `npm run test:run` — 83 files / 458 tests passed; `npm run lint`, `npm run typecheck`, `npm run build:dev`, `npm run build:prod`, `git diff --check` — exit 0. build의 500kB chunk warning은 기존 비차단 경고다.
- 브라우저: `npm run e2e:mock -- tests/e2e/community.spec.ts` — Chromium/mobile Chrome 14/14 passed. 375px mobile read-only, 768px·1280px final GIF preview·conditional audio·제거·blank 가격·keyboard·overflow·axe를 확인했다. 사용자 결정에 따라 browser zoom 검증은 제외했다.
- 리뷰: 목표·품질 review에서 문서 추적성과 Audio raw `00` zero reset blocker를 발견했다. `parsePrice(value) === 0`으로 보완해 Audio focused 9 files / 69 tests를 통과했고, 문서 상태·파일 지도·Decision Log를 정렬했다.
- 최종 browser/visual: 768px·1280px Chromium/mobile Chrome에서 PNG crop/applied/replacement/error와 기존 GIF/audio/price 상태를 포함해 Community E2E 14/14를 통과했다. 4개 project/viewport evidence directory의 32개 표준 배율 PNG를 기능·디자인 시스템 및 CJK reviewer가 전수 확인해 PASS/HIGH, blocker 0건으로 판정했다. ``은 PRD의 정상 CAN 단위이며, 사용자 결정에 따라 browser zoom 검증은 제외했다.
---
## Phase 7. FanTalk vertical slice

View File

@@ -305,19 +305,20 @@ AI 캐릭터를 생성하고, AI 캐릭터가 사람 크리에이터처럼 콘
|---|---|---|
| COMMUNITY-001 | 확정 | 선택 캐릭터의 게시글 목록 기반 조회·등록·수정·고정·비활성화를 제공한다. |
| COMMUNITY-002 | 확정 | 생성 요청에는 `isActive`를 보내지 않는다. |
| COMMUNITY-003 | 확정 | 이미지와 오디오 파일은 선택 첨부다. |
| COMMUNITY-003 | 확정 | 이미지와 오디오 파일은 선택 첨부다. 생성 UI는 게시글 이미지, 오디오 파일, 내용 순서로 배치하고, 오디오 파일은 적용 완료된 이미지가 있을 때만 표시·선택할 수 있다. 이미지를 제거하거나 새 이미지 선택을 시작하면 기존 오디오 선택도 제거한다. |
| COMMUNITY-004 | 확정 | 첨부 오디오가 있으면 목록 행/카드와 게시글 Sheet에서 재생할 수 있다. |
| COMMUNITY-005 | 확정 | 일반 수정 요청에서는 `isActive`를 생략하고 soft delete 요청에만 `isActive=false`를 보낸다. `isActive=true`는 전송하지 않는다. |
| COMMUNITY-006 | 확정 | soft delete request에는 `isActive=false``isFixed=false`를 함께 보낸다. 현 목록 응답에는 `fixedAtUtc`가 없으므로 해당 field를 DTO·UI에 만들지 않는다. |
| COMMUNITY-007 | 확정 | 가격은 오디오와 동일하게 `0..99999` 정수 “캔” 단위를 사용한다. |
| COMMUNITY-007 | 확정 | 가격은 오디오와 동일하게 기본값 `0`, 빈 값 불가, `0..99999` 정수 “캔” 단위를 사용한다. 생성 UI는 가격을 항상 request에 포함한다. |
| COMMUNITY-008 | 확정 | 커뮤니티 목록 API는 `isActive=true`인 게시글만 반환한다. request에는 활성 상태 query를 추가하지 않고 item에도 client-side 활성 filter를 적용하지 않는다. |
| COMMUNITY-009 | 확정 | 커뮤니티 전용 상세 GET과 상세·수정 직접 route를 추가하지 않는다. 목록 응답으로 행/카드의 Sheet를 열어 조회·수정·고정·비활성화·댓글 진입을 제공한다. |
| COMMUNITY-010 | 확정 | 커뮤니티 게시글 soft delete 성공 시 열린 Sheet를 닫고 목록을 무효화·재조회하며 성공 알림을 표시한다. 서버의 active-only 목록에서 해당 게시글이 제외돼야 한다. |
| COMMUNITY-011 | 확정 | 첨부 audio URL 갱신만을 위한 자동 요청은 하지 않으며 media error도 refetch trigger로 사용하지 않는다. 사용자 페이지 새로고침이나 mutation 후 cache 무효화 등 일반 목록 재조회가 발생하면 새 응답의 `audioUrl`을 사용한다. |
| COMMUNITY-012 | 확정 | 목록 GET은 `timezone` 없이 `page`, `size`를 사용하고 `data.totalCount`, `data.page`, `data.size`, `data.hasNext`, `data.items[]`를 소비한다. 전체 건수와 다음 page 여부는 서버 metadata를 그대로 사용한다. |
| COMMUNITY-013 | 확정 | 생성 multipart는 optional `audioFile`, optional `postImage`, 필수 `request`를 사용하고 request에 필수 `content`, `isCommentAvailable`, `isAdult`와 optional `price`만 보낸다. |
| COMMUNITY-013 | 확정 | 생성 multipart는 optional `audioFile`, optional `postImage`, 필수 `request`를 사용한다. OpenAPI의 `price`는 optional이지만 생성 UI는 필수 `content`, `isCommentAvailable`, `isAdult`, `price`를 항상 보내며 기본 가격은 `0`다. |
| COMMUNITY-014 | 확정 | 수정 multipart는 optional `postImage`와 필수 `request`만 허용한다. 수정에서 가격·첨부 audio 교체는 제공하지 않고, 고정은 `isFixed`, soft delete는 `isActive=false`로 처리한다. |
| COMMUNITY-015 | 확정 | 생성·수정·고정·soft delete 성공은 `data=null`이므로 목록을 무효화·재조회하고 mutation 응답에 게시글 DTO가 있다고 가정하지 않는다. |
| COMMUNITY-016 | 확정 | 생성 화면은 crop 적용 또는 GIF 검증이 끝난 실제 업로드 대상 이미지의 미리보기를 파일명과 함께 표시한다. crop 전 원본이나 준비 중 이미지를 최종 미리보기로 표시하지 않는다. |
### 8.6 FanTalk
@@ -361,13 +362,14 @@ AI 캐릭터를 생성하고, AI 캐릭터가 사람 크리에이터처럼 콘
| FILE-006 | 확정 | 오디오 콘텐츠 cover는 `1:1` 고정 aspect ratio로 크롭하며 결과의 최대 가로 폭은 800px다. |
| FILE-007 | 확정 | 커뮤니티 JPEG/PNG·시리즈·오디오 콘텐츠에서 새 image를 선택하면 업로드 전에 crop UI를 반드시 거친다. 커뮤니티 GIF는 예외다. |
| FILE-008 | 확정 | crop UI는 pointer drag/pinch와 keyboard 방향키·확대/축소, 초기화, 결과 미리보기, 취소, 적용을 제공한다. cropper가 직접 조작을 제공하므로 별도 방향·zoom 버튼은 두지 않는다. |
| FILE-009 | 확정 | optional 교체 파일 미전송은 기존 media 유지다. crop 취소도 기존 media를 변경하지 않는다. 기존 media 자체 제거는 별도 remove contract가 없어 범위 밖이다. |
| FILE-009 | 확정 | 수정 화면에서 optional 교체 파일 미전송은 서버에 저장된 기존 media 유지다. 수정 crop 취소도 저장된 기존 media를 변경하지 않으며 기존 media 자체 제거는 별도 remove contract가 없어 범위 밖이다. Community 생성 폼의 아직 저장되지 않은 로컬 이미지는 `COMMUNITY-003`에 따라 새 이미지 선택 시작 시 제거되고 crop 취소 후 복원하지 않는다. |
| FILE-010 | 확정 | 캐릭터 image는 JPEG/PNG만 허용하고 `1:1` 고정 aspect ratio로 크롭하며 결과의 최대 가로·세로는 800px다. |
| FILE-011 | 확정 | 커뮤니티 GIF는 crop하지 않는다. crop Dialog를 열지 않고 원본 비율과 animation을 유지한 File을 등록한다. |
| FILE-012 | 확정 | JPEG/PNG crop 결과는 선택된 원본 crop 영역의 pixel 크기보다 확대하지 않는다. resource별 800px/1,000px 값은 최대 출력 폭이며 작은 원본은 가능한 원본 크기로 출력한다. |
| FILE-013 | 확정 | 커뮤니티 첨부 audio는 오디오 콘텐츠와 동일하게 MP3(`.mp3`, `audio/mpeg`), AAC(`.aac`, `audio/aac`), M4A(`.m4a`, `audio/mp4` 또는 `audio/x-m4a`), 최대 `1,024,000,000 bytes`, 재생 길이 제한 없음 정책을 사용하고 WAV는 거부한다. `audio/x-m4a``.m4a`와 실제 container/codec 검증이 일치할 때만 허용한다. |
| FILE-014 | 확정 | 커뮤니티 GIF의 원본 가로가 800px을 초과하면 등록을 거부한다. client에서 제출 전에 차단하고 server도 같은 제한을 검증한다. GIF를 축소·crop·재인코딩하지 않는다. |
| FILE-015 | 확정 | Series crop 결과의 세로 pixel은 `round(width × 297 ÷ 210)`으로 계산한다. 최대 폭에서는 1,000×1,414px이며 비율 검증은 계산된 세로값 기준 1px 이내 오차를 허용한다. |
| FILE-016 | 확정 | 공통 FileField는 controlled value가 이미지 File이면 실제 업로드 대상의 미리보기를 표시하고 교체·제거·unmount 시 preview Blob URL을 해제한다. 오디오 등 비이미지 File은 기존 파일명 표시를 유지한다. |
### 8.9 개발 전용 Mock Preview
@@ -938,3 +940,4 @@ vertical slice와 남은 수동 QA에서 검증한다. `EXT-006`은 현재 확
| 2026-08-03 | 공통 `AdminAudioPlayer`의 native media 동작과 단일 재생·오류 계약은 유지하고, Plyr·Media Chrome의 audio-only control 배치를 참고한 compact 가로형 UI로 개선한다. 플레이어 내부 image·video 영역과 새 외부 라이브러리·waveform은 추가하지 않으며 오디오 콘텐츠 목록·상세와 커뮤니티 목록·Sheet에 동일하게 적용한다. |
| 2026-08-04 | Audio form은 tags를 chip으로 입력하고 Enter/comma 추가와 remove button 삭제를 제공하되 payload는 comma-separated string으로 유지한다. 가격은 숫자만 표시·저장하는 `0..99999` 정수이며 0은 무료다. 가격이 0보다 클 때만 purchase option, preview 생성·시간, point 사용 가능 여부를 보이고, 0 전환 시 `purchaseOption=BOTH`, `isGeneratePreview=false`, `isPointAvailable=false`, preview times=`null`로 초기화한다. `limited`는 문서화되지 않은 NullableInt32이므로 UI를 제거하고 `null`을 보내며, `languageCode` UI는 제거하고 `null`, 독립 `isOnlyRental` UI는 제거하고 `false`를 보낸다. preview 시작·종료는 오디오 duration offset으로 text 입력하며 완전한 `HH:MM:SS`만 안내하고 request에는 입력한 `HH:mm:ss` 값을 그대로 보낸다. 예약 일시는 예약 공개에서만 native `datetime-local`로 렌더링하고 custom `showPicker()`는 사용하지 않는다. native radio/checkbox는 하나의 선택 카드 시각 문법을 사용한다. |
| 2026-08-04 | Series 생성 form은 image 입력을 최상단에 배치하고 keyword를 Audio tags와 같은 공용 `TagInput` chip UI로 입력한다. Enter/comma로 확정하고 remove button으로 삭제하며 server request는 기존 comma-separated `keyword` 문자열 계약을 유지한다. |
| 2026-08-04 | Community 생성 form은 최종 적용 이미지 preview를 표시하고 이미지 → 조건부 오디오 → 내용 → 가격 순서로 배치한다. 새 이미지 선택 시작 시 아직 저장되지 않은 기존 로컬 이미지와 오디오를 제거하며 crop 취소 후 로컬 이미지를 복원하지 않는다. 가격은 Audio와 같은 기본 0·blank/음수/소수/상한 거부 계약으로 항상 request에 포함한다. 내부 관리자 화면이므로 P6-R5 browser QA에서 별도 zoom 검증은 제외하고 375/768/1280 표준 viewport를 확인한다. |

View File

@@ -29,7 +29,7 @@ test("renders the protected admin shell for an existing ADMIN session", async ()
expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
expect(requests[0]?.url).toContain("size=20");
expect(screen.getByRole("link", { name: "본문으로 건너뛰기" })).toHaveAttribute("href", "#app-main");
expect(screen.getByRole("banner")).toBeInTheDocument();
expect(screen.getByRole("banner")).toHaveClass("flex-wrap", "py-2");
expect(screen.getByRole("button", { name: "모바일 메뉴 열기" })).toHaveClass("whitespace-nowrap");
expect(screen.getByRole("navigation", { name: "브레드크럼" })).toHaveClass("whitespace-nowrap");
expect(screen.getByRole("button", { name: "로그아웃" })).toHaveClass("whitespace-nowrap");

View File

@@ -167,7 +167,7 @@ export function ProtectedAdminShell({ apiClient, apiMode, routeError }: { readon
</nav>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex min-h-14 items-center justify-between gap-3 border-b border-border bg-card px-4" role="banner">
<header className="flex min-h-14 flex-wrap items-center justify-between gap-3 border-b border-border bg-card px-4 py-2" role="banner">
<div className="flex min-w-0 items-center gap-3">
<button
aria-expanded={isMobileMenuOpen}

View File

@@ -5,7 +5,7 @@ import { routePaths } from "@/app/route-paths";
import { createAudioContentCreateBody, deactivateAudioContent, updateAudioContent } from "@/features/audio-contents/api/audio-content-api";
import { uploadAudioContent } from "@/features/audio-contents/api/upload-audio-content";
import type { UploadAudioContentOptions, UploadAuthDependencies } from "@/features/audio-contents/api/upload-audio-content";
import { audioErrorMessage, coverErrorMessage, createInitialPrice, defaultAudioContentCreateSettings, formatPrice, isFutureLocalDateTime, parsePrice, toCreateRequest, toUpdateRequest } from "@/features/audio-contents/components/audio-content-form-helpers";
import { audioErrorMessage, coverErrorMessage, createInitialPrice, defaultAudioContentCreateSettings, isFutureLocalDateTime, parsePrice, toCreateRequest, toUpdateRequest } from "@/features/audio-contents/components/audio-content-form-helpers";
import type { AudioContentCreateSettings } from "@/features/audio-contents/components/audio-content-form-helpers";
import { AudioContentCreateOptions } from "@/features/audio-contents/components/AudioContentCreateOptions";
import { AudioContentThemeSelect } from "@/features/audio-contents/components/AudioContentThemeSelect";
@@ -21,6 +21,7 @@ import type { CropRenderRequest } from "@/shared/lib/crop-image";
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
import { formatSeoulDateTime } from "@/shared/lib/formatters";
import { ConfirmDeactivateDialog } from "@/shared/ui/confirm-deactivate-dialog";
import { CanPriceField } from "@/shared/ui/can-price-field";
import { FileField } from "@/shared/ui/file-field";
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
@@ -80,9 +81,8 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
useEffect(() => () => cropSource?.release?.(), [cropSource]);
function changePrice(value: string) {
const nextPrice = formatPrice(value);
setPrice(nextPrice);
if (mode === "create" && nextPrice === "0") {
setPrice(value);
if (mode === "create" && parsePrice(value) === 0) {
setCreateSettings((current) => ({ ...current, purchaseOption: "BOTH", isGeneratePreview: false, isPointAvailable: false, previewStartTime: null, previewEndTime: null }));
}
}
@@ -236,8 +236,7 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
{errors.detail === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.detail} role="alert">{errors.detail}</p>}
<TagInput error={errors.tags} errorId={errorIds.tags} label="태그" onChange={setTags} value={tags} />
{mode === "create" ? <AudioContentThemeSelect error={errors.theme} errorId={errorIds.theme} onChange={setThemeId} themes={themes} value={themeId} /> : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">: {audio?.themeStr ?? "-"} ( )</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"><input aria-describedby={errors.price === undefined ? undefined : errorIds.price} aria-invalid={errors.price === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" inputMode="numeric" min="0" onChange={(event) => changePrice(event.currentTarget.value)} step="1" type="number" value={price} /></label><p className="text-sm text-muted-foreground">단위: </p>
{errors.price === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.price} role="alert">{errors.price}</p>}
<CanPriceField error={errors.price} errorId={errorIds.price} onChange={changePrice} value={price} />
{mode === "create" ? <AudioContentCreateOptions isPaid={isPaid} onChange={setCreateSettings} value={createSettings} /> : null}
{mode === "create" ? <ReleaseScheduleField error={errors.releaseDate} errorId={errorIds.releaseDate} onChange={setReleaseSchedule} value={releaseSchedule} /> : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">: {audio?.releaseDate == null ? "즉시 공개" : formatSeoulDateTime(audio.releaseDate)} ( )</p>}
<p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">시리즈: 현재 .</p>

View File

@@ -13,14 +13,6 @@ export function parsePrice(value: string): number | null {
return parseCanPriceInput(value);
}
export function formatPrice(value: string): string {
if (value.startsWith("-")) {
return "0";
}
return value.replace(/\D/g, "");
}
export const selectionCardClassName = "flex min-h-11 cursor-pointer items-center gap-3 rounded-md border border-border bg-card px-3 py-2 text-sm font-semibold text-foreground hover:bg-accent hover:text-accent-foreground focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 has-[:checked]:border-input has-[:checked]:bg-accent has-[:checked]:text-accent-foreground";
const localDateTimePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;

View File

@@ -67,10 +67,10 @@ test("AudioContentFormPage commits tag chips with Enter and comma without submit
});
test.each([
{ value: "-1", expected: "0" },
{ value: "1.5", expected: "15" },
{ value: "100000", expected: "100000" },
])("AudioContentFormPage keeps only digits in the price input for $value", async ({ value, expected }) => {
"-1",
"1.5",
"100000",
])("AudioContentFormPage preserves the raw price input %s", async (value) => {
// Given
renderCreateForm();
const priceInput = await screen.findByLabelText("가격");
@@ -83,7 +83,7 @@ test.each([
fireEvent.change(priceInput, { target: { value } });
// Then
expect(priceInput).toHaveValue(Number(expected));
expect(priceInput).toHaveValue(Number(value));
});
test("AudioContentFormPage shows paid options only for a positive price", async () => {
@@ -110,7 +110,7 @@ test("AudioContentFormPage shows paid options only for a positive price", async
expect(screen.getByLabelText("포인트 사용")).toBeInTheDocument();
});
test("AudioContentFormPage resets paid settings and retains removed-control defaults when price returns to zero", async () => {
test("AudioContentFormPage resets paid settings and retains removed-control defaults when price is numeric zero", async () => {
// Given
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
renderCreateForm(uploadedBodies);
@@ -124,7 +124,7 @@ test("AudioContentFormPage resets paid settings and retains removed-control defa
fireEvent.change(screen.getByLabelText("미리듣기 종료"), { target: { value: "01:00:05" } });
// When
fireEvent.change(priceInput, { target: { value: "0" } });
fireEvent.change(priceInput, { target: { value: "00" } });
// Then
expect(screen.queryByRole("group", { name: "구매 옵션" })).not.toBeInTheDocument();

View File

@@ -26,6 +26,38 @@ function createInactiveFormClient(requests: CapturedRequest[]): ApiClient {
};
}
test.each(["-1", "1.5", "100000"])("AudioContentFormPage rejects invalid edit price %s before update", async (value) => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" />);
// When
const priceInput = await screen.findByLabelText("가격");
fireEvent.change(priceInput, { target: { value } });
fireEvent.submit(screen.getByRole("form", { name: "오디오 콘텐츠 수정 입력 화면" }));
// Then
expect(priceInput).toHaveValue(Number(value));
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
});
test("AudioContentFormPage allows the maximum edit price", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" />);
// When
fireEvent.change(await screen.findByLabelText("가격"), { target: { value: "99999" } });
fireEvent.click(screen.getByRole("button", { name: "저장" }));
// Then
await waitFor(() => expect(requests.filter((request) => request.method === "PUT")).toHaveLength(1));
expect(await readJsonPart(requireFormData(requests.at(-1)?.body).get("request"))).toMatchObject({ price: 99999 });
});
test("AudioContentFormPage update omits unsupported controls and soft delete navigates to the audio list", async () => {
// Given
const requests: CapturedRequest[] = [];

View File

@@ -161,9 +161,10 @@ test("AudioContentFormPage serializes supported create settings with removed-con
});
test.each([
{ label: "음수 기호", value: "-1", expected: "0" },
{ label: "소수점", value: "1.5", expected: "15" },
])("AudioContentFormPage removes non-digits from price input for %s", async ({ label, value, expected }) => {
"-1",
"1.5",
"100000",
])("AudioContentFormPage preserves and rejects invalid price input %s before upload", async (value) => {
// Given
const requests: CapturedRequest[] = [];
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
@@ -171,17 +172,21 @@ test.each([
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" createCropSource={(file) => Promise.resolve({ file, height: 800, previewUrl: "blob:cover", width: 800 })} renderCrop={(request) => Promise.resolve(request.file)} uploadAudioContentRequest={createSuccessfulUpload(uploadedBodies)} />);
// When
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: label } });
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "잘못된 가격" } });
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "설명" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "태그" } });
fireEvent.keyDown(screen.getByLabelText("태그"), { key: "Enter" });
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [fileWithSize("voice.mp3", "audio/mpeg", 10)] } });
fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "cover.png", { type: "image/png" })] } });
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
await waitFor(() => expect(screen.getByText("cover.png")).toBeInTheDocument());
fireEvent.change(screen.getByLabelText("가격"), { target: { value } });
fireEvent.submit(screen.getByRole("form", { name: "오디오 콘텐츠 생성 입력 화면" }));
// Then
expect(screen.getByLabelText("가격")).toHaveValue(Number(expected));
expect(screen.getByLabelText("가격")).toHaveValue(Number(value));
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(uploadedBodies).toHaveLength(0);
});
@@ -237,32 +242,6 @@ test("AudioContentFormPage validates audio boundary and serializes scheduled Asi
expect(request).not.toHaveProperty("timezone");
});
test("AudioContentFormPage blocks prices outside the CAN range before upload", async () => {
// Given
const requests: CapturedRequest[] = [];
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" createCropSource={(file) => Promise.resolve({ file, height: 800, previewUrl: "blob:cover", width: 800 })} renderCrop={(request) => Promise.resolve(request.file)} uploadAudioContentRequest={createSuccessfulUpload(uploadedBodies)} />);
// When
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "가격 경계" } });
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "설명" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "태그" } });
fireEvent.keyDown(screen.getByLabelText("태그"), { key: "Enter" });
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [fileWithSize("voice.mp3", "audio/mpeg", 10)] } });
fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "cover.png", { type: "image/png" })] } });
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
await waitFor(() => expect(screen.getByText("cover.png")).toBeInTheDocument());
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100000" } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(screen.getByLabelText("가격")).toHaveValue(100000);
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(uploadedBodies).toHaveLength(0);
});
test("AudioContentFormPage links validation errors and focuses the first invalid control", async () => {
// Given
const requests: CapturedRequest[] = [];
@@ -288,7 +267,7 @@ test("AudioContentFormPage links validation errors and focuses the first invalid
expect(screen.getByLabelText("상세 설명")).toHaveAttribute("aria-describedby", "audio-content-detail-error");
expect(screen.getByLabelText("태그").getAttribute("aria-describedby")?.split(" ")).toContain("audio-content-tags-error");
expect(screen.getByLabelText("태그")).toHaveAccessibleDescription("쉼표 또는 Enter로 태그를 추가하세요. 태그를 입력하세요.");
expect(screen.getByLabelText("가격")).not.toHaveAttribute("aria-describedby");
expect(screen.getByLabelText("가격")).toHaveAccessibleDescription("단위: 캔");
expect(screen.getByLabelText("오디오 테마")).toHaveAttribute("aria-describedby", "audio-content-theme-error");
expect(titleInput).toHaveAttribute("aria-invalid", "true");
expect(coverInput).toHaveAttribute("aria-invalid", "true");

View File

@@ -1,13 +1,14 @@
import { useEffect, useRef, useState } from "react";
import { createCommunityPost } from "@/features/community-posts/api/community-post-api";
import { communityPostAudioErrorMessage, formatCommunityPostPrice, hasCommunityPostFormErrors, parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
import { communityPostAudioErrorMessage, hasCommunityPostFormErrors, parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
import type { CommunityPostFormErrors } from "@/features/community-posts/components/community-post-form-helpers";
import { COMMUNITY_POST_IMAGE_POLICY, prepareCommunityPostImage } from "@/features/community-posts/validation/community-post-media-policy";
import { ApiError } from "@/shared/api/api-error";
import type { ApiClient } from "@/shared/api/client";
import type { CropRenderRequest } from "@/shared/lib/crop-image";
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
import { CanPriceField } from "@/shared/ui/can-price-field";
import { FileField } from "@/shared/ui/file-field";
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
@@ -27,12 +28,6 @@ const errorIds = {
price: "community-post-price-error",
} as const;
function buildCreateRequest(content: string, isAdult: boolean, isCommentAvailable: boolean, price: number | null) {
const base = { content, isAdult, isCommentAvailable };
return price === null ? base : { ...base, price };
}
export function CommunityPostForm({ apiClient, characterId, createCropSource, onCreated, renderCrop }: CommunityPostFormProps) {
const [audioFile, setAudioFile] = useState<File | null>(null);
const [content, setContent] = useState("");
@@ -43,7 +38,7 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
const [isImagePreparing, setIsImagePreparing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [postImage, setPostImage] = useState<File | null>(null);
const [price, setPrice] = useState("");
const [price, setPrice] = useState("0");
const formRef = useRef<HTMLFormElement>(null);
const imageSelectionId = useRef(0);
const isSavingRef = useRef(false);
@@ -53,9 +48,11 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
async function selectPostImage(file: File | null) {
imageSelectionId.current += 1;
const currentSelectionId = imageSelectionId.current;
setAudioFile(null);
setErrors((current) => ({ ...current, audio: undefined }));
setPostImage(null);
setCropSource(null);
if (file === null) {
setPostImage(null);
setCropSource(null);
setIsImagePreparing(false);
setErrors((current) => ({ ...current, image: undefined }));
return;
@@ -110,13 +107,12 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
function validateForm(): CommunityPostFormErrors {
const parsedPrice = parseCommunityPostPrice(price);
const hasPriceInput = price.trim().length > 0;
return {
audio: communityPostAudioErrorMessage(audioFile),
content: content.trim().length === 0 ? "내용을 입력하세요." : undefined,
image: isImagePreparing || cropSource !== null ? "이미지 처리가 끝난 뒤 저장하세요." : errors.image,
price: (hasPriceInput && parsedPrice === null) || (parsedPrice !== null && (!Number.isInteger(parsedPrice) || parsedPrice < 0 || parsedPrice > CAN_PRICE_MAX)) ? "가격은 0 이상 99,999 이하 정수 캔으로 입력하세요." : undefined,
price: parsedPrice === null || !Number.isInteger(parsedPrice) || parsedPrice < 0 || parsedPrice > CAN_PRICE_MAX ? "가격은 0 이상 99,999 이하 정수 캔으로 입력하세요." : undefined,
};
}
@@ -131,6 +127,10 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
queueMicrotask(() => focusFirstInvalidControl(formRef.current));
return;
}
const parsedPrice = parseCommunityPostPrice(price);
if (parsedPrice === null) {
return;
}
isSavingRef.current = true;
setIsSaving(true);
@@ -138,7 +138,7 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
await createCommunityPost(apiClient, characterId, {
audioFile: audioFile ?? undefined,
postImage: postImage ?? undefined,
request: buildCreateRequest(content.trim(), isAdult, isCommentAvailable, parseCommunityPostPrice(price)),
request: { content: content.trim(), isAdult, isCommentAvailable, price: parsedPrice },
});
onCreated();
} catch (error: unknown) {
@@ -154,17 +154,16 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
<div className="flex flex-col gap-2">
<p className="text-xs font-semibold text-info">COMMUNITY</p>
<h2 className="text-2xl font-bold leading-tight" id="community-post-form-title"> </h2>
<p className="text-sm text-muted-foreground"> . GIF는 crop .</p>
<p className="break-keep text-sm text-muted-foreground"> . GIF는 crop .</p>
</div>
<form aria-label="커뮤니티 게시글 생성 입력 화면" 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,image/gif" acceptDescription="JPEG 또는 PNG는 자유 ratio crop 후 최대 800px로 전송합니다. GIF는 원본 width 800px 이하만 crop 없이 전송합니다." error={errors.image} label="게시글 이미지" onChange={(file) => void selectPostImage(file)} value={postImage} />
{postImage === null ? null : <FileField accept={AUDIO_FILE_POLICY.allowedMimeTypes.join(",")} acceptDescription="MP3, AAC, M4A, 최대 1,024,000,000 bytes. WAV는 지원하지 않습니다." error={errors.audio} label="오디오 파일" onChange={setAudioFile} value={audioFile} />}
<label className="flex flex-col gap-2 text-sm font-semibold"><textarea aria-describedby={errors.content === undefined ? undefined : errorIds.content} aria-invalid={errors.content === undefined ? undefined : true} className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setContent(event.currentTarget.value)} value={content} /></label>
{errors.content === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.content} role="alert">{errors.content}</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"><input aria-describedby={errors.price === undefined ? undefined : errorIds.price} aria-invalid={errors.price === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" inputMode="numeric" onChange={(event) => setPrice(formatCommunityPostPrice(event.currentTarget.value))} value={price} /></label>
{errors.price === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.price} role="alert">{errors.price}</p>}
<CanPriceField error={errors.price} errorId={errorIds.price} onChange={setPrice} value={price} />
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={isCommentAvailable} onChange={(event) => setIsCommentAvailable(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>
<FileField accept={AUDIO_FILE_POLICY.allowedMimeTypes.join(",")} acceptDescription="MP3, AAC, M4A, 최대 1,024,000,000 bytes. WAV는 지원하지 않습니다." error={errors.audio} label="오디오 파일" onChange={setAudioFile} value={audioFile} />
<FileField accept="image/jpeg,image/png,image/gif" acceptDescription="JPEG 또는 PNG는 자유 ratio crop 후 최대 800px로 전송합니다. GIF는 원본 width 800px 이하만 crop 없이 전송합니다." error={errors.image} label="게시글 이미지" onChange={(file) => void selectPostImage(file)} value={postImage} />
{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}
<div className="flex justify-end gap-2">

View File

@@ -1,5 +1,5 @@
import { validateAudioFile } from "@/shared/validation/audio-file-policy";
import { formatCanPriceInput, parseCanPriceInput } from "@/shared/validation/can-price";
import { parseCanPriceInput } from "@/shared/validation/can-price";
export type CommunityPostFormErrors = {
readonly audio?: string;
@@ -13,10 +13,6 @@ export function parseCommunityPostPrice(value: string): number | null {
return parseCanPriceInput(value);
}
export function formatCommunityPostPrice(value: string): string {
return formatCanPriceInput(value);
}
export function communityPostAudioErrorMessage(file: File | null): string | undefined {
if (file === null) {
return undefined;

View File

@@ -68,6 +68,7 @@ describe("Community post contract", () => {
// Then
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101/community-posts", method: "POST", body: requests[0]?.body }]);
const body = requireFormData(requests[0]?.body);
expect([...body.keys()]).toEqual(["audioFile", "postImage", "request"]);
expect(body.get("audioFile")).toBe(audioFile);
expect(body.get("postImage")).toBe(postImage);
const request = await readJsonPart(body.get("request"));

View File

@@ -1,6 +1,6 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { afterEach, expect, test, vi } from "vitest";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { App } from "@/app/App";
import { apiBaseUrl, saveAdminSession, useAiCharacterDetailResponse, useAiCharactersResponse } from "@/app/app-test-support";
@@ -71,6 +71,18 @@ function createCropSource(file: File): Promise<CropSourceImage> {
return Promise.resolve({ file, height: 600, previewUrl: "blob:gif", width: 800 });
}
beforeEach(() => {
class TestUrl extends URL {
static createObjectURL(blob: Blob): string {
return `blob:community-${blob.size}`;
}
static revokeObjectURL(): void {}
}
vi.stubGlobal("URL", TestUrl);
});
afterEach(() => {
vi.unstubAllEnvs();
window.history.replaceState({}, "", "/");
@@ -91,6 +103,7 @@ test("Community create route opens from the existing list workspace", async () =
// Then
expect(await screen.findByRole("heading", { name: "커뮤니티 게시글 생성" })).toBeInTheDocument();
expect(screen.getByText("내용과 첨부 파일은 저장 전 운영 기준에 맞게 검토하세요. GIF는 crop 없이 원본을 보존합니다.")).toHaveClass("break-keep");
expect(window.location.pathname).toBe("/ai-characters/101/community-posts/new");
});
@@ -138,6 +151,8 @@ test("Community create form validates content audio policy and GIF width before
// When
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "GIF와 오디오가 있는 게시글" } });
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [animatedGif] } });
await screen.findByLabelText("오디오 파일");
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [fileWithSize("voice.wav", "audio/wav", 10)] } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
@@ -145,7 +160,6 @@ test("Community create form validates content audio policy and GIF width before
expect(await screen.findByText("MP3, AAC, M4A 파일만 업로드하세요.")).toBeInTheDocument();
// When
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [audioFile] } });
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "big.gif", { type: "image/gif" })] } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
@@ -155,6 +169,9 @@ test("Community create form validates content audio policy and GIF width before
// When
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [animatedGif] } });
const revealedAudioInput = await screen.findByLabelText("오디오 파일");
expect(screen.queryByText("MP3, AAC, M4A 파일만 업로드하세요.")).not.toBeInTheDocument();
fireEvent.change(revealedAudioInput, { target: { files: [audioFile] } });
await waitFor(() => expect(screen.getByRole("button", { name: "생성" })).not.toBeDisabled());
fireEvent.click(screen.getByRole("button", { name: "생성" }));
@@ -164,7 +181,98 @@ test("Community create form validates content audio policy and GIF width before
const body = requireFormData(requests.at(-1)?.body);
expect(body.get("audioFile")).toBe(audioFile);
expect(body.get("postImage")).toBe(animatedGif);
expect(await readJsonPart(body.get("request"))).toEqual({ content: "GIF와 오디오가 있는 게시글", isAdult: false, isCommentAvailable: true });
expect(await readJsonPart(body.get("request"))).toEqual({ content: "GIF와 오디오가 있는 게시글", isAdult: false, isCommentAvailable: true, price: 0 });
});
test("Community create form renders final image preview first and reveals audio before content", async () => {
// Given
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
render(<CommunityPostFormPage apiClient={createCommunityFormClient([])} characterId="101" createCropSource={createCropSource} />);
const form = await screen.findByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" });
if (!(form instanceof HTMLFormElement)) {
throw new Error("expected community create form");
}
const imageInput = screen.getByLabelText("게시글 이미지");
const contentInput = screen.getByLabelText("내용");
// Then
expect(screen.queryByLabelText("오디오 파일")).not.toBeInTheDocument();
expect(Array.from(form.elements).indexOf(imageInput)).toBeLessThan(Array.from(form.elements).indexOf(contentInput));
// When
fireEvent.change(imageInput, { target: { files: [new File(["gif"], "post.gif", { type: "image/gif" })] } });
// Then
const preview = await screen.findByRole("img", { name: "게시글 이미지 업로드 미리보기" });
const audioInput = screen.getByLabelText("오디오 파일");
expect(preview).toHaveAttribute("src", "blob:community-3");
expect(Array.from(form.elements).indexOf(imageInput)).toBeLessThan(Array.from(form.elements).indexOf(audioInput));
expect(Array.from(form.elements).indexOf(audioInput)).toBeLessThan(Array.from(form.elements).indexOf(contentInput));
});
test("Community create form clears final image and audio as soon as image replacement starts", async () => {
// Given
let resolveReplacement: (source: CropSourceImage) => void = () => undefined;
const replacementReady = new Promise<CropSourceImage>((resolve) => {
resolveReplacement = resolve;
});
const createReplacementCropSource = (file: File) => file.name === "replacement.png" ? replacementReady : createCropSource(file);
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
render(<CommunityPostFormPage apiClient={createCommunityFormClient([])} characterId="101" createCropSource={createReplacementCropSource} />);
fireEvent.change(await screen.findByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "ready.gif", { type: "image/gif" })] } });
const audioInput = await screen.findByLabelText("오디오 파일");
fireEvent.change(audioInput, { target: { files: [new File(["audio"], "voice.m4a", { type: "audio/x-m4a" })] } });
// When
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["image"], "replacement.png", { type: "image/png" })] } });
// Then
expect(screen.queryByLabelText("오디오 파일")).not.toBeInTheDocument();
expect(screen.queryByRole("img", { name: "게시글 이미지 업로드 미리보기" })).not.toBeInTheDocument();
await act(async () => {
resolveReplacement({ file: new File(["image"], "replacement.png", { type: "image/png" }), height: 500, previewUrl: "blob:replacement", width: 1000 });
await replacementReady;
});
expect(await screen.findByRole("dialog", { name: "이미지 crop" })).toBeInTheDocument();
});
test("Community create form hides and clears audio when the final image is removed", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
render(<CommunityPostFormPage apiClient={createCommunityFormClient(requests)} characterId="101" createCropSource={createCropSource} />);
fireEvent.change(await screen.findByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "ready.gif", { type: "image/gif" })] } });
fireEvent.change(await screen.findByLabelText("오디오 파일"), { target: { files: [new File(["audio"], "voice.m4a", { type: "audio/x-m4a" })] } });
// When
fireEvent.click(screen.getByRole("button", { name: "게시글 이미지 선택 취소" }));
// Then
await waitFor(() => expect(screen.queryByLabelText("오디오 파일")).not.toBeInTheDocument());
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "이미지 제거 게시글" } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
await waitFor(() => expect(requests.filter((request) => request.method === "POST")).toHaveLength(1));
const body = requireFormData(requests.at(-1)?.body);
expect(body.has("audioFile")).toBe(false);
expect(body.has("postImage")).toBe(false);
});
test("Community create form defaults price to zero and rejects blank price", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
render(<CommunityPostFormPage apiClient={createCommunityFormClient(requests)} characterId="101" createCropSource={createCropSource} />);
const priceInput = await screen.findByLabelText("가격");
expect(priceInput).toHaveValue(0);
// When
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "빈 가격 게시글" } });
fireEvent.change(priceInput, { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
});
test("Community create form crops JPEG PNG with free ratio and no upscale", async () => {
@@ -177,7 +285,7 @@ test("Community create form crops JPEG PNG with free ratio and no upscale", asyn
// When
fireEvent.change(await screen.findByLabelText("내용"), { target: { value: "PNG crop 게시글" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "1,000" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "1000" } });
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["image"], "wide.png", { type: "image/png" })] } });
expect(await screen.findByRole("dialog", { name: "이미지 crop" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "생성" })).toBeDisabled();
@@ -185,6 +293,7 @@ test("Community create form crops JPEG PNG with free ratio and no upscale", asyn
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
await waitFor(() => expect(renderCrop).toHaveBeenCalled());
expect(await screen.findByRole("img", { name: "게시글 이미지 업로드 미리보기" })).toHaveAttribute("src", "blob:community-7");
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
@@ -203,7 +312,7 @@ test("Community create form blocks prices outside the CAN range before submit",
// When
fireEvent.change(await screen.findByLabelText("내용"), { target: { value: "가격 경계 게시글" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100,000" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100000" } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
@@ -219,7 +328,7 @@ test("Community create form links validation errors and focuses the first invali
// When
const contentInput = await screen.findByLabelText("내용");
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100,000" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100000" } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
@@ -228,7 +337,7 @@ test("Community create form links validation errors and focuses the first invali
expect(contentError).toHaveAttribute("id", "community-post-content-error");
expect(priceError).toHaveAttribute("id", "community-post-price-error");
expect(contentInput).toHaveAttribute("aria-describedby", "community-post-content-error");
expect(screen.getByLabelText("가격")).toHaveAttribute("aria-describedby", "community-post-price-error");
expect(screen.getByLabelText("가격").getAttribute("aria-describedby")?.split(" ")).toContain("community-post-price-error");
expect(contentInput).toHaveAttribute("aria-invalid", "true");
expect(screen.getByLabelText("가격")).toHaveAttribute("aria-invalid", "true");
await waitFor(() => expect(contentInput).toHaveFocus());
@@ -295,7 +404,7 @@ test("Community create form ignores stale image preparation results", async () =
expect(submittedImage.name).toBe("fresh.gif");
});
test("Community create form clears pending image preparation when selection is canceled", async () => {
test("Community create form clears image replacement when crop is canceled", async () => {
let resolveCropSource: (source: CropSourceImage) => void = () => undefined;
const cropSourceReady = new Promise<CropSourceImage>((resolve) => {
resolveCropSource = resolve;
@@ -309,11 +418,11 @@ test("Community create form clears pending image preparation when selection is c
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "ready.gif", { type: "image/gif" })] } });
await waitFor(() => expect(screen.getByRole("button", { name: "생성" })).not.toBeDisabled());
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["image"], "slow.png", { type: "image/png" })] } });
fireEvent.click(screen.getByRole("button", { name: "선택 취소" }));
await act(async () => {
resolveCropSource({ file: new File(["image"], "slow.png", { type: "image/png" }), height: 500, previewUrl: "blob:slow", width: 1000 });
await cropSourceReady;
});
fireEvent.click(await screen.findByRole("button", { name: "취소" }));
expect(screen.getByRole("button", { name: "생성" })).not.toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "생성" }));

View File

@@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { CommunityPostForm } from "@/features/community-posts/components/CommunityPostForm";
import { formatCommunityPostPrice, parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
import { parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
type CapturedRequest = {
@@ -27,25 +27,25 @@ function renderForm(requests: CapturedRequest[]) {
return onCreated;
}
async function submitWithPrice(value: string, requests: CapturedRequest[]) {
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "가격 검증 게시글" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.");
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
}
test.each(["-1", "1.5"])("Community price keeps invalid raw input %s and blocks submit", async (value) => {
test.each([
"-1",
"1.5",
"100000",
])("Community price preserves and rejects invalid raw input %s", async (rawValue) => {
const requests: CapturedRequest[] = [];
const onCreated = renderForm(requests);
await submitWithPrice(value, requests);
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "가격 검증 게시글" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: rawValue } });
fireEvent.submit(screen.getByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" }));
expect(screen.getByLabelText("가격")).toHaveValue(value);
expect(screen.getByLabelText("가격")).toHaveValue(Number(rawValue));
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
expect(onCreated).not.toHaveBeenCalled();
});
test.each(["0", "99,999"])("Community price allows boundary input %s", async (value) => {
test.each(["0", "99999"])("Community price allows boundary input %s", async (value) => {
const requests: CapturedRequest[] = [];
const onCreated = renderForm(requests);
@@ -57,9 +57,8 @@ test.each(["0", "99,999캔"])("Community price allows boundary input %s", async
expect(requests.filter((request) => request.method === "POST")).toHaveLength(1);
});
test("Community price parser rejects negative and decimal raw input", () => {
test("Community price parser rejects blank, negative, and decimal raw input", () => {
expect(parseCommunityPostPrice("")).toBeNull();
expect(parseCommunityPostPrice("-1")).toBeNull();
expect(parseCommunityPostPrice("1.5")).toBeNull();
expect(formatCommunityPostPrice("-1")).toBe("-1");
expect(formatCommunityPostPrice("1.5")).toBe("1.5");
});

View File

@@ -0,0 +1,37 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { CanPriceField } from "@/shared/ui/can-price-field";
test("CanPriceField renders the native numeric CAN price contract", () => {
render(<CanPriceField error={undefined} errorId="price-error" onChange={() => undefined} value="0" />);
const input = screen.getByLabelText("가격");
expect(input).toHaveAttribute("type", "number");
expect(input).toHaveAttribute("inputmode", "numeric");
expect(input).toHaveAttribute("min", "0");
expect(input).toHaveAttribute("step", "1");
expect(input).toHaveAccessibleDescription("단위: 캔");
});
test.each([
"-1",
"1.5",
])("CanPriceField emits the raw input value %s unchanged", (rawValue) => {
const onChange = vi.fn<(value: string) => void>();
render(<CanPriceField error={undefined} errorId="price-error" onChange={onChange} value="0" />);
fireEvent.change(screen.getByLabelText("가격"), { target: { value: rawValue } });
expect(onChange).toHaveBeenCalledWith(rawValue);
});
test("CanPriceField links the visible error and unit description to the invalid input", () => {
render(<CanPriceField error="가격 오류" errorId="price-error" onChange={() => undefined} value="100000" />);
const input = screen.getByLabelText("가격");
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input.getAttribute("aria-describedby")?.split(" ")).toContain("price-error");
expect(input).toHaveAccessibleDescription("단위: 캔 가격 오류");
expect(screen.getByRole("alert")).toHaveAttribute("id", "price-error");
});

View File

@@ -1,8 +1,36 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { StrictMode } from "react";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { FileField } from "@/shared/ui/file-field";
const createObjectUrl = vi.fn<(blob: Blob) => string>();
const revokeObjectUrl = vi.fn<(url: string) => void>();
let originalCreateObjectUrl: PropertyDescriptor | undefined;
let originalRevokeObjectUrl: PropertyDescriptor | undefined;
beforeEach(() => {
createObjectUrl.mockReset().mockImplementation(() => `blob:preview-${createObjectUrl.mock.calls.length}`);
revokeObjectUrl.mockReset();
originalCreateObjectUrl = Object.getOwnPropertyDescriptor(URL, "createObjectURL");
originalRevokeObjectUrl = Object.getOwnPropertyDescriptor(URL, "revokeObjectURL");
Object.defineProperty(URL, "createObjectURL", { configurable: true, value: createObjectUrl });
Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: revokeObjectUrl });
});
afterEach(() => {
if (originalCreateObjectUrl === undefined) {
Reflect.deleteProperty(URL, "createObjectURL");
} else {
Object.defineProperty(URL, "createObjectURL", originalCreateObjectUrl);
}
if (originalRevokeObjectUrl === undefined) {
Reflect.deleteProperty(URL, "revokeObjectURL");
} else {
Object.defineProperty(URL, "revokeObjectURL", originalRevokeObjectUrl);
}
});
test("FileField exposes label, description, error, accept guidance, keyboard file input, and controlled value", () => {
const onChange = vi.fn();
const value = new File(["image"], "profile.png", { type: "image/png" });
@@ -23,9 +51,11 @@ test("FileField exposes label, description, error, accept guidance, keyboard fil
expect(input).toHaveAttribute("accept", "image/png");
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input).toHaveAccessibleDescription("프로필 이미지를 선택하세요. PNG만 업로드할 수 있습니다. 파일이 너무 큽니다.");
expect(screen.getByText("프로필 이미지를 선택하세요.")).toHaveClass("break-keep");
expect(screen.getByText("PNG만 업로드할 수 있습니다.")).toHaveClass("break-keep");
expect(screen.getByRole("button", { name: "대표 이미지 파일 선택" })).toBeInTheDocument();
expect(screen.getByText("profile.png")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "선택 취소" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "대표 이미지 선택 취소" })).toHaveTextContent("선택 취소");
});
test("FileField emits File or null and clear selection without owning upload policy", () => {
@@ -37,10 +67,62 @@ test("FileField emits File or null and clear selection without owning upload pol
expect(onChange).toHaveBeenCalledWith(selected);
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={onChange} value={selected} />);
fireEvent.click(screen.getByRole("button", { name: "선택 취소" }));
fireEvent.click(screen.getByRole("button", { name: "오디오 선택 취소" }));
expect(onChange).toHaveBeenCalledWith(null);
});
test("FileField clears the native selection when the controlled value is cleared externally", () => {
// Given
const selected = new File(["audio"], "voice.mp3", { type: "audio/mpeg" });
const { rerender } = render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={null} />);
const input = screen.getByLabelText<HTMLInputElement>("오디오");
fireEvent.change(input, { target: { files: [selected] } });
let nativeValue = "C:\\fakepath\\voice.mp3";
Object.defineProperty(input, "value", { configurable: true, get: () => nativeValue, set: (value: string) => { nativeValue = value; } });
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={selected} />);
expect(input.files?.[0]).toBe(selected);
expect(input.value).toBe("C:\\fakepath\\voice.mp3");
// When
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={null} />);
// Then
expect(input.value).toBe("");
});
test("FileField clears a rejected native selection when only the error changes", () => {
// Given
const rejected = new File(["audio"], "voice.wav", { type: "audio/wav" });
const { rerender } = render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={null} />);
const input = screen.getByLabelText<HTMLInputElement>("오디오");
fireEvent.change(input, { target: { files: [rejected] } });
let nativeValue = "C:\\fakepath\\voice.wav";
Object.defineProperty(input, "value", { configurable: true, get: () => nativeValue, set: (value: string) => { nativeValue = value; } });
// When
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" error="지원하지 않는 파일입니다." label="오디오" onChange={vi.fn()} value={null} />);
// Then
expect(input.value).toBe("");
});
test("FileField clears the native selection when the controlled value is a different File", () => {
// Given
const selected = new File(["source"], "profile.png", { type: "image/png" });
const cropped = new File(["cropped"], "profile.png", { type: "image/png" });
const { rerender } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={null} />);
const input = screen.getByLabelText<HTMLInputElement>("대표 이미지");
fireEvent.change(input, { target: { files: [selected] } });
let nativeValue = "C:\\fakepath\\profile.png";
Object.defineProperty(input, "value", { configurable: true, get: () => nativeValue, set: (value: string) => { nativeValue = value; } });
// When
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={cropped} />);
// Then
expect(input.value).toBe("");
});
test("FileField gives same visible file buttons field-specific accessible names", () => {
render(
<>
@@ -66,3 +148,102 @@ test("FileField visible container exposes the focus-within ring contract", () =>
expect(input).toHaveFocus();
expect(container.firstElementChild).toHaveClass("focus-within:ring-2", "focus-within:ring-ring", "focus-within:ring-offset-2");
});
test("FileField reserves a bounded preview frame for the current image value", async () => {
// Given
const firstImage = new File(["first"], "first.png", { type: "image/png" });
const secondImage = new File(["second"], "second.png", { type: "image/png" });
createObjectUrl.mockImplementation((blob) => blob === firstImage ? "blob:first" : "blob:second");
const { rerender } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={firstImage} />);
// When
const frame = screen.getByRole("figure", { name: "대표 이미지 업로드 미리보기 영역" });
// Then
expect(frame).toHaveClass("aspect-video", "max-h-64", "w-full", "max-w-md");
expect(await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" })).toHaveAttribute("src", "blob:first");
// When
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={secondImage} />);
// Then
expect(screen.getByRole("figure", { name: "대표 이미지 업로드 미리보기 영역" })).toBeInTheDocument();
await waitFor(() => expect(screen.getByRole("img", { name: "대표 이미지 업로드 미리보기" })).toHaveAttribute("src", "blob:second"));
});
test("FileField revokes every object URL created under StrictMode", async () => {
// Given
const image = new File(["image"], "profile.png", { type: "image/png" });
// When
const { unmount } = render(
<StrictMode>
<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={image} />
</StrictMode>,
);
await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" });
unmount();
// Then
const createdUrls = createObjectUrl.mock.results.map(({ value }) => value);
const revokedUrls = revokeObjectUrl.mock.calls.map(([url]) => url);
expect(new Set(revokedUrls)).toEqual(new Set(createdUrls));
});
test("FileField previews an image and revokes each owned object URL when its controlled value changes", async () => {
// Given
const firstImage = new File(["first"], "first.png", { type: "image/png" });
const secondImage = new File(["second"], "second.png", { type: "image/png" });
const onChange = vi.fn();
const { rerender, unmount } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={firstImage} />);
// When
const preview = await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" });
const firstUrl = preview.getAttribute("src");
// Then
expect(firstUrl).toMatch(/^blob:preview-/);
expect(createObjectUrl).toHaveBeenCalledWith(firstImage);
expect(preview).toHaveClass("h-full", "w-full", "object-contain");
// When
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={secondImage} />);
// Then
await waitFor(() => expect(screen.getByRole("img", { name: "대표 이미지 업로드 미리보기" }).getAttribute("src")).not.toBe(firstUrl));
const secondUrl = screen.getByRole("img", { name: "대표 이미지 업로드 미리보기" }).getAttribute("src");
expect(createObjectUrl).toHaveBeenCalledWith(secondImage);
expect(revokeObjectUrl).toHaveBeenCalledWith(firstUrl);
// When
fireEvent.click(screen.getByRole("button", { name: "대표 이미지 선택 취소" }));
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={null} />);
// Then
expect(onChange).toHaveBeenCalledWith(null);
expect(screen.queryByRole("img", { name: "대표 이미지 업로드 미리보기" })).not.toBeInTheDocument();
await waitFor(() => expect(revokeObjectUrl).toHaveBeenCalledWith(secondUrl));
// When
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={firstImage} />);
const finalPreview = await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" });
const finalUrl = finalPreview.getAttribute("src");
unmount();
// Then
expect(revokeObjectUrl).toHaveBeenCalledWith(finalUrl);
});
test("FileField keeps non-image files filename-only without creating object URLs", () => {
// Given
const audio = new File(["audio"], "voice.mp3", { type: "audio/mpeg" });
// When
render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={audio} />);
// Then
expect(screen.getByText("voice.mp3")).toBeInTheDocument();
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(createObjectUrl).not.toHaveBeenCalled();
expect(revokeObjectUrl).not.toHaveBeenCalled();
});

View File

@@ -8,6 +8,8 @@ test("PageState exposes accessible loading, empty, error, retry, and content sta
const { rerender } = render(<PageState description="자료를 불러오는 중입니다." state="loading" title="불러오는 중" />);
expect(screen.getByRole("status")).toHaveTextContent("불러오는 중");
expect(screen.getByRole("heading", { name: "불러오는 중" })).toHaveClass("break-keep");
expect(screen.getByText("자료를 불러오는 중입니다.")).toHaveClass("break-keep");
rerender(<PageState description="조건에 맞는 자료가 없습니다." state="empty" title="자료 없음" />);

View File

@@ -0,0 +1,33 @@
import { useId } from "react";
export type CanPriceFieldProps = {
readonly error: string | undefined;
readonly errorId: string;
readonly onChange: (value: string) => void;
readonly value: string;
};
export function CanPriceField({ error, errorId, onChange, value }: CanPriceFieldProps) {
const inputId = useId();
const unitId = `${inputId}-unit`;
return (
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold" htmlFor={inputId}></label>
<input
aria-describedby={`${unitId}${error === undefined ? "" : ` ${errorId}`}`}
aria-invalid={error === undefined ? undefined : true}
className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground"
id={inputId}
inputMode="numeric"
min="0"
onChange={(event) => onChange(event.currentTarget.value)}
step="1"
type="number"
value={value}
/>
<p className="text-sm text-muted-foreground" id={unitId}>단위: </p>
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useId, useRef } from "react";
import { useId, useLayoutEffect, useRef } from "react";
export type FileFieldProps = {
readonly accept: string;
@@ -16,8 +16,33 @@ export function FileField({ accept, acceptDescription, description, error, label
const acceptId = useId();
const errorId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const previewRef = useRef<HTMLImageElement>(null);
const isImage = value !== null && value.type.startsWith("image/");
const describedBy = [description === undefined ? null : descriptionId, acceptId, error === undefined ? null : errorId].filter((id): id is string => id !== null).join(" ");
useLayoutEffect(() => {
if (value === null || !value.type.startsWith("image/")) {
return undefined;
}
const objectUrl = URL.createObjectURL(value);
const previewElement = previewRef.current;
previewElement?.setAttribute("src", objectUrl);
return () => {
URL.revokeObjectURL(objectUrl);
previewElement?.removeAttribute("src");
};
}, [value]);
useLayoutEffect(() => {
const input = inputRef.current;
const selectedFile = input?.files?.[0];
if (input !== null && selectedFile !== undefined && selectedFile !== value) {
input.value = "";
}
});
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const files = event.currentTarget.files;
onChange(files === null ? null : files[0] ?? null);
@@ -33,16 +58,21 @@ export function FileField({ accept, acceptDescription, description, error, label
return (
<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>
{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>
{description === undefined ? null : <p className="break-keep text-sm text-muted-foreground" id={descriptionId}>{description}</p>}
<p className="break-keep text-sm text-muted-foreground" id={acceptId}>{acceptDescription}</p>
<input accept={accept} aria-describedby={describedBy} aria-invalid={error === undefined ? undefined : true} className="sr-only" id={inputId} onChange={handleChange} ref={inputRef} type="file" />
<button aria-label={`${label} 파일 선택`} className="w-fit rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => inputRef.current?.click()} type="button"> </button>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">{value === null ? "선택된 파일 없음" : value.name}</span>
{value === null ? null : (
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={clearSelection} type="button"> </button>
<button aria-label={`${label} 선택 취소`} className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={clearSelection} type="button"> </button>
)}
</div>
{isImage ? (
<figure aria-label={`${label} 업로드 미리보기 영역`} className="aspect-video max-h-64 w-full max-w-md overflow-hidden rounded-md border border-border bg-muted p-2">
<img alt={`${label} 업로드 미리보기`} className="h-full w-full object-contain" ref={previewRef} />
</figure>
) : null}
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
</div>
);

View File

@@ -37,22 +37,22 @@ export function PageState(props: PageStateProps) {
case "loading":
return (
<section className="rounded-lg border border-border bg-card p-6" role="status">
<h2 className="text-xl font-semibold">{props.title}</h2>
{props.description === undefined ? null : <p className="mt-2 text-sm text-muted-foreground">{props.description}</p>}
<h2 className="break-keep text-xl font-semibold">{props.title}</h2>
{props.description === undefined ? null : <p className="mt-2 break-keep text-sm text-muted-foreground">{props.description}</p>}
</section>
);
case "empty":
return (
<section className="rounded-lg border border-border bg-card p-6" role="status">
<h2 className="text-xl font-semibold">{props.title}</h2>
{props.description === undefined ? null : <p className="mt-2 text-sm text-muted-foreground">{props.description}</p>}
<h2 className="break-keep text-xl font-semibold">{props.title}</h2>
{props.description === undefined ? null : <p className="mt-2 break-keep text-sm text-muted-foreground">{props.description}</p>}
</section>
);
case "error":
return (
<section className="rounded-lg border border-destructive bg-card p-6 text-destructive" role="alert">
<h2 className="text-xl font-semibold">{props.title}</h2>
{props.description === undefined ? null : <p className="mt-2 text-sm">{props.description}</p>}
<h2 className="break-keep text-xl font-semibold">{props.title}</h2>
{props.description === undefined ? null : <p className="mt-2 break-keep text-sm">{props.description}</p>}
{props.onRetry === undefined ? null : (
<button className="mt-4 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={props.onRetry} type="button">

View File

@@ -1,8 +1,18 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import type { Locator, Page, TestInfo } from "@playwright/test";
// allow: SIZE_OK - ordered Community browser states intentionally share one E2E narrative.
const interactiveActionRoles = ["button", "link", "menuitem"] as const;
const tinyGif = Buffer.from("R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==", "base64");
const tinyPng = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFElEQVQYV2NkYGD4z0AEYBxVSF+FAAfiAQELhYkNAAAAAElFTkSuQmCC", "base64");
async function captureScreenshot(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const path = testInfo.outputPath(name);
await page.screenshot({ fullPage: true, path });
await testInfo.attach(name, { contentType: "image/png", path });
}
async function expectNoHorizontalOverflow(page: Page): Promise<void> {
const hasHorizontalOverflow = await page.evaluate(
@@ -12,10 +22,17 @@ async function expectNoHorizontalOverflow(page: Page): Promise<void> {
expect(hasHorizontalOverflow).toBe(false);
}
async function zoomTo200Percent(page: Page): Promise<void> {
await page.evaluate(() => {
document.documentElement.style.zoom = "2";
});
async function expectNoClippedKoreanText(root: Locator): Promise<void> {
const clippedText = await root.evaluate((element) => Array.from(element.querySelectorAll<HTMLElement>("*"))
.filter((candidate) => candidate.childElementCount === 0 && /[가-힣]/.test(candidate.textContent ?? ""))
.filter((candidate) => {
const style = getComputedStyle(candidate);
return style.display !== "none" && style.visibility !== "hidden" && candidate.clientWidth > 0 && candidate.clientHeight > 0;
})
.filter((candidate) => candidate.scrollWidth > candidate.clientWidth + 1 || candidate.scrollHeight > candidate.clientHeight + 1)
.map((candidate) => candidate.textContent?.trim()));
expect(clippedText).toEqual([]);
}
async function expectNoCriticalOrSeriousAxeViolations(page: Page): Promise<void> {
@@ -119,9 +136,9 @@ test("mobile Community route stays read-only while list sheet and audio remain a
await expectNoHorizontalOverflow(page);
});
test("mobile Community create route shows guidance instead of the create form", async ({ page }) => {
test("mobile Community create route shows guidance instead of the create form", async ({ page }, testInfo) => {
// Given
await page.setViewportSize({ width: 320, height: 640 });
await page.setViewportSize({ width: 375, height: 812 });
await loginThroughMockMode(page);
// When
@@ -132,10 +149,13 @@ test("mobile Community create route shows guidance instead of the create form",
await expect(page.getByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" })).toBeHidden();
await expectActionAbsentAcrossInteractiveRoles(page, "생성");
await expectNoHorizontalOverflow(page);
await expectNoClippedKoreanText(page.locator("main"));
await expectNoCriticalOrSeriousAxeViolations(page);
await captureScreenshot(page, testInfo, "community-create-375-guidance.png");
});
for (const width of [768, 1280] as const) {
test(`tablet and desktop Community management remains available at ${width}px`, async ({ page }) => {
test(`tablet and desktop Community management remains available at ${width}px`, async ({ page }, testInfo) => {
// Given
await page.setViewportSize({ width, height: 900 });
await loginThroughMockMode(page);
@@ -156,8 +176,119 @@ for (const width of [768, 1280] as const) {
await dialog.getByRole("button", { name: "닫기" }).click();
await page.getByRole("link", { name: "커뮤니티 게시글 생성" }).click();
await expect(page.getByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" })).toBeVisible();
const form = page.getByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" });
const imageInput = form.locator("input[type='file']").first();
const audioInput = form.locator("input[type='file']").nth(1);
const imageSelectButton = form.getByRole("button", { name: "게시글 이미지 파일 선택" });
const contentInput = form.getByRole("textbox", { name: "내용" });
const priceInput = form.getByRole("spinbutton", { name: "가격" });
await expect(form).toBeVisible();
await expect(page.getByRole("button", { name: "생성" })).toBeVisible();
await expect(audioInput).toBeHidden();
await expect(priceInput).toHaveValue("0");
expect(await form.evaluate((element) => {
const image = element.querySelector("input[type='file']");
const content = element.querySelector("textarea");
return image !== null && content !== null && Boolean(image.compareDocumentPosition(content) & Node.DOCUMENT_POSITION_FOLLOWING);
})).toBe(true);
await expectNoHorizontalOverflow(page);
await expectNoClippedKoreanText(form);
await expectNoCriticalOrSeriousAxeViolations(page);
await captureScreenshot(page, testInfo, `community-create-${width}-initial.png`);
await pressTabUntilFocused(page, imageSelectButton);
await expect(imageSelectButton).toBeFocused();
expect(await imageSelectButton.evaluate((element) => {
const style = getComputedStyle(element);
return style.outlineStyle !== "none" || style.boxShadow !== "none";
})).toBe(true);
const imagePreview = form.getByRole("img", { name: "게시글 이미지 업로드 미리보기" });
const cropDialog = page.getByRole("dialog", { name: "이미지 crop" });
await imageInput.setInputFiles({ buffer: tinyPng, mimeType: "image/png", name: "community-crop.png" });
await expect(cropDialog).toBeVisible();
await expect(imagePreview).toBeHidden();
await expect(audioInput).toBeHidden();
await captureScreenshot(page, testInfo, `community-create-${width}-crop.png`);
await cropDialog.getByRole("button", { name: "적용" }).click();
await expect(cropDialog).toBeHidden();
await expect(imagePreview).toBeVisible();
expect(await imagePreview.evaluate((image) => image instanceof HTMLImageElement && image.complete && image.naturalWidth > 0)).toBe(true);
await expect(audioInput).toBeVisible();
await captureScreenshot(page, testInfo, `community-create-${width}-applied.png`);
await audioInput.setInputFiles({ buffer: Buffer.from("ID3"), mimeType: "audio/mpeg", name: "community-audio.mp3" });
await imageInput.setInputFiles({ buffer: tinyPng, mimeType: "image/png", name: "community-replacement.png" });
await expect(cropDialog).toBeVisible();
await expect(imagePreview).toBeHidden();
await expect(audioInput).toBeHidden();
await captureScreenshot(page, testInfo, `community-create-${width}-replacement.png`);
await cropDialog.getByRole("button", { name: "취소" }).click();
await expect(cropDialog).toBeHidden();
await expect(imagePreview).toBeHidden();
await expect(audioInput).toBeHidden();
await imageInput.setInputFiles({ buffer: Buffer.from("not-an-image"), mimeType: "image/png", name: "community-invalid.png" });
const imageErrorAlert = form.getByRole("alert");
await expect(cropDialog).toBeHidden();
await expect(imagePreview).toBeHidden();
await expect(audioInput).toBeHidden();
await expect(imageErrorAlert).toContainText("이미지 미리보기를 불러오지 못했습니다.");
await expect(imageErrorAlert).toBeVisible();
await imageErrorAlert.scrollIntoViewIfNeeded();
await captureScreenshot(page, testInfo, `community-create-${width}-error.png`);
await imageInput.setInputFiles({ buffer: tinyGif, mimeType: "image/gif", name: "community-final.gif" });
await expect(imagePreview).toBeVisible();
expect(await imagePreview.evaluate((image) => image instanceof HTMLImageElement && image.complete && image.naturalWidth > 0)).toBe(true);
await expect(audioInput).toBeVisible();
expect(await form.evaluate((element) => {
const [image, audio] = element.querySelectorAll("input[type='file']");
const content = element.querySelector("textarea");
return image !== undefined && audio !== undefined && content !== null
&& Boolean(image.compareDocumentPosition(audio) & Node.DOCUMENT_POSITION_FOLLOWING)
&& Boolean(audio.compareDocumentPosition(content) & Node.DOCUMENT_POSITION_FOLLOWING);
})).toBe(true);
await expectNoHorizontalOverflow(page);
await expectNoClippedKoreanText(form);
await expectNoCriticalOrSeriousAxeViolations(page);
await captureScreenshot(page, testInfo, `community-create-${width}-gif-ready.png`);
await audioInput.setInputFiles({ buffer: Buffer.from("ID3"), mimeType: "audio/mpeg", name: "community-audio.mp3" });
await expect(audioInput).not.toHaveValue("");
await form.getByRole("button", { name: "게시글 이미지 선택 취소" }).click();
await expect(form.locator("img")).toBeHidden();
await expect(audioInput).toBeHidden();
await expectNoHorizontalOverflow(page);
await expectNoClippedKoreanText(form);
await captureScreenshot(page, testInfo, `community-create-${width}-image-removed.png`);
await imageInput.setInputFiles({ buffer: tinyGif, mimeType: "image/gif", name: "community-final.gif" });
await expect(audioInput).toBeVisible();
await expect(audioInput).toHaveValue("");
await contentInput.fill("가격 검증을 위한 커뮤니티 게시글입니다.");
await priceInput.fill("");
const createRequests: string[] = [];
page.on("request", (request) => {
if (request.method() === "POST" && request.url().includes("/community-posts")) {
createRequests.push(request.url());
}
});
await page.getByRole("button", { name: "생성" }).click();
await expect(page.getByRole("alert")).toContainText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.");
await expect(priceInput).toBeFocused();
await expect(priceInput).toHaveValue("");
await expect(page).toHaveURL(/\/ai-characters\/101\/community-posts\/new$/);
expect(createRequests).toEqual([]);
await expectNoHorizontalOverflow(page);
await expectNoClippedKoreanText(form);
await expectNoCriticalOrSeriousAxeViolations(page);
await page.evaluate(() => window.scrollTo(0, 0));
await captureScreenshot(page, testInfo, `community-create-${width}-blank-price-error.png`);
});
}
@@ -191,20 +322,19 @@ test("Community sheet traps focus closes on Escape and returns focus", async ({
await expect(opener).toBeFocused();
});
test("Community list and create routes keep zoom overflow and axe coverage across capability widths", async ({ page }) => {
test("Community list and create routes keep overflow and axe coverage across capability widths", async ({ page }) => {
// Given
await loginThroughMockMode(page);
for (const width of [320, 768, 1280] as const) {
for (const width of [320, 375, 768, 1280] as const) {
for (const route of ["/ai-characters/101/community-posts", "/ai-characters/101/community-posts/new"] as const) {
await page.setViewportSize({ width, height: 900 });
await page.goto(route);
// When
await zoomTo200Percent(page);
// Then
await expectNoHorizontalOverflow(page);
if (width !== 320) {
await expectNoClippedKoreanText(page.locator("main"));
}
await expectNoCriticalOrSeriousAxeViolations(page);
}
}