fix(ai-character): 폼 도메인 검증 복구
This commit is contained in:
@@ -10,12 +10,12 @@
|
|||||||
|
|
||||||
| 문서 항목 | 내용 |
|
| 문서 항목 | 내용 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 상태 | 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-08-04 `P6-R7` Audio preview 시간 domain validation 회귀 수정과 코드-only 재리뷰 완료. `P6-R6` 가격 domain validation submit 경로 회귀 수정 완료. `P6-R5` FileField 이미지 preview·Community 생성 media flow·공용 CAN 가격 계약 구현 완료. `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-25 |
|
||||||
| 재작성일 | 2026-07-26 |
|
| 재작성일 | 2026-07-26 |
|
||||||
| 요구사항 기준 | [prd.md](./prd.md) |
|
| 요구사항 기준 | [prd.md](./prd.md) |
|
||||||
| API 기준 | [api-contract.openapi.json](./api-contract.openapi.json) |
|
| API 기준 | [api-contract.openapi.json](./api-contract.openapi.json) |
|
||||||
| 현재 활성 Goal | `P6-R5` 완료 |
|
| 현재 활성 Goal | `P6-R7` 완료 |
|
||||||
|
|
||||||
## 목표
|
## 목표
|
||||||
|
|
||||||
@@ -3147,6 +3147,56 @@ focus가 Sheet trigger로 복귀하는지 본다.
|
|||||||
- 리뷰: 목표·품질 review에서 문서 추적성과 Audio raw `00` zero reset blocker를 발견했다. `parsePrice(value) === 0`으로 보완해 Audio focused 9 files / 69 tests를 통과했고, 문서 상태·파일 지도·Decision Log를 정렬했다.
|
- 리뷰: 목표·품질 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 검증은 제외했다.
|
- 최종 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 검증은 제외했다.
|
||||||
|
|
||||||
|
### Task R6.6 — 가격 domain validation submit 경로 복구
|
||||||
|
|
||||||
|
**Goal 실행 `P6-R6`:** Audio와 Community 가격 입력의 native constraint가 domain submit validation을 우회하지 않게 하고 inline 오류·오류 focus 계약을 복구한다.
|
||||||
|
|
||||||
|
- **연결 리뷰:** [P6-R5 코드 리뷰](./reviews/phase6-community-create-media-flow.md) — `REV-P6-R5-001`.
|
||||||
|
- **시작 조건:** `P6-R5` 완료, `REV-P6-R5-001` 확정, `plan-task.md:3128`의 range validation domain submit 소유 계약.
|
||||||
|
- **완료 증거:** 음수·소수 가격을 실제 submit button으로 제출했을 때 Audio 생성·수정과 Community 생성 모두 domain inline 오류를 표시하고 request를 전송하지 않는 RED/GREEN, focused test·전체 unit·typecheck·lint·build·diff check 통과, 코드-only 재리뷰.
|
||||||
|
- **Files:** Modify: `src/features/audio-contents/components/AudioContentForm.tsx`, `src/features/audio-contents/tests/audio-form.test.tsx`, `src/features/audio-contents/tests/audio-form-update.test.tsx`, `src/features/community-posts/components/CommunityPostForm.tsx`, `src/features/community-posts/tests/community-price-validation.test.tsx`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`; Create: `docs/20260725_AI캐릭터관리자웹/reviews/phase6-community-create-media-flow.md`.
|
||||||
|
- **Interfaces:** 두 form은 `noValidate`로 native submit 차단만 해제하고 `CanPriceField`의 `type=number`, `min=0`, `step=1`과 domain `parseCanPriceInput` validation은 유지한다.
|
||||||
|
- **범위 밖:** 가격 schema·오류 문구·payload 변경, FileField·media flow 변경, 브라우저/E2E 실행.
|
||||||
|
|
||||||
|
- [x] **RED:** 기존 음수·소수 테스트를 `fireEvent.submit(form)` 대신 실제 `생성`/`저장` button click으로 실행해 domain inline 오류 assertion이 실패하는 것을 확인한다.
|
||||||
|
- [x] **GREEN:** Audio와 Community form에 `noValidate`를 추가해 동일 테스트를 통과시킨다.
|
||||||
|
- [x] **REFACTOR:** 추가 abstraction 없이 focused/full 정적 Gate와 staged diff 재리뷰를 통과하고 review·Progress를 누적한다.
|
||||||
|
- **실행 명령:** `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/community-posts/tests/community-price-validation.test.tsx`; `npm run test:run -- src/shared/ui/__tests__/file-field.test.tsx src/shared/ui/__tests__/can-price-field.test.tsx 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`; `git diff --check`.
|
||||||
|
- **기대 결과:** 모든 명령 exit 0, 음수·소수 실제 submit에서 domain 오류 누락 0건, invalid price request 0건, 기존 FileField/media/price 회귀 0건.
|
||||||
|
- **수동 확인:** 없음 — 사용자 지시에 따라 browser를 실행하지 않고 실제 submit button을 사용하는 jsdom integration test로 검증한다.
|
||||||
|
|
||||||
|
**P6-R6 진행 기록 (2026-08-04):**
|
||||||
|
|
||||||
|
- RED: Audio 생성·수정과 Community 생성의 invalid price test를 실제 submit button click으로 변경했다. `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/community-posts/tests/community-price-validation.test.tsx` — 음수·소수 inline 오류 누락 6건 실패, 나머지 22건 통과로 native constraint의 submit 차단을 재현했다.
|
||||||
|
- GREEN: 두 form에 `noValidate`만 추가했다. 동일 명령 — 3 files / 28 tests passed.
|
||||||
|
- REFACTOR/Gate: 추가 abstraction 없음. domain focused — 18 files / 134 tests passed; full unit — 83 files / 458 tests passed; `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod`, `git diff --check` — exit 0. build의 500kB chunk warning은 기존 비차단 경고다.
|
||||||
|
- 리뷰: [P6-R5 코드 리뷰](./reviews/phase6-community-create-media-flow.md)의 `REV-P6-R5-001`을 수정 완료로 판정했다. 독립 코드 재리뷰에서 form-level `noValidate`가 기존 preview 시간 `pattern` 검증도 해제하는 `REV-P6-R5-002`를 추가 확정해 `P6-R7`로 전환했다. 사용자 지시에 따라 browser/E2E는 실행하지 않았다.
|
||||||
|
|
||||||
|
### Task R6.7 — Audio preview 시간 domain validation 복구
|
||||||
|
|
||||||
|
**Goal 실행 `P6-R7`:** Audio form의 `noValidate`를 유지하면서 preview 시작·종료의 완전한 `HH:MM:SS` 형식을 domain submit validation으로 보장한다.
|
||||||
|
|
||||||
|
- **연결 리뷰:** [P6-R5 코드 리뷰](./reviews/phase6-community-create-media-flow.md) — `REV-P6-R5-002`.
|
||||||
|
- **시작 조건:** `P6-R6` 완료, `REV-P6-R5-002` 확정, PRD `AUDIO-033`.
|
||||||
|
- **완료 증거:** preview 생성이 켜진 Audio 생성에서 잘못된 시작·종료 값을 실제 submit button으로 제출하면 inline 오류를 표시하고 request를 전송하지 않는 RED/GREEN, Audio focused·전체 unit·typecheck·lint·build·diff check 통과, 코드-only 재리뷰.
|
||||||
|
- **Files:** Modify: `src/features/audio-contents/components/AudioContentForm.tsx`, `src/features/audio-contents/components/AudioContentCreateOptions.tsx`, `src/features/audio-contents/tests/audio-form-create-red.test.tsx`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase6-community-create-media-flow.md`.
|
||||||
|
- **Interfaces:** `noValidate`와 가격 domain validation은 유지한다. preview 생성이 켜지고 값이 입력된 경우에만 기존 input `pattern`과 동일한 완전한 `HH:MM:SS` 형식을 domain에서 검증해 inline 오류와 오류 focus를 제공한다.
|
||||||
|
- **범위 밖:** preview offset의 오디오 duration 상관관계, nullable contract 변경, 브라우저/E2E 실행.
|
||||||
|
|
||||||
|
- [x] **RED:** 잘못된 preview 시작·종료 값을 실제 `생성` button으로 제출해 request가 전송되는 실패를 확인한다.
|
||||||
|
- [x] **GREEN:** Audio domain validation과 input 오류 연결을 최소 추가해 request를 차단한다.
|
||||||
|
- [x] **REFACTOR:** 추가 abstraction 없이 Audio focused/full 정적 Gate와 코드 재리뷰를 통과하고 review·Progress를 누적한다.
|
||||||
|
- **실행 명령:** `npm run test:run -- src/features/audio-contents/tests/audio-form-create-red.test.tsx`; `npm run test:run -- src/features/audio-contents`; `npm run test:run`; `npm run typecheck`; `npm run lint`; `npm run build:dev`; `npm run build:prod`; `git diff --check`.
|
||||||
|
- **기대 결과:** 모든 명령 exit 0, malformed preview time request 0건, 정상 `HH:MM:SS`·가격 domain validation 회귀 0건.
|
||||||
|
- **수동 확인:** 없음 — 사용자 지시에 따라 browser를 실행하지 않고 실제 submit button을 사용하는 jsdom integration test로 검증한다.
|
||||||
|
|
||||||
|
**P6-R7 진행 기록 (2026-08-04):**
|
||||||
|
|
||||||
|
- RED: malformed preview 시작·종료를 실제 `생성` button으로 제출하는 test를 추가했다. `npm run test:run -- src/features/audio-contents/tests/audio-form-create-red.test.tsx` — 신규 1건 실패, 기존 9건 통과로 inline 오류 없이 request 경로가 진행되는 회귀를 재현했다.
|
||||||
|
- GREEN: preview 생성이 켜지고 값이 입력된 경우에만 기존 input `pattern`과 같은 `HH:MM:SS` 형식을 domain에서 검사하고, 시작·종료 inline 오류·`aria-describedby`·`aria-invalid`·첫 오류 focus를 연결했다. 동일 명령 — 1 file / 10 tests passed.
|
||||||
|
- REFACTOR/Gate: 추가 abstraction 없음. Audio·Community·shared 회귀 — 18 files / 135 tests passed; full unit — 83 files / 459 tests passed; `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod` — exit 0. build의 500kB chunk warning은 기존 비차단 경고다.
|
||||||
|
- 리뷰: 두 `noValidate` form의 native constraint와 domain 검증을 다시 추적했다. Audio 예약 일시는 기존 domain 검증이 담당하며 Community/FileField/media/price를 포함한 추가 확정 발견 사항은 없었다. 사용자 지시에 따라 browser/E2E는 실행하지 않았다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 7. FanTalk vertical slice
|
## Phase 7. FanTalk vertical slice
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Phase 6 Community 생성 media flow 코드 리뷰
|
||||||
|
|
||||||
|
## 1. 리뷰 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 리뷰 대상 | Phase 6 / `P6-R5`, 회귀 수정 `P6-R6`~`P6-R7` |
|
||||||
|
| 기준 commit 또는 working tree | `766a06a` + staged/working tree 변경 |
|
||||||
|
| 리뷰 일자 | `2026-08-04` |
|
||||||
|
| 리뷰어 | Codex |
|
||||||
|
| 기준 문서 | `../prd.md`, `../api-contract.openapi.json`, `../plan-task.md` |
|
||||||
|
| 리뷰 상태 | 수정 검증 완료 |
|
||||||
|
|
||||||
|
## 2. 리뷰 목적과 범위
|
||||||
|
|
||||||
|
### 목적
|
||||||
|
|
||||||
|
- `COMMUNITY-003`, `COMMUNITY-007`, `COMMUNITY-013`, `COMMUNITY-016`, `FILE-016`과 `P6-R5`가 변경 코드에 구현됐는지 확인한다.
|
||||||
|
- 발견된 가격 submit validation 우회와 Audio preview 시간 검증 회귀를 `P6-R6`~`P6-R7`에서 수정하고 관련 회귀가 없는지 확인한다.
|
||||||
|
|
||||||
|
### 포함 범위
|
||||||
|
|
||||||
|
- 코드: `src/shared/ui/file-field.tsx`, `src/shared/ui/can-price-field.tsx`, Audio/Community form과 helper, staged UI 보완 파일
|
||||||
|
- 테스트: 관련 shared UI·Audio·Community Vitest와 staged `tests/e2e/community.spec.ts`의 정적 검토
|
||||||
|
- 문서: 위 요구사항, OpenAPI Community/Audio create contract, `P6-R5`~`P6-R7`, `DESIGN.md`
|
||||||
|
- 수동 검증: 없음
|
||||||
|
|
||||||
|
### 제외 범위
|
||||||
|
|
||||||
|
- 사용자 지시에 따라 browser/E2E 실행과 visual QA를 제외한다.
|
||||||
|
- 실제 개발 API와 server file policy는 기존 외부 수동 QA 범위로 유지한다.
|
||||||
|
- `P6-R5`와 무관한 기존 코드·Phase는 검토하지 않는다.
|
||||||
|
|
||||||
|
## 3. 판정 기준
|
||||||
|
|
||||||
|
심각도와 상태는 `docs/agent-guide/review.md`의 `Blocker`·`High`·`Medium`·`Low`, `후보`·`확정`·`오탐`·`보류`·`수정 완료` 정의를 사용한다.
|
||||||
|
|
||||||
|
## 4. 검토한 근거
|
||||||
|
|
||||||
|
### 문서와 코드
|
||||||
|
|
||||||
|
- 요구사항: `AUDIO-033`, `COMMUNITY-003`, `COMMUNITY-007`, `COMMUNITY-013`, `COMMUNITY-016`, `FILE-009`, `FILE-016`
|
||||||
|
- API Contract: `AudioContentCreateRequest`, `CommunityPostCreateRequest`, `CommunityPostCreateMultipart`
|
||||||
|
- 계획: `P6-R5`, `P6-R6`, `P6-R7`
|
||||||
|
- 코드: `FileField`, `CanPriceField`, `AudioContentForm`, `CommunityPostForm`
|
||||||
|
- 테스트: shared FileField/CAN field, Audio form create/update, Community form/price/contract
|
||||||
|
|
||||||
|
### 실행 환경
|
||||||
|
|
||||||
|
```text
|
||||||
|
OS: Darwin 25.0.0 x86_64
|
||||||
|
Node: v24.12.0
|
||||||
|
npm: 11.7.0
|
||||||
|
Browser/viewport: 실행하지 않음(사용자 지시)
|
||||||
|
환경 변수: Vitest 기본 test 환경, build development/production mode
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행한 검증
|
||||||
|
|
||||||
|
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|
||||||
|
|---|---|---|
|
||||||
|
| `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/community-posts/tests/community-price-validation.test.tsx` (RED) | 의도한 실패 | 음수·소수 6건 inline 오류 누락, 22건 통과 |
|
||||||
|
| 동일 focused 명령 (GREEN) | 성공 | 3 files / 28 tests passed |
|
||||||
|
| `npm run test:run -- src/shared/ui/__tests__/file-field.test.tsx src/shared/ui/__tests__/can-price-field.test.tsx src/features/audio-contents src/features/community-posts` | 성공 | 18 files / 134 tests passed |
|
||||||
|
| `npm run test:run -- src/features/audio-contents/tests/audio-form-create-red.test.tsx` (`P6-R7` RED) | 의도한 실패 | malformed preview 신규 1건 실패, 기존 9건 통과 |
|
||||||
|
| 동일 focused 명령 (`P6-R7` GREEN) | 성공 | 1 file / 10 tests passed |
|
||||||
|
| Audio·Community·shared 회귀 | 성공 | 18 files / 135 tests passed |
|
||||||
|
| `npm run test:run` | 성공 | 83 files / 459 tests passed |
|
||||||
|
| `npm run typecheck` | 성공 | exit 0 |
|
||||||
|
| `npm run lint` | 성공 | exit 0 |
|
||||||
|
| `npm run build:dev` | 성공 | exit 0, 기존 500kB chunk warning |
|
||||||
|
| `npm run build:prod` | 성공 | exit 0, 기존 500kB chunk warning |
|
||||||
|
| `git diff --check` | 성공 | exit 0 |
|
||||||
|
| Browser/E2E | 제외 | 사용자 지시에 따라 실행하지 않음 |
|
||||||
|
|
||||||
|
## 5. 발견 사항 요약
|
||||||
|
|
||||||
|
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `REV-P6-R5-001` | Medium | 수정 완료 | native 가격 constraint가 domain submit validation을 우회함 | `P6-R6` | 완료 |
|
||||||
|
| `REV-P6-R5-002` | Medium | 수정 완료 | Audio preview 시간 검증이 `noValidate`로 무력화됨 | `P6-R7` | 완료 |
|
||||||
|
|
||||||
|
`REV-P6-R5-002` 수정 후 코드-only 재리뷰에서 추가 확정 발견 사항은 없다.
|
||||||
|
|
||||||
|
## 6. 발견 사항 상세
|
||||||
|
|
||||||
|
### REV-P6-R5-001 — native 가격 constraint가 domain submit validation을 우회함
|
||||||
|
|
||||||
|
- **심각도:** Medium
|
||||||
|
- **상태:** 수정 완료
|
||||||
|
- **관련 요구사항:** `COMMUNITY-007`, `P6-R5` Interface의 domain submit validation 소유 계약
|
||||||
|
- **관련 계약:** `CommunityPostCreateRequest.price` `0..99999` integer
|
||||||
|
- **소유 Task:** `P6-R6`
|
||||||
|
|
||||||
|
**관찰 내용**
|
||||||
|
|
||||||
|
`CanPriceField`는 `type=number`, `min=0`, `step=1`을 사용하지만 Audio·Community form에 `noValidate`가 없었다. 실제 submit button click에서는 음수·소수의 native constraint가 submit event를 먼저 차단해 domain inline 오류와 오류 focus가 실행되지 않았다.
|
||||||
|
|
||||||
|
**근거**
|
||||||
|
|
||||||
|
- 코드: `src/shared/ui/can-price-field.tsx`, `src/features/audio-contents/components/AudioContentForm.tsx`, `src/features/community-posts/components/CommunityPostForm.tsx`
|
||||||
|
- 테스트: invalid price test가 `fireEvent.submit(form)`을 직접 호출해 native constraint 경로를 우회했다.
|
||||||
|
- 문서: `plan-task.md` `P6-R5` Interface는 range validation을 domain submit이 소유한다고 명시한다.
|
||||||
|
|
||||||
|
**재현 또는 검증 절차**
|
||||||
|
|
||||||
|
1. Audio 생성·수정 또는 Community 생성 form 가격에 `-1` 또는 `1.5`를 입력한다.
|
||||||
|
2. form event 직접 dispatch가 아니라 실제 `생성` 또는 `저장` button을 클릭한다.
|
||||||
|
3. 수정 전에는 submit handler가 실행되지 않아 domain 가격 오류가 표시되지 않았다.
|
||||||
|
4. 수정 후에는 domain 오류가 표시되고 request가 전송되지 않는다.
|
||||||
|
|
||||||
|
**영향**
|
||||||
|
|
||||||
|
invalid request 자체는 native validation이 막지만, 문서화된 공통 inline 오류·접근성 연결·오류 focus가 음수·소수에서 누락됐다.
|
||||||
|
|
||||||
|
**권장 조치**
|
||||||
|
|
||||||
|
Audio와 Community form에 `noValidate`를 추가하고 invalid price test를 실제 submit button click으로 유지한다.
|
||||||
|
|
||||||
|
**판정 기록**
|
||||||
|
|
||||||
|
- `2026-08-04` — 코드와 실제 submit button 기반 RED에서 확정.
|
||||||
|
- `2026-08-04` — `P6-R6` 최소 수정과 focused/full Gate 통과로 수정 완료.
|
||||||
|
|
||||||
|
### REV-P6-R5-002 — Audio preview 시간 검증이 `noValidate`로 무력화됨
|
||||||
|
|
||||||
|
- **심각도:** Medium
|
||||||
|
- **상태:** 수정 완료
|
||||||
|
- **관련 요구사항:** `AUDIO-033`
|
||||||
|
- **관련 계약:** `AudioContentCreateRequest.previewStartTime`, `previewEndTime`의 nullable `HH:mm:ss`
|
||||||
|
- **소유 Task:** `P6-R7`
|
||||||
|
|
||||||
|
**관찰 내용**
|
||||||
|
|
||||||
|
`P6-R6`에서 Audio form에 추가한 `noValidate`는 가격 constraint뿐 아니라 preview 시작·종료 input의 기존 `pattern` 검증도 해제했다. 수정 전 form domain validation과 request schema는 잘못된 시간 문자열을 거부하지 않았다.
|
||||||
|
|
||||||
|
**근거**
|
||||||
|
|
||||||
|
- 코드: `src/features/audio-contents/components/AudioContentForm.tsx`, `src/features/audio-contents/components/AudioContentCreateOptions.tsx`, `src/features/audio-contents/schemas/audio-content-schema.ts`
|
||||||
|
- 수정 전 테스트: 정상 `HH:MM:SS` 제출과 `pattern` attribute만 확인하며 malformed submit을 검증하지 않았다.
|
||||||
|
- 문서: PRD `AUDIO-033`은 preview 시간 입력을 완전한 `HH:MM:SS`로 제한한다.
|
||||||
|
|
||||||
|
**영향**
|
||||||
|
|
||||||
|
preview 생성이 켜진 Audio 생성에서 malformed preview 시간이 multipart request에 포함될 수 있다.
|
||||||
|
|
||||||
|
**권장 조치**
|
||||||
|
|
||||||
|
`noValidate`는 유지하고 preview 시작·종료 형식을 domain validation에 추가해 inline 오류와 오류 focus를 제공한다. 실제 submit button 기반 malformed 값 회귀 테스트를 유지한다.
|
||||||
|
|
||||||
|
**판정 기록**
|
||||||
|
|
||||||
|
- `2026-08-04` — 독립 코드 재리뷰에서 확정하고 `P6-R7`로 전환.
|
||||||
|
- `2026-08-04` — 실제 submit button RED와 domain inline 오류·focus 최소 수정, focused/full Gate 통과로 수정 완료.
|
||||||
|
|
||||||
|
## 7. 확정 항목의 plan·goal 전환
|
||||||
|
|
||||||
|
- `REV-P6-R5-001`을 `plan-task.md`의 `P6-R6`으로 전환했고 수정·검증을 완료했다.
|
||||||
|
- `REV-P6-R5-002`를 `plan-task.md`의 `P6-R7`으로 전환했고 수정·검증을 완료했다.
|
||||||
|
|
||||||
|
## 8. 리뷰 종료 판정
|
||||||
|
|
||||||
|
| 판정 항목 | 결과 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| 리뷰 범위 전체 확인 | 충족 | staged/working tree 변경과 관련 caller·test 추적 |
|
||||||
|
| 후보 항목 판정 완료 | 충족 | `REV-P6-R5-001`, `REV-P6-R5-002` 확정 |
|
||||||
|
| 확정 항목 plan 반영 | 충족 | `P6-R6`, `P6-R7` |
|
||||||
|
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 항목 없음 |
|
||||||
|
| 검증 명령과 결과 기록 | 충족 | 4절과 `plan-task.md` 진행 기록 |
|
||||||
|
|
||||||
|
**최종 결론:** 수정 검증 완료, 추가 확정 발견 사항 없음.
|
||||||
|
|
||||||
|
**남은 항목:** browser/E2E와 실제 개발 API 수동 QA는 이번 사용자 요청에서 제외했다.
|
||||||
|
|
||||||
|
## 9. 수정 후 검증 기록
|
||||||
|
|
||||||
|
### 1차 수정 검증 — 2026-08-04
|
||||||
|
|
||||||
|
- 무엇을: `REV-P6-R5-001`의 native constraint submit 우회를 수정했다.
|
||||||
|
- 왜: Audio와 Community가 공통 domain 가격 오류·접근성·focus 계약을 동일하게 실행해야 한다.
|
||||||
|
- 어떻게:
|
||||||
|
- RED — 음수·소수 6건 실패, 22건 통과.
|
||||||
|
- GREEN — 3 files / 28 tests passed.
|
||||||
|
- 회귀 — domain 18 files / 134 tests, full 83 files / 458 tests passed.
|
||||||
|
- 정적/build — typecheck·lint·dev/prod build·diff check exit 0.
|
||||||
|
- 남은 항목: 이번 코드-only 리뷰 범위의 확정 발견 사항 없음.
|
||||||
|
|
||||||
|
### 2차 수정 검증 — 2026-08-04
|
||||||
|
|
||||||
|
- 무엇을: `REV-P6-R5-002`의 Audio preview 시간 검증 회귀를 수정했다.
|
||||||
|
- 왜: form-level `noValidate`를 유지하면서 PRD `AUDIO-033`의 완전한 `HH:MM:SS` 계약을 domain submit 경로가 소유해야 한다.
|
||||||
|
- 어떻게:
|
||||||
|
- RED — malformed preview 신규 1건 실패, 기존 9건 통과.
|
||||||
|
- GREEN — 1 file / 10 tests passed, request 0건과 inline 오류·첫 오류 focus 확인.
|
||||||
|
- 회귀 — 관련 18 files / 135 tests, full 83 files / 459 tests passed.
|
||||||
|
- 정적/build — typecheck·lint·dev/prod build exit 0.
|
||||||
|
- 남은 항목: 이번 코드-only 리뷰 범위의 확정 발견 사항 없음.
|
||||||
@@ -4,10 +4,12 @@ import type { AudioContentCreateSettings } from "@/features/audio-contents/compo
|
|||||||
type AudioContentCreateOptionsProps = {
|
type AudioContentCreateOptionsProps = {
|
||||||
readonly isPaid: boolean;
|
readonly isPaid: boolean;
|
||||||
readonly onChange: (value: AudioContentCreateSettings) => void;
|
readonly onChange: (value: AudioContentCreateSettings) => void;
|
||||||
|
readonly previewEndError?: string;
|
||||||
|
readonly previewStartError?: string;
|
||||||
readonly value: AudioContentCreateSettings;
|
readonly value: AudioContentCreateSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AudioContentCreateOptions({ isPaid, onChange, value }: AudioContentCreateOptionsProps) {
|
export function AudioContentCreateOptions({ isPaid, onChange, previewEndError, previewStartError, value }: AudioContentCreateOptionsProps) {
|
||||||
function updatePurchaseOption(purchaseOption: string) {
|
function updatePurchaseOption(purchaseOption: string) {
|
||||||
if (purchaseOption === "BOTH" || purchaseOption === "BUY_ONLY" || purchaseOption === "RENT_ONLY") {
|
if (purchaseOption === "BOTH" || purchaseOption === "BUY_ONLY" || purchaseOption === "RENT_ONLY") {
|
||||||
onChange({ ...value, purchaseOption });
|
onChange({ ...value, purchaseOption });
|
||||||
@@ -30,7 +32,7 @@ export function AudioContentCreateOptions({ isPaid, onChange, value }: AudioCont
|
|||||||
</fieldset>
|
</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.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>
|
<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}
|
{value.isGeneratePreview ? <><label className="flex flex-col gap-2 text-sm font-semibold">미리듣기 시작<input aria-describedby={previewStartError === undefined ? undefined : "audio-content-preview-start-error"} aria-invalid={previewStartError === 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({ ...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>{previewStartError === undefined ? null : <p className="text-sm font-semibold text-destructive" id="audio-content-preview-start-error" role="alert">{previewStartError}</p>}<label className="flex flex-col gap-2 text-sm font-semibold">미리듣기 종료<input aria-describedby={previewEndError === undefined ? undefined : "audio-content-preview-end-error"} aria-invalid={previewEndError === 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({ ...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>{previewEndError === undefined ? null : <p className="text-sm font-semibold text-destructive" id="audio-content-preview-end-error" role="alert">{previewEndError}</p>}</> : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import type { UploadProgressStatus } from "@/shared/ui/upload-progress";
|
|||||||
|
|
||||||
export type UploadAudioContentRequest = <Data>(options: UploadAudioContentOptions<Data>) => Promise<Data>;
|
export type UploadAudioContentRequest = <Data>(options: UploadAudioContentOptions<Data>) => Promise<Data>;
|
||||||
|
|
||||||
type FieldName = "audio" | "cover" | "detail" | "form" | "price" | "releaseDate" | "tags" | "theme" | "title";
|
type FieldName = "audio" | "cover" | "detail" | "form" | "previewEnd" | "previewStart" | "price" | "releaseDate" | "tags" | "theme" | "title";
|
||||||
type FieldErrors = Partial<Record<FieldName, string>>;
|
type FieldErrors = Partial<Record<FieldName, string>>;
|
||||||
|
|
||||||
type UploadState = {
|
type UploadState = {
|
||||||
@@ -51,6 +51,8 @@ const errorIds = {
|
|||||||
title: "audio-content-title-error",
|
title: "audio-content-title-error",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const previewTimePattern = /^\d{2}:\d{2}:\d{2}$/;
|
||||||
|
|
||||||
export function AudioContentForm({ apiClient, audio, characterId, createCropSource, mode, renderCrop, themes, uploadAuth, uploadAudioContentRequest = uploadAudioContent }: { readonly apiClient: ApiClient; readonly audio?: AudioContentDetail; readonly characterId: string; readonly createCropSource: (file: File) => Promise<CropSourceImage>; readonly mode: "create" | "edit"; readonly renderCrop?: (request: CropRenderRequest) => Promise<File>; readonly themes: readonly AudioContentTheme[]; readonly uploadAuth?: UploadAuthDependencies; readonly uploadAudioContentRequest?: UploadAudioContentRequest }) {
|
export function AudioContentForm({ apiClient, audio, characterId, createCropSource, mode, renderCrop, themes, uploadAuth, uploadAudioContentRequest = uploadAudioContent }: { readonly apiClient: ApiClient; readonly audio?: AudioContentDetail; readonly characterId: string; readonly createCropSource: (file: File) => Promise<CropSourceImage>; readonly mode: "create" | "edit"; readonly renderCrop?: (request: CropRenderRequest) => Promise<File>; readonly themes: readonly AudioContentTheme[]; readonly uploadAuth?: UploadAuthDependencies; readonly uploadAudioContentRequest?: UploadAudioContentRequest }) {
|
||||||
const [audioFile, setAudioFile] = useState<File | null>(null);
|
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||||
const [createSettings, setCreateSettings] = useState<AudioContentCreateSettings>(defaultAudioContentCreateSettings);
|
const [createSettings, setCreateSettings] = useState<AudioContentCreateSettings>(defaultAudioContentCreateSettings);
|
||||||
@@ -126,6 +128,8 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
|
|||||||
audio: audioErrorMessage(audioFile, mode),
|
audio: audioErrorMessage(audioFile, mode),
|
||||||
cover: isCoverSubmitBlocked ? "이미지 처리가 끝난 뒤 저장하세요." : coverErrorMessage(coverImage, mode),
|
cover: isCoverSubmitBlocked ? "이미지 처리가 끝난 뒤 저장하세요." : coverErrorMessage(coverImage, mode),
|
||||||
detail: detail.trim().length === 0 ? "상세 설명을 입력하세요." : undefined,
|
detail: detail.trim().length === 0 ? "상세 설명을 입력하세요." : undefined,
|
||||||
|
previewEnd: mode === "create" && createSettings.isGeneratePreview && createSettings.previewEndTime !== null && !previewTimePattern.test(createSettings.previewEndTime) ? "미리듣기 시간은 HH:MM:SS 형식으로 입력하세요." : undefined,
|
||||||
|
previewStart: mode === "create" && createSettings.isGeneratePreview && createSettings.previewStartTime !== null && !previewTimePattern.test(createSettings.previewStartTime) ? "미리듣기 시간은 HH:MM:SS 형식으로 입력하세요." : undefined,
|
||||||
price: parsedPrice === null || parsedPrice < 0 || parsedPrice > CAN_PRICE_MAX || !Number.isInteger(parsedPrice) ? "가격은 0 이상 99,999 이하 정수 캔으로 입력하세요." : undefined,
|
price: parsedPrice === null || parsedPrice < 0 || parsedPrice > CAN_PRICE_MAX || !Number.isInteger(parsedPrice) ? "가격은 0 이상 99,999 이하 정수 캔으로 입력하세요." : undefined,
|
||||||
releaseDate: releaseSchedule.publishMode === "scheduled" && !isFutureLocalDateTime(releaseSchedule.releaseDateTime) ? "미래 Asia/Seoul 시각을 입력하세요." : undefined,
|
releaseDate: releaseSchedule.publishMode === "scheduled" && !isFutureLocalDateTime(releaseSchedule.releaseDateTime) ? "미래 Asia/Seoul 시각을 입력하세요." : undefined,
|
||||||
tags: tags.trim().length === 0 ? "태그를 입력하세요." : undefined,
|
tags: tags.trim().length === 0 ? "태그를 입력하세요." : undefined,
|
||||||
@@ -227,7 +231,7 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
|
|||||||
<h2 className="text-2xl font-bold leading-tight" id="audio-form-title">{mode === "create" ? "오디오 콘텐츠 생성" : "오디오 콘텐츠 수정"}</h2>
|
<h2 className="text-2xl font-bold leading-tight" id="audio-form-title">{mode === "create" ? "오디오 콘텐츠 생성" : "오디오 콘텐츠 수정"}</h2>
|
||||||
<p className="text-sm text-muted-foreground">제목, 상세 설명, 태그는 저장 전 운영 기준에 맞게 검토하세요. 업로드 제한은 안내된 파일 정책을 따릅니다.</p>
|
<p className="text-sm text-muted-foreground">제목, 상세 설명, 태그는 저장 전 운영 기준에 맞게 검토하세요. 업로드 제한은 안내된 파일 정책을 따릅니다.</p>
|
||||||
</div>
|
</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}>
|
<form className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" aria-label={mode === "create" ? "오디오 콘텐츠 생성 입력 화면" : "오디오 콘텐츠 수정 입력 화면"} noValidate 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} />
|
<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>}
|
{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>
|
<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>
|
||||||
@@ -237,7 +241,7 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
|
|||||||
<TagInput error={errors.tags} errorId={errorIds.tags} label="태그" onChange={setTags} value={tags} />
|
<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>}
|
{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>}
|
||||||
<CanPriceField error={errors.price} errorId={errorIds.price} onChange={changePrice} value={price} />
|
<CanPriceField error={errors.price} errorId={errorIds.price} onChange={changePrice} value={price} />
|
||||||
{mode === "create" ? <AudioContentCreateOptions isPaid={isPaid} onChange={setCreateSettings} value={createSettings} /> : null}
|
{mode === "create" ? <AudioContentCreateOptions isPaid={isPaid} onChange={setCreateSettings} previewEndError={errors.previewEnd} previewStartError={errors.previewStart} 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" ? <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>
|
<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>}
|
{errors.form === undefined ? null : <p className="text-sm font-semibold text-destructive" role="alert">{errors.form}</p>}
|
||||||
|
|||||||
@@ -191,6 +191,29 @@ test("AudioContentFormPage reveals preview duration offset inputs only when prev
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("AudioContentFormPage rejects malformed preview offsets through the submit button", async () => {
|
||||||
|
// Given
|
||||||
|
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
|
||||||
|
renderCreateForm(uploadedBodies);
|
||||||
|
await fillRequiredCreateFields();
|
||||||
|
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "1" } });
|
||||||
|
fireEvent.click(screen.getByLabelText("미리듣기 생성"));
|
||||||
|
const previewStart = screen.getByLabelText("미리듣기 시작");
|
||||||
|
const previewEnd = screen.getByLabelText("미리듣기 종료");
|
||||||
|
|
||||||
|
// When
|
||||||
|
fireEvent.change(previewStart, { target: { value: "30" } });
|
||||||
|
fireEvent.change(previewEnd, { target: { value: "01:00" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(await screen.findAllByText("미리듣기 시간은 HH:MM:SS 형식으로 입력하세요.")).toHaveLength(2);
|
||||||
|
expect(previewStart).toHaveAttribute("aria-invalid", "true");
|
||||||
|
expect(previewEnd).toHaveAttribute("aria-invalid", "true");
|
||||||
|
await waitFor(() => expect(previewStart).toHaveFocus());
|
||||||
|
expect(uploadedBodies).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("AudioContentFormPage conditionally renders and clears the native scheduled release datetime", async () => {
|
test("AudioContentFormPage conditionally renders and clears the native scheduled release datetime", async () => {
|
||||||
// Given
|
// Given
|
||||||
renderCreateForm();
|
renderCreateForm();
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function createInactiveFormClient(requests: CapturedRequest[]): ApiClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test.each(["-1", "1.5", "100000"])("AudioContentFormPage rejects invalid edit price %s before update", async (value) => {
|
test.each(["-1", "1.5", "100000"])("AudioContentFormPage rejects invalid edit price %s through the submit button", async (value) => {
|
||||||
// Given
|
// Given
|
||||||
const requests: CapturedRequest[] = [];
|
const requests: CapturedRequest[] = [];
|
||||||
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
||||||
@@ -35,7 +35,7 @@ test.each(["-1", "1.5", "100000"])("AudioContentFormPage rejects invalid edit pr
|
|||||||
// When
|
// When
|
||||||
const priceInput = await screen.findByLabelText("가격");
|
const priceInput = await screen.findByLabelText("가격");
|
||||||
fireEvent.change(priceInput, { target: { value } });
|
fireEvent.change(priceInput, { target: { value } });
|
||||||
fireEvent.submit(screen.getByRole("form", { name: "오디오 콘텐츠 수정 입력 화면" }));
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(priceInput).toHaveValue(Number(value));
|
expect(priceInput).toHaveValue(Number(value));
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ test.each([
|
|||||||
"-1",
|
"-1",
|
||||||
"1.5",
|
"1.5",
|
||||||
"100000",
|
"100000",
|
||||||
])("AudioContentFormPage preserves and rejects invalid price input %s before upload", async (value) => {
|
])("AudioContentFormPage preserves and rejects invalid price input %s through the submit button", async (value) => {
|
||||||
// Given
|
// Given
|
||||||
const requests: CapturedRequest[] = [];
|
const requests: CapturedRequest[] = [];
|
||||||
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
|
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
|
||||||
@@ -182,7 +182,7 @@ test.each([
|
|||||||
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
|
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
|
||||||
await waitFor(() => expect(screen.getByText("cover.png")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("cover.png")).toBeInTheDocument());
|
||||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value } });
|
fireEvent.change(screen.getByLabelText("가격"), { target: { value } });
|
||||||
fireEvent.submit(screen.getByRole("form", { name: "오디오 콘텐츠 생성 입력 화면" }));
|
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(screen.getByLabelText("가격")).toHaveValue(Number(value));
|
expect(screen.getByLabelText("가격")).toHaveValue(Number(value));
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
|
|||||||
<h2 className="text-2xl font-bold leading-tight" id="community-post-form-title">커뮤니티 게시글 생성</h2>
|
<h2 className="text-2xl font-bold leading-tight" id="community-post-form-title">커뮤니티 게시글 생성</h2>
|
||||||
<p className="break-keep text-sm text-muted-foreground">내용과 첨부 파일은 저장 전 운영 기준에 맞게 검토하세요. GIF는 crop 없이 원본을 보존합니다.</p>
|
<p className="break-keep text-sm text-muted-foreground">내용과 첨부 파일은 저장 전 운영 기준에 맞게 검토하세요. GIF는 crop 없이 원본을 보존합니다.</p>
|
||||||
</div>
|
</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}>
|
<form aria-label="커뮤니티 게시글 생성 입력 화면" className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" noValidate 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} />
|
<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} />}
|
{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>
|
<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>
|
||||||
|
|||||||
@@ -31,13 +31,13 @@ test.each([
|
|||||||
"-1",
|
"-1",
|
||||||
"1.5",
|
"1.5",
|
||||||
"100000",
|
"100000",
|
||||||
])("Community price preserves and rejects invalid raw input %s", async (rawValue) => {
|
])("Community price preserves and rejects invalid raw input %s through the submit button", async (rawValue) => {
|
||||||
const requests: CapturedRequest[] = [];
|
const requests: CapturedRequest[] = [];
|
||||||
const onCreated = renderForm(requests);
|
const onCreated = renderForm(requests);
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "가격 검증 게시글" } });
|
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "가격 검증 게시글" } });
|
||||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value: rawValue } });
|
fireEvent.change(screen.getByLabelText("가격"), { target: { value: rawValue } });
|
||||||
fireEvent.submit(screen.getByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" }));
|
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||||
|
|
||||||
expect(screen.getByLabelText("가격")).toHaveValue(Number(rawValue));
|
expect(screen.getByLabelText("가격")).toHaveValue(Number(rawValue));
|
||||||
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
|
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
|
||||||
|
|||||||
Reference in New Issue
Block a user