feat(ai-character): 오디오 콘텐츠 폼 입력 UX 정렬

This commit is contained in:
Yu Sung
2026-08-04 12:20:04 +09:00
parent a213479e8e
commit b62c416fa1
13 changed files with 608 additions and 173 deletions

View File

@@ -151,6 +151,23 @@ Primary font stack: `Pretendard`, `Noto Sans KR`, `Apple SD Gothic Neo`, `system
- Accessibility: label targets the input; description, accept guidance, and error are connected with `aria-describedby`; clear is a native button.
- Motion: none.
### TagChipInput
- Structure: controlled comma-separated string boundary that renders committed values as visible wrapping chips and preserves an inline draft input. A visible label, help text, and error text accompany the field.
- Controlled value: the caller owns one comma-separated string. Enter or a comma commits the non-empty draft into that string; each chip's native remove button removes only its value. Duplicate prevention and maximum-count limits are caller policy, not this pattern's policy.
- States: default, hover, focus-visible, disabled, invalid, and empty-with-draft. The input boundary uses `--input`; focus-visible uses `--ring`; invalid text and boundary use `--destructive`; chip and helper surfaces use `--muted`, `--accent`, `--accent-foreground`, `--foreground`, and `--muted-foreground` only.
- Responsive: chips wrap within the field at 375px, 768px, and 1280px without clipping or horizontal overflow. The inline draft remains usable after wrapped chips, Korean and CJK text may wrap naturally, and every input or remove target remains at least 44px.
- Accessibility: label is always visible and associated with the inline input; help and error text are connected with `aria-describedby`. Native remove buttons expose the chip value in their accessible name, and focus-visible remains visible for the input and every remove button.
- Motion: none.
### SelectionCard
- Structure: a native radio or checkbox remains in the DOM inside a full-card label, making the entire restrained card surface the hit target. The same grammar applies to purchase choices, preview, point, adult, comments, full-detail, and release mode.
- States: default uses `--card`, `--border`, and `--foreground`; hover uses `--accent` and `--accent-foreground`; checked uses `--accent`, `--accent-foreground`, and `--border`; focus-visible uses `--ring`; disabled uses `--muted` and `--muted-foreground`; error uses `--destructive` with the visible error message. Control boundaries use `--input` and selected native-control accents use `--accent` with `--accent-foreground` only.
- Responsive: cards wrap in a single column or available-width grid at 375px, 768px, and 1280px. Long Korean and CJK labels wrap without clipping, overflow, or shrinking the native control below its 44px target.
- Accessibility: the native radio or checkbox keeps its normal keyboard and focus behavior and is never replaced with a custom control. The whole label activates the control, visible labels and error text state the choice and problem, and focus-visible is clear on the active card and native control.
- Motion: none; hover and checked feedback are tonal and border state changes only.
### ImageCropDialog
- Structure: modal crop surface with preview, output size, directional move buttons, zoom range, reset, cancel, and apply.

View File

@@ -10,12 +10,12 @@
| 문서 항목 | 내용 |
|---|---|
| 상태 | 2026-08-01 `P9-R18`~`P9-R19`, `P10-R16`~`P10-R17` 자동 보완 완료, 실제 crop pixel·stale ADMIN·Series/FanTalk/Comments/file policy 수동 QA 대기 |
| 상태 | 2026-08-04 `P4-R10` Audio form UX 회귀 자동 검증 완료. `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 | 없음 (`P4-R9` 완료) |
| 현재 활성 Goal | `P4-R10` 자동 검증 완료 (`P4-R9` 완료) |
## 목표
@@ -25,7 +25,7 @@ ADMIN이 로그인한 뒤 AI 캐릭터를 선택하고, 선택한 캐릭터 문
| 구분 | 현재 상태 | 남은 조건 |
|---|---|---|
| 자동 검증 범위 | `P9-R18`~`P9-R19`, `P10-R16`~`P10-R17` current-state contract 자동 보완 완료 | 실제 crop pixel·stale ADMIN·개발 API 수동 QA |
| 자동 검증 범위 | `P4-R10`, `P9-R18`~`P9-R19`, `P10-R16`~`P10-R17` current-state contract 자동 보완 완료 | 실제 crop pixel·stale ADMIN·개발 API 수동 QA |
| 실제 개발 API | Series/FanTalk/Comments/file policy 수동 QA 대기 | ADMIN credential과 고정 fixture 필요 |
| 브라우저·인가 수동 QA | 실제 crop pixel 비교와 stale ADMIN server 확인 대기 | Chromium/mobile Chrome 환경과 stale ADMIN fixture 필요 |
| 문서 구조 | `P10-R9`에서 Goal 실행형 필수 section navigation 복구 | 기존 Progress·Decision 이력은 보존 |
@@ -75,7 +75,7 @@ ADMIN이 로그인한 뒤 AI 캐릭터를 선택하고, 선택한 캐릭터 문
## Progress
상세 진행 기록은 [검증 기록](#7-검증-기록)에 누적한다. `P9-R18`~`P9-R19`, `P10-R16`~`P10-R17`에서 finding·Task·H2·실제 마지막 Progress contract를 보완해 자동 보완 완료로 판정했다. 실제 crop pixel·stale ADMIN·Series/FanTalk/Comments/file policy 수동 QA는 별도 대기다.
상세 진행 기록은 [검증 기록](#7-검증-기록)에 누적한다. `P4-R10`은 가격 기본값 0, native number stepper, 조건부 유료 옵션과 preview duration offset을 자동 검증 완료했다. `P9-R18`~`P9-R19`, `P10-R16`~`P10-R17`에서 finding·Task·H2·실제 마지막 Progress contract를 보완해 자동 보완 완료로 판정했다. 실제 crop pixel·stale ADMIN·Series/FanTalk/Comments/file policy 수동 QA는 별도 대기다.
## Decision Log
@@ -83,7 +83,7 @@ ADMIN이 로그인한 뒤 AI 캐릭터를 선택하고, 선택한 캐릭터 문
## 발견된 문제
제품·Playwright 설정의 신규 문제는 없다. `P9-R18`, `P9-R19`, `P10-R16`, `P10-R17`로 finding·Task 경계, fenced·중복 H2, 최신 및 중복 Progress marker contract를 보완했다. 실제 crop pixel·stale ADMIN과 개발 API Series/FanTalk/Comments/file policy 수동 QA가 남아 있다.
제품·Playwright 설정의 신규 문제는 없다. `P4-R10`은 2026-08-04에 확정된 Audio form UX 정책을 현재 구현과 정렬했고, 가격 native stepper와 `0..99999` 검증을 Chromium/mobile Chrome mock E2E로 확인했다. `P9-R18`, `P9-R19`, `P10-R16`, `P10-R17`로 finding·Task 경계, fenced·중복 H2, 최신 및 중복 Progress marker contract를 보완했다. 실제 crop pixel·stale ADMIN과 개발 API Series/FanTalk/Comments/file policy 수동 QA가 남아 있다.
## 최종 보고 형식
@@ -2563,6 +2563,67 @@ field와 수정 금지 control이 실제 network 요청·화면에 일치하는
- **기능·시각 판정:** play/pause, seek, rate 1.5, volume 0.5, 단일 active track, 일반 오류·수동 retry와 API refetch delta 0을 확인했다. 독립 design-system/functional 및 visual/CJK reviewer가 같은 V3 세트를 각각 PASS로 판정했고 blocker는 없다.
- **Teardown:** Playwright browser는 종료됐다. QA-owned server는 없었고, QA 이전부터 실행 중인 8889의 PID 4837/4875는 사용자 프로세스로 보존했다.
### Task R4.10 — Audio form 입력 정책과 조건부 설정 UX 정렬
**Goal 실행 `P4-R10`:** 2026-08-04 확정 Audio form 정책을 구현하고 create/update payload 기본값과 접근 가능한 native control UX를 회귀로 고정한다.
- **시작 조건:** `P4-R9` 완료, PRD `AUDIO-008~010`, `AUDIO-019`, `AUDIO-033`과 2026-08-04 Decision Log 확인.
- **완료 증거:** tags, 숫자 전용 가격, 가격 조건부 설정·초기화, 숨긴 OpenAPI field의 고정 payload, 예약 datetime과 preview duration offset control, 선택 카드 문법이 RED/GREEN/REFACTOR test와 Chromium/mobile Chrome mock browser QA로 검증된다. 실행 결과와 QA teardown을 `§7 검증 기록`에 누적한다.
- **범위 밖:** OpenAPI/backend schema 변경, `limited` 의미 추정, `isOnlyRental` 동기화 규칙 발명, custom date/time picker, 새 form library, 기존 dirty `AudioContentForm.tsx`와 `audio-form.test.tsx`의 사용자 reorder 변경.
**Files:**
- Modify: `DESIGN.md`
- Modify: `src/features/audio-contents/components/AudioContentForm.tsx`
- Create: `src/features/audio-contents/components/AudioTagInput.tsx`
- Create: `src/features/audio-contents/components/AudioContentCreateOptions.tsx`
- Modify: `src/features/audio-contents/components/ReleaseScheduleField.tsx`
- Modify: `src/features/audio-contents/components/audio-content-form-helpers.ts`
- Modify: `src/features/audio-contents/tests/audio-form.test.tsx`
- Modify: `src/features/audio-contents/tests/audio-form-update.test.tsx`
- Modify: `src/features/audio-contents/tests/audio-form-create-red.test.tsx` by consolidating its create assertions into the focused form suite, then remove the redundant file only after those assertions pass there
- Modify: `tests/e2e/audio-content.spec.ts`
**Interfaces:**
- `AudioTagInput({ value, onChange })` consumes and emits a comma-separated `string`; it renders chips, commits nonempty trimmed entries on Enter or comma, and exposes a labeled remove button for each chip.
- `AudioContentCreateOptions({ price, value, onChange })` owns only create-only option controls. For `price > 0`, it renders native selection-card controls for `purchaseOption`, `isGeneratePreview`, `isPointAvailable`, `isAdult`, `isCommentAvailable`, and `isFullDetailVisible`. For price `0`, it hides purchase/preview/point controls and emits `purchaseOption: "BOTH"`, `isGeneratePreview: false`, `isPointAvailable: false`, `previewStartTime: null`, `previewEndTime: null`.
- Price form state accepts digits only and serializes an integer in `0..99999`. `0` means free. Create serialization always sends `limited: null`, `languageCode: null`, and `isOnlyRental: false`, without rendering controls for those fields.
- `ReleaseScheduleField({ releaseMode, value, onChange })` renders native radio selection cards and only renders `<input type="datetime-local">` in scheduled mode. It does not call `showPicker()`.
- Preview start/end controls render only when `isGeneratePreview` is true. They use text inputs for audio duration offsets in full `HH:MM:SS`; create serialization sends the entered `HH:mm:ss` value without shorthand normalization.
- `DESIGN.md` defines one selection-card visual grammar for native radio and checkbox controls, preserving visible labels, keyboard operation, focus indication, and 44px minimum targets.
**TDD 예외 사유:** 이 Task의 PRD·plan 갱신은 문서 산출물만 바꾸므로 application behavior를 대상으로 한 새 실패 test를 만들지 않는다. 문서 계약 test와 Markdown whitespace 검사가 해당 변경의 직접 검증이다. 아래 RED/GREEN/REFACTOR는 애플리케이션 구현 시작 후에만 실행한다.
**대체 검증 방법:** `npm run test:run -- src/shared/mocks/__tests__/mock-preview-docs.test.ts`와 scoped `git diff --check`로 문서 계약과 형식을 확인하고, 실제 application RED/GREEN/REFACTOR 결과는 실행 뒤 Progress에만 기록한다.
**TDD 절차:**
- [x] **RED: 실패 테스트 작성/실패 확인** — `audio-form.test.tsx`에 Enter/comma chip 추가, remove button, comma-separated payload, numeric price와 `0/99999` 허용·`100000` 차단을 추가한다. `audio-form-update.test.tsx`에 update payload가 create-only control을 다시 보내지 않는 경계를 추가한다. `audio-form-create-red.test.tsx`의 create assertion은 focused 회귀로 유지한다. `tests/e2e/audio-content.spec.ts`에 예약/즉시 전환과 가격 0 전환 시 옵션 숨김·기본값 초기화, ArrowDown/ArrowUp min 0 stepper를 추가한다. focused Vitest가 초기 가격 `""`/text input과 기존 payload assertion으로 실패하는지 확인했다.
- [x] **GREEN: 최소 구현/통과 확인** — `AudioTagInput`, `AudioContentCreateOptions`, helper와 existing form/schedule field에 필요한 최소 조합만 추가했다. 가격 기본값 0, native `type="number" min="0" step="1"`, price=0 reset, fixed `limited/languageCode/isOnlyRental` serialization, 예약 datetime과 preview duration offset input 조건부 렌더링, radio/checkbox selection card를 구현하고 focused command를 통과시켰다.
- [x] **REFACTOR: 정리/회귀 확인** — create-only option state를 `AudioContentCreateOptions`에 국한하고 form helper의 serialization 중복만 정리했다. `DESIGN.md`와 구현의 선택 카드 규칙을 대조한 뒤 focused form tests, Audio feature regression, typecheck, lint, build, mock E2E를 실행했다.
**검증 기준:**
- **실행 명령:** `npm run test:run -- src/features/audio-contents/tests/audio-form.test.tsx src/features/audio-contents/tests/audio-form-update.test.tsx src/features/audio-contents/tests/audio-form-create-red.test.tsx`; `npm run test:run -- src/features/audio-contents`; `npm run typecheck`; `npm run lint`; `npm run build`; `npm run e2e:mock -- tests/e2e/audio-content.spec.ts`; `npm run test:run -- src/shared/mocks/__tests__/mock-preview-docs.test.ts`; `git diff --check -- DESIGN.md docs/20260725_AI캐릭터관리자웹/prd.md docs/20260725_AI캐릭터관리자웹/plan-task.md src/features/audio-contents tests/e2e/audio-content.spec.ts`.
- **기대 결과:** 실행한 모든 명령이 exit 0이다. tags payload는 comma-separated string이고, price는 native number input으로 `0..99999` integer를 보낸다. price=0에서 purchase/preview/point control과 preview time은 없고 지정 기본값만 전송된다. `limited=null`, `languageCode=null`, `isOnlyRental=false`가 유지되며 세 field의 UI와 임의 동기화는 없다. 예약은 native `datetime-local`, preview는 full `HH:MM:SS` text duration offset input으로 조건부 렌더링한다. preview request는 입력한 `HH:mm:ss` 값을 그대로 보내며 `showPicker()` 호출은 없다.
- **수동 확인:** `npm run dev:mock`으로 desktop Chromium과 mobile Chrome에서 Audio 생성 form을 연다. keyboard로 tags를 Enter와 comma로 추가하고 remove button으로 삭제한다. 가격을 유료값에서 0으로 바꿔 숨김과 reset을 확인하고 다시 유료값으로 바꾼다. 예약 공개와 preview 생성 on/off를 전환해 native datetime과 text duration offset input, selection-card focus, 200% zoom, axe critical·serious 0건을 확인한다. mock browser와 dev server를 종료하고 QA가 시작한 listener만 정리한다.
- [x] RED/GREEN/REFACTOR의 실제 명령·test 수·browser QA·teardown 결과를 새 Progress 기록에 누적한다.
**P4-R10 실행 전 Progress — 2026-08-04:**
- 문서 정책 결정과 실행 계약만 확정했다. 애플리케이션 source, `DESIGN.md`, form test, E2E는 아직 수정하거나 실행하지 않았으므로 RED/GREEN/REFACTOR 결과와 browser QA 통과를 기록하지 않는다.
- 문서 대체 검증은 이 문서 갱신 직후 `mock-preview-docs.test.ts`와 scoped `git diff --check`로 수행하며, 그 결과는 현재 문서 변경의 검증일 뿐 `P4-R10` 구현 완료 증거가 아니다.
**P4-R10 완료 Progress — 2026-08-04:**
- **RED/GREEN:** preview duration 회귀는 `type="text"` 기대에 기존 `type="time"`이 반환되어 RED를 확인했고, 최소 구현 후 focused `1/1`, create form `10/10`, Audio `66/66`, 전체 Vitest `81 files / 434 tests`, 문서 계약 `9/9`가 통과했다.
- **가격 스테퍼 보완:** 가격 기본값 0과 native `type="number" min="0" step="1"` 회귀는 focused test에서 기존 빈 문자열/text input으로 RED를 확인했다. 최소 구현 후 focused form `18/18`, Audio `65/65`, 전체 Vitest `81 files / 433 tests`, `typecheck`, `lint`, `build:prod`, mock E2E `11 passed / 1 intentional skip`가 통과했다. Chromium/mobile Chrome에서 ArrowDown at 0은 0 유지, ArrowUp은 1 증가를 확인했다.
- **정적 검증:** `npm run typecheck`, `npm run lint`, `npm run build:prod`, TypeScript no-excuse 검사, `git diff --check`가 exit 0이었다. production build에는 기존 500kB chunk warning만 남았다.
- **브라우저 검증:** `npm run e2e:mock -- tests/e2e/audio-content.spec.ts`는 Chromium/mobile Chrome에서 `11 passed / 1 intentional skip / 0 failed`였다. 실제 1280/768 DOM에서 preview start/end는 `type=text`, `step=null`, 값 `00:00:30`/`01:00:05`, `input[type=time]=0`이었고 예약 입력만 `datetime-local`이었다. 375px은 기존 정책대로 mutation form을 숨겼으며 세 viewport 모두 horizontal overflow가 없었다.
- **시각·리뷰 판정:** `.playwright-mcp/audio-preview-duration-{1280,768,375-blocked}-20260804.png` fresh 캡처를 대상으로 design-system/functional 및 visual/CJK Oracle이 모두 blocker 없이 PASS했고, 최종 code reviewer도 APPROVE했다.
- **Teardown:** Playwright browser를 닫고 QA가 시작한 Vite PID `69030`과 parent PID `69001`을 종료했다. 이후 `127.0.0.1:8889` listener가 없음을 확인했다.
---
## Phase 5. Series vertical slice

View File

@@ -6,7 +6,7 @@
|---|---|
| 문서 상태 | OpenAPI 반영 구현 기준 |
| 작성일 | 2026-07-25 |
| 최종 수정일 | 2026-07-30 |
| 최종 수정일 | 2026-08-04 |
| 대상 제품 | AI 캐릭터 전용 독립 관리자 웹 |
| 구현 대상 | React + TypeScript + Vite SPA |
| UI 기반 | Tailwind CSS + shadcn/ui |
@@ -223,9 +223,9 @@ AI 캐릭터를 생성하고, AI 캐릭터가 사람 크리에이터처럼 콘
| AUDIO-005 | 제외 | 현 OpenAPI에 status filter가 없으므로 status query를 보내거나 현재 page를 client에서 status별로 거르지 않는다. |
| AUDIO-006 | 확정 | 생성 요청에는 `isActive`를 보내지 않는다. |
| AUDIO-007 | 확정 | 일반 수정 요청에서는 `isActive`를 생략하고 soft delete 요청에만 `isActive=false`를 보낸다. `isActive=true`는 전송하지 않는다. |
| AUDIO-008 | 확정 | 공개 방식은 “지금 즉시 공개”와 “예약 공개” 선택 버튼으로 제공한다. |
| AUDIO-009 | 확정 | 생성 시 즉시 공개가 기본값이며 `releaseDate=null`을 보내고 `timezone`은 보내지 않는다. 예약 날짜 입력은 비활성화하고 기존 값을 지운다. |
| AUDIO-010 | 확정 | 예약 공개를 선택한 경우에만 날짜·시간을 입력할 수 있고 미래 시각 필수다. |
| AUDIO-008 | 확정 | 공개 방식은 native radio를 사용하는 “지금 즉시 공개”와 “예약 공개” 선택 카드로 제공한다. radio와 checkbox는 같은 선택 카드 시각 문법을 사용한다. |
| AUDIO-009 | 확정 | 생성 시 즉시 공개가 기본값이며 `releaseDate=null`을 보내고 `timezone`은 보내지 않는다. 즉시 공개에서는 예약 일시 입력을 렌더링하지 않고 예약 값을 지운다. |
| AUDIO-010 | 확정 | 예약 공개를 선택한 경우에만 native `datetime-local` 입력을 렌더링하고 미래 시각 필수로 받는다. `showPicker()` 같은 custom picker 호출은 사용하지 않는다. |
| AUDIO-011 | 확정 | 예약 시각은 Asia/Seoul로 입력·표시하되, 생성 API에는 해당 시각을 클라이언트에서 UTC로 변환한 ISO-8601 `Z` 형식의 `releaseDate`를 보낸다. 예를 들어 `2026-07-29 18:00` Asia/Seoul은 `2026-07-29T09:00:00Z`로 전송한다. |
| AUDIO-012 | 확정 | 생성 multipart의 `contentFile`, `coverImage`, `request`는 필수다. 수정은 `coverImage``request`만 허용하므로 오디오 원본 파일 교체 UI를 제공하지 않는다. |
| AUDIO-013 | 확정 | 오디오 확장자는 `.mp3`, `.aac`, `.m4a`를 허용한다. WAV는 허용하지 않는다. |
@@ -234,7 +234,7 @@ AI 캐릭터를 생성하고, AI 캐릭터가 사람 크리에이터처럼 콘
| AUDIO-016 | 확정 | 확장자와 MIME만 신뢰하지 않고 실제 컨테이너·코덱 검증은 백엔드가 수행해야 한다. |
| AUDIO-017 | 확정 | 업로드 진행률, 취소, 전체 재시도를 제공하고 resumable upload는 제공하지 않는다. |
| AUDIO-018 | 확정 | 업로드 실패 후 입력한 폼 값과 선택 가능한 파일 상태를 최대한 유지한다. |
| AUDIO-019 | 확정 | 가격 단위는 “캔”이고 `0..99999` 정수다. 0은 무료이며 UI는 예: `1,000캔`으로 표시한다. |
| AUDIO-019 | 확정 | 가격 단위는 “캔”이고 입력·저장 값은 숫자만 사용한다. 생성 기본값은 0이며 native number input은 `min=0`, `step=1`로 방향키 위/아래 증감을 제공한다. 유효 범위는 `0..99999` 정수이며 0은 무료다. payload에는 정수 price를 보낸다. |
| AUDIO-020 | 확정 | Audio 생성·수정 request에는 `seriesIds`가 없다. 시리즈 연결은 Audio form이 아니라 Series 콘텐츠 연결 endpoint와 Phase 5 UI에서 관리한다. |
| AUDIO-021 | 확정 | 목록과 상세에서 오디오를 재생할 수 있다. |
| AUDIO-022 | 확정 | 오디오 목록 API는 `isActive=true`인 항목만 반환한다. request에는 활성 상태 query를 추가하지 않고 응답에도 client-side 활성 filter를 적용하지 않는다. status query는 여전히 제공하지 않는다. |
@@ -248,7 +248,7 @@ AI 캐릭터를 생성하고, AI 캐릭터가 사람 크리에이터처럼 콘
| AUDIO-030 | 확정 | 상세 GET에는 `timezone` query를 보내지 않는다. 응답의 nullable `releaseDate`는 ISO-8601 UTC `Z` 값으로 소비하고 화면 표시 시 Asia/Seoul로 변환한다. |
| AUDIO-031 | 확정 | 생성 성공은 `data.contentId`를 사용해 상세로 이동할 수 있다. 수정·soft delete 성공은 `data=null`이므로 기존 ID 기준 cache를 무효화한다. |
| AUDIO-032 | 확정 | 생성 `request`는 필수 `title`, `detail`, `tags`, `price`와 OpenAPI의 optional field만 보낸다. 수정은 `title`, `detail`, `tags`, `price`, `isAdult`, `isActive`, `isPointAvailable`, `isCommentAvailable`만 변경할 수 있다. |
| AUDIO-033 | 확정 | 생성 form은 OpenAPI의 `purchaseOption`, `limited`, `isAdult`, `isGeneratePreview`, `isOnlyRental`, `isPointAvailable`, `isCommentAvailable`, `isFullDetailVisible`, `previewStartTime`, `previewEndTime`, `languageCode`를 계약 enum·type과 default에 맞춰 제공한다. 계약에 없는 추가 상관관계 validation은 만들지 않는다. |
| AUDIO-033 | 확정 | 태그는 chip으로 표시하며 Enter 또는 comma로 추가하고 각 chip의 remove button으로 삭제한다. request `tags` payload는 comma-separated string을 유지한다. 가격이 0보다 클 때만 `purchaseOption`, preview 생성 여부·시간, point 사용 가능 여부를 표시한다. 가격을 0으로 바꾸면 `purchaseOption=BOTH`, `isGeneratePreview=false`, `isPointAvailable=false`, `previewStartTime=null`, `previewEndTime=null`으로 즉시 초기화한다. preview 시작·종료는 시각이 아니라 오디오 duration 내 offset이며 preview 생성이 켜진 경우에만 text control로 완전한 `HH:MM:SS`를 입력한다. request는 입력한 `HH:mm:ss` 값을 그대로 보낸다. 문서화되지 않은 nullable `limited` UI, `languageCode` UI, 독립 `isOnlyRental` UI는 제공하지 않으며 각각 `limited=null`, `languageCode=null`, `isOnlyRental=false`를 계속 전송한다. `isAdult`, `isCommentAvailable`, `isFullDetailVisible`과 preview 생성 여부는 native checkbox 선택 카드로 제공한다. |
| AUDIO-034 | 확정 | 공통 관리자 오디오 플레이어는 오디오 콘텐츠 목록·상세와 커뮤니티 목록·Sheet에 동일한 compact audio-only control bar를 사용한다. Plyr audio player를 1차 시각 기준, Media Chrome audio player를 control anatomy 기준으로 삼아 표준 viewport에서는 재생·진행·현재/전체 시간·배속·음량을 상시 텍스트 label 없이 한 줄에 표시한다. 별도 image·video 영역을 만들지 않고 기존 화면의 cover·게시물 media는 그대로 유지한다. |
현 수정 계약에는 `releaseDate`, `themeId`, `contentFile`이 없다. 따라서 공개 예약·테마·오디오 원본 변경은 생성 화면에서만 제공하고 수정 화면에서는 읽기 전용으로 표시한다.
@@ -936,3 +936,4 @@ vertical slice와 남은 수동 QA에서 검증한다. `EXT-006`은 현재 확
| 2026-07-29 | 현재 FanTalk UI는 목록 item 기반 Sheet와 backend 반환 순서만 사용하므로 별도 상세 GET·답변 상태 filter·sort query가 필요하지 않다고 확정했다. 중복 생성 전용 오류 key 분기도 제외하고 일반 오류 후 목록을 재조회한다. 답변 1개 불변식은 `FANTALK-003`의 backend 수용 기준으로 server integration에서 검증하므로 `EXT-004`를 해결로 종결한다. |
| 2026-07-31 | 사용자 직접 지시에 따라 로컬 자동 Gate와 지원 browser 범위는 데스크톱 Chrome·모바일 Chrome의 Chrome 2종으로 한정한다. Playwright는 Chromium/mobile Chrome project만 유지하고, 현재 제품 지원 대상이 아닌 WebKit·Mobile Safari 자동 실행과 수동 QA는 테스트 시간을 크게 늘리므로 현재 릴리스 범위에서 제외한다. |
| 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는 하나의 선택 카드 시각 문법을 사용한다. |

View File

@@ -0,0 +1,38 @@
import { selectionCardClassName } from "@/features/audio-contents/components/audio-content-form-helpers";
import type { AudioContentCreateSettings } from "@/features/audio-contents/components/audio-content-form-helpers";
type AudioContentCreateOptionsProps = {
readonly isPaid: boolean;
readonly onChange: (value: AudioContentCreateSettings) => void;
readonly value: AudioContentCreateSettings;
};
export function AudioContentCreateOptions({ isPaid, onChange, value }: AudioContentCreateOptionsProps) {
function updatePurchaseOption(purchaseOption: string) {
if (purchaseOption === "BOTH" || purchaseOption === "BUY_ONLY" || purchaseOption === "RENT_ONLY") {
onChange({ ...value, purchaseOption });
}
}
return (
<fieldset className="grid gap-3 rounded-lg border border-border bg-card p-4 sm:grid-cols-2">
<legend className="text-sm font-semibold"> </legend>
<label className={selectionCardClassName}><input checked={value.isAdult} className="size-4 accent-primary" onChange={(event) => onChange({ ...value, isAdult: event.currentTarget.checked })} type="checkbox" /> </label>
<label className={selectionCardClassName}><input checked={value.isCommentAvailable} className="size-4 accent-primary" onChange={(event) => onChange({ ...value, isCommentAvailable: event.currentTarget.checked })} type="checkbox" /> </label>
<label className={selectionCardClassName}><input checked={value.isFullDetailVisible} className="size-4 accent-primary" onChange={(event) => onChange({ ...value, isFullDetailVisible: event.currentTarget.checked })} type="checkbox" /> </label>
{isPaid ? (
<>
<fieldset aria-label="구매 옵션" className="grid gap-2 sm:col-span-2 sm:grid-cols-3">
<legend className="mb-2 text-sm font-semibold"> </legend>
<label className={selectionCardClassName}><input checked={value.purchaseOption === "BOTH"} className="size-4 accent-primary" name="purchaseOption" onChange={(event) => updatePurchaseOption(event.currentTarget.value)} type="radio" value="BOTH" />/</label>
<label className={selectionCardClassName}><input checked={value.purchaseOption === "BUY_ONLY"} className="size-4 accent-primary" name="purchaseOption" onChange={(event) => updatePurchaseOption(event.currentTarget.value)} type="radio" value="BUY_ONLY" /> </label>
<label className={selectionCardClassName}><input checked={value.purchaseOption === "RENT_ONLY"} className="size-4 accent-primary" name="purchaseOption" onChange={(event) => updatePurchaseOption(event.currentTarget.value)} type="radio" value="RENT_ONLY" /> </label>
</fieldset>
<label className={selectionCardClassName}><input checked={value.isGeneratePreview} className="size-4 accent-primary" onChange={(event) => onChange({ ...value, isGeneratePreview: event.currentTarget.checked, previewStartTime: event.currentTarget.checked ? value.previewStartTime : null, previewEndTime: event.currentTarget.checked ? value.previewEndTime : null })} type="checkbox" /> </label>
<label className={selectionCardClassName}><input checked={value.isPointAvailable} className="size-4 accent-primary" onChange={(event) => onChange({ ...value, isPointAvailable: event.currentTarget.checked })} type="checkbox" /> </label>
{value.isGeneratePreview ? <><label className="flex flex-col gap-2 text-sm font-semibold"> <input className="min-h-11 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onChange={(event) => onChange({ ...value, previewStartTime: event.currentTarget.value.length === 0 ? null : event.currentTarget.value })} pattern="[0-9]{2}:[0-9]{2}:[0-9]{2}" placeholder="예: 00:00:30" type="text" value={value.previewStartTime ?? ""} /></label><label className="flex flex-col gap-2 text-sm font-semibold"> <input className="min-h-11 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onChange={(event) => onChange({ ...value, previewEndTime: event.currentTarget.value.length === 0 ? null : event.currentTarget.value })} pattern="[0-9]{2}:[0-9]{2}:[0-9]{2}" placeholder="예: 01:00:05" type="text" value={value.previewEndTime ?? ""} /></label></> : null}
</>
) : null}
</fieldset>
);
}

View File

@@ -7,6 +7,8 @@ import { uploadAudioContent } from "@/features/audio-contents/api/upload-audio-c
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 type { AudioContentCreateSettings } from "@/features/audio-contents/components/audio-content-form-helpers";
import { AudioContentCreateOptions } from "@/features/audio-contents/components/AudioContentCreateOptions";
import { AudioTagInput } from "@/features/audio-contents/components/AudioTagInput";
import { AudioContentThemeSelect } from "@/features/audio-contents/components/AudioContentThemeSelect";
import { ReleaseScheduleField } from "@/features/audio-contents/components/ReleaseScheduleField";
import type { ReleaseScheduleValue } from "@/features/audio-contents/components/ReleaseScheduleField";
@@ -73,12 +75,15 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
const contentId = audio === undefined ? null : String(audio.contentId);
const isDirty = title !== (audio?.title ?? "") || detail !== (audio?.detail ?? "") || tags !== (audio?.tag ?? "") || price !== createInitialPrice(audio?.price) || audioFile !== null || coverImage !== null || themeId !== null || releaseSchedule.publishMode !== "immediate" || releaseSchedule.releaseDateTime !== "" || createSettings !== defaultAudioContentCreateSettings;
const isCoverSubmitBlocked = isCoverPreparing || cropSource !== null;
const isPaid = (parsePrice(price) ?? 0) > 0;
useEffect(() => () => cropSource?.release?.(), [cropSource]);
function updatePurchaseOption(value: string) {
if (value === "BOTH" || value === "BUY_ONLY" || value === "RENT_ONLY") {
setCreateSettings((current) => ({ ...current, purchaseOption: value }));
function changePrice(value: string) {
const nextPrice = formatPrice(value);
setPrice(nextPrice);
if (mode === "create" && nextPrice === "0") {
setCreateSettings((current) => ({ ...current, purchaseOption: "BOTH", isGeneratePreview: false, isPointAvailable: false, previewStartTime: null, previewEndTime: null }));
}
}
@@ -223,34 +228,18 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
<p className="text-sm text-muted-foreground">, , . .</p>
</div>
<form className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" aria-label={mode === "create" ? "오디오 콘텐츠 생성 입력 화면" : "오디오 콘텐츠 수정 입력 화면"} onSubmit={(event) => void submit(event)} ref={formRef}>
<FileField accept="image/jpeg,image/png" acceptDescription="JPEG 또는 PNG, 10MB 이하, 1:1 crop 후 최대 800×800px로 전송합니다." error={errors.cover} label="커버 이미지" onChange={(file) => void selectCoverImage(file)} value={coverImage} />
{mode === "create" ? <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} /> : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground"> .</p>}
<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>}
<label className="flex flex-col gap-2 text-sm font-semibold"> <textarea aria-describedby={errors.detail === undefined ? undefined : errorIds.detail} aria-invalid={errors.detail === 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) => setDetail(event.currentTarget.value)} value={detail} /></label>
{errors.detail === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.detail} role="alert">{errors.detail}</p>}
<label className="flex flex-col gap-2 text-sm font-semibold"><input aria-describedby={errors.tags === undefined ? undefined : errorIds.tags} aria-invalid={errors.tags === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setTags(event.currentTarget.value)} value={tags} /></label>
{errors.tags === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.tags} role="alert">{errors.tags}</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(formatPrice(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>}
{mode === "create" ? (
<fieldset className="grid gap-3 rounded-lg border border-border bg-card p-4 sm:grid-cols-2">
<legend className="text-sm font-semibold"> </legend>
<label className="flex flex-col gap-2 text-sm font-semibold"> <select aria-label="구매 옵션" className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => updatePurchaseOption(event.currentTarget.value)} value={createSettings.purchaseOption}><option value="BOTH">/</option><option value="BUY_ONLY"> </option><option value="RENT_ONLY"> </option></select></label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.limited !== null} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, limited: checked ? 1 : null })); }} type="checkbox" /></label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isAdult} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isAdult: checked })); }} type="checkbox" /> </label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isGeneratePreview} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isGeneratePreview: checked })); }} type="checkbox" /> </label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isOnlyRental} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isOnlyRental: checked })); }} type="checkbox" /> </label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isPointAvailable} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isPointAvailable: checked })); }} type="checkbox" /> </label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isCommentAvailable} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isCommentAvailable: checked })); }} type="checkbox" /> </label>
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={createSettings.isFullDetailVisible} onChange={(event) => { const checked = event.currentTarget.checked; setCreateSettings((current) => ({ ...current, isFullDetailVisible: 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) => { const value = event.currentTarget.value; setCreateSettings((current) => ({ ...current, previewStartTime: value.length === 0 ? null : value })); }} value={createSettings.previewStartTime ?? ""} /></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) => { const value = event.currentTarget.value; setCreateSettings((current) => ({ ...current, previewEndTime: value.length === 0 ? null : value })); }} value={createSettings.previewEndTime ?? ""} /></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) => { const value = event.currentTarget.value; setCreateSettings((current) => ({ ...current, languageCode: value.length === 0 ? null : value })); }} value={createSettings.languageCode ?? ""} /></label>
</fieldset>
) : null}
<AudioTagInput error={errors.tags} errorId={errorIds.tags} 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>}
{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>}
{mode === "create" ? <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} /> : <p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground"> .</p>}
<FileField accept="image/jpeg,image/png" acceptDescription="JPEG 또는 PNG, 10MB 이하, 1:1 crop 후 최대 800×800px로 전송합니다." error={errors.cover} label="커버 이미지" onChange={(file) => void selectCoverImage(file)} value={coverImage} />
<p className="rounded-lg border border-border bg-muted p-3 text-sm font-semibold text-muted-foreground">시리즈: 현재 .</p>
{errors.form === undefined ? null : <p className="text-sm font-semibold text-destructive" role="alert">{errors.form}</p>}
{uploadState.status === "idle" ? null : <UploadProgress fileName={audioFile?.name ?? coverImage?.name} onCancel={abortController === null ? undefined : () => abortController.abort()} onRetry={uploadState.status === "error" ? () => void submitWithCurrentState() : undefined} progress={uploadState.progress} status={uploadState.status} />}

View File

@@ -0,0 +1,54 @@
import { useId, useState } from "react";
type AudioTagInputProps = {
readonly error: string | undefined;
readonly errorId: string;
readonly onChange: (value: string) => void;
readonly value: string;
};
export function AudioTagInput({ error, errorId, onChange, value }: AudioTagInputProps) {
const [draft, setDraft] = useState("");
const inputId = useId();
const helpId = `${inputId}-help`;
const tags = value.split(",").map((tag) => tag.trim()).filter((tag) => tag.length > 0);
function commitDraft() {
const nextTag = draft.trim();
if (nextTag.length === 0) {
return;
}
onChange([...tags, nextTag].join(","));
setDraft("");
}
function changeDraft(nextDraft: string) {
const parts = nextDraft.split(",");
if (parts.length === 1) {
setDraft(nextDraft);
return;
}
const committedTags = parts.slice(0, -1).map((tag) => tag.trim()).filter((tag) => tag.length > 0);
if (committedTags.length > 0) {
onChange([...tags, ...committedTags].join(","));
}
setDraft(parts[parts.length - 1] ?? "");
}
return (
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold" htmlFor={inputId}></label>
<div className={`flex min-h-11 flex-wrap items-center gap-2 rounded-md border bg-card px-2 py-1 text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 ${error === undefined ? "border-input" : "border-destructive"}`}>
{tags.map((tag, index) => (
<span className="flex min-h-11 items-center rounded-md bg-muted pl-3 text-sm font-semibold" key={`${tag}-${index}`}>
{tag}
<button aria-label={`태그 ${tag} 삭제`} className="min-h-11 rounded-md px-3 text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={() => onChange(tags.filter((_, tagIndex) => tagIndex !== index).join(","))} type="button"></button>
</span>
))}
<input aria-describedby={`${helpId}${error === undefined ? "" : ` ${errorId}`}`} aria-invalid={error === undefined ? undefined : true} className="min-h-11 min-w-32 flex-1 bg-transparent px-2 text-base font-normal text-foreground outline-none" id={inputId} onChange={(event) => changeDraft(event.currentTarget.value)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); commitDraft(); } }} value={draft} />
</div>
<p className="text-sm text-muted-foreground" id={helpId}> Enter로 .</p>
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
</div>
);
}

View File

@@ -1,3 +1,5 @@
import { selectionCardClassName } from "@/features/audio-contents/components/audio-content-form-helpers";
export type ReleaseScheduleValue = {
readonly publishMode: "immediate" | "scheduled";
readonly releaseDateTime: string;
@@ -11,19 +13,18 @@ export function ReleaseScheduleField({ error, errorId, onChange, value }: { read
return (
<fieldset className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4">
<legend className="text-sm font-semibold"> </legend>
<label className="flex items-center gap-2 text-sm font-semibold">
<input checked={value.publishMode === "immediate"} name="publishMode" onChange={() => setMode("immediate")} type="radio" />
<label className={selectionCardClassName}>
<input checked={value.publishMode === "immediate"} className="size-4 accent-primary" name="publishMode" onChange={() => setMode("immediate")} type="radio" />
</label>
<label className="flex items-center gap-2 text-sm font-semibold">
<input checked={value.publishMode === "scheduled"} name="publishMode" onChange={() => setMode("scheduled")} type="radio" />
<label className={selectionCardClassName}>
<input checked={value.publishMode === "scheduled"} className="size-4 accent-primary" name="publishMode" onChange={() => setMode("scheduled")} type="radio" />
</label>
<label className="flex flex-col gap-2 text-sm font-semibold">
{value.publishMode === "scheduled" ? <label className="flex flex-col gap-2 text-sm font-semibold">
<input aria-describedby={error === undefined ? 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 disabled:bg-muted disabled:text-muted-foreground" disabled={value.publishMode === "immediate"} onChange={(event) => onChange({ publishMode: value.publishMode, releaseDateTime: event.currentTarget.value })} type="datetime-local" value={value.releaseDateTime} />
</label>
<p className="text-sm text-muted-foreground">Asia/Seoul .</p>
<input aria-describedby={error === undefined ? undefined : errorId} aria-invalid={error === undefined ? undefined : true} className="min-h-11 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onChange={(event) => onChange({ publishMode: value.publishMode, releaseDateTime: event.currentTarget.value })} type="datetime-local" value={value.releaseDateTime} />
</label> : null}
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
</fieldset>
);

View File

@@ -3,10 +3,10 @@ import type { AudioContentDetail } from "@/features/audio-contents/model/types";
import type { AudioContentCreateRequest, AudioContentUpdateRequest } from "@/features/audio-contents/schemas/audio-content-schema";
import { validateAudioCoverFile } from "@/features/audio-contents/validation/audio-cover-policy";
import { validateAudioFile } from "@/shared/validation/audio-file-policy";
import { formatCanPriceInput, parseCanPriceInput } from "@/shared/validation/can-price";
import { parseCanPriceInput } from "@/shared/validation/can-price";
export function createInitialPrice(price: number | undefined): string {
return price === undefined ? "" : `${price.toLocaleString("ko-KR")}`;
return String(price ?? 0);
}
export function parsePrice(value: string): number | null {
@@ -14,9 +14,15 @@ export function parsePrice(value: string): number | null {
}
export function formatPrice(value: string): string {
return formatCanPriceInput(value);
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}$/;
function toUtcMilliseconds(value: string, offsetHours: number): number {

View File

@@ -1,8 +1,8 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { expect, test } from "vitest";
import { AudioContentFormPage } from "@/features/audio-contents/pages/AudioContentFormPage";
import type { UploadAudioContentRequest } from "@/features/audio-contents/components/AudioContentForm";
import { AudioContentFormPage } from "@/features/audio-contents/pages/AudioContentFormPage";
import type { CapturedRequest } from "@/features/audio-contents/tests/audio-form-test-support";
import { createFormClient, fileWithSize, readJsonPart, requireFormData } from "@/features/audio-contents/tests/audio-form-test-support";
@@ -13,97 +13,228 @@ function createSuccessfulUpload(uploadedBodies: XMLHttpRequestBodyInit[]): Uploa
};
}
test("AudioContentFormPage serializes editable create settings and excludes unsupported create fields", async () => {
// Given
function renderCreateForm(uploadedBodies: XMLHttpRequestBodyInit[] = []) {
const requests: CapturedRequest[] = [];
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
const contentFile = fileWithSize("voice.m4a", "audio/x-m4a", 1_024_000_000);
const coverImage = new File(["cover"], "cover.png", { type: "image/png" });
window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
render(
<AudioContentFormPage
apiClient={createFormClient(requests)}
characterId="101"
createCropSource={(file) => Promise.resolve({ file, height: 1200, previewUrl: "blob:cover", width: 1200 })}
createCropSource={(file) => Promise.resolve({ file, height: 800, previewUrl: "blob:cover", width: 800 })}
renderCrop={(request) => Promise.resolve(request.file)}
uploadAudioContentRequest={createSuccessfulUpload(uploadedBodies)}
/>,
);
}
// When
async function fillRequiredCreateFields() {
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "옵션 오디오" } });
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "옵션 설명" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "옵션" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "99999캔" } });
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("구매 옵션"), { target: { value: "RENT_ONLY" } });
fireEvent.click(screen.getByLabelText("기간제"));
fireEvent.click(screen.getByLabelText("성인 콘텐츠"));
fireEvent.click(screen.getByLabelText("미리듣기 생성"));
fireEvent.click(screen.getByLabelText("대여 전용"));
fireEvent.click(screen.getByLabelText("포인트 사용"));
fireEvent.click(screen.getByLabelText("댓글 허용"));
fireEvent.click(screen.getByLabelText("상세 정보 전체 공개"));
fireEvent.change(screen.getByLabelText("미리듣기 시작"), { target: { value: "00:30" } });
fireEvent.change(screen.getByLabelText("미리듣기 종료"), { target: { value: "01:00" } });
fireEvent.change(screen.getByLabelText("언어 코드"), { target: { value: "ko" } });
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [contentFile] } });
fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [coverImage] } });
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
await waitFor(() => expect(screen.queryByRole("button", { name: "적용" })).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(screen.getByLabelText("구매 옵션")).toBeInTheDocument();
const body = requireFormData(uploadedBodies.at(-1));
expect(await readJsonPart(body.get("request"))).toEqual({
title: "옵션 오디오",
detail: "옵션 설명",
tags: "옵션",
price: 99999,
purchaseOption: "RENT_ONLY",
limited: 1,
releaseDate: null,
themeId: 7,
isAdult: true,
isGeneratePreview: true,
isOnlyRental: true,
isPointAvailable: true,
isCommentAvailable: true,
isFullDetailVisible: false,
previewStartTime: "00:30",
previewEndTime: "01:00",
languageCode: "ko",
});
expect(await readJsonPart(body.get("request"))).not.toHaveProperty("isActive");
expect(await readJsonPart(body.get("request"))).not.toHaveProperty("seriesIds");
expect(await readJsonPart(body.get("request"))).not.toHaveProperty("timezone");
});
test.each([
{ label: "음수 가격", value: "-1", expected: "-1" },
{ label: "소수 가격", value: "1.5", expected: "1.5" },
])("AudioContentFormPage keeps raw price input and blocks upload for %s", async ({ label, value, expected }) => {
// 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: label } });
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 } });
await waitFor(() => expect(screen.queryByRole("button", { name: "적용" })).not.toBeInTheDocument());
}
test("AudioContentFormPage commits tag chips with Enter and comma without submitting, then removes one with a native button", async () => {
// Given
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
renderCreateForm(uploadedBodies);
const tagInput = await screen.findByLabelText("태그");
// When
fireEvent.change(tagInput, { target: { value: "상담" } });
fireEvent.keyDown(tagInput, { key: "Enter" });
fireEvent.change(tagInput, { target: { value: "힐링" } });
fireEvent.keyDown(tagInput, { key: "," });
// Then
expect(tagInput).toHaveValue("");
expect(uploadedBodies).toHaveLength(0);
const removeConsulting = screen.getByRole("button", { name: "태그 상담 삭제" });
expect(removeConsulting.tagName).toBe("BUTTON");
expect(screen.getByRole("button", { name: "태그 힐링 삭제" })).toBeInTheDocument();
// When
fireEvent.click(removeConsulting);
// Then
expect(screen.queryByRole("button", { name: "태그 상담 삭제" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "태그 힐링 삭제" })).toBeInTheDocument();
});
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 }) => {
// Given
renderCreateForm();
const priceInput = await screen.findByLabelText("가격");
expect(priceInput).toHaveValue(0);
expect(priceInput).toHaveAttribute("type", "number");
expect(priceInput).toHaveAttribute("min", "0");
expect(priceInput).toHaveAttribute("step", "1");
// When
fireEvent.change(priceInput, { target: { value } });
// Then
expect(priceInput).toHaveValue(Number(expected));
});
test("AudioContentFormPage shows paid options only for a positive price", async () => {
// Given
renderCreateForm();
const priceInput = await screen.findByLabelText("가격");
// When
fireEvent.change(priceInput, { target: { value: "0" } });
// Then
expect(screen.queryByRole("group", { name: "구매 옵션" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("미리듣기 생성")).not.toBeInTheDocument();
expect(screen.queryByLabelText("포인트 사용")).not.toBeInTheDocument();
expect(screen.queryByLabelText("미리듣기 시작")).not.toBeInTheDocument();
expect(screen.queryByLabelText("미리듣기 종료")).not.toBeInTheDocument();
// When
fireEvent.change(priceInput, { target: { value: "1" } });
// Then
expect(screen.getByRole("group", { name: "구매 옵션" })).toBeInTheDocument();
expect(screen.getByLabelText("미리듣기 생성")).toBeInTheDocument();
expect(screen.getByLabelText("포인트 사용")).toBeInTheDocument();
});
test("AudioContentFormPage resets paid settings and retains removed-control defaults when price returns to zero", async () => {
// Given
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
renderCreateForm(uploadedBodies);
await fillRequiredCreateFields();
const priceInput = screen.getByLabelText("가격");
fireEvent.change(priceInput, { target: { value: "1" } });
fireEvent.click(screen.getByRole("radio", { name: "대여 전용" }));
fireEvent.click(screen.getByLabelText("미리듣기 생성"));
fireEvent.click(screen.getByLabelText("포인트 사용"));
fireEvent.change(screen.getByLabelText("미리듣기 시작"), { target: { value: "00:00:30" } });
fireEvent.change(screen.getByLabelText("미리듣기 종료"), { target: { value: "01:00:05" } });
// When
fireEvent.change(priceInput, { target: { value: "0" } });
// Then
expect(screen.queryByRole("group", { name: "구매 옵션" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("미리듣기 생성")).not.toBeInTheDocument();
expect(screen.queryByLabelText("포인트 사용")).not.toBeInTheDocument();
expect(screen.queryByLabelText("미리듣기 시작")).not.toBeInTheDocument();
expect(screen.queryByLabelText("미리듣기 종료")).not.toBeInTheDocument();
expect(screen.queryByLabelText("기간제")).not.toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: "대여 전용" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("언어 코드")).not.toBeInTheDocument();
// When
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(screen.getByLabelText("가격")).toHaveValue(expected);
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(uploadedBodies).toHaveLength(0);
await waitFor(() => expect(uploadedBodies).toHaveLength(1));
expect(await readJsonPart(requireFormData(uploadedBodies.at(-1)).get("request"))).toMatchObject({
price: 0,
purchaseOption: "BOTH",
limited: null,
isGeneratePreview: false,
isOnlyRental: false,
isPointAvailable: false,
previewStartTime: null,
previewEndTime: null,
languageCode: null,
});
});
test("AudioContentFormPage reveals preview duration offset inputs only when preview generation is enabled and submits full HH:MM:SS values", async () => {
// Given
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
renderCreateForm(uploadedBodies);
await fillRequiredCreateFields();
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "1" } });
expect(screen.queryByLabelText("미리듣기 시작")).not.toBeInTheDocument();
expect(screen.queryByLabelText("미리듣기 종료")).not.toBeInTheDocument();
// When
fireEvent.click(screen.getByLabelText("미리듣기 생성"));
// Then
const previewStart = screen.getByLabelText("미리듣기 시작");
const previewEnd = screen.getByLabelText("미리듣기 종료");
expect(previewStart).toHaveAttribute("type", "text");
expect(previewStart).toHaveAttribute("placeholder", "예: 00:00:30");
expect(previewStart).toHaveAttribute("pattern", "[0-9]{2}:[0-9]{2}:[0-9]{2}");
expect(previewStart).not.toHaveAttribute("step");
expect(previewEnd).toHaveAttribute("type", "text");
expect(previewEnd).toHaveAttribute("placeholder", "예: 01:00:05");
expect(previewEnd).toHaveAttribute("pattern", "[0-9]{2}:[0-9]{2}:[0-9]{2}");
expect(previewEnd).not.toHaveAttribute("step");
// When
fireEvent.change(previewStart, { target: { value: "00:00:30" } });
fireEvent.change(previewEnd, { target: { value: "01:00:05" } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
await waitFor(() => expect(uploadedBodies).toHaveLength(1));
expect(await readJsonPart(requireFormData(uploadedBodies.at(-1)).get("request"))).toMatchObject({
previewStartTime: "00:00:30",
previewEndTime: "01:00:05",
});
});
test("AudioContentFormPage conditionally renders and clears the native scheduled release datetime", async () => {
// Given
renderCreateForm();
await screen.findByLabelText("즉시 공개");
expect(screen.queryByLabelText("예약 공개일")).not.toBeInTheDocument();
// When
fireEvent.click(screen.getByLabelText("예약 공개"));
// Then
const scheduledInput = screen.getByLabelText("예약 공개일");
expect(scheduledInput).toHaveAttribute("type", "datetime-local");
// When
fireEvent.change(scheduledInput, { target: { value: "2026-08-05T12:00" } });
fireEvent.click(screen.getByLabelText("즉시 공개"));
// Then
expect(screen.queryByLabelText("예약 공개일")).not.toBeInTheDocument();
// When
fireEvent.click(screen.getByLabelText("예약 공개"));
// Then
expect(screen.getByLabelText("예약 공개일")).toHaveValue("");
});
test("AudioContentFormPage turns edit tags into removable chips and serializes the remaining chips as a comma string", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" />);
// Then
await waitFor(() => expect(screen.queryByRole("button", { name: "태그 상담 삭제" })).toBeInTheDocument());
expect(screen.getByRole("button", { name: "태그 힐링 삭제" })).toBeInTheDocument();
// When
fireEvent.click(screen.getByRole("button", { name: "태그 상담 삭제" }));
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "집중" } });
fireEvent.keyDown(screen.getByLabelText("태그"), { key: "Enter" });
fireEvent.click(screen.getByRole("button", { name: "저장" }));
// Then
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9001"));
expect(await readJsonPart(requireFormData(requests.at(-1)?.body).get("request"))).toMatchObject({ tags: "힐링,집중" });
});

View File

@@ -42,7 +42,6 @@ test("AudioContentFormPage update omits unsupported controls and soft delete nav
// When
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "수정 오디오" } });
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "수정 설명" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "수정" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "0" } });
fireEvent.click(screen.getByRole("button", { name: "저장" }));
@@ -57,7 +56,7 @@ test("AudioContentFormPage update omits unsupported controls and soft delete nav
expect(await readJsonPart(updateBody.get("request"))).toEqual({
title: "수정 오디오",
detail: "수정 설명",
tags: "수정",
tags: "상담,힐링",
price: 0,
isAdult: false,
isPointAvailable: true,

View File

@@ -60,7 +60,7 @@ afterEach(() => {
async function fillValidCreateForm(): Promise<void> {
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "재시도 오디오" } });
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "재시도 설명" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "재시도" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "재시도," } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100" } });
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [new File(["audio"], "voice.aac", { type: "audio/aac" })] } });
@@ -293,7 +293,7 @@ test("AudioContentFormPage ignores stale cover crop sources and uploads the late
});
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "최신 커버 오디오" } });
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "최신 커버 설명" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "최신" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "최신," } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100" } });
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [new File(["audio"], "voice.aac", { type: "audio/aac" })] } });

View File

@@ -16,7 +16,21 @@ function createSuccessfulUpload(uploadedBodies: XMLHttpRequestBodyInit[]): Uploa
};
}
test("AudioContentFormPage creates immediate audio with required theme, crop policy, 캔 price, and no unsupported upload limits", async () => {
test("AudioContentFormPage renders upload fields in the requested order", async () => {
const expectedOrder = ["커버 이미지", "오디오 파일", "제목", "상세 설명", "태그", "오디오 테마", "가격", "생성 옵션", "공개 일정"];
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" />);
const form = await screen.findByRole("form", { name: "오디오 콘텐츠 생성 입력 화면" });
const actualOrder = Array.from(form.querySelectorAll("label, legend"))
.map((element) => element.childNodes[0]?.textContent?.trim())
.filter((label): label is string => label !== undefined && expectedOrder.includes(label));
expect(actualOrder).toEqual(expectedOrder);
});
test("AudioContentFormPage creates immediate audio with required theme, crop policy, numeric price, and no unsupported upload limits", async () => {
// Given
const requests: CapturedRequest[] = [];
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
@@ -38,9 +52,12 @@ test("AudioContentFormPage creates immediate audio with required theme, crop pol
// When
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "달빛 상담 오디오" } });
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "잠들기 전 듣는 상담 오디오" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "상담,힐링" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "1,000캔" } });
expect(screen.getByLabelText("가격")).toHaveValue("1,000캔");
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "상담" } });
fireEvent.keyDown(screen.getByLabelText("태그"), { key: "Enter" });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "힐링" } });
fireEvent.keyDown(screen.getByLabelText("태그"), { key: "," });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "1000" } });
expect(screen.getByLabelText("가격")).toHaveValue(1000);
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [contentFile] } });
fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [coverImage] } });
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
@@ -50,7 +67,7 @@ test("AudioContentFormPage creates immediate audio with required theme, crop pol
// Then
expect(await screen.findByText("테마를 선택하세요.")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
expect(screen.getByLabelText("예약 공개일")).toBeDisabled();
expect(screen.queryByLabelText("예약 공개일")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "생성" }));
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9301"));
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ aspect: 1, outputWidth: 800, outputHeight: 800 }));
@@ -80,7 +97,7 @@ test("AudioContentFormPage creates immediate audio with required theme, crop pol
expect(screen.getByText("제목, 상세 설명, 태그는 저장 전 운영 기준에 맞게 검토하세요. 업로드 제한은 안내된 파일 정책을 따릅니다.")).toBeInTheDocument();
});
test("AudioContentFormPage serializes editable create settings and excludes unsupported create fields", async () => {
test("AudioContentFormPage serializes supported create settings with removed-control defaults and excludes unsupported create fields", async () => {
// Given
const requests: CapturedRequest[] = [];
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
@@ -101,19 +118,12 @@ test("AudioContentFormPage serializes editable create settings and excludes unsu
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "옵션 오디오" } });
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "옵션 설명" } });
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "옵션" } });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "99999캔" } });
fireEvent.keyDown(screen.getByLabelText("태그"), { key: "Enter" });
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "99999" } });
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("구매 옵션"), { target: { value: "RENT_ONLY" } });
fireEvent.click(screen.getByLabelText("기간제"));
fireEvent.click(screen.getByLabelText("성인 콘텐츠"));
fireEvent.click(screen.getByLabelText("미리듣기 생성"));
fireEvent.click(screen.getByLabelText("대여 전용"));
fireEvent.click(screen.getByLabelText("포인트 사용"));
fireEvent.click(screen.getByLabelText("댓글 허용"));
fireEvent.click(screen.getByLabelText("상세 정보 전체 공개"));
fireEvent.change(screen.getByLabelText("미리듣기 시작"), { target: { value: "00:30" } });
fireEvent.change(screen.getByLabelText("미리듣기 종료"), { target: { value: "01:00" } });
fireEvent.change(screen.getByLabelText("언어 코드"), { target: { value: "ko" } });
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [contentFile] } });
fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [coverImage] } });
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
@@ -121,26 +131,29 @@ test("AudioContentFormPage serializes editable create settings and excludes unsu
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(screen.getByLabelText("구매 옵션")).toBeInTheDocument();
await waitFor(() => expect(uploadedBodies).toHaveLength(1));
expect(screen.queryByLabelText("기간제")).not.toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: "대여 전용" })).not.toBeInTheDocument();
expect(screen.queryByLabelText("언어 코드")).not.toBeInTheDocument();
const body = requireFormData(uploadedBodies.at(-1));
expect(await readJsonPart(body.get("request"))).toEqual({
title: "옵션 오디오",
detail: "옵션 설명",
tags: "옵션",
price: 99999,
purchaseOption: "RENT_ONLY",
limited: 1,
purchaseOption: "BOTH",
limited: null,
releaseDate: null,
themeId: 7,
isAdult: true,
isGeneratePreview: true,
isOnlyRental: true,
isPointAvailable: true,
isGeneratePreview: false,
isOnlyRental: false,
isPointAvailable: false,
isCommentAvailable: true,
isFullDetailVisible: false,
previewStartTime: "00:30",
previewEndTime: "01:00",
languageCode: "ko",
previewStartTime: null,
previewEndTime: null,
languageCode: null,
});
expect(await readJsonPart(body.get("request"))).not.toHaveProperty("isActive");
expect(await readJsonPart(body.get("request"))).not.toHaveProperty("seriesIds");
@@ -148,9 +161,9 @@ test("AudioContentFormPage serializes editable create settings and excludes unsu
});
test.each([
{ label: "음수 가격", value: "-1", expected: "-1" },
{ label: "소수 가격", value: "1.5", expected: "1.5" },
])("AudioContentFormPage keeps raw price input and blocks upload for %s", async ({ label, value, expected }) => {
{ label: "음수 기호", value: "-1", expected: "0" },
{ label: "소수", value: "1.5", expected: "15" },
])("AudioContentFormPage removes non-digits from price input for %s", async ({ label, value, expected }) => {
// Given
const requests: CapturedRequest[] = [];
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
@@ -167,11 +180,8 @@ test.each([
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
await waitFor(() => expect(screen.getByText("cover.png")).toBeInTheDocument());
fireEvent.change(screen.getByLabelText("가격"), { target: { value } });
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
expect(screen.getByLabelText("가격")).toHaveValue(expected);
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
expect(screen.getByLabelText("가격")).toHaveValue(Number(expected));
expect(uploadedBodies).toHaveLength(0);
});
@@ -188,6 +198,7 @@ test("AudioContentFormPage validates audio boundary and serializes scheduled Asi
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: "0" } });
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [fileWithSize("voice.wav", "audio/wav", 10)] } });
@@ -237,15 +248,17 @@ test("AudioContentFormPage blocks prices outside the CAN range before upload", a
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: "100,000" } });
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);
});
@@ -259,26 +272,27 @@ test("AudioContentFormPage links validation errors and focuses the first invalid
// When
const titleInput = await screen.findByLabelText("제목");
const coverInput = screen.getByLabelText("커버 이미지");
fireEvent.click(screen.getByRole("button", { name: "생성" }));
// Then
const titleError = await screen.findByText("제목을 입력하세요.");
const detailError = screen.getByText("상세 설명을 입력하세요.");
const tagsError = screen.getByText("태그를 입력하세요.");
const priceError = screen.getByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.");
const themeError = screen.getByText("테마를 선택하세요.");
expect(titleError).toHaveAttribute("id", "audio-content-title-error");
expect(detailError).toHaveAttribute("id", "audio-content-detail-error");
expect(tagsError).toHaveAttribute("id", "audio-content-tags-error");
expect(priceError).toHaveAttribute("id", "audio-content-price-error");
expect(themeError).toHaveAttribute("id", "audio-content-theme-error");
expect(titleInput).toHaveAttribute("aria-describedby", "audio-content-title-error");
expect(screen.getByLabelText("상세 설명")).toHaveAttribute("aria-describedby", "audio-content-detail-error");
expect(screen.getByLabelText("태그")).toHaveAttribute("aria-describedby", "audio-content-tags-error");
expect(screen.getByLabelText("가격")).toHaveAttribute("aria-describedby", "audio-content-price-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("오디오 테마")).toHaveAttribute("aria-describedby", "audio-content-theme-error");
expect(titleInput).toHaveAttribute("aria-invalid", "true");
await waitFor(() => expect(titleInput).toHaveFocus());
expect(coverInput).toHaveAttribute("aria-invalid", "true");
await waitFor(() => expect(coverInput).toHaveFocus());
expect(uploadedBodies).toHaveLength(0);
});

View File

@@ -106,7 +106,7 @@ test("desktop and tablet expose audio mutation and upload actions", async ({ pag
}
});
test("desktop create form exposes create settings and rejects raw invalid price", async ({ page }) => {
test("desktop create form exposes P4-R10 native controls and conditional states without overflow", async ({ page }) => {
// Given
let createRequests = 0;
page.on("request", (request) => {
@@ -121,26 +121,150 @@ test("desktop create form exposes create settings and rejects raw invalid price"
// When
await page.goto("/ai-characters/101/audio-contents/new");
// Then
// Then: free state
await expect(page.getByRole("form", { name: "오디오 콘텐츠 생성 입력 화면" })).toBeVisible();
await expect(page.getByLabel("구매 옵션")).toBeVisible();
await expect(page.getByLabel("기간제")).toBeVisible();
await expect(page.getByRole("group", { name: "구매 옵션" })).toHaveCount(0);
await expect(page.getByLabel("미리듣기 생성")).toHaveCount(0);
await expect(page.getByLabel("포인트 사용")).toHaveCount(0);
await expect(page.getByLabel("미리듣기 시작")).toHaveCount(0);
await expect(page.getByLabel("미리듣기 종료")).toHaveCount(0);
await expect(page.getByLabel("예약 공개일")).toHaveCount(0);
const priceInput = page.getByLabel("가격", { exact: true });
await expect(priceInput).toHaveAttribute("type", "number");
await expect(priceInput).toHaveAttribute("min", "0");
await expect(priceInput).toHaveAttribute("step", "1");
await expect(priceInput).toHaveValue("0");
await priceInput.focus();
await page.keyboard.press("ArrowDown");
await expect(priceInput).toHaveValue("0");
await page.keyboard.press("ArrowUp");
await expect(priceInput).toHaveValue("1");
await priceInput.fill("0");
await expectNoHorizontalOverflow(page);
// When: tags commit through both keyboard separators
const tagInput = page.getByLabel("태그", { exact: true });
await tagInput.fill("상담");
await tagInput.press("Enter");
await tagInput.fill("힐링");
await tagInput.press(",");
// Then
await expect(tagInput).toHaveValue("");
await expect(page.getByRole("button", { name: "태그 상담 삭제" })).toBeVisible();
await expect(page.getByRole("button", { name: "태그 힐링 삭제" })).toBeVisible();
expect(createRequests).toBe(0);
// When
await page.getByRole("button", { name: "태그 상담 삭제" }).click();
// Then
await expect(page.getByRole("button", { name: "태그 상담 삭제" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "태그 힐링 삭제" })).toBeVisible();
// When: a positive normalized price enables paid controls
await priceInput.fill("1000");
// Then: paid state
await expect(priceInput).toHaveValue("1000");
const purchaseOptions = page.getByRole("group", { name: "구매 옵션" });
await expect(purchaseOptions).toBeVisible();
const bothRadio = page.getByRole("radio", { name: "구매/대여" });
const buyOnlyRadio = page.getByRole("radio", { name: "구매 전용" });
const rentOnlyRadio = page.getByRole("radio", { name: "대여 전용" });
await expect(bothRadio).toHaveAttribute("type", "radio");
await expect(buyOnlyRadio).toHaveAttribute("type", "radio");
await expect(rentOnlyRadio).toHaveAttribute("type", "radio");
await expect(bothRadio).toBeChecked();
await rentOnlyRadio.focus();
await page.keyboard.press("Space");
await expect(rentOnlyRadio).toBeChecked();
await expect(page.getByLabel("기간제")).toHaveCount(0);
await expect(page.getByRole("checkbox", { name: "대여 전용" })).toHaveCount(0);
await expect(page.getByLabel("언어 코드")).toHaveCount(0);
await expect(page.getByLabel("성인 콘텐츠")).toBeVisible();
await expect(page.getByLabel("미리듣기 생성")).toBeVisible();
await expect(page.getByLabel("대여 전용")).toBeVisible();
await expect(page.getByLabel("포인트 사용")).toBeVisible();
await expect(page.getByLabel("댓글 허용")).toBeVisible();
await expect(page.getByLabel("상세 정보 전체 공개")).toBeVisible();
await expect(page.getByLabel("미리듣기 시작")).toBeVisible();
await expect(page.getByLabel("미리듣기 종료")).toBeVisible();
await expect(page.getByLabel("언어 코드")).toBeVisible();
await expectNoHorizontalOverflow(page);
// When
await page.getByLabel("가격").fill("-1");
// When: preview is enabled
const previewCheckbox = page.getByLabel("미리듣기 생성");
await previewCheckbox.check();
// Then: preview duration offsets
const previewStart = page.getByLabel("미리듣기 시작");
const previewEnd = page.getByLabel("미리듣기 종료");
await expect(previewStart).toHaveAttribute("type", "text");
await expect(previewStart).toHaveAttribute("placeholder", "예: 00:00:30");
await expect(previewStart).toHaveAttribute("pattern", "[0-9]{2}:[0-9]{2}:[0-9]{2}");
await expect(previewStart).not.toHaveAttribute("step");
await expect(previewEnd).toHaveAttribute("type", "text");
await expect(previewEnd).toHaveAttribute("placeholder", "예: 01:00:05");
await expect(previewEnd).toHaveAttribute("pattern", "[0-9]{2}:[0-9]{2}:[0-9]{2}");
await expect(previewEnd).not.toHaveAttribute("step");
await expectNoHorizontalOverflow(page);
// When / Then: unchecking preview clears the visible time controls
await previewCheckbox.uncheck();
await expect(previewStart).toHaveCount(0);
await expect(previewEnd).toHaveCount(0);
// When: preview state is populated before returning to free
await previewCheckbox.check();
await page.getByLabel("포인트 사용").check();
await previewStart.fill("00:00:30");
await previewEnd.fill("01:00:05");
// Then: immediate mode has no datetime
await expect(page.getByLabel("즉시 공개")).toBeChecked();
await expect(page.getByLabel("예약 공개일")).toHaveCount(0);
// When: scheduled mode is selected
await page.getByLabel("예약 공개").check();
// Then: exactly one native scheduled datetime is rendered
const releaseDateTime = page.getByLabel("예약 공개일");
await expect(releaseDateTime).toHaveCount(1);
await expect(releaseDateTime).toHaveAttribute("type", "datetime-local");
await releaseDateTime.fill("2026-08-05T12:00");
await expectNoHorizontalOverflow(page);
// When / Then: immediate mode removes and clears the scheduled datetime
await page.getByLabel("즉시 공개").check();
await expect(releaseDateTime).toHaveCount(0);
await page.getByLabel("예약 공개").check();
await expect(page.getByLabel("예약 공개일")).toHaveValue("");
await page.getByLabel("즉시 공개").check();
// When: returning to zero resets and hides paid state
await priceInput.fill("0");
// Then
await expect(purchaseOptions).toHaveCount(0);
await expect(previewCheckbox).toHaveCount(0);
await expect(page.getByLabel("포인트 사용")).toHaveCount(0);
await expect(previewStart).toHaveCount(0);
await expect(previewEnd).toHaveCount(0);
await expectNoHorizontalOverflow(page);
// When: paid controls return
await priceInput.fill("1");
// Then: their reset defaults are visible
await expect(page.getByRole("radio", { name: "구매/대여" })).toBeChecked();
await expect(page.getByRole("radio", { name: "대여 전용" })).not.toBeChecked();
await expect(page.getByLabel("미리듣기 생성")).not.toBeChecked();
await expect(page.getByLabel("포인트 사용")).not.toBeChecked();
await expect(page.getByLabel("미리듣기 시작")).toHaveCount(0);
await expect(page.getByLabel("미리듣기 종료")).toHaveCount(0);
// When: the preserved max boundary is exceeded
await priceInput.fill("100000");
await page.getByRole("button", { name: "생성" }).click();
// Then
await expect(page.getByLabel("가격")).toHaveValue("-1");
await expect(priceInput).toHaveValue("100000");
await expect(page.getByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeVisible();
expect(createRequests).toBe(0);
});