Compare commits
10 Commits
b01fda4400
...
9e2c910f0a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e2c910f0a | ||
|
|
ca5aa990d3 | ||
|
|
fb9b550040 | ||
|
|
766a06ad0a | ||
|
|
bd37e05d82 | ||
|
|
d030d2eebd | ||
|
|
b62c416fa1 | ||
|
|
a213479e8e | ||
|
|
344674c756 | ||
|
|
8265fe4947 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@
|
||||
.codex
|
||||
.opencode
|
||||
.DS_Store
|
||||
/*.png
|
||||
|
||||
mise.toml
|
||||
|
||||
|
||||
46
DESIGN.md
46
DESIGN.md
@@ -146,17 +146,42 @@ Primary font stack: `Pretendard`, `Noto Sans KR`, `Apple SD Gothic Neo`, `system
|
||||
|
||||
### FileField
|
||||
|
||||
- Structure: controlled `File | null` field with visible label, native file input, accept guidance, selected filename, and clear button.
|
||||
- Variants: domain-neutral only; allowed extensions, MIME, and max bytes are injected by caller policy.
|
||||
- Accessibility: label targets the input; description, accept guidance, and error are connected with `aria-describedby`; clear is a native button.
|
||||
- Structure: controlled `File | null` field with visible label, native file input, accept guidance, selected filename, clear button, and an actual upload preview when the controlled value is an image File.
|
||||
- Variants: image values render an object-contain preview inside a bounded tokenized surface; non-image values retain the filename-only layout. Allowed extensions, MIME, and max bytes are injected by caller policy.
|
||||
- Lifecycle: the field owns only its preview Blob URL and revokes it on value replacement, clear, and unmount; the caller continues to own validation, crop state, and the File itself.
|
||||
- Accessibility: label targets the input; description, accept guidance, and error are connected with `aria-describedby`; the image preview uses the field label in its alt text; clear is a native button.
|
||||
- Motion: none.
|
||||
|
||||
### CanPriceField
|
||||
|
||||
- Structure: controlled numeric CAN price input with a visible label, native number input, visible `단위: 캔` guidance, and an inline error slot.
|
||||
- Contract: callers provide the controlled string and error state; the field preserves the native number input value and renders `min=0`, `step=1`. Domain submit logic rejects blank, negative, decimal, and out-of-range values against the shared `0..99,999` integer schema.
|
||||
- Accessibility: the visible label targets the input, the unit guidance and error are connected with `aria-describedby`, invalid state uses `aria-invalid`, and mobile input text remains 16px.
|
||||
- Motion: none.
|
||||
|
||||
### TagInput
|
||||
|
||||
- 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.
|
||||
- Variants: caller injects `aspect`, `maxWidth`, and `noUpscale`; domain profile names and GIF exceptions stay outside the primitive.
|
||||
- Accessibility: dialog has visible title, keyboard preview controls, range input, and button alternatives. No pointer-only requirement in Phase 1.6.
|
||||
- Motion: transform-only preview adjustment. No crop dependency is added; Canvas is used only when generating the final `File`.
|
||||
- Structure: `react-advanced-cropper` viewport, expected result dimensions, and native reset, cancel, and apply actions form the modal crop surface; pending and error states remain visible while the result is prepared or cannot be generated.
|
||||
- Contract: the caller continues to supply the external `aspect`, `maxWidth`, and `noUpscale` policies; `aspect: 'free'` preserves the source image ratio, and apply produces the final `File` through the existing `renderCrop` seam. Domain profile names and GIF exceptions stay outside the primitive.
|
||||
- Accessibility: the dialog has a visible title and focus trap, Escape cancels it, and the viewport supports keyboard movement and zoom as well as pointer input. Reset, cancel, and apply are native button targets of at least 44px, so cropping is never pointer-only.
|
||||
- Motion: the cropper library owns interaction transitions inside its viewport; the surrounding dialog adds no decorative motion.
|
||||
|
||||
### UploadProgress
|
||||
|
||||
@@ -175,9 +200,12 @@ Primary font stack: `Pretendard`, `Noto Sans KR`, `Apple SD Gothic Neo`, `system
|
||||
|
||||
### AdminAudioPlayer
|
||||
|
||||
- Structure: native audio element wrapped with play/pause, seek, time, volume, speed, generic error, and manual retry controls.
|
||||
- Reference: [Plyr audio](https://plyr.io/#audio) is the primary surface reference; [Media Chrome audio](https://www.media-chrome.org/docs/en/audio-player) supplies the explicit current/duration and playback-rate anatomy. Runtime extraction on 2026-08-03 found 44–52px single-row bars, icon-only media controls, a flexible progress range, compact time/rate values, and no persistent descriptive labels.
|
||||
- Structure: native audio element wrapped by one compact control bar ordered play/pause → flexible seek → current/duration → playback rate → volume icon/range, followed by the existing generic error and manual retry block. No image, poster, video viewport, waveform, or settings menu is rendered.
|
||||
- Surface: `card`, `border`, `input`, `primary`, `primary-foreground`, and existing radius/spacing tokens only. The standard bar is approximately one 44px control high with 4–12px token spacing; remaining inline space goes to seek before volume.
|
||||
- Variants: shared signed-URL player only; it never downloads, autoplays, auto-refetches, or infers signed URL expiry.
|
||||
- Accessibility: player region is named by title, keyboard Space/Enter toggles play, seek/volume use range inputs, speed uses native select.
|
||||
- Responsive: 375px, 768px, and 1280px standard viewports keep a single visual bar. Only constrained containers at 200% zoom may wrap secondary controls; clipping and horizontal overflow are forbidden.
|
||||
- Accessibility: player region is named by title, keyboard Space/Enter toggles play, seek/volume use range inputs, speed uses native select, and icon-only controls retain accessible names. Visible `볼륨` and `재생 속도` labels are omitted to match the compact reference while labels remain available to assistive technology.
|
||||
- Motion: none.
|
||||
|
||||
### AudioPlaybackProvider
|
||||
|
||||
@@ -10,11 +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 `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-26 |
|
||||
| 요구사항 기준 | [prd.md](./prd.md) |
|
||||
| API 기준 | [api-contract.openapi.json](./api-contract.openapi.json) |
|
||||
| 현재 활성 Goal | `P6-R7` 완료 |
|
||||
|
||||
## 목표
|
||||
|
||||
@@ -24,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 이력은 보존 |
|
||||
@@ -74,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
|
||||
|
||||
@@ -82,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가 남아 있다.
|
||||
|
||||
## 최종 보고 형식
|
||||
|
||||
@@ -2438,6 +2439,191 @@ field와 수정 금지 control이 실제 network 요청·화면에 일치하는
|
||||
- REFACTOR/회귀: 기존 success/progress/415/malformed response/cancel retry contract를 유지했다. reviewer blocker로 fetch-first 순서 회귀를 추가한 뒤 `auth` 주입 시 storage fallback을 쓰지 않도록 보완했다. `npm run test:run -- src/shared/api/__tests__/client-auth.test.ts src/features/audio-contents/tests/audio-upload.test.ts src/features/audio-contents/tests/audio-upload-auth-lifecycle.test.ts` — 3 files / 22 tests passed. `npm run test:run -- src/features/audio-contents` — 9 files / 58 tests passed. `npm run test:run -- src/app src/features/auth src/shared/api src/features/audio-contents/tests/audio-upload.test.ts` — 13 files / 91 tests passed. `npm run test:run` — 79 files / 397 tests passed. `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod`, targeted `git diff --check` — 모두 exit 0. build는 기존 503.04kB chunk warning만 표시했다. LSP diagnostics는 변경 파일·디렉터리 기준 오류 0건이었다.
|
||||
- E2E/수동 확인: 사용자 지시에 따라 개발 중 E2E는 반복 실행하지 않고 모든 Task 구현 뒤 필요 시 수행한다.
|
||||
|
||||
### Task R4.9 — 공통 오디오 플레이어 compact UI
|
||||
|
||||
**Goal 실행 `P4-R9`:** 공통 `AdminAudioPlayer`를 image·video 영역 없는 compact audio-only UI로 개선해 오디오 콘텐츠 목록·상세와 커뮤니티 목록·Sheet에 동일하게 적용한다.
|
||||
|
||||
- **Reference packet:** `.omo/evidence/p4-r9-reference/plyr-audio-{375,768,1280}.png`, `.omo/evidence/p4-r9-reference/media-chrome-audio-{375,768,1280}.png`. Plyr의 밝은 52px compact bar를 primary surface로, Media Chrome의 시간·배속·음량 control anatomy를 secondary structure로 사용한다.
|
||||
|
||||
- **시작 조건:** PRD `AUDIO-021`, `AUDIO-026`, `AUDIO-034`와 기존 `AdminAudioPlayer`·`AudioPlaybackProvider` 단일 재생 계약.
|
||||
- **완료 증거:** 아래 RED/GREEN/REFACTOR 체크박스, focused·전체 unit·mock E2E·typecheck·lint·build 통과, Chromium/mobile Chrome 실제 화면 screenshot과 visual QA 판정, QA resource teardown 기록.
|
||||
- **범위 밖:** 새 audio player dependency, waveform, playlist, download control, cover/poster/video viewport, API·DTO·signed URL lifecycle 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/shared/ui/admin-audio-player.tsx`
|
||||
- Modify: `src/shared/ui/__tests__/admin-audio-player.test.tsx`
|
||||
- Verify unchanged: `src/features/audio-contents/components/AudioContentList.tsx`
|
||||
- Verify unchanged: `src/features/audio-contents/components/AudioContentListItem.tsx`
|
||||
- Verify unchanged: `src/features/audio-contents/pages/AudioContentDetailPage.tsx`
|
||||
- Verify unchanged: `src/features/community-posts/components/CommunityPostList.tsx`
|
||||
- Verify unchanged: `src/features/community-posts/components/CommunityPostListItem.tsx`
|
||||
- Verify unchanged: `src/features/community-posts/components/CommunityPostSheet.tsx`
|
||||
- Test: `src/features/audio-contents/tests/audio-list.test.tsx`
|
||||
- Test: `src/features/audio-contents/tests/audio-player.test.tsx`
|
||||
- Test: `src/features/community-posts/tests/community-list.test.tsx`
|
||||
- Test: `src/features/community-posts/tests/community-sheet.test.tsx`
|
||||
- E2E: `tests/e2e/audio-content.spec.ts`
|
||||
- E2E: `tests/e2e/community.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `AdminAudioPlayerProps { playerId, src, title }`, `useAudioPlayback`, native `HTMLAudioElement` events.
|
||||
- Produces: 기존 props와 재생 동작을 바꾸지 않는 compact audio-only control; `재생 진행`과 `재생 설정` accessible group.
|
||||
|
||||
**시나리오 계약:**
|
||||
|
||||
| 시나리오 | 이진 통과 조건 | 실제 surface | 자동 test |
|
||||
|---|---|---|---|
|
||||
| Happy path | 오디오 목록에서 재생 버튼을 누르면 버튼 이름이 `일시정지`로 바뀌고, 상시 설명 label 없는 재생·진행 slider·현재/전체 시간·배속·음량 control이 표준 viewport에서 Plyr/Media Chrome처럼 한 줄에 표시된다. | mock Chromium `/ai-characters/101/audio-contents`에서 `page.click({ name: "재생" })` 후 reference-fidelity screenshot | `src/shared/ui/__tests__/admin-audio-player.test.tsx`의 `AdminAudioPlayer matches the compact reference control anatomy` |
|
||||
| Edge | 320px viewport와 200% zoom에서 player의 `scrollWidth <= clientWidth`이고 모든 control이 viewport 안에 있다. | mock mobile Chrome 목록·상세 screenshot과 overflow probe | `tests/e2e/audio-content.spec.ts`의 기존 320px·200% zoom player 시나리오 |
|
||||
| Adjacent regression | 커뮤니티 목록과 Sheet가 같은 player 구조를 사용하고 두 player 중 새 항목 재생 시 기존 항목이 pause되며 오류·수동 재시도 계약이 유지된다. | mock Chromium `/ai-characters/101/community-posts` 목록·Sheet 재생과 screenshot | `src/shared/ui/__tests__/admin-audio-player.test.tsx`, `src/features/community-posts/tests/community-sheet.test.tsx`, `tests/e2e/community.spec.ts` |
|
||||
|
||||
**TDD 절차:**
|
||||
|
||||
- [x] **RED: 실패 테스트 작성/실패 확인** — `src/shared/ui/__tests__/admin-audio-player.test.tsx`에 다음 reference-anatomy test를 작성한다.
|
||||
|
||||
production code를 수정하기 전에 mock Chromium의 오디오 콘텐츠 목록·상세와 커뮤니티 목록·Sheet 현재 화면을 reference screenshot으로 저장한다.
|
||||
|
||||
```tsx
|
||||
test("AdminAudioPlayer matches the compact reference control anatomy", () => {
|
||||
render(<AdminAudioPlayer playerId="one" src="https://cdn.example.com/audio.m4a" title="샘플 오디오" />);
|
||||
|
||||
const player = screen.getByRole("group", { name: "샘플 오디오 오디오 플레이어" });
|
||||
const controls = within(player).getByRole("group", { name: "재생 제어" });
|
||||
expect(within(controls).getByRole("button", { name: "재생" })).toBeInTheDocument();
|
||||
expect(within(controls).getByRole("slider", { name: "재생 위치" })).toBeInTheDocument();
|
||||
expect(within(controls).getByRole("combobox", { name: "재생 속도" })).toBeInTheDocument();
|
||||
expect(within(controls).getByRole("slider", { name: "볼륨" })).toBeInTheDocument();
|
||||
expect(within(player).queryByText("볼륨")).not.toBeInTheDocument();
|
||||
expect(within(player).queryByText("재생 속도")).not.toBeInTheDocument();
|
||||
expect(player.querySelector("img")).not.toBeInTheDocument();
|
||||
expect(player.querySelector("video")).not.toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
`npm run test:run -- src/shared/ui/__tests__/admin-audio-player.test.tsx -t "compact reference control anatomy"` 실행 시 `재생 제어` group을 찾지 못해 실패해야 한다.
|
||||
|
||||
- [x] **GREEN: 최소 구현/통과 확인** — `src/shared/ui/admin-audio-player.tsx`의 기존 state·handler·native `<audio>`는 유지하고 반환 JSX만 다음 구조로 교체한다.
|
||||
|
||||
```tsx
|
||||
<section aria-label={`${title} 오디오 플레이어`} className="flex min-w-0 flex-col gap-2 rounded-lg border border-border bg-card px-1 py-1 sm:px-3" onKeyDown={handleKeyDown} role="group" tabIndex={0}>
|
||||
<audio controlsList="nodownload" onDurationChange={(event) => setDuration(event.currentTarget.duration)} onEnded={() => { setIsPlaying(false); clearPlayer(); }} onError={() => { setHasError(true); setIsPlaying(false); }} onPause={() => setIsPlaying(false)} onPlay={() => setIsPlaying(true)} onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)} preload="metadata" ref={audioRef} src={src} />
|
||||
<div aria-label="재생 제어" className="flex min-w-0 flex-wrap items-center gap-1 sm:gap-2" role="group">
|
||||
<button aria-label={isPlaying ? "일시정지" : "재생"} className="grid size-11 shrink-0 place-items-center rounded-full border border-input bg-primary text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={togglePlay} type="button">
|
||||
<svg aria-hidden="true" className="size-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
{isPlaying ? <path d="M7 5h4v14H7zm6 0h4v14h-4z" /> : <path d="m8 5 11 7-11 7z" />}
|
||||
</svg>
|
||||
</button>
|
||||
<input aria-label="재생 위치" className="h-11 min-w-11 flex-1 accent-primary" max={duration || 0} min="0" onChange={(event) => changeCurrentTime(Number(event.currentTarget.value))} step="1" type="range" value={currentTime} />
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">{formatTime(currentTime)}/{formatTime(duration)}</span>
|
||||
<select aria-label="재생 속도" className="min-h-11 w-11 shrink-0 rounded-md border border-input bg-card px-1 text-center text-base font-semibold sm:text-sm" defaultValue="1" onChange={(event) => changePlaybackRate(Number(event.currentTarget.value))}>
|
||||
<option value="0.75">0.75×</option><option value="1">1×</option><option value="1.25">1.25×</option><option value="1.5">1.5×</option><option value="2">2×</option>
|
||||
</select>
|
||||
<svg aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" fill="currentColor" viewBox="0 0 24 24"><path d="M4 9v6h4l5 4V5L8 9zm11.5 3a3.5 3.5 0 0 0-1.5-2.87v5.74A3.5 3.5 0 0 0 15.5 12Zm-1.5-7.46v2.06a6 6 0 0 1 0 10.8v2.06a8 8 0 0 0 0-14.92Z" /></svg>
|
||||
<input aria-label="볼륨" className="h-11 min-w-11 basis-11 shrink grow-0 accent-primary" defaultValue="1" max="1" min="0" onChange={(event) => changeVolume(Number(event.currentTarget.value))} step="0.05" type="range" />
|
||||
</div>
|
||||
{hasError ? <div className="flex min-w-0 flex-col gap-2 rounded-md border border-destructive bg-card p-3 text-sm text-destructive" role="alert"><p className="break-words">오디오를 재생할 수 없습니다. 페이지 새로고침 후 다시 시도하세요.</p><button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={retry} type="button">오디오 다시 시도</button></div> : null}
|
||||
</section>
|
||||
```
|
||||
|
||||
같은 focused 명령이 `exit 0`이고 신규 test가 통과해야 한다.
|
||||
|
||||
- [x] **REFACTOR: 정리/회귀 확인** — event handler나 player context를 추출하지 않고 JSX의 반복 class와 접근성 이름만 점검한다. `src/shared/ui/admin-audio-player.tsx`를 250 pure LOC 이하로 유지하고 기존 signed URL 비기록, 단일 재생, keyboard, seek, volume, speed, 오류·수동 재시도 test를 모두 통과시킨다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:**
|
||||
|
||||
```bash
|
||||
npm run test:run -- src/shared/ui/__tests__/admin-audio-player.test.tsx src/features/audio-contents/tests/audio-list.test.tsx src/features/audio-contents/tests/audio-player.test.tsx src/features/community-posts/tests/community-list.test.tsx src/features/community-posts/tests/community-sheet.test.tsx
|
||||
npm run test:run
|
||||
npm run e2e:mock -- tests/e2e/audio-content.spec.ts tests/e2e/community.spec.ts
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run build:dev
|
||||
npm run build:prod
|
||||
git diff --check -- src/shared/ui/admin-audio-player.tsx src/shared/ui/__tests__/admin-audio-player.test.tsx docs/20260725_AI캐릭터관리자웹/prd.md docs/20260725_AI캐릭터관리자웹/plan-task.md
|
||||
```
|
||||
|
||||
- **기대 결과:** 모든 명령 `exit 0`; 신규 compact group test와 기존 전체 unit·Chromium/mobile Chrome mock E2E 통과; 변경 TSX LSP diagnostics 오류 0건; 다운로드 control·image·video·waveform 0개.
|
||||
- **수동 확인:** `npm run dev:mock`을 실행하고 Playwright로 오디오 콘텐츠 목록·상세, 커뮤니티 목록·Sheet를 desktop 1280px와 mobile 320px에서 연다. 재생, seek, 볼륨, 배속, 단일 재생, 오류·재시도를 조작하고 각 surface screenshot을 저장한다. 200% zoom에서 clipping·overflow를 검사하고 `visual-qa`의 pixel diff 및 디자인 시스템/기능 무결성·시각 충실도/CJK 판정을 모두 통과시킨다.
|
||||
- **QA teardown:** Playwright browser/context를 닫고 mock dev server PID를 종료한 뒤 8889 port listener가 없음을 확인해 Progress에 기록한다.
|
||||
- [x] RED·GREEN·REFACTOR, browser artifact 경로, visual QA 판정, teardown 결과를 이 Task 하단에 누적한다.
|
||||
|
||||
**전체 중단 조건:** `P4-R9`의 세 시나리오가 RED→GREEN 및 실제 Chromium/mobile Chrome surface에서 통과하고, 전체 unit·mock E2E·typecheck·lint·build가 green이며 LSP 오류와 QA resource가 0개일 때 즉시 종료한다.
|
||||
|
||||
**P4-R9 구현·검증 기록 — 2026-08-03:**
|
||||
|
||||
- **RED:** 첫 reference-anatomy test는 `재생 제어` group 부재로 `1 failed / 5 skipped`였다. 후속 폭·접근성 RED는 volume의 `hidden` class, seek/rate/volume 최소 폭·font·basis, 표준 viewport 단일 행 폭 예산 assertion이 각각 기존 구현에서 실패했다.
|
||||
- **GREEN:** native `<audio>`와 기존 상태·handler는 유지하고 Plyr의 밝은 compact surface와 Media Chrome의 명시적 time/rate/volume anatomy를 semantic token 기반 live DOM으로 구현했다. 최종 focused player test는 `1 passed / 5 skipped`, player 전체는 `6 passed`였다.
|
||||
- **REFACTOR:** 새 dependency·consumer 변경·상태 abstraction 없이 class와 accessible name만 정리했다. `AdminAudioPlayer`는 250 pure LOC 이하이며 변경 TSX 2개 LSP diagnostics는 오류 0건이다.
|
||||
- **자동 검증:** focused `5 files / 19 tests`, 전체 `81 files / 426 tests`, `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod`, `git diff --check`가 모두 exit 0이었다. 두 build에는 기존 500kB chunk warning만 남았다.
|
||||
- **브라우저 V3:** `npm run e2e:mock -- tests/e2e/audio-content.spec.ts tests/e2e/community.spec.ts`는 Chromium/mobile Chrome에서 `25 passed / 1 intentional skip / 0 failed`였다. `.omo/evidence/p4-r9-v3/`의 player 16개·context 16개·상태 3개 fresh PNG에서 375/768/1280의 12개 표준 조합은 모두 54–55px 단일 행, 320px/200% 4개 조합은 모든 control을 유지한 채 overflow 없이 wrap했다.
|
||||
- **기능·시각 판정:** 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
|
||||
@@ -2699,6 +2885,30 @@ reorder를 확인한다. genre lookup과 상세 수정용 원본값 제공 후
|
||||
- **GREEN:** `SeriesForm`에 image selection token, `isImagePreparing`, 준비/crop 중 submit guard, preview reject inline 오류를 추가했다. `image`는 crop 적용 결과만 commit하고, 수정 화면 crop 취소는 기존 서버 image 유지 계약을 보존한다. focused 재실행 결과 `series-form.test.tsx`와 `series-form-crop.test.tsx` 2 files / 13 tests passed였다.
|
||||
- **REFACTOR/회귀:** crop lifecycle test를 `series-form-crop.test.tsx`로 분리해 `SeriesForm.tsx` 220 LOC, `series-form.test.tsx` 230 LOC, `series-form-crop.test.tsx` 85 LOC로 유지했다. `npm run test:run -- src/features/series` 결과 8 files / 37 tests passed였다. 사용자 지시에 따라 mock E2E는 전체 Task 완료 전까지 보류했다. `npm run typecheck`, `npm run lint`, `npm run build`, `git diff --check`는 모두 exit 0 또는 no output이었다. Series directory LSP diagnostics는 17 TSX files / 오류 0건이었다.
|
||||
|
||||
### Task R5.6 — Series 생성 form 이미지·keyword 입력 UX 정렬
|
||||
|
||||
**Goal 실행 `P5-R6`:** Series 생성 form의 image 입력을 최상단에 배치하고 Audio tags와 동일한 공용 `TagInput`으로 keyword를 입력하되 기존 comma-separated request 계약을 유지한다.
|
||||
|
||||
- **시작 조건:** `P4-R10`, `P5-R5`, `SERIES-014`, 사용자 승인 설계.
|
||||
- **완료 증거:** image-first field order와 Enter/comma chip·remove 동작의 실패 test, 공용 `TagInput` 재사용, Series comma-separated `keyword` request, Audio tag 회귀, Chromium mock browser와 시각 QA 증거.
|
||||
- **Files:** Move/Modify: `src/features/audio-contents/components/AudioTagInput.tsx` → `src/shared/ui/tag-input.tsx`; Modify: `src/features/audio-contents/components/AudioContentForm.tsx`, `src/features/series/components/SeriesForm.tsx`, `src/features/series/tests/series-form.test.tsx`, `src/shared/ui/__tests__/tag-input.test.tsx`, `tests/e2e/series.spec.ts`, `DESIGN.md`, `docs/20260725_AI캐릭터관리자웹/prd.md`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`.
|
||||
- **Interfaces:** `TagInput({label,value,onChange,error,errorId})`는 comma-separated string을 consume/emit하고 Enter/comma로 non-empty trimmed chip을 확정하며 remove button으로 한 chip만 삭제한다. Series create는 `keyword`, Audio create/update는 `tags` field명을 그대로 유지한다.
|
||||
- **범위 밖:** Series 수정·상세 keyword, duplicate/max-count 정책, API schema·endpoint·multipart part 변경, 새 dependency.
|
||||
|
||||
- [x] **RED:** Series image-first order와 keyword chip→`"달빛,상담"` request, 공용 input의 blank/remove 동작 실패 test를 작성하고 의도한 실패를 확인했다.
|
||||
- [x] **GREEN:** `AudioTagInput`을 공용 `TagInput`으로 이름·위치를 정리하고 Audio/Series에서 재사용하며 Series `FileField`를 form 첫 field로 이동했다.
|
||||
- [x] **REFACTOR:** feature 간 역참조와 중복 chip 구현이 없는지 확인하고 focused·전체 회귀, typecheck·lint·build, Chromium mock E2E와 시각 QA를 실행했다.
|
||||
- **실행 명령:** `npm run test:run -- src/shared/ui/__tests__/tag-input.test.tsx src/features/series/tests/series-form.test.tsx src/features/audio-contents/tests/audio-form.test.tsx`; `npm run test:run`; `npm run typecheck`; `npm run lint`; `npm run build:prod`; `npm run e2e:mock -- tests/e2e/series.spec.ts --project=chromium`.
|
||||
- **기대 결과:** 모든 명령 exit 0, Series image-first order와 `keyword="달빛,상담"`, Audio `tags="상담,힐링"`, 빈 chip 0건, 삭제 대상 외 chip 손실 0건, API 계약 변경 0건.
|
||||
- **수동 확인:** Chromium 1280px Series 생성 화면에서 image가 첫 입력이고 keyword chip 추가·삭제 및 Korean text clipping/overflow가 없는지 확인한다.
|
||||
|
||||
**P5-R6 완료 Progress — 2026-08-04:**
|
||||
|
||||
- **RED:** shared `TagInput` import 부재, Series image가 title 뒤에 있는 DOM 순서, plain keyword input이 첫 값을 덮어 `"상담"`만 보내는 실패를 확인했다. validation focus 정렬은 image 대신 title이 focus되는 실패로, FileField visible focus는 `focus-within` ring class 부재 실패로 각각 고정했다.
|
||||
- **GREEN:** `AudioTagInput`을 `src/shared/ui/tag-input.tsx`의 공용 `TagInput`으로 이동·일반화하고 Audio `tags`와 Series `keyword`가 같은 chip UI를 사용하게 했다. Series image를 form 첫 field로 이동하고 request는 `keyword: "달빛,상담"` comma-separated string을 유지했다. image-first validation focus가 보이도록 공용 `FileField` 카드에 기존 ring token의 `focus-within` 상태만 추가했다.
|
||||
- **REFACTOR/회귀:** focused 3 files / 20 tests와 FileField 포함 focused 3 files / 15 tests를 통과했다. 최종 `npm run test:run`은 82 files / 437 tests, `npm run typecheck`, `npm run lint`, `npm run build:prod`, `git diff --check`는 모두 통과했고 build에는 기존 500kB chunk warning만 남았다. `npm run e2e:mock -- tests/e2e/audio-content.spec.ts tests/e2e/series.spec.ts --project=chromium`은 13 tests passed였다.
|
||||
- **수동·시각 QA:** Playwright Chromium 768×900·1280×900에서 image-first, Enter/comma chip 2개, remove 후 나머지 값, 빈 draft, 가로 overflow 0건을 확인했다. validation 후 sr-only file input focus와 visible cyan FileField ring을 768×900에서 확인했다. 독립 visual functional/CJK reviewer와 고엄격 diff reviewer의 최종 판정은 모두 PASS/APPROVE, blocker 0건이었다. Mock Preview sticky banner 아래로 static header가 이동한 프레임은 `scrollY=47`의 정상 문서 스크롤로 측정했고 `scrollY=0`에서 동일 focus/ring과 온전한 header를 재확인했다.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6. Community vertical slice
|
||||
@@ -2908,6 +3118,85 @@ focus가 Sheet trigger로 복귀하는지 본다.
|
||||
- 리뷰어 게이트: 1차 review에서 width 판정 전 release 순서 blocker를 지적받아 보완했고, delta review에서 `APPROVED` 판정을 받았다.
|
||||
- E2E/수동: 개발 중 E2E와 반복 GIF browser 수동 확인은 사용자 지시에 따라 전체 Task 구현 후 필요 시 수행한다.
|
||||
|
||||
### Task R6.5 — 업로드 이미지 미리보기와 Community 생성 media flow 정렬
|
||||
|
||||
**Goal 실행 `P6-R5`:** 공통 `FileField`에 실제 업로드 이미지 미리보기를 제공하고 Community 생성 화면을 이미지 → 조건부 오디오 → 내용 순서와 Audio 동일 가격 계약으로 정렬한다.
|
||||
|
||||
- **시작 조건:** PRD `COMMUNITY-003`, `COMMUNITY-007`, `COMMUNITY-013`, `COMMUNITY-016`, `FILE-016`; 사용자 승인 설계.
|
||||
- **완료 증거:** 이미지 preview URL 생성·교체·해제, 비이미지 filename-only, Community media 순서·audio 조건부 표시/제거, 기본 가격 0·blank 거부·항상 price 전송의 RED/GREEN, Audio 가격 회귀, Chromium mock browser와 visual QA.
|
||||
- **Files:** Create: `src/shared/ui/can-price-field.tsx`, `src/shared/ui/__tests__/can-price-field.test.tsx`; Modify: `src/app/protected-admin-shell.tsx`, `src/app/App.protected-shell.test.tsx`, `src/shared/ui/file-field.tsx`, `src/shared/ui/page-state.tsx`, `src/shared/ui/__tests__/file-field.test.tsx`, `src/shared/ui/__tests__/page-state.test.tsx`, `src/features/audio-contents/components/AudioContentForm.tsx`, `src/features/audio-contents/components/audio-content-form-helpers.ts`, `src/features/audio-contents/tests/audio-form-create-red.test.tsx`, `src/features/audio-contents/tests/audio-form-update.test.tsx`, `src/features/audio-contents/tests/audio-form.test.tsx`, `src/features/community-posts/components/CommunityPostForm.tsx`, `src/features/community-posts/components/community-post-form-helpers.ts`, `src/features/community-posts/tests/community-form.test.tsx`, `src/features/community-posts/tests/community-price-validation.test.tsx`, `src/features/community-posts/tests/community-contract.test.ts`, `tests/e2e/community.spec.ts`, `DESIGN.md`, `docs/20260725_AI캐릭터관리자웹/prd.md`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`.
|
||||
- **Interfaces:** `FileField`는 image File일 때만 object URL preview를 소유하고 교체·제거·unmount에서 revoke한다. `CanPriceField({value,onChange,error,errorId})`는 Audio와 Community의 native number/min=0/step=1/단위·오류 연결을 공유하고 range validation은 domain submit이 소유한다. Community는 최종 `postImage`가 있을 때만 audio input을 렌더하며 image 제거·교체 시작 시 `audioFile`을 제거한다.
|
||||
- **범위 밖:** Community 수정 Sheet, multipart append 순서, API schema의 optional price, 비이미지 preview, 새 dependency, GIF resize·crop·re-encode.
|
||||
|
||||
- [x] **RED:** `FileField previews image values and revokes object URLs on replacement, clear, and unmount`; `FileField keeps non-image values filename-only without creating an object URL`을 실패시킨다.
|
||||
- [x] **GREEN:** image value에만 bounded object-contain preview를 렌더하고 URL lifecycle을 `FileField` 한 곳에서 소유한다.
|
||||
- [x] **RED:** `CanPriceField renders the Audio numeric CAN price contract`; `CanPriceField connects its inline error and emits Audio-compatible values`를 import 부재로 실패시킨다.
|
||||
- [x] **GREEN:** 공통 numeric CAN field를 추가하고 Audio inline price markup을 교체하되 paid option reset과 request를 보존한다.
|
||||
- [x] **RED:** Community 최종 preview/media-first order, image 전 audio 부재·image 변경 시 stale audio 제거, 기본 0·blank 거부·항상 price 전송을 실패시킨다.
|
||||
- [x] **GREEN:** Community form state·render order·request를 최소 수정하고 multipart serializer 순서는 변경하지 않는다.
|
||||
- [x] **REFACTOR:** 불필요해진 domain price formatter wrapper를 제거하고 shared/domain focused test, full Gate, 375/768/1280 browser·visual QA와 독립 review를 통과한다.
|
||||
- **실행 명령:** `npm run test:run -- src/shared/ui/__tests__/file-field.test.tsx src/shared/ui/__tests__/can-price-field.test.tsx`; `npm run test:run -- src/features/audio-contents src/features/community-posts`; `npm run test:run`; `npm run typecheck`; `npm run lint`; `npm run build:dev`; `npm run build:prod`; `npm run e2e:mock -- tests/e2e/community.spec.ts`; `git diff --check`.
|
||||
- **기대 결과:** 모든 명령 exit 0, crop/GIF 적용 전 최종 preview 0건, image 없는 audio input·stale audio multipart 0건, Community valid request의 price 누락 0건, Audio 가격 회귀 0건, object URL 미해제 0건.
|
||||
- **수동 확인:** mock mode 375px read-only 정책, 768px·1280px Community 생성의 initial/crop/applied/replacement/error 상태를 keyboard·가로 overflow·Korean text 기준으로 확인하고 QA browser/server/temp fixture를 모두 정리한다. 내부 관리자 화면이므로 별도 browser zoom 검증은 제외한다.
|
||||
|
||||
**P6-R5 진행 기록 (2026-08-04):**
|
||||
|
||||
- RED/GREEN: FileField preview·Blob URL·native input 동기화 11개, 공용 CAN field와 Audio/Community 음수·소수·상한·blank·`00` zero reset, Community media 순서·조건부 audio·stale state·request 회귀를 실패 확인 후 최소 수정했다.
|
||||
- 자동 Gate: `npm run test:run` — 83 files / 458 tests passed; `npm run lint`, `npm run typecheck`, `npm run build:dev`, `npm run build:prod`, `git diff --check` — exit 0. build의 500kB chunk warning은 기존 비차단 경고다.
|
||||
- 브라우저: `npm run e2e:mock -- tests/e2e/community.spec.ts` — Chromium/mobile Chrome 14/14 passed. 375px mobile read-only, 768px·1280px final GIF preview·conditional audio·제거·blank 가격·keyboard·overflow·axe를 확인했다. 사용자 결정에 따라 browser zoom 검증은 제외했다.
|
||||
- 리뷰: 목표·품질 review에서 문서 추적성과 Audio raw `00` zero reset blocker를 발견했다. `parsePrice(value) === 0`으로 보완해 Audio focused 9 files / 69 tests를 통과했고, 문서 상태·파일 지도·Decision Log를 정렬했다.
|
||||
- 최종 browser/visual: 768px·1280px Chromium/mobile Chrome에서 PNG crop/applied/replacement/error와 기존 GIF/audio/price 상태를 포함해 Community E2E 14/14를 통과했다. 4개 project/viewport evidence directory의 32개 표준 배율 PNG를 기능·디자인 시스템 및 CJK reviewer가 전수 확인해 PASS/HIGH, blocker 0건으로 판정했다. `캔`은 PRD의 정상 CAN 단위이며, 사용자 결정에 따라 browser zoom 검증은 제외했다.
|
||||
|
||||
### 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
|
||||
@@ -4478,7 +4767,7 @@ critical·serious 0건과 mock/server 상태 분리를 확인한다. E2E용 ADMI
|
||||
| 1 | `AUTH-001~013`, `UX-001~002`, `FILE`의 domain-neutral component mechanics, §7, §10 공통, §11.1~11.2, §12 | `src/shared`, `src/features/auth`, `tests/e2e/auth.spec.ts` |
|
||||
| 2 | `MOCK-001~009`, §11.1~11.2, §12~13 | `src/shared/mocks`, `tests/e2e/mock-preview-shell.spec.ts` |
|
||||
| 3 | `CHAR-001~018`, Character 관련 `FILE`, `MOCK`, §7, §9 | `src/features/characters`, `tests/e2e/character-workspace.spec.ts` |
|
||||
| 4 | `AUDIO-001~033`, Audio 관련 `FILE`, `MOCK`, §9 | `src/features/audio-contents`, `tests/e2e/audio-content.spec.ts` |
|
||||
| 4 | `AUDIO-001~034`, Audio 관련 `FILE`, `MOCK`, §9 | `src/features/audio-contents`, `tests/e2e/audio-content.spec.ts` |
|
||||
| 5 | `SERIES-001~018`, Series 관련 `FILE`, `MOCK`, §9 | `src/features/series`, `tests/e2e/series.spec.ts` |
|
||||
| 6 | `COMMUNITY-001~015`, Community 관련 `FILE`, `MOCK`, §9 | `src/features/community-posts`, `tests/e2e/community.spec.ts` |
|
||||
| 7 | `FANTALK-001~011`, `MOCK`, §9 | `src/features/fan-talks`, `tests/e2e/fan-talk.spec.ts` |
|
||||
@@ -4494,7 +4783,7 @@ critical·serious 0건과 mock/server 상태 분리를 확인한다. E2E용 ADMI
|
||||
|---|---|---|
|
||||
| `AUTH-001~013`, PRD `14.1` 로그인·세션·로그아웃 | Phase 1 Gate, `tests/e2e/auth.spec.ts`, P9 server-boundary 재검증 | 활성 범위 검증 완료 |
|
||||
| `CHAR-001~018`와 Character image/file 기준 | Phase 3 Gate, `tests/e2e/character-workspace.spec.ts`, P9 resource/error regression, P10-T1 | 기존 mock UI 완료. `EXT-001`, `EXT-007` 해결; v2 원작 선택기는 P10-T1에서 완료, active-only server 검증은 P10-GATE에서 분리 |
|
||||
| `AUDIO-001~033`와 Audio file/player 기준 | Phase 4 focused/mock 기록, `tests/e2e/audio-content.spec.ts`, P9 resource/error regression, P10-T2 | 기존 mock UI와 UTC 전송·`timezone` 제거 client/mock 완료. 실제 개발 API Audio 수동 QA는 별도 대기 |
|
||||
| `AUDIO-001~034`와 Audio file/player 기준 | Phase 4 focused/mock 기록, `tests/e2e/audio-content.spec.ts`, P9 resource/error regression, P10-T2, `P4-R9` | 기존 mock UI와 UTC 전송·`timezone` 제거 client/mock 완료. compact audio-only 공용 player UI는 `P4-R9`에서 구현·V3 시각 QA 완료했고 실제 개발 API Audio 수동 QA는 별도 대기 |
|
||||
| `SERIES-001~018` | Phase 5 focused/mock 기록, `tests/e2e/series.spec.ts`, P9 resource/error regression, P10-T3 | 연결·순서와 v2 장르·CRUD mock/client 완료. server Series E2E는 로그인 fixture 차단으로 P10-GATE에서 분리 |
|
||||
| `COMMUNITY-001~015` | Phase 6 focused/mock 기록, `tests/e2e/community.spec.ts`, P9 resource/error regression, P10-T4 | 기존 mock UI와 pagination object·`timezone` 제거 client/mock 완료. 실제 개발 API Community 수동 QA는 별도 대기 |
|
||||
| `FANTALK-001~012` | Phase 7 focused/mock 기록, `tests/e2e/fan-talk.spec.ts`, P9 resource/error regression, P10-T5 | 목록·답변 생성·답변 수정·팬 원글 삭제 mock/client 완료. 실제 개발 API FanTalk 수동 QA는 별도 대기 |
|
||||
@@ -5821,6 +6110,15 @@ critical·serious 0건과 mock/server 상태 분리를 확인한다. E2E용 ADMI
|
||||
- 판정: 마지막 marker 문자열을 첫 동일 occurrence로 다시 찾는 `REV-P10-018` Low를 확정해 신규 `P10-R17`로 전환했다. 나머지 finding/checklist/H2/top-tail 동기화에는 신규 문제가 없었다.
|
||||
- 남은 항목: `P10-R17`, 실제 crop pixel 비교, stale ADMIN server 확인, 실제 개발 API Series/FanTalk/Comments/file policy 수동 QA. Chromium/mobile Chrome 지원 범위만 유지한다.
|
||||
|
||||
**ImageCropDialog advanced cropper 전환 검증 기록 — 2026-08-04:**
|
||||
|
||||
- 무엇을: 공통 `ImageCropDialog`의 custom 이동·zoom UI를 `react-advanced-cropper@0.20.1`로 교체하고, 기존 `aspect`, `maxWidth`, `noUpscale`, `renderCrop` 계약과 pending/error/single-flight 처리를 유지했다. 사용자의 명시적 결정에 따라 별도 방향·zoom 버튼은 제거하고 pointer와 keyboard 조작만 유지하도록 PRD `FILE-008`과 UI 원칙을 정렬했다.
|
||||
- TDD/회귀: viewport·aspect·keyboard·null coordinates·orientation request·focus wrapper를 각각 RED/GREEN으로 확인했다. 독립 리뷰가 제기한 비중앙 좌표 이중 축척 후보는 `scalePreviewOffset`이 원본 크기가 아니라 `baseWidth`/`baseHeight`를 사용함을 확인했고, off-center 1:1과 210:297 좌표를 `calculateCropSourceRect`로 되돌리는 회귀 2건을 추가해 선택 source rect가 일치함을 검증했다. 적용 중 Escape가 disabled 취소 버튼을 우회하는 RED도 재현해 pending 동안 Escape를 무시하도록 정렬했다.
|
||||
- 자동 검증: `npm run test:run`은 82 files / 438 tests passed, `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod`, `git diff --check`는 exit 0 또는 no output이었다. dev/prod build는 309 modules, JS 597.35kB(gzip 158.54~158.55kB)와 기존 500kB chunk warning을 기록했다.
|
||||
- 브라우저 QA: `npm run e2e:mock -- tests/e2e/series.spec.ts --project=chromium`은 7 passed였다. mock UI 768/1280px에서 pointer drag, 방향키, `+`/`-`, 초기화, 취소/Escape, focus trap, 적용과 가로 overflow 0을 확인했고, 2400×1804 입력은 210:297 비율의 1000×1414px PNG로 생성됐다. 375px Series 생성은 기존 모바일 read-only 정책에 따라 crop form을 노출하지 않는다.
|
||||
- 공급망: `npm audit --omit=dev`는 vulnerability 0건이다. 전체 `npm audit`의 high 1건은 ESLint가 사용하는 dev-only `brace-expansion@5.0.8` 경로이며 production cropper dependency에는 포함되지 않는다.
|
||||
- 남은 항목: 실제 개발 API 파일 업로드 수동 QA는 기존 server integration 대기로 유지한다. 375px Series 안내의 기존 오탈자·줄바꿈은 crop surface 밖의 별도 UI 정리 범위다.
|
||||
|
||||
**P10-R17 수정 검증 기록 — 2026-08-01:**
|
||||
|
||||
- 무엇을: 동일 제목·날짜 Progress marker가 반복돼도 마지막 occurrence의 record를 선택하도록 보완했다.
|
||||
|
||||
@@ -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,13 +248,19 @@ 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`이 없다. 따라서 공개 예약·테마·오디오 원본 변경은 생성 화면에서만 제공하고 수정 화면에서는 읽기 전용으로 표시한다.
|
||||
|
||||
#### 관리자 오디오 플레이어
|
||||
|
||||
- 재생/일시정지, 탐색, 현재/전체 시간, 볼륨, 배속을 제공한다.
|
||||
- native `<audio>`와 현재 재생 상태·오류 처리 로직은 유지하고, [Plyr audio](https://plyr.io/#audio)의 밝은 compact bar를 1차 시각 기준, [Media Chrome audio](https://www.media-chrome.org/docs/en/audio-player)의 명시적 시간·배속 anatomy를 보조 기준으로 사용한다.
|
||||
- 표준 viewport의 기본 배치는 원형 재생 버튼, 유동형 재생 위치 slider, 현재/전체 시간, compact 배속 select, 음량 icon과 slider 순서의 단일 control bar다. `볼륨`, `재생 속도` 같은 설명 label은 화면에 상시 노출하지 않고 accessible name으로 제공한다.
|
||||
- 200% zoom처럼 실제 player 폭이 control 최소 폭보다 작을 때만 secondary control을 다음 줄로 보내며, control이 잘리거나 가로 overflow가 생기지 않아야 한다.
|
||||
- 플레이어 내부에는 cover image, poster, video viewport를 표시하지 않는다. 오디오 콘텐츠 cover와 게시물 media는 기존 화면 영역에서만 표시한다.
|
||||
- 새 audio player library, waveform, playlist와 download control은 추가하지 않는다.
|
||||
- 명시적인 다운로드 버튼은 제공하지 않는다.
|
||||
- 여러 행의 플레이어가 동시에 재생되지 않게 현재 재생 항목을 단일화한다.
|
||||
- 재생 오류는 원인을 signed URL 만료로 구분하지 않고 “오디오를 재생할 수 없습니다”와 수동 재시도·페이지 새로고침 안내를 표시한다.
|
||||
@@ -279,7 +285,7 @@ AI 캐릭터를 생성하고, AI 캐릭터가 사람 크리에이터처럼 콘
|
||||
| SERIES-011 | 확정 | 장르 목록 endpoint는 검색·페이지 query 없이 활성 장르 전체를 반환한다. 생성 초기 state의 정확한 기본값은 프론트엔드 의존사항이 아니다. |
|
||||
| SERIES-012 | 확정 | 시리즈 목록 API는 `isActive=true`인 항목만 반환한다. 프론트엔드는 활성 상태 query나 client-side filter를 추가하지 않는다. |
|
||||
| SERIES-013 | 확정 | 시리즈 soft delete 성공 시 선택 캐릭터의 시리즈 목록으로 이동해 재조회하고 성공 알림을 표시한다. 현재 상세 화면에 머물지 않으며 서버의 active-only 목록에서 비활성 항목이 제외돼야 한다. |
|
||||
| SERIES-014 | 확정 | 생성 multipart의 `image`와 `request`는 필수다. 생성 request는 `keyword` 단일 문자열을 사용하며 `keywords` 배열을 보내지 않는다. |
|
||||
| SERIES-014 | 확정 | 생성 multipart의 `image`와 `request`는 필수다. 생성 화면은 image 입력을 폼 최상단에 두고 keyword를 Enter/comma로 확정·삭제하는 chip UI로 제공한다. 생성 request는 chip 값을 comma-separated `keyword` 단일 문자열로 보내며 `keywords` 배열을 보내지 않는다. |
|
||||
| SERIES-015 | 확정 | 목록과 상세는 동일한 `SeriesListItem` schema를 사용하고 `genreId`, enum 배열 `publishedDaysOfWeek`, enum `state`를 반환한다. 화면 label은 클라이언트에서 표시용으로 변환하되 원본 enum과 ID를 수정 payload에 사용한다. |
|
||||
| SERIES-016 | 확정 | 연결 후보는 `GET .../contents/search?search_word=...`, 연결은 `{ "contentIdList": [...] }`, 해제는 body 없는 DELETE를 사용한다. |
|
||||
| SERIES-017 | 확정 | 시리즈 상세 응답은 목록 item과 동일한 수정용 원본값 `genreId`, enum `publishedDaysOfWeek`, enum `state`를 제공하므로 별도 edit DTO가 필요 없다. 수정 화면은 상세 응답으로 기존 선택값을 초기화하고, 장르 API는 option 목록 표시용으로 호출한다. |
|
||||
@@ -299,19 +305,20 @@ AI 캐릭터를 생성하고, AI 캐릭터가 사람 크리에이터처럼 콘
|
||||
|---|---|---|
|
||||
| COMMUNITY-001 | 확정 | 선택 캐릭터의 게시글 목록 기반 조회·등록·수정·고정·비활성화를 제공한다. |
|
||||
| COMMUNITY-002 | 확정 | 생성 요청에는 `isActive`를 보내지 않는다. |
|
||||
| COMMUNITY-003 | 확정 | 이미지와 오디오 파일은 선택 첨부다. |
|
||||
| COMMUNITY-003 | 확정 | 이미지와 오디오 파일은 선택 첨부다. 생성 UI는 게시글 이미지, 오디오 파일, 내용 순서로 배치하고, 오디오 파일은 적용 완료된 이미지가 있을 때만 표시·선택할 수 있다. 이미지를 제거하거나 새 이미지 선택을 시작하면 기존 오디오 선택도 제거한다. |
|
||||
| COMMUNITY-004 | 확정 | 첨부 오디오가 있으면 목록 행/카드와 게시글 Sheet에서 재생할 수 있다. |
|
||||
| COMMUNITY-005 | 확정 | 일반 수정 요청에서는 `isActive`를 생략하고 soft delete 요청에만 `isActive=false`를 보낸다. `isActive=true`는 전송하지 않는다. |
|
||||
| COMMUNITY-006 | 확정 | soft delete request에는 `isActive=false`와 `isFixed=false`를 함께 보낸다. 현 목록 응답에는 `fixedAtUtc`가 없으므로 해당 field를 DTO·UI에 만들지 않는다. |
|
||||
| COMMUNITY-007 | 확정 | 가격은 오디오와 동일하게 `0..99999` 정수 “캔” 단위를 사용한다. |
|
||||
| COMMUNITY-007 | 확정 | 가격은 오디오와 동일하게 기본값 `0`, 빈 값 불가, `0..99999` 정수 “캔” 단위를 사용한다. 생성 UI는 가격을 항상 request에 포함한다. |
|
||||
| COMMUNITY-008 | 확정 | 커뮤니티 목록 API는 `isActive=true`인 게시글만 반환한다. request에는 활성 상태 query를 추가하지 않고 item에도 client-side 활성 filter를 적용하지 않는다. |
|
||||
| COMMUNITY-009 | 확정 | 커뮤니티 전용 상세 GET과 상세·수정 직접 route를 추가하지 않는다. 목록 응답으로 행/카드의 Sheet를 열어 조회·수정·고정·비활성화·댓글 진입을 제공한다. |
|
||||
| COMMUNITY-010 | 확정 | 커뮤니티 게시글 soft delete 성공 시 열린 Sheet를 닫고 목록을 무효화·재조회하며 성공 알림을 표시한다. 서버의 active-only 목록에서 해당 게시글이 제외돼야 한다. |
|
||||
| COMMUNITY-011 | 확정 | 첨부 audio URL 갱신만을 위한 자동 요청은 하지 않으며 media error도 refetch trigger로 사용하지 않는다. 사용자 페이지 새로고침이나 mutation 후 cache 무효화 등 일반 목록 재조회가 발생하면 새 응답의 `audioUrl`을 사용한다. |
|
||||
| COMMUNITY-012 | 확정 | 목록 GET은 `timezone` 없이 `page`, `size`를 사용하고 `data.totalCount`, `data.page`, `data.size`, `data.hasNext`, `data.items[]`를 소비한다. 전체 건수와 다음 page 여부는 서버 metadata를 그대로 사용한다. |
|
||||
| COMMUNITY-013 | 확정 | 생성 multipart는 optional `audioFile`, optional `postImage`, 필수 `request`를 사용하고 request에 필수 `content`, `isCommentAvailable`, `isAdult`와 optional `price`만 보낸다. |
|
||||
| COMMUNITY-013 | 확정 | 생성 multipart는 optional `audioFile`, optional `postImage`, 필수 `request`를 사용한다. OpenAPI의 `price`는 optional이지만 생성 UI는 필수 `content`, `isCommentAvailable`, `isAdult`, `price`를 항상 보내며 기본 가격은 `0`이다. |
|
||||
| COMMUNITY-014 | 확정 | 수정 multipart는 optional `postImage`와 필수 `request`만 허용한다. 수정에서 가격·첨부 audio 교체는 제공하지 않고, 고정은 `isFixed`, soft delete는 `isActive=false`로 처리한다. |
|
||||
| COMMUNITY-015 | 확정 | 생성·수정·고정·soft delete 성공은 `data=null`이므로 목록을 무효화·재조회하고 mutation 응답에 게시글 DTO가 있다고 가정하지 않는다. |
|
||||
| COMMUNITY-016 | 확정 | 생성 화면은 crop 적용 또는 GIF 검증이 끝난 실제 업로드 대상 이미지의 미리보기를 파일명과 함께 표시한다. crop 전 원본이나 준비 중 이미지를 최종 미리보기로 표시하지 않는다. |
|
||||
|
||||
### 8.6 FanTalk
|
||||
|
||||
@@ -350,18 +357,19 @@ AI 캐릭터를 생성하고, AI 캐릭터가 사람 크리에이터처럼 콘
|
||||
| FILE-001 | 확정 | 캐릭터·오디오 cover·시리즈·커뮤니티 image의 최대 크기는 `10,485,760 bytes` 이하다. `10,485,761 bytes`부터 거부한다. |
|
||||
| FILE-002 | 확정 | 기본 image 형식은 JPEG(`.jpg`/`.jpeg`, `image/jpeg`)와 PNG(`.png`, `image/png`)다. WebP 등 다른 형식은 허용하지 않는다. |
|
||||
| FILE-003 | 확정 | GIF(`.gif`, `image/gif`)는 커뮤니티 image에서만 허용한다. 캐릭터·시리즈·오디오 cover에서는 거부한다. |
|
||||
| FILE-004 | 확정 | 커뮤니티 JPEG/PNG image는 자유 aspect ratio로 크롭하며 결과의 최대 가로 폭은 800px, 세로는 선택한 crop ratio에 따라 결정한다. |
|
||||
| FILE-004 | 확정 | 커뮤니티 JPEG/PNG image는 원본 aspect ratio를 유지해 크롭하며 결과의 최대 가로 폭은 800px, 세로는 원본 ratio에 따라 결정한다. |
|
||||
| FILE-005 | 확정 | 시리즈 image는 `210:297` 세로형 고정 aspect ratio로 크롭하며 결과의 최대 가로 폭은 1,000px다. |
|
||||
| FILE-006 | 확정 | 오디오 콘텐츠 cover는 `1:1` 고정 aspect ratio로 크롭하며 결과의 최대 가로 폭은 800px다. |
|
||||
| FILE-007 | 확정 | 커뮤니티 JPEG/PNG·시리즈·오디오 콘텐츠에서 새 image를 선택하면 업로드 전에 crop UI를 반드시 거친다. 커뮤니티 GIF는 예외다. |
|
||||
| FILE-008 | 확정 | crop UI는 이동, 확대/축소, 초기화, 결과 미리보기, 취소, 적용을 제공한다. drag/pinch만 강제하지 않고 키보드와 버튼 대안을 제공한다. |
|
||||
| FILE-009 | 확정 | optional 교체 파일 미전송은 기존 media 유지다. crop 취소도 기존 media를 변경하지 않는다. 기존 media 자체 제거는 별도 remove contract가 없어 범위 밖이다. |
|
||||
| FILE-008 | 확정 | crop UI는 pointer drag/pinch와 keyboard 방향키·확대/축소, 초기화, 결과 미리보기, 취소, 적용을 제공한다. cropper가 직접 조작을 제공하므로 별도 방향·zoom 버튼은 두지 않는다. |
|
||||
| FILE-009 | 확정 | 수정 화면에서 optional 교체 파일 미전송은 서버에 저장된 기존 media 유지다. 수정 crop 취소도 저장된 기존 media를 변경하지 않으며 기존 media 자체 제거는 별도 remove contract가 없어 범위 밖이다. Community 생성 폼의 아직 저장되지 않은 로컬 이미지는 `COMMUNITY-003`에 따라 새 이미지 선택 시작 시 제거되고 crop 취소 후 복원하지 않는다. |
|
||||
| FILE-010 | 확정 | 캐릭터 image는 JPEG/PNG만 허용하고 `1:1` 고정 aspect ratio로 크롭하며 결과의 최대 가로·세로는 800px다. |
|
||||
| FILE-011 | 확정 | 커뮤니티 GIF는 crop하지 않는다. crop Dialog를 열지 않고 원본 비율과 animation을 유지한 File을 등록한다. |
|
||||
| FILE-012 | 확정 | JPEG/PNG crop 결과는 선택된 원본 crop 영역의 pixel 크기보다 확대하지 않는다. resource별 800px/1,000px 값은 최대 출력 폭이며 작은 원본은 가능한 원본 크기로 출력한다. |
|
||||
| FILE-013 | 확정 | 커뮤니티 첨부 audio는 오디오 콘텐츠와 동일하게 MP3(`.mp3`, `audio/mpeg`), AAC(`.aac`, `audio/aac`), M4A(`.m4a`, `audio/mp4` 또는 `audio/x-m4a`), 최대 `1,024,000,000 bytes`, 재생 길이 제한 없음 정책을 사용하고 WAV는 거부한다. `audio/x-m4a`는 `.m4a`와 실제 container/codec 검증이 일치할 때만 허용한다. |
|
||||
| FILE-014 | 확정 | 커뮤니티 GIF의 원본 가로가 800px을 초과하면 등록을 거부한다. client에서 제출 전에 차단하고 server도 같은 제한을 검증한다. GIF를 축소·crop·재인코딩하지 않는다. |
|
||||
| FILE-015 | 확정 | Series crop 결과의 세로 pixel은 `round(width × 297 ÷ 210)`으로 계산한다. 최대 폭에서는 1,000×1,414px이며 비율 검증은 계산된 세로값 기준 1px 이내 오차를 허용한다. |
|
||||
| FILE-016 | 확정 | 공통 FileField는 controlled value가 이미지 File이면 실제 업로드 대상의 미리보기를 표시하고 교체·제거·unmount 시 preview Blob URL을 해제한다. 오디오 등 비이미지 File은 기존 파일명 표시를 유지한다. |
|
||||
|
||||
### 8.9 개발 전용 Mock Preview
|
||||
|
||||
@@ -518,7 +526,7 @@ AI 캐릭터를 생성하고, AI 캐릭터가 사람 크리에이터처럼 콘
|
||||
- 비활성화처럼 영향이 큰 동작은 Switch가 아니라 AlertDialog를 사용한다.
|
||||
- icon-only 버튼에는 `aria-label`과 Tooltip을 제공한다.
|
||||
- 모바일의 보조 작업은 DropdownMenu 또는 Drawer에 배치하되 핵심 답변·댓글 동작은 한 번에 찾을 수 있어야 한다.
|
||||
- crop Dialog는 pointer drag와 pinch/zoom을 지원하되 이동·확대·축소·초기화를 실행하는 명시적 버튼과 keyboard 조작도 제공한다.
|
||||
- crop Dialog는 pointer drag와 pinch/zoom, 방향키와 `+`/`-` keyboard 조작, 초기화를 제공하며 별도 방향·zoom 버튼은 두지 않는다.
|
||||
- crop frame, preview, 적용/취소 control은 tablet touch target 44×44px 이상과 보이는 label 또는 accessible name을 가진다.
|
||||
|
||||
### 10.5 화면 상태
|
||||
@@ -866,6 +874,7 @@ vertical slice와 남은 수동 QA에서 검증한다. `EXT-006`은 현재 확
|
||||
- `prefers-reduced-motion`에서 불필요한 transition이 제거된다.
|
||||
- 모든 핵심 route의 axe 기반 자동 검사에서 critical·serious 접근성 위반이 0건이다.
|
||||
- `ui-ux-pro-max`의 loading, reduced motion, z-index, touch 검증 항목을 확인한다.
|
||||
- 공통 오디오 플레이어가 오디오 콘텐츠 목록·상세와 커뮤니티 목록·Sheet에서 동일한 compact audio-only UI로 표시되고, 320px와 200% zoom에서도 핵심 control이 가려지거나 가로 overflow를 만들지 않는다.
|
||||
|
||||
## 15. Open Questions와 결정 절차
|
||||
|
||||
@@ -928,3 +937,7 @@ vertical slice와 남은 수동 QA에서 검증한다. `EXT-006`은 현재 확
|
||||
| 2026-07-29 | `EXT-009`의 가격 범위 `0..99999`를 Audio 생성·수정과 Community 생성 OpenAPI request schema, 요구사항, Phase 10 경계 test에 동일하게 적용한다. |
|
||||
| 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는 하나의 선택 카드 시각 문법을 사용한다. |
|
||||
| 2026-08-04 | Series 생성 form은 image 입력을 최상단에 배치하고 keyword를 Audio tags와 같은 공용 `TagInput` chip UI로 입력한다. Enter/comma로 확정하고 remove button으로 삭제하며 server request는 기존 comma-separated `keyword` 문자열 계약을 유지한다. |
|
||||
| 2026-08-04 | Community 생성 form은 최종 적용 이미지 preview를 표시하고 이미지 → 조건부 오디오 → 내용 → 가격 순서로 배치한다. 새 이미지 선택 시작 시 아직 저장되지 않은 기존 로컬 이미지와 오디오를 제거하며 crop 취소 후 로컬 이미지를 복원하지 않는다. 가격은 Audio와 같은 기본 0·blank/음수/소수/상한 거부 계약으로 항상 request에 포함한다. 내부 관리자 화면이므로 P6-R5 browser QA에서 별도 zoom 검증은 제외하고 375/768/1280 표준 viewport를 확인한다. |
|
||||
|
||||
@@ -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 리뷰 범위의 확정 발견 사항 없음.
|
||||
42
package-lock.json
generated
42
package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"react": "19.2.8",
|
||||
"react-advanced-cropper": "0.20.1",
|
||||
"react-dom": "19.2.8",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -2407,6 +2408,19 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/advanced-cropper": {
|
||||
"version": "0.17.1",
|
||||
"resolved": "https://registry.npmjs.org/advanced-cropper/-/advanced-cropper-0.17.1.tgz",
|
||||
"integrity": "sha512-Z1P0sYOXa2tqZjeY742QtNERofXh1AuOa27LEurO9rbx3IfzLrGQlzy7sWEc5VN9hRg+J/qCiMmnB6tUDLb1TA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
"npm": ">=5"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
@@ -2588,6 +2602,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cli-width": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
|
||||
@@ -4186,6 +4206,24 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-advanced-cropper": {
|
||||
"version": "0.20.1",
|
||||
"resolved": "https://registry.npmjs.org/react-advanced-cropper/-/react-advanced-cropper-0.20.1.tgz",
|
||||
"integrity": "sha512-Pcmkv0xQMpig6+LkM+zLbEuqBbYG3+CwXvIfYU+LDNn9l8t91Jm0fp9MSTNW0pjIvT6frAGTfmlnvnZW4PEs7Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"advanced-cropper": "~0.17.1",
|
||||
"classnames": "^2.2.6",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
"npm": ">=5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||
@@ -4589,9 +4627,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"react": "19.2.8",
|
||||
"react-advanced-cropper": "0.20.1",
|
||||
"react-dom": "19.2.8",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { http, HttpResponse } from "msw";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { App } from "./App";
|
||||
import { apiBaseUrl, saveAdminSession, useAiCharactersFailure, useAiCharactersResponse } from "./app-test-support";
|
||||
import { apiBaseUrl, saveAdminSession, useAiCharacterDetailResponse, useAiCharactersFailure, useAiCharactersResponse } from "./app-test-support";
|
||||
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
|
||||
import { UNKNOWN_API_ERROR_MESSAGE } from "@/shared/api/api-error";
|
||||
import { server } from "@/shared/test/server";
|
||||
@@ -142,7 +142,7 @@ test("retries a protected route 404 and reveals the shell only after the current
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
render(<App />);
|
||||
const alert = await screen.findByRole("alert");
|
||||
const retryButton = screen.getByRole("button", { name: "보호 route 다시 시도" });
|
||||
const retryButton = screen.getByRole("button", { name: "관리자 권한 다시 확인" });
|
||||
retryButton.focus();
|
||||
expect(alert).toHaveTextContent("없습니다.");
|
||||
expect(retryButton).toHaveFocus();
|
||||
@@ -153,7 +153,7 @@ test("retries a protected route 404 and reveals the shell only after the current
|
||||
const finishRetry = await retryReady;
|
||||
|
||||
expect(requestCount).toBe(2);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("관리자 권한 확인 중");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
|
||||
finishRetry();
|
||||
@@ -170,37 +170,40 @@ test("keeps retry available and the protected shell hidden when a network retry
|
||||
}));
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
render(<App />);
|
||||
const retryButton = await screen.findByRole("button", { name: "보호 route 다시 시도" });
|
||||
const retryButton = await screen.findByRole("button", { name: "관리자 권한 다시 확인" });
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(UNKNOWN_API_ERROR_MESSAGE);
|
||||
expect(requestCount).toBe(1);
|
||||
|
||||
fireEvent.click(retryButton);
|
||||
|
||||
await waitFor(() => expect(requestCount).toBe(2));
|
||||
expect(await screen.findByRole("button", { name: "보호 route 다시 시도" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("button", { name: "관리자 권한 다시 확인" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(UNKNOWN_API_ERROR_MESSAGE);
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clears a previous protected route verification before the same session re-enters the route", async () => {
|
||||
test("reuses successful protected route verification when the same session re-enters", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
const verificationRequests: Request[] = [];
|
||||
useAiCharactersResponse(200, (request) => verificationRequests.push(request));
|
||||
useAiCharacterDetailResponse("101");
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
await waitFor(() => expect(verificationRequests).toHaveLength(2));
|
||||
window.history.pushState({}, "", "/login");
|
||||
fireEvent.popState(window);
|
||||
await waitFor(() => expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument());
|
||||
useAiCharactersFailure(404);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
window.history.pushState({}, "", "/ai-characters/101/edit");
|
||||
fireEvent.popState(window);
|
||||
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "AI 캐릭터 수정" })).toBeInTheDocument();
|
||||
expect(verificationRequests).toHaveLength(2);
|
||||
expect(screen.getByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps the protected shell hidden while a stale ADMIN probe is pending and then denied", async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { http, HttpResponse } from "msw";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { App } from "./App";
|
||||
import { apiBaseUrl, installDesktopMediaQuery, requireElement, saveAdminSession, useAiCharactersResponse } from "./app-test-support";
|
||||
import { apiBaseUrl, installDesktopMediaQuery, requireElement, saveAdminSession, useAiCharacterDetailResponse, useAiCharactersResponse } from "./app-test-support";
|
||||
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
|
||||
import { server } from "@/shared/test/server";
|
||||
|
||||
@@ -29,7 +29,7 @@ test("renders the protected admin shell for an existing ADMIN session", async ()
|
||||
expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
|
||||
expect(requests[0]?.url).toContain("size=20");
|
||||
expect(screen.getByRole("link", { name: "본문으로 건너뛰기" })).toHaveAttribute("href", "#app-main");
|
||||
expect(screen.getByRole("banner")).toBeInTheDocument();
|
||||
expect(screen.getByRole("banner")).toHaveClass("flex-wrap", "py-2");
|
||||
expect(screen.getByRole("button", { name: "모바일 메뉴 열기" })).toHaveClass("whitespace-nowrap");
|
||||
expect(screen.getByRole("navigation", { name: "브레드크럼" })).toHaveClass("whitespace-nowrap");
|
||||
expect(screen.getByRole("button", { name: "로그아웃" })).toHaveClass("whitespace-nowrap");
|
||||
@@ -41,6 +41,26 @@ test("renders the protected admin shell for an existing ADMIN session", async ()
|
||||
await waitFor(() => expect(requests).toHaveLength(2));
|
||||
});
|
||||
|
||||
test("reuses successful ADMIN verification during protected intra-app navigation", async () => {
|
||||
saveAdminSession();
|
||||
const verificationRequests: Request[] = [];
|
||||
useAiCharactersResponse(200, (request) => verificationRequests.push(request));
|
||||
useAiCharacterDetailResponse("101");
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
await screen.findByRole("main", { name: "AI 캐릭터 관리" });
|
||||
await waitFor(() => expect(verificationRequests).toHaveLength(2));
|
||||
|
||||
window.history.pushState({}, "", "/ai-characters/101/edit");
|
||||
fireEvent.popState(window);
|
||||
|
||||
expect(screen.getByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: "관리자 권한 확인 중" })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "AI 캐릭터 수정" })).toBeInTheDocument();
|
||||
expect(verificationRequests).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("shows an accessible status while the initial protected route probe is pending", async () => {
|
||||
saveAdminSession();
|
||||
let resolveProbeReady: (finishProbe: () => void) => void = () => undefined;
|
||||
@@ -59,7 +79,7 @@ test("shows an accessible status while the initial protected route probe is pend
|
||||
render(<App />);
|
||||
const finishProbe = await probeReady;
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("관리자 권한 확인 중");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
|
||||
finishProbe();
|
||||
@@ -84,7 +104,7 @@ test("keeps malformed protected routes behind the ADMIN probe", async () => {
|
||||
render(<App />);
|
||||
const finishProbe = await probeReady;
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("관리자 권한 확인 중");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
finishProbe();
|
||||
expect(await screen.findByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
|
||||
@@ -122,7 +142,7 @@ test.each([
|
||||
render(<App />);
|
||||
const finishProbe = await probeReady;
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("관리자 권한 확인 중");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
|
||||
finishProbe();
|
||||
|
||||
@@ -22,13 +22,6 @@ const sessionExpiredNotice = "세션이 만료되었습니다. 다시 로그인
|
||||
type ProtectedRouteError = {
|
||||
readonly message: string;
|
||||
readonly session: NonNullable<ReturnType<typeof useAuthSession>["session"]>;
|
||||
readonly routeVisitKey: number;
|
||||
readonly protectedRouteRetryKey: number;
|
||||
};
|
||||
|
||||
type ProtectedRouteVerification = {
|
||||
readonly session: ProtectedRouteError["session"];
|
||||
readonly routeVisitKey: number;
|
||||
readonly protectedRouteRetryKey: number;
|
||||
};
|
||||
|
||||
@@ -44,7 +37,7 @@ function ProtectedRouteErrorPage({ message, onRetry }: { readonly message: strin
|
||||
onClick={onRetry}
|
||||
type="button"
|
||||
>
|
||||
보호 route 다시 시도
|
||||
관리자 권한 다시 확인
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
@@ -74,7 +67,7 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
|
||||
);
|
||||
const location = useBrowserLocation();
|
||||
const [routeError, setRouteError] = useState<ProtectedRouteError | null>(null);
|
||||
const [verifiedProtectedRouteSession, setVerifiedProtectedRouteSession] = useState<ProtectedRouteVerification | null>(null);
|
||||
const [verifiedProtectedRouteSession, setVerifiedProtectedRouteSession] = useState<ProtectedRouteError["session"] | null>(null);
|
||||
const [protectedRouteRetryKey, setProtectedRouteRetryKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -84,13 +77,12 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
|
||||
}, [auth.session, location.path]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAiCharactersRoute(location.path) || auth.session === null) {
|
||||
if (!isAiCharactersRoute(location.path) || auth.session === null || verifiedProtectedRouteSession === auth.session) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let isCurrent = true;
|
||||
const session = auth.session;
|
||||
const routeVisitKey = location.visitKey;
|
||||
const currentProtectedRouteRetryKey = protectedRouteRetryKey;
|
||||
void protectedRouteApiClient
|
||||
.request({
|
||||
@@ -101,7 +93,7 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
|
||||
.then(() => {
|
||||
if (isCurrent) {
|
||||
setRouteError(null);
|
||||
setVerifiedProtectedRouteSession({ session, routeVisitKey, protectedRouteRetryKey: currentProtectedRouteRetryKey });
|
||||
setVerifiedProtectedRouteSession(session);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -115,9 +107,8 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
|
||||
}
|
||||
|
||||
setRouteError({
|
||||
message: error instanceof ApiError ? error.message : "보호 route 확인에 실패했습니다.",
|
||||
message: error instanceof ApiError ? error.message : "관리자 권한을 확인하지 못했습니다.",
|
||||
session,
|
||||
routeVisitKey,
|
||||
protectedRouteRetryKey: currentProtectedRouteRetryKey,
|
||||
});
|
||||
});
|
||||
@@ -125,7 +116,7 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [auth.session, location.path, location.visitKey, protectedRouteApiClient, protectedRouteRetryKey]);
|
||||
}, [auth.session, location.path, protectedRouteApiClient, protectedRouteRetryKey, verifiedProtectedRouteSession]);
|
||||
|
||||
if (location.path === routePaths.login) {
|
||||
return (
|
||||
@@ -154,22 +145,15 @@ function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
|
||||
}
|
||||
|
||||
const currentRouteError =
|
||||
routeError?.session === auth.session &&
|
||||
routeError.routeVisitKey === location.visitKey &&
|
||||
routeError.protectedRouteRetryKey === protectedRouteRetryKey
|
||||
routeError?.session === auth.session && routeError.protectedRouteRetryKey === protectedRouteRetryKey
|
||||
? routeError.message
|
||||
: null;
|
||||
|
||||
if (
|
||||
isAiCharactersRoute(location.path) &&
|
||||
(verifiedProtectedRouteSession?.session !== auth.session ||
|
||||
verifiedProtectedRouteSession.routeVisitKey !== location.visitKey ||
|
||||
verifiedProtectedRouteSession.protectedRouteRetryKey !== protectedRouteRetryKey)
|
||||
) {
|
||||
if (isAiCharactersRoute(location.path) && verifiedProtectedRouteSession !== auth.session) {
|
||||
return currentRouteError === null ? (
|
||||
<RouteFrame apiMode={apiMode}>
|
||||
<main className="min-h-[100dvh] bg-background p-4 text-foreground">
|
||||
<PageState state="loading" title="보호 route 확인 중" description="관리자 권한을 확인하는 동안 잠시 기다려 주세요." />
|
||||
<PageState state="loading" title="관리자 권한 확인 중" description="관리자 권한을 확인하는 동안 잠시 기다려 주세요." />
|
||||
</main>
|
||||
</RouteFrame>
|
||||
) : (
|
||||
|
||||
@@ -167,7 +167,7 @@ export function ProtectedAdminShell({ apiClient, apiMode, routeError }: { readon
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex min-h-14 items-center justify-between gap-3 border-b border-border bg-card px-4" role="banner">
|
||||
<header className="flex min-h-14 flex-wrap items-center justify-between gap-3 border-b border-border bg-card px-4 py-2" role="banner">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
aria-expanded={isMobileMenuOpen}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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 previewEndError?: string;
|
||||
readonly previewStartError?: string;
|
||||
readonly value: AudioContentCreateSettings;
|
||||
};
|
||||
|
||||
export function AudioContentCreateOptions({ isPaid, onChange, previewEndError, previewStartError, 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 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}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,9 @@ import { routePaths } from "@/app/route-paths";
|
||||
import { createAudioContentCreateBody, deactivateAudioContent, updateAudioContent } from "@/features/audio-contents/api/audio-content-api";
|
||||
import { uploadAudioContent } from "@/features/audio-contents/api/upload-audio-content";
|
||||
import type { UploadAudioContentOptions, UploadAuthDependencies } from "@/features/audio-contents/api/upload-audio-content";
|
||||
import { audioErrorMessage, coverErrorMessage, createInitialPrice, defaultAudioContentCreateSettings, formatPrice, isFutureLocalDateTime, parsePrice, toCreateRequest, toUpdateRequest } from "@/features/audio-contents/components/audio-content-form-helpers";
|
||||
import { audioErrorMessage, coverErrorMessage, createInitialPrice, defaultAudioContentCreateSettings, isFutureLocalDateTime, parsePrice, toCreateRequest, toUpdateRequest } from "@/features/audio-contents/components/audio-content-form-helpers";
|
||||
import type { AudioContentCreateSettings } from "@/features/audio-contents/components/audio-content-form-helpers";
|
||||
import { AudioContentCreateOptions } from "@/features/audio-contents/components/AudioContentCreateOptions";
|
||||
import { AudioContentThemeSelect } from "@/features/audio-contents/components/AudioContentThemeSelect";
|
||||
import { ReleaseScheduleField } from "@/features/audio-contents/components/ReleaseScheduleField";
|
||||
import type { ReleaseScheduleValue } from "@/features/audio-contents/components/ReleaseScheduleField";
|
||||
@@ -20,9 +21,11 @@ import type { CropRenderRequest } from "@/shared/lib/crop-image";
|
||||
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
|
||||
import { formatSeoulDateTime } from "@/shared/lib/formatters";
|
||||
import { ConfirmDeactivateDialog } from "@/shared/ui/confirm-deactivate-dialog";
|
||||
import { CanPriceField } from "@/shared/ui/can-price-field";
|
||||
import { FileField } from "@/shared/ui/file-field";
|
||||
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
|
||||
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
|
||||
import { TagInput } from "@/shared/ui/tag-input";
|
||||
import { CAN_PRICE_MAX } from "@/shared/validation/can-price";
|
||||
import { AUDIO_FILE_POLICY } from "@/shared/validation/audio-file-policy";
|
||||
import { UnsavedChangesGuard } from "@/shared/ui/unsaved-changes-guard";
|
||||
@@ -31,7 +34,7 @@ import type { UploadProgressStatus } from "@/shared/ui/upload-progress";
|
||||
|
||||
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 UploadState = {
|
||||
@@ -48,6 +51,8 @@ const errorIds = {
|
||||
title: "audio-content-title-error",
|
||||
} 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 }) {
|
||||
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||
const [createSettings, setCreateSettings] = useState<AudioContentCreateSettings>(defaultAudioContentCreateSettings);
|
||||
@@ -73,12 +78,14 @@ 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) {
|
||||
setPrice(value);
|
||||
if (mode === "create" && parsePrice(value) === 0) {
|
||||
setCreateSettings((current) => ({ ...current, purchaseOption: "BOTH", isGeneratePreview: false, isPointAvailable: false, previewStartTime: null, previewEndTime: null }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +128,8 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
|
||||
audio: audioErrorMessage(audioFile, mode),
|
||||
cover: isCoverSubmitBlocked ? "이미지 처리가 끝난 뒤 저장하세요." : coverErrorMessage(coverImage, mode),
|
||||
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,
|
||||
releaseDate: releaseSchedule.publishMode === "scheduled" && !isFutureLocalDateTime(releaseSchedule.releaseDateTime) ? "미래 Asia/Seoul 시각을 입력하세요." : undefined,
|
||||
tags: tags.trim().length === 0 ? "태그를 입력하세요." : undefined,
|
||||
@@ -222,35 +231,18 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
|
||||
<h2 className="text-2xl font-bold leading-tight" id="audio-form-title">{mode === "create" ? "오디오 콘텐츠 생성" : "오디오 콘텐츠 수정"}</h2>
|
||||
<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}>
|
||||
<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} />
|
||||
{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}
|
||||
<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>}
|
||||
<CanPriceField error={errors.price} errorId={errorIds.price} onChange={changePrice} value={price} />
|
||||
{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" ? <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} />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -3,19 +3,17 @@ 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 {
|
||||
return parseCanPriceInput(value);
|
||||
}
|
||||
|
||||
export function formatPrice(value: string): string {
|
||||
return formatCanPriceInput(value);
|
||||
}
|
||||
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}$/;
|
||||
|
||||
|
||||
@@ -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,251 @@ 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([
|
||||
"-1",
|
||||
"1.5",
|
||||
"100000",
|
||||
])("AudioContentFormPage preserves the raw price input %s", async (value) => {
|
||||
// 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(value));
|
||||
});
|
||||
|
||||
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 is numeric 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: "00" } });
|
||||
|
||||
// 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();
|
||||
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 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 () => {
|
||||
// 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: "힐링,집중" });
|
||||
});
|
||||
|
||||
@@ -26,6 +26,38 @@ function createInactiveFormClient(requests: CapturedRequest[]): ApiClient {
|
||||
};
|
||||
}
|
||||
|
||||
test.each(["-1", "1.5", "100000"])("AudioContentFormPage rejects invalid edit price %s through the submit button", async (value) => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
||||
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" />);
|
||||
|
||||
// When
|
||||
const priceInput = await screen.findByLabelText("가격");
|
||||
fireEvent.change(priceInput, { target: { value } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
||||
|
||||
// Then
|
||||
expect(priceInput).toHaveValue(Number(value));
|
||||
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
|
||||
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("AudioContentFormPage allows the maximum edit price", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
||||
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" />);
|
||||
|
||||
// When
|
||||
fireEvent.change(await screen.findByLabelText("가격"), { target: { value: "99999" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(requests.filter((request) => request.method === "PUT")).toHaveLength(1));
|
||||
expect(await readJsonPart(requireFormData(requests.at(-1)?.body).get("request"))).toMatchObject({ price: 99999 });
|
||||
});
|
||||
|
||||
test("AudioContentFormPage update omits unsupported controls and soft delete navigates to the audio list", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
@@ -42,7 +74,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 +88,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,
|
||||
|
||||
@@ -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" })] } });
|
||||
|
||||
@@ -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,10 @@ 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 }) => {
|
||||
"-1",
|
||||
"1.5",
|
||||
"100000",
|
||||
])("AudioContentFormPage preserves and rejects invalid price input %s through the submit button", async (value) => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
|
||||
@@ -158,9 +172,10 @@ test.each([
|
||||
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" createCropSource={(file) => Promise.resolve({ file, height: 800, previewUrl: "blob:cover", width: 800 })} renderCrop={(request) => Promise.resolve(request.file)} uploadAudioContentRequest={createSuccessfulUpload(uploadedBodies)} />);
|
||||
|
||||
// When
|
||||
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: label } });
|
||||
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "잘못된 가격" } });
|
||||
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "설명" } });
|
||||
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "태그" } });
|
||||
fireEvent.keyDown(screen.getByLabelText("태그"), { key: "Enter" });
|
||||
fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
|
||||
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [fileWithSize("voice.mp3", "audio/mpeg", 10)] } });
|
||||
fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "cover.png", { type: "image/png" })] } });
|
||||
@@ -170,7 +185,7 @@ test.each([
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("가격")).toHaveValue(expected);
|
||||
expect(screen.getByLabelText("가격")).toHaveValue(Number(value));
|
||||
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
|
||||
expect(uploadedBodies).toHaveLength(0);
|
||||
});
|
||||
@@ -188,6 +203,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)] } });
|
||||
@@ -226,30 +242,6 @@ test("AudioContentFormPage validates audio boundary and serializes scheduled Asi
|
||||
expect(request).not.toHaveProperty("timezone");
|
||||
});
|
||||
|
||||
test("AudioContentFormPage blocks prices outside the CAN range before upload", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
const uploadedBodies: XMLHttpRequestBodyInit[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
|
||||
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" createCropSource={(file) => Promise.resolve({ file, height: 800, previewUrl: "blob:cover", width: 800 })} renderCrop={(request) => Promise.resolve(request.file)} uploadAudioContentRequest={createSuccessfulUpload(uploadedBodies)} />);
|
||||
|
||||
// When
|
||||
fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "가격 경계" } });
|
||||
fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "설명" } });
|
||||
fireEvent.change(screen.getByLabelText("태그"), { target: { value: "태그" } });
|
||||
fireEvent.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.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
|
||||
expect(uploadedBodies).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("AudioContentFormPage links validation errors and focuses the first invalid control", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
@@ -259,26 +251,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("가격")).toHaveAccessibleDescription("단위: 캔");
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -165,5 +165,5 @@ test("soft delete success re-enters the list route and fetches the list", async
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters"));
|
||||
expect(await screen.findByRole("status", { name: "작업 성공" })).toHaveTextContent("AI 캐릭터를 비활성화했습니다.");
|
||||
expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
|
||||
await waitFor(() => expect(listRequests).toBeGreaterThanOrEqual(3));
|
||||
await waitFor(() => expect(listRequests).toBe(2));
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { createCommunityPost } from "@/features/community-posts/api/community-post-api";
|
||||
import { communityPostAudioErrorMessage, formatCommunityPostPrice, hasCommunityPostFormErrors, parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
|
||||
import { communityPostAudioErrorMessage, hasCommunityPostFormErrors, parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
|
||||
import type { CommunityPostFormErrors } from "@/features/community-posts/components/community-post-form-helpers";
|
||||
import { COMMUNITY_POST_IMAGE_POLICY, prepareCommunityPostImage } from "@/features/community-posts/validation/community-post-media-policy";
|
||||
import { ApiError } from "@/shared/api/api-error";
|
||||
import type { ApiClient } from "@/shared/api/client";
|
||||
import type { CropRenderRequest } from "@/shared/lib/crop-image";
|
||||
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
|
||||
import { CanPriceField } from "@/shared/ui/can-price-field";
|
||||
import { FileField } from "@/shared/ui/file-field";
|
||||
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
|
||||
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
|
||||
@@ -27,12 +28,6 @@ const errorIds = {
|
||||
price: "community-post-price-error",
|
||||
} as const;
|
||||
|
||||
function buildCreateRequest(content: string, isAdult: boolean, isCommentAvailable: boolean, price: number | null) {
|
||||
const base = { content, isAdult, isCommentAvailable };
|
||||
|
||||
return price === null ? base : { ...base, price };
|
||||
}
|
||||
|
||||
export function CommunityPostForm({ apiClient, characterId, createCropSource, onCreated, renderCrop }: CommunityPostFormProps) {
|
||||
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||
const [content, setContent] = useState("");
|
||||
@@ -43,7 +38,7 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
|
||||
const [isImagePreparing, setIsImagePreparing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [postImage, setPostImage] = useState<File | null>(null);
|
||||
const [price, setPrice] = useState("");
|
||||
const [price, setPrice] = useState("0");
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const imageSelectionId = useRef(0);
|
||||
const isSavingRef = useRef(false);
|
||||
@@ -53,9 +48,11 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
|
||||
async function selectPostImage(file: File | null) {
|
||||
imageSelectionId.current += 1;
|
||||
const currentSelectionId = imageSelectionId.current;
|
||||
if (file === null) {
|
||||
setAudioFile(null);
|
||||
setErrors((current) => ({ ...current, audio: undefined }));
|
||||
setPostImage(null);
|
||||
setCropSource(null);
|
||||
if (file === null) {
|
||||
setIsImagePreparing(false);
|
||||
setErrors((current) => ({ ...current, image: undefined }));
|
||||
return;
|
||||
@@ -110,13 +107,12 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
|
||||
|
||||
function validateForm(): CommunityPostFormErrors {
|
||||
const parsedPrice = parseCommunityPostPrice(price);
|
||||
const hasPriceInput = price.trim().length > 0;
|
||||
|
||||
return {
|
||||
audio: communityPostAudioErrorMessage(audioFile),
|
||||
content: content.trim().length === 0 ? "내용을 입력하세요." : undefined,
|
||||
image: isImagePreparing || cropSource !== null ? "이미지 처리가 끝난 뒤 저장하세요." : errors.image,
|
||||
price: (hasPriceInput && parsedPrice === null) || (parsedPrice !== null && (!Number.isInteger(parsedPrice) || parsedPrice < 0 || parsedPrice > CAN_PRICE_MAX)) ? "가격은 0 이상 99,999 이하 정수 캔으로 입력하세요." : undefined,
|
||||
price: parsedPrice === null || !Number.isInteger(parsedPrice) || parsedPrice < 0 || parsedPrice > CAN_PRICE_MAX ? "가격은 0 이상 99,999 이하 정수 캔으로 입력하세요." : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,6 +127,10 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
|
||||
queueMicrotask(() => focusFirstInvalidControl(formRef.current));
|
||||
return;
|
||||
}
|
||||
const parsedPrice = parseCommunityPostPrice(price);
|
||||
if (parsedPrice === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSavingRef.current = true;
|
||||
setIsSaving(true);
|
||||
@@ -138,7 +138,7 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
|
||||
await createCommunityPost(apiClient, characterId, {
|
||||
audioFile: audioFile ?? undefined,
|
||||
postImage: postImage ?? undefined,
|
||||
request: buildCreateRequest(content.trim(), isAdult, isCommentAvailable, parseCommunityPostPrice(price)),
|
||||
request: { content: content.trim(), isAdult, isCommentAvailable, price: parsedPrice },
|
||||
});
|
||||
onCreated();
|
||||
} catch (error: unknown) {
|
||||
@@ -154,17 +154,16 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-semibold text-info">COMMUNITY</p>
|
||||
<h2 className="text-2xl font-bold leading-tight" id="community-post-form-title">커뮤니티 게시글 생성</h2>
|
||||
<p className="text-sm text-muted-foreground">내용과 첨부 파일은 저장 전 운영 기준에 맞게 검토하세요. GIF는 crop 없이 원본을 보존합니다.</p>
|
||||
<p className="break-keep text-sm text-muted-foreground">내용과 첨부 파일은 저장 전 운영 기준에 맞게 검토하세요. GIF는 crop 없이 원본을 보존합니다.</p>
|
||||
</div>
|
||||
<form aria-label="커뮤니티 게시글 생성 입력 화면" className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" onSubmit={(event) => void submit(event)} ref={formRef}>
|
||||
<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} />
|
||||
{postImage === null ? null : <FileField accept={AUDIO_FILE_POLICY.allowedMimeTypes.join(",")} acceptDescription="MP3, AAC, M4A, 최대 1,024,000,000 bytes. WAV는 지원하지 않습니다." error={errors.audio} label="오디오 파일" onChange={setAudioFile} value={audioFile} />}
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">내용<textarea aria-describedby={errors.content === undefined ? undefined : errorIds.content} aria-invalid={errors.content === undefined ? undefined : true} className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setContent(event.currentTarget.value)} value={content} /></label>
|
||||
{errors.content === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.content} role="alert">{errors.content}</p>}
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">가격<input aria-describedby={errors.price === undefined ? undefined : errorIds.price} aria-invalid={errors.price === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" inputMode="numeric" onChange={(event) => setPrice(formatCommunityPostPrice(event.currentTarget.value))} value={price} /></label>
|
||||
{errors.price === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.price} role="alert">{errors.price}</p>}
|
||||
<CanPriceField error={errors.price} errorId={errorIds.price} onChange={setPrice} value={price} />
|
||||
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={isCommentAvailable} onChange={(event) => setIsCommentAvailable(event.currentTarget.checked)} type="checkbox" />댓글 허용</label>
|
||||
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={isAdult} onChange={(event) => setIsAdult(event.currentTarget.checked)} type="checkbox" />성인 콘텐츠</label>
|
||||
<FileField accept={AUDIO_FILE_POLICY.allowedMimeTypes.join(",")} acceptDescription="MP3, AAC, M4A, 최대 1,024,000,000 bytes. WAV는 지원하지 않습니다." error={errors.audio} label="오디오 파일" onChange={setAudioFile} value={audioFile} />
|
||||
<FileField accept="image/jpeg,image/png,image/gif" acceptDescription="JPEG 또는 PNG는 자유 ratio crop 후 최대 800px로 전송합니다. GIF는 원본 width 800px 이하만 crop 없이 전송합니다." error={errors.image} label="게시글 이미지" onChange={(file) => void selectPostImage(file)} value={postImage} />
|
||||
{errors.form === undefined ? null : <p className="text-sm font-semibold text-destructive" role="alert">{errors.form}</p>}
|
||||
{isSaving ? <p className="rounded-md border border-border bg-muted p-3 text-sm font-semibold" role="status">커뮤니티 게시글을 저장하는 중</p> : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { validateAudioFile } from "@/shared/validation/audio-file-policy";
|
||||
import { formatCanPriceInput, parseCanPriceInput } from "@/shared/validation/can-price";
|
||||
import { parseCanPriceInput } from "@/shared/validation/can-price";
|
||||
|
||||
export type CommunityPostFormErrors = {
|
||||
readonly audio?: string;
|
||||
@@ -13,10 +13,6 @@ export function parseCommunityPostPrice(value: string): number | null {
|
||||
return parseCanPriceInput(value);
|
||||
}
|
||||
|
||||
export function formatCommunityPostPrice(value: string): string {
|
||||
return formatCanPriceInput(value);
|
||||
}
|
||||
|
||||
export function communityPostAudioErrorMessage(file: File | null): string | undefined {
|
||||
if (file === null) {
|
||||
return undefined;
|
||||
|
||||
@@ -68,6 +68,7 @@ describe("Community post contract", () => {
|
||||
// Then
|
||||
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101/community-posts", method: "POST", body: requests[0]?.body }]);
|
||||
const body = requireFormData(requests[0]?.body);
|
||||
expect([...body.keys()]).toEqual(["audioFile", "postImage", "request"]);
|
||||
expect(body.get("audioFile")).toBe(audioFile);
|
||||
expect(body.get("postImage")).toBe(postImage);
|
||||
const request = await readJsonPart(body.get("request"));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { App } from "@/app/App";
|
||||
import { apiBaseUrl, saveAdminSession, useAiCharacterDetailResponse, useAiCharactersResponse } from "@/app/app-test-support";
|
||||
@@ -71,6 +71,18 @@ function createCropSource(file: File): Promise<CropSourceImage> {
|
||||
return Promise.resolve({ file, height: 600, previewUrl: "blob:gif", width: 800 });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
class TestUrl extends URL {
|
||||
static createObjectURL(blob: Blob): string {
|
||||
return `blob:community-${blob.size}`;
|
||||
}
|
||||
|
||||
static revokeObjectURL(): void {}
|
||||
}
|
||||
|
||||
vi.stubGlobal("URL", TestUrl);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
window.history.replaceState({}, "", "/");
|
||||
@@ -91,6 +103,7 @@ test("Community create route opens from the existing list workspace", async () =
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("heading", { name: "커뮤니티 게시글 생성" })).toBeInTheDocument();
|
||||
expect(screen.getByText("내용과 첨부 파일은 저장 전 운영 기준에 맞게 검토하세요. GIF는 crop 없이 원본을 보존합니다.")).toHaveClass("break-keep");
|
||||
expect(window.location.pathname).toBe("/ai-characters/101/community-posts/new");
|
||||
});
|
||||
|
||||
@@ -138,6 +151,8 @@ test("Community create form validates content audio policy and GIF width before
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "GIF와 오디오가 있는 게시글" } });
|
||||
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [animatedGif] } });
|
||||
await screen.findByLabelText("오디오 파일");
|
||||
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [fileWithSize("voice.wav", "audio/wav", 10)] } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
@@ -145,7 +160,6 @@ test("Community create form validates content audio policy and GIF width before
|
||||
expect(await screen.findByText("MP3, AAC, M4A 파일만 업로드하세요.")).toBeInTheDocument();
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [audioFile] } });
|
||||
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "big.gif", { type: "image/gif" })] } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
@@ -155,6 +169,9 @@ test("Community create form validates content audio policy and GIF width before
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [animatedGif] } });
|
||||
const revealedAudioInput = await screen.findByLabelText("오디오 파일");
|
||||
expect(screen.queryByText("MP3, AAC, M4A 파일만 업로드하세요.")).not.toBeInTheDocument();
|
||||
fireEvent.change(revealedAudioInput, { target: { files: [audioFile] } });
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "생성" })).not.toBeDisabled());
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
@@ -164,7 +181,98 @@ test("Community create form validates content audio policy and GIF width before
|
||||
const body = requireFormData(requests.at(-1)?.body);
|
||||
expect(body.get("audioFile")).toBe(audioFile);
|
||||
expect(body.get("postImage")).toBe(animatedGif);
|
||||
expect(await readJsonPart(body.get("request"))).toEqual({ content: "GIF와 오디오가 있는 게시글", isAdult: false, isCommentAvailable: true });
|
||||
expect(await readJsonPart(body.get("request"))).toEqual({ content: "GIF와 오디오가 있는 게시글", isAdult: false, isCommentAvailable: true, price: 0 });
|
||||
});
|
||||
|
||||
test("Community create form renders final image preview first and reveals audio before content", async () => {
|
||||
// Given
|
||||
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
|
||||
render(<CommunityPostFormPage apiClient={createCommunityFormClient([])} characterId="101" createCropSource={createCropSource} />);
|
||||
const form = await screen.findByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" });
|
||||
if (!(form instanceof HTMLFormElement)) {
|
||||
throw new Error("expected community create form");
|
||||
}
|
||||
const imageInput = screen.getByLabelText("게시글 이미지");
|
||||
const contentInput = screen.getByLabelText("내용");
|
||||
|
||||
// Then
|
||||
expect(screen.queryByLabelText("오디오 파일")).not.toBeInTheDocument();
|
||||
expect(Array.from(form.elements).indexOf(imageInput)).toBeLessThan(Array.from(form.elements).indexOf(contentInput));
|
||||
|
||||
// When
|
||||
fireEvent.change(imageInput, { target: { files: [new File(["gif"], "post.gif", { type: "image/gif" })] } });
|
||||
|
||||
// Then
|
||||
const preview = await screen.findByRole("img", { name: "게시글 이미지 업로드 미리보기" });
|
||||
const audioInput = screen.getByLabelText("오디오 파일");
|
||||
expect(preview).toHaveAttribute("src", "blob:community-3");
|
||||
expect(Array.from(form.elements).indexOf(imageInput)).toBeLessThan(Array.from(form.elements).indexOf(audioInput));
|
||||
expect(Array.from(form.elements).indexOf(audioInput)).toBeLessThan(Array.from(form.elements).indexOf(contentInput));
|
||||
});
|
||||
|
||||
test("Community create form clears final image and audio as soon as image replacement starts", async () => {
|
||||
// Given
|
||||
let resolveReplacement: (source: CropSourceImage) => void = () => undefined;
|
||||
const replacementReady = new Promise<CropSourceImage>((resolve) => {
|
||||
resolveReplacement = resolve;
|
||||
});
|
||||
const createReplacementCropSource = (file: File) => file.name === "replacement.png" ? replacementReady : createCropSource(file);
|
||||
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
|
||||
render(<CommunityPostFormPage apiClient={createCommunityFormClient([])} characterId="101" createCropSource={createReplacementCropSource} />);
|
||||
fireEvent.change(await screen.findByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "ready.gif", { type: "image/gif" })] } });
|
||||
const audioInput = await screen.findByLabelText("오디오 파일");
|
||||
fireEvent.change(audioInput, { target: { files: [new File(["audio"], "voice.m4a", { type: "audio/x-m4a" })] } });
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["image"], "replacement.png", { type: "image/png" })] } });
|
||||
|
||||
// Then
|
||||
expect(screen.queryByLabelText("오디오 파일")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("img", { name: "게시글 이미지 업로드 미리보기" })).not.toBeInTheDocument();
|
||||
await act(async () => {
|
||||
resolveReplacement({ file: new File(["image"], "replacement.png", { type: "image/png" }), height: 500, previewUrl: "blob:replacement", width: 1000 });
|
||||
await replacementReady;
|
||||
});
|
||||
expect(await screen.findByRole("dialog", { name: "이미지 crop" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Community create form hides and clears audio when the final image is removed", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
|
||||
render(<CommunityPostFormPage apiClient={createCommunityFormClient(requests)} characterId="101" createCropSource={createCropSource} />);
|
||||
fireEvent.change(await screen.findByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "ready.gif", { type: "image/gif" })] } });
|
||||
fireEvent.change(await screen.findByLabelText("오디오 파일"), { target: { files: [new File(["audio"], "voice.m4a", { type: "audio/x-m4a" })] } });
|
||||
|
||||
// When
|
||||
fireEvent.click(screen.getByRole("button", { name: "게시글 이미지 선택 취소" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.queryByLabelText("오디오 파일")).not.toBeInTheDocument());
|
||||
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "이미지 제거 게시글" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
await waitFor(() => expect(requests.filter((request) => request.method === "POST")).toHaveLength(1));
|
||||
const body = requireFormData(requests.at(-1)?.body);
|
||||
expect(body.has("audioFile")).toBe(false);
|
||||
expect(body.has("postImage")).toBe(false);
|
||||
});
|
||||
|
||||
test("Community create form defaults price to zero and rejects blank price", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101/community-posts/new");
|
||||
render(<CommunityPostFormPage apiClient={createCommunityFormClient(requests)} characterId="101" createCropSource={createCropSource} />);
|
||||
const priceInput = await screen.findByLabelText("가격");
|
||||
expect(priceInput).toHaveValue(0);
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "빈 가격 게시글" } });
|
||||
fireEvent.change(priceInput, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
|
||||
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("Community create form crops JPEG PNG with free ratio and no upscale", async () => {
|
||||
@@ -177,7 +285,7 @@ test("Community create form crops JPEG PNG with free ratio and no upscale", asyn
|
||||
|
||||
// When
|
||||
fireEvent.change(await screen.findByLabelText("내용"), { target: { value: "PNG crop 게시글" } });
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "1,000캔" } });
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "1000" } });
|
||||
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["image"], "wide.png", { type: "image/png" })] } });
|
||||
expect(await screen.findByRole("dialog", { name: "이미지 crop" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "생성" })).toBeDisabled();
|
||||
@@ -185,6 +293,7 @@ test("Community create form crops JPEG PNG with free ratio and no upscale", asyn
|
||||
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "적용" }));
|
||||
await waitFor(() => expect(renderCrop).toHaveBeenCalled());
|
||||
expect(await screen.findByRole("img", { name: "게시글 이미지 업로드 미리보기" })).toHaveAttribute("src", "blob:community-7");
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
@@ -203,7 +312,7 @@ test("Community create form blocks prices outside the CAN range before submit",
|
||||
|
||||
// When
|
||||
fireEvent.change(await screen.findByLabelText("내용"), { target: { value: "가격 경계 게시글" } });
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100,000캔" } });
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100000" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
@@ -219,7 +328,7 @@ test("Community create form links validation errors and focuses the first invali
|
||||
|
||||
// When
|
||||
const contentInput = await screen.findByLabelText("내용");
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100,000캔" } });
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100000" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
// Then
|
||||
@@ -228,7 +337,7 @@ test("Community create form links validation errors and focuses the first invali
|
||||
expect(contentError).toHaveAttribute("id", "community-post-content-error");
|
||||
expect(priceError).toHaveAttribute("id", "community-post-price-error");
|
||||
expect(contentInput).toHaveAttribute("aria-describedby", "community-post-content-error");
|
||||
expect(screen.getByLabelText("가격")).toHaveAttribute("aria-describedby", "community-post-price-error");
|
||||
expect(screen.getByLabelText("가격").getAttribute("aria-describedby")?.split(" ")).toContain("community-post-price-error");
|
||||
expect(contentInput).toHaveAttribute("aria-invalid", "true");
|
||||
expect(screen.getByLabelText("가격")).toHaveAttribute("aria-invalid", "true");
|
||||
await waitFor(() => expect(contentInput).toHaveFocus());
|
||||
@@ -295,7 +404,7 @@ test("Community create form ignores stale image preparation results", async () =
|
||||
expect(submittedImage.name).toBe("fresh.gif");
|
||||
});
|
||||
|
||||
test("Community create form clears pending image preparation when selection is canceled", async () => {
|
||||
test("Community create form clears image replacement when crop is canceled", async () => {
|
||||
let resolveCropSource: (source: CropSourceImage) => void = () => undefined;
|
||||
const cropSourceReady = new Promise<CropSourceImage>((resolve) => {
|
||||
resolveCropSource = resolve;
|
||||
@@ -309,11 +418,11 @@ test("Community create form clears pending image preparation when selection is c
|
||||
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["gif"], "ready.gif", { type: "image/gif" })] } });
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "생성" })).not.toBeDisabled());
|
||||
fireEvent.change(screen.getByLabelText("게시글 이미지"), { target: { files: [new File(["image"], "slow.png", { type: "image/png" })] } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "선택 취소" }));
|
||||
await act(async () => {
|
||||
resolveCropSource({ file: new File(["image"], "slow.png", { type: "image/png" }), height: 500, previewUrl: "blob:slow", width: 1000 });
|
||||
await cropSourceReady;
|
||||
});
|
||||
fireEvent.click(await screen.findByRole("button", { name: "취소" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "생성" })).not.toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
@@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { CommunityPostForm } from "@/features/community-posts/components/CommunityPostForm";
|
||||
import { formatCommunityPostPrice, parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
|
||||
import { parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
|
||||
import type { ApiClient, ApiRequestOptions } from "@/shared/api/client";
|
||||
|
||||
type CapturedRequest = {
|
||||
@@ -27,25 +27,25 @@ function renderForm(requests: CapturedRequest[]) {
|
||||
return onCreated;
|
||||
}
|
||||
|
||||
async function submitWithPrice(value: string, requests: CapturedRequest[]) {
|
||||
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "가격 검증 게시글" } });
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.");
|
||||
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
|
||||
}
|
||||
|
||||
test.each(["-1", "1.5"])("Community price keeps invalid raw input %s and blocks submit", async (value) => {
|
||||
test.each([
|
||||
"-1",
|
||||
"1.5",
|
||||
"100000",
|
||||
])("Community price preserves and rejects invalid raw input %s through the submit button", async (rawValue) => {
|
||||
const requests: CapturedRequest[] = [];
|
||||
const onCreated = renderForm(requests);
|
||||
|
||||
await submitWithPrice(value, requests);
|
||||
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "가격 검증 게시글" } });
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value: rawValue } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성" }));
|
||||
|
||||
expect(screen.getByLabelText("가격")).toHaveValue(value);
|
||||
expect(screen.getByLabelText("가격")).toHaveValue(Number(rawValue));
|
||||
expect(await screen.findByText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.")).toBeInTheDocument();
|
||||
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
|
||||
expect(onCreated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each(["0", "99,999캔"])("Community price allows boundary input %s", async (value) => {
|
||||
test.each(["0", "99999"])("Community price allows boundary input %s", async (value) => {
|
||||
const requests: CapturedRequest[] = [];
|
||||
const onCreated = renderForm(requests);
|
||||
|
||||
@@ -57,9 +57,8 @@ test.each(["0", "99,999캔"])("Community price allows boundary input %s", async
|
||||
expect(requests.filter((request) => request.method === "POST")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("Community price parser rejects negative and decimal raw input", () => {
|
||||
test("Community price parser rejects blank, negative, and decimal raw input", () => {
|
||||
expect(parseCommunityPostPrice("")).toBeNull();
|
||||
expect(parseCommunityPostPrice("-1")).toBeNull();
|
||||
expect(parseCommunityPostPrice("1.5")).toBeNull();
|
||||
expect(formatCommunityPostPrice("-1")).toBe("-1");
|
||||
expect(formatCommunityPostPrice("1.5")).toBe("1.5");
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ConfirmDeactivateDialog } from "@/shared/ui/confirm-deactivate-dialog";
|
||||
import { FileField } from "@/shared/ui/file-field";
|
||||
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
|
||||
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
|
||||
import { TagInput } from "@/shared/ui/tag-input";
|
||||
import { UnsavedChangesGuard } from "@/shared/ui/unsaved-changes-guard";
|
||||
|
||||
type FieldErrors = {
|
||||
@@ -216,19 +217,18 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
|
||||
<p className="text-sm text-muted-foreground">제목, 소개, 장르, 연재 요일과 이미지를 저장 전 운영 기준에 맞게 검토하세요.</p>
|
||||
</div>
|
||||
<form aria-label={mode === "create" ? "시리즈 생성 입력 화면" : "시리즈 수정 입력 화면"} className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" onSubmit={(event) => void submit(event)} ref={formRef}>
|
||||
<FileField accept="image/jpeg,image/png" acceptDescription="JPEG 또는 PNG, 10MB 이하, 210:297 crop 후 최대 폭 1,000px로 전송합니다." error={errors.image} label="시리즈 이미지" onChange={(file) => void selectImage(file)} value={image} />
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">제목<input aria-describedby={errors.title === undefined ? undefined : errorIds.title} aria-invalid={errors.title === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setTitle(event.currentTarget.value)} value={title} /></label>
|
||||
{errors.title === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.title} role="alert">{errors.title}</p>}
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">소개<textarea aria-describedby={errors.introduction === undefined ? undefined : errorIds.introduction} aria-invalid={errors.introduction === undefined ? undefined : true} className="min-h-28 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setIntroduction(event.currentTarget.value)} value={introduction} /></label>
|
||||
{errors.introduction === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.introduction} role="alert">{errors.introduction}</p>}
|
||||
{mode === "create" ? <label className="flex flex-col gap-2 text-sm font-semibold">키워드<input aria-describedby={errors.keyword === undefined ? undefined : errorIds.keyword} aria-invalid={errors.keyword === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setKeyword(event.currentTarget.value)} value={keyword} /></label> : null}
|
||||
{errors.keyword === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorIds.keyword} role="alert">{errors.keyword}</p>}
|
||||
{mode === "create" ? <TagInput error={errors.keyword} errorId={errorIds.keyword} label="키워드" onChange={setKeyword} value={keyword} /> : null}
|
||||
<GenreCombobox error={errors.genre} errorId={errorIds.genre} genres={genres} onChange={setGenreId} value={genreId} />
|
||||
<PublishedDaysField error={errors.days} errorId={errorIds.days} onChange={setPublishedDays} value={publishedDays} />
|
||||
{mode === "edit" ? <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) => setState(event.currentTarget.value as SeriesState)} value={state}>{stateOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label> : null}
|
||||
<label className="flex items-center gap-2 text-sm font-semibold"><input checked={isAdult} onChange={(event) => setIsAdult(event.currentTarget.checked)} type="checkbox" />성인 콘텐츠</label>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">작가<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setWriter(event.currentTarget.value)} value={writer} /></label>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">스튜디오<input className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => setStudio(event.currentTarget.value)} value={studio} /></label>
|
||||
<FileField accept="image/jpeg,image/png" acceptDescription="JPEG 또는 PNG, 10MB 이하, 210:297 crop 후 최대 폭 1,000px로 전송합니다." error={errors.image} label="시리즈 이미지" onChange={(file) => void selectImage(file)} value={image} />
|
||||
{errors.form === undefined ? null : <p className="text-sm font-semibold text-destructive" role="alert">{errors.form}</p>}
|
||||
{isSaving ? <p className="rounded-md border border-border bg-muted p-3 text-sm font-semibold" role="status">시리즈를 저장하는 중</p> : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
@@ -111,6 +111,20 @@ async function renderCrop(request: CropRenderRequest): Promise<File> {
|
||||
return new File([String(request.outputWidth)], "cropped-series.png", { type: "image/png" });
|
||||
}
|
||||
|
||||
test("SeriesForm renders the required image before the title when creating", () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101/series/new");
|
||||
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="create" renderCrop={renderCrop} />);
|
||||
|
||||
// When
|
||||
const imageInput = screen.getByLabelText("시리즈 이미지");
|
||||
const titleInput = screen.getByLabelText("제목");
|
||||
|
||||
// Then
|
||||
expect(imageInput.compareDocumentPosition(titleInput)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
});
|
||||
|
||||
test("SeriesForm creates with required image request and navigates to list notification", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
@@ -121,6 +135,9 @@ test("SeriesForm creates with required image request and navigates to list notif
|
||||
fireEvent.change(screen.getByLabelText("제목"), { 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: "상담" } });
|
||||
fireEvent.keyDown(screen.getByLabelText("키워드"), { key: "," });
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "월요일" }));
|
||||
fireEvent.change(screen.getByLabelText("장르"), { target: { value: "77" } });
|
||||
fireEvent.change(screen.getByLabelText("작가"), { target: { value: "작가" } });
|
||||
@@ -135,7 +152,7 @@ test("SeriesForm creates with required image request and navigates to list notif
|
||||
await waitFor(() => expect(requests.some((request) => request.method === "POST")).toBe(true));
|
||||
const requestBody = requests.find((request) => request.method === "POST")?.body as FormData;
|
||||
expect(requestBody.get("image")).toBeInstanceOf(File);
|
||||
await expect((requestBody.get("request") as Blob).text()).resolves.toBe(JSON.stringify({ title: "새 시리즈", introduction: "새 시리즈 소개", publishedDaysOfWeek: ["MON"], keyword: "달빛", genreId: 77, isAdult: false, writer: "작가", studio: "스튜디오" }));
|
||||
await expect((requestBody.get("request") as Blob).text()).resolves.toBe(JSON.stringify({ title: "새 시리즈", introduction: "새 시리즈 소개", publishedDaysOfWeek: ["MON"], keyword: "달빛,상담", genreId: 77, isAdult: false, writer: "작가", studio: "스튜디오" }));
|
||||
expect(window.location.pathname).toBe("/ai-characters/101/series");
|
||||
expect(window.history.state.successNotification).toBe("시리즈를 생성했습니다.");
|
||||
});
|
||||
@@ -261,6 +278,7 @@ test("SeriesForm links validation errors and focuses the first invalid control",
|
||||
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="create" renderCrop={renderCrop} />);
|
||||
|
||||
// When
|
||||
const imageInput = screen.getByLabelText("시리즈 이미지");
|
||||
const titleInput = screen.getByLabelText("제목");
|
||||
fireEvent.submit(screen.getByRole("form", { name: "시리즈 생성 입력 화면" }));
|
||||
|
||||
@@ -277,10 +295,10 @@ test("SeriesForm links validation errors and focuses the first invalid control",
|
||||
expect(daysError).toHaveAttribute("id", "series-days-error");
|
||||
expect(titleInput).toHaveAttribute("aria-describedby", "series-title-error");
|
||||
expect(screen.getByLabelText("소개")).toHaveAttribute("aria-describedby", "series-introduction-error");
|
||||
expect(screen.getByLabelText("키워드")).toHaveAttribute("aria-describedby", "series-keyword-error");
|
||||
expect(screen.getByLabelText("키워드").getAttribute("aria-describedby")?.split(" ")).toContain("series-keyword-error");
|
||||
expect(screen.getByLabelText("장르")).toHaveAttribute("aria-describedby", "series-genre-error");
|
||||
expect(screen.getByRole("checkbox", { name: "월요일" })).toHaveAttribute("aria-describedby", "series-days-error");
|
||||
expect(titleInput).toHaveAttribute("aria-invalid", "true");
|
||||
await waitFor(() => expect(titleInput).toHaveFocus());
|
||||
await waitFor(() => expect(imageInput).toHaveFocus());
|
||||
expect(requests.filter((request) => request.method === "POST")).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { AdminAudioPlayer } from "@/shared/ui/admin-audio-player";
|
||||
@@ -33,6 +33,30 @@ test("AdminAudioPlayer wraps native audio with controls and no download or autop
|
||||
expect(screen.getByRole("combobox", { name: "재생 속도" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("AdminAudioPlayer matches the compact reference control anatomy", () => {
|
||||
render(<AdminAudioPlayer playerId="one" src="https://cdn.example.com/signed/audio.m4a?token=secret" title="샘플 오디오" />);
|
||||
|
||||
const player = screen.getByRole("group", { name: "샘플 오디오 오디오 플레이어" });
|
||||
const controls = within(player).getByRole("group", { name: "재생 제어" });
|
||||
expect(within(controls).getByRole("button", { name: "재생" })).toBeInTheDocument();
|
||||
const seek = within(controls).getByRole("slider", { name: "재생 위치" });
|
||||
const speed = within(controls).getByRole("combobox", { name: "재생 속도" });
|
||||
const volume = within(controls).getByRole("slider", { name: "볼륨" });
|
||||
expect.soft(player).toHaveClass("px-1", "sm:px-3");
|
||||
expect(seek).toHaveClass("min-w-11");
|
||||
expect.soft(speed).toHaveClass("w-11");
|
||||
expect(speed).toHaveClass("text-base");
|
||||
expect.soft(volume).toHaveClass("basis-11");
|
||||
expect(volume).toHaveClass("min-w-11");
|
||||
expect(volume).toBeInTheDocument();
|
||||
expect(volume.className).not.toContain("hidden");
|
||||
expect.soft(within(controls).queryByText("0:00/0:00")).toBeInTheDocument();
|
||||
expect(within(player).queryByText("볼륨")).not.toBeInTheDocument();
|
||||
expect(within(player).queryByText("재생 속도")).not.toBeInTheDocument();
|
||||
expect(player.querySelector("img")).not.toBeInTheDocument();
|
||||
expect(player.querySelector("video")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("AudioPlaybackProvider keeps only one player active by player id", () => {
|
||||
render(
|
||||
<AudioPlaybackProvider>
|
||||
|
||||
37
src/shared/ui/__tests__/can-price-field.test.tsx
Normal file
37
src/shared/ui/__tests__/can-price-field.test.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { CanPriceField } from "@/shared/ui/can-price-field";
|
||||
|
||||
test("CanPriceField renders the native numeric CAN price contract", () => {
|
||||
render(<CanPriceField error={undefined} errorId="price-error" onChange={() => undefined} value="0" />);
|
||||
|
||||
const input = screen.getByLabelText("가격");
|
||||
expect(input).toHaveAttribute("type", "number");
|
||||
expect(input).toHaveAttribute("inputmode", "numeric");
|
||||
expect(input).toHaveAttribute("min", "0");
|
||||
expect(input).toHaveAttribute("step", "1");
|
||||
expect(input).toHaveAccessibleDescription("단위: 캔");
|
||||
});
|
||||
|
||||
test.each([
|
||||
"-1",
|
||||
"1.5",
|
||||
])("CanPriceField emits the raw input value %s unchanged", (rawValue) => {
|
||||
const onChange = vi.fn<(value: string) => void>();
|
||||
render(<CanPriceField error={undefined} errorId="price-error" onChange={onChange} value="0" />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("가격"), { target: { value: rawValue } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(rawValue);
|
||||
});
|
||||
|
||||
test("CanPriceField links the visible error and unit description to the invalid input", () => {
|
||||
render(<CanPriceField error="가격 오류" errorId="price-error" onChange={() => undefined} value="100000" />);
|
||||
|
||||
const input = screen.getByLabelText("가격");
|
||||
expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
expect(input.getAttribute("aria-describedby")?.split(" ")).toContain("price-error");
|
||||
expect(input).toHaveAccessibleDescription("단위: 캔 가격 오류");
|
||||
expect(screen.getByRole("alert")).toHaveAttribute("id", "price-error");
|
||||
});
|
||||
@@ -1,8 +1,36 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { StrictMode } from "react";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { FileField } from "@/shared/ui/file-field";
|
||||
|
||||
const createObjectUrl = vi.fn<(blob: Blob) => string>();
|
||||
const revokeObjectUrl = vi.fn<(url: string) => void>();
|
||||
let originalCreateObjectUrl: PropertyDescriptor | undefined;
|
||||
let originalRevokeObjectUrl: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
createObjectUrl.mockReset().mockImplementation(() => `blob:preview-${createObjectUrl.mock.calls.length}`);
|
||||
revokeObjectUrl.mockReset();
|
||||
originalCreateObjectUrl = Object.getOwnPropertyDescriptor(URL, "createObjectURL");
|
||||
originalRevokeObjectUrl = Object.getOwnPropertyDescriptor(URL, "revokeObjectURL");
|
||||
Object.defineProperty(URL, "createObjectURL", { configurable: true, value: createObjectUrl });
|
||||
Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: revokeObjectUrl });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCreateObjectUrl === undefined) {
|
||||
Reflect.deleteProperty(URL, "createObjectURL");
|
||||
} else {
|
||||
Object.defineProperty(URL, "createObjectURL", originalCreateObjectUrl);
|
||||
}
|
||||
if (originalRevokeObjectUrl === undefined) {
|
||||
Reflect.deleteProperty(URL, "revokeObjectURL");
|
||||
} else {
|
||||
Object.defineProperty(URL, "revokeObjectURL", originalRevokeObjectUrl);
|
||||
}
|
||||
});
|
||||
|
||||
test("FileField exposes label, description, error, accept guidance, keyboard file input, and controlled value", () => {
|
||||
const onChange = vi.fn();
|
||||
const value = new File(["image"], "profile.png", { type: "image/png" });
|
||||
@@ -23,9 +51,11 @@ test("FileField exposes label, description, error, accept guidance, keyboard fil
|
||||
expect(input).toHaveAttribute("accept", "image/png");
|
||||
expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
expect(input).toHaveAccessibleDescription("프로필 이미지를 선택하세요. PNG만 업로드할 수 있습니다. 파일이 너무 큽니다.");
|
||||
expect(screen.getByText("프로필 이미지를 선택하세요.")).toHaveClass("break-keep");
|
||||
expect(screen.getByText("PNG만 업로드할 수 있습니다.")).toHaveClass("break-keep");
|
||||
expect(screen.getByRole("button", { name: "대표 이미지 파일 선택" })).toBeInTheDocument();
|
||||
expect(screen.getByText("profile.png")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "선택 취소" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "대표 이미지 선택 취소" })).toHaveTextContent("선택 취소");
|
||||
});
|
||||
|
||||
test("FileField emits File or null and clear selection without owning upload policy", () => {
|
||||
@@ -37,10 +67,62 @@ test("FileField emits File or null and clear selection without owning upload pol
|
||||
expect(onChange).toHaveBeenCalledWith(selected);
|
||||
|
||||
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={onChange} value={selected} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "선택 취소" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "오디오 선택 취소" }));
|
||||
expect(onChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
test("FileField clears the native selection when the controlled value is cleared externally", () => {
|
||||
// Given
|
||||
const selected = new File(["audio"], "voice.mp3", { type: "audio/mpeg" });
|
||||
const { rerender } = render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={null} />);
|
||||
const input = screen.getByLabelText<HTMLInputElement>("오디오");
|
||||
fireEvent.change(input, { target: { files: [selected] } });
|
||||
let nativeValue = "C:\\fakepath\\voice.mp3";
|
||||
Object.defineProperty(input, "value", { configurable: true, get: () => nativeValue, set: (value: string) => { nativeValue = value; } });
|
||||
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={selected} />);
|
||||
expect(input.files?.[0]).toBe(selected);
|
||||
expect(input.value).toBe("C:\\fakepath\\voice.mp3");
|
||||
|
||||
// When
|
||||
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={null} />);
|
||||
|
||||
// Then
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
test("FileField clears a rejected native selection when only the error changes", () => {
|
||||
// Given
|
||||
const rejected = new File(["audio"], "voice.wav", { type: "audio/wav" });
|
||||
const { rerender } = render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={null} />);
|
||||
const input = screen.getByLabelText<HTMLInputElement>("오디오");
|
||||
fireEvent.change(input, { target: { files: [rejected] } });
|
||||
let nativeValue = "C:\\fakepath\\voice.wav";
|
||||
Object.defineProperty(input, "value", { configurable: true, get: () => nativeValue, set: (value: string) => { nativeValue = value; } });
|
||||
|
||||
// When
|
||||
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" error="지원하지 않는 파일입니다." label="오디오" onChange={vi.fn()} value={null} />);
|
||||
|
||||
// Then
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
test("FileField clears the native selection when the controlled value is a different File", () => {
|
||||
// Given
|
||||
const selected = new File(["source"], "profile.png", { type: "image/png" });
|
||||
const cropped = new File(["cropped"], "profile.png", { type: "image/png" });
|
||||
const { rerender } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={null} />);
|
||||
const input = screen.getByLabelText<HTMLInputElement>("대표 이미지");
|
||||
fireEvent.change(input, { target: { files: [selected] } });
|
||||
let nativeValue = "C:\\fakepath\\profile.png";
|
||||
Object.defineProperty(input, "value", { configurable: true, get: () => nativeValue, set: (value: string) => { nativeValue = value; } });
|
||||
|
||||
// When
|
||||
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={cropped} />);
|
||||
|
||||
// Then
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
test("FileField gives same visible file buttons field-specific accessible names", () => {
|
||||
render(
|
||||
<>
|
||||
@@ -53,3 +135,115 @@ test("FileField gives same visible file buttons field-specific accessible names"
|
||||
expect(screen.getByRole("button", { name: "오디오 파일 파일 선택" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "커버 이미지 파일 선택" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("FileField visible container exposes the focus-within ring contract", () => {
|
||||
// Given
|
||||
const { container } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={null} />);
|
||||
const input = screen.getByLabelText("대표 이미지");
|
||||
|
||||
// When
|
||||
input.focus();
|
||||
|
||||
// Then
|
||||
expect(input).toHaveFocus();
|
||||
expect(container.firstElementChild).toHaveClass("focus-within:ring-2", "focus-within:ring-ring", "focus-within:ring-offset-2");
|
||||
});
|
||||
|
||||
test("FileField reserves a bounded preview frame for the current image value", async () => {
|
||||
// Given
|
||||
const firstImage = new File(["first"], "first.png", { type: "image/png" });
|
||||
const secondImage = new File(["second"], "second.png", { type: "image/png" });
|
||||
createObjectUrl.mockImplementation((blob) => blob === firstImage ? "blob:first" : "blob:second");
|
||||
const { rerender } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={firstImage} />);
|
||||
|
||||
// When
|
||||
const frame = screen.getByRole("figure", { name: "대표 이미지 업로드 미리보기 영역" });
|
||||
|
||||
// Then
|
||||
expect(frame).toHaveClass("aspect-video", "max-h-64", "w-full", "max-w-md");
|
||||
expect(await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" })).toHaveAttribute("src", "blob:first");
|
||||
|
||||
// When
|
||||
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={secondImage} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("figure", { name: "대표 이미지 업로드 미리보기 영역" })).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole("img", { name: "대표 이미지 업로드 미리보기" })).toHaveAttribute("src", "blob:second"));
|
||||
});
|
||||
|
||||
test("FileField revokes every object URL created under StrictMode", async () => {
|
||||
// Given
|
||||
const image = new File(["image"], "profile.png", { type: "image/png" });
|
||||
|
||||
// When
|
||||
const { unmount } = render(
|
||||
<StrictMode>
|
||||
<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={vi.fn()} value={image} />
|
||||
</StrictMode>,
|
||||
);
|
||||
await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" });
|
||||
unmount();
|
||||
|
||||
// Then
|
||||
const createdUrls = createObjectUrl.mock.results.map(({ value }) => value);
|
||||
const revokedUrls = revokeObjectUrl.mock.calls.map(([url]) => url);
|
||||
expect(new Set(revokedUrls)).toEqual(new Set(createdUrls));
|
||||
});
|
||||
|
||||
test("FileField previews an image and revokes each owned object URL when its controlled value changes", async () => {
|
||||
// Given
|
||||
const firstImage = new File(["first"], "first.png", { type: "image/png" });
|
||||
const secondImage = new File(["second"], "second.png", { type: "image/png" });
|
||||
const onChange = vi.fn();
|
||||
const { rerender, unmount } = render(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={firstImage} />);
|
||||
|
||||
// When
|
||||
const preview = await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" });
|
||||
const firstUrl = preview.getAttribute("src");
|
||||
|
||||
// Then
|
||||
expect(firstUrl).toMatch(/^blob:preview-/);
|
||||
expect(createObjectUrl).toHaveBeenCalledWith(firstImage);
|
||||
expect(preview).toHaveClass("h-full", "w-full", "object-contain");
|
||||
|
||||
// When
|
||||
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={secondImage} />);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.getByRole("img", { name: "대표 이미지 업로드 미리보기" }).getAttribute("src")).not.toBe(firstUrl));
|
||||
const secondUrl = screen.getByRole("img", { name: "대표 이미지 업로드 미리보기" }).getAttribute("src");
|
||||
expect(createObjectUrl).toHaveBeenCalledWith(secondImage);
|
||||
expect(revokeObjectUrl).toHaveBeenCalledWith(firstUrl);
|
||||
|
||||
// When
|
||||
fireEvent.click(screen.getByRole("button", { name: "대표 이미지 선택 취소" }));
|
||||
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={null} />);
|
||||
|
||||
// Then
|
||||
expect(onChange).toHaveBeenCalledWith(null);
|
||||
expect(screen.queryByRole("img", { name: "대표 이미지 업로드 미리보기" })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(revokeObjectUrl).toHaveBeenCalledWith(secondUrl));
|
||||
|
||||
// When
|
||||
rerender(<FileField accept="image/png" acceptDescription="PNG" label="대표 이미지" onChange={onChange} value={firstImage} />);
|
||||
const finalPreview = await screen.findByRole("img", { name: "대표 이미지 업로드 미리보기" });
|
||||
const finalUrl = finalPreview.getAttribute("src");
|
||||
unmount();
|
||||
|
||||
// Then
|
||||
expect(revokeObjectUrl).toHaveBeenCalledWith(finalUrl);
|
||||
});
|
||||
|
||||
test("FileField keeps non-image files filename-only without creating object URLs", () => {
|
||||
// Given
|
||||
const audio = new File(["audio"], "voice.mp3", { type: "audio/mpeg" });
|
||||
|
||||
// When
|
||||
render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={vi.fn()} value={audio} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("voice.mp3")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
expect(createObjectUrl).not.toHaveBeenCalled();
|
||||
expect(revokeObjectUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,197 +1,233 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
|
||||
import { calculateCropSourceRect } from "@/shared/lib/crop-image";
|
||||
import type { CropRenderRequest } from "@/shared/lib/crop-image";
|
||||
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
|
||||
import type { ImageCropDialogProps } from "@/shared/ui/image-crop-dialog";
|
||||
|
||||
type FakeCropperCoordinates = {
|
||||
readonly height: number;
|
||||
readonly left: number;
|
||||
readonly top: number;
|
||||
readonly width: number;
|
||||
};
|
||||
|
||||
type FakeCropperProps = {
|
||||
readonly checkOrientation?: boolean;
|
||||
readonly src?: string;
|
||||
readonly stencilProps?: {
|
||||
readonly aspectRatio?: number;
|
||||
};
|
||||
};
|
||||
|
||||
type FakeCropperHandle = {
|
||||
readonly getCoordinates: () => FakeCropperCoordinates | null;
|
||||
readonly moveImage: (left: number, top?: number) => void;
|
||||
readonly reset: () => void;
|
||||
readonly zoomImage: (scale: number) => void;
|
||||
};
|
||||
|
||||
const cropperFake = vi.hoisted(() => {
|
||||
let checkOrientation: boolean | undefined;
|
||||
|
||||
return {
|
||||
get checkOrientation() {
|
||||
return checkOrientation;
|
||||
},
|
||||
getCoordinates: vi.fn<() => FakeCropperCoordinates | null>(() => ({ height: 600, left: 300, top: 0, width: 600 })),
|
||||
moveImage: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
set checkOrientation(value: boolean | undefined) {
|
||||
checkOrientation = value;
|
||||
},
|
||||
src: "",
|
||||
stencilAspectRatio: 0,
|
||||
zoomImage: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-advanced-cropper", async () => {
|
||||
const { forwardRef, useImperativeHandle } = await vi.importActual<typeof import("react")>("react");
|
||||
|
||||
return {
|
||||
Cropper: forwardRef<FakeCropperHandle, FakeCropperProps>(function FakeCropper(props, ref) {
|
||||
cropperFake.checkOrientation = props.checkOrientation;
|
||||
cropperFake.src = props.src ?? "";
|
||||
cropperFake.stencilAspectRatio = props.stencilProps?.aspectRatio ?? 0;
|
||||
useImperativeHandle(ref, () => ({
|
||||
getCoordinates: cropperFake.getCoordinates,
|
||||
moveImage: cropperFake.moveImage,
|
||||
reset: cropperFake.reset,
|
||||
zoomImage: cropperFake.zoomImage,
|
||||
}));
|
||||
|
||||
return <div data-testid="advanced-cropper" />;
|
||||
}),
|
||||
ImageRestriction: {
|
||||
fillArea: "fillArea",
|
||||
fitArea: "fitArea",
|
||||
none: "none",
|
||||
stencil: "stencil",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const image = {
|
||||
file: new File(["image"], "profile.png", { type: "image/png" }),
|
||||
height: 600,
|
||||
previewUrl: "blob:profile",
|
||||
width: 600,
|
||||
width: 1200,
|
||||
};
|
||||
|
||||
function setPreviewFrameSize(width: number, height: number): void {
|
||||
Object.defineProperty(HTMLImageElement.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => ({ bottom: height, height, left: 0, right: width, top: 0, width, x: 0, y: 0, toJSON: () => ({}) }),
|
||||
});
|
||||
const squarePolicy = { aspect: 1, maxWidth: 800, noUpscale: true } as const;
|
||||
|
||||
function renderDialog(props: ImageCropDialogProps): void {
|
||||
render(<ImageCropDialog {...props} />);
|
||||
}
|
||||
|
||||
function rect(width: number, height: number): DOMRect {
|
||||
return { bottom: height, height, left: 0, right: width, top: 0, width, x: 0, y: 0, toJSON: () => ({}) };
|
||||
}
|
||||
|
||||
async function withElementRects(testBody: () => Promise<void>): Promise<void> {
|
||||
const originalElementRect = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "getBoundingClientRect");
|
||||
const originalImageRect = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, "getBoundingClientRect");
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value(this: HTMLElement) {
|
||||
if (this.getAttribute("aria-label") === "이미지 crop viewport") {
|
||||
return rect(181, 256);
|
||||
}
|
||||
|
||||
return rect(0, 0);
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLImageElement.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => rect(384, 384),
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cropperFake.checkOrientation = undefined;
|
||||
cropperFake.getCoordinates.mockReturnValue({ height: 600, left: 300, top: 0, width: 600 });
|
||||
cropperFake.src = "";
|
||||
cropperFake.stencilAspectRatio = 0;
|
||||
});
|
||||
|
||||
return testBody().finally(() => {
|
||||
if (originalElementRect === undefined) {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, "getBoundingClientRect");
|
||||
} else {
|
||||
Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", originalElementRect);
|
||||
}
|
||||
test("Given an open dialog, when it renders, then it shows the Cropper viewport without legacy direction or range controls", () => {
|
||||
renderDialog({ image, onApply: vi.fn(), onCancel: vi.fn(), open: true, policy: squarePolicy });
|
||||
|
||||
if (originalImageRect === undefined) {
|
||||
Reflect.deleteProperty(HTMLImageElement.prototype, "getBoundingClientRect");
|
||||
} else {
|
||||
Object.defineProperty(HTMLImageElement.prototype, "getBoundingClientRect", originalImageRect);
|
||||
}
|
||||
expect(screen.getByRole("application", { name: "이미지 crop viewport" })).toHaveAttribute("tabindex", "0");
|
||||
expect(cropperFake.src).toBe(image.previewUrl);
|
||||
expect(screen.queryByRole("button", { name: "위로 이동" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "아래로 이동" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "왼쪽으로 이동" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "오른쪽으로 이동" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("slider", { name: "확대 비율" })).not.toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
|
||||
test("ImageCropDialog provides move, zoom, reset, preview, cancel, and apply controls", async () => {
|
||||
test("Given a local preview URL, when the Cropper renders, then it disables the library orientation request", () => {
|
||||
renderDialog({ image, onApply: vi.fn(), onCancel: vi.fn(), open: true, policy: squarePolicy });
|
||||
|
||||
expect(cropperFake.checkOrientation).toBe(false);
|
||||
});
|
||||
|
||||
test("Given Cropper coordinates, when apply is selected, then it maps the existing render request to the same source rectangle and returns its File", async () => {
|
||||
const croppedFile = new File(["crop"], "profile-crop.png", { type: "image/png" });
|
||||
const onApply = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([String(request.zoom)], "crop.png", { type: "image/png" })));
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => {
|
||||
expect(calculateCropSourceRect(request)).toEqual({ height: 600, sourceX: 300, sourceY: 0, width: 600 });
|
||||
return Promise.resolve(croppedFile);
|
||||
});
|
||||
renderDialog({ image, onApply, onCancel: vi.fn(), open: true, policy: squarePolicy, renderCrop });
|
||||
|
||||
render(<ImageCropDialog image={image} onApply={onApply} onCancel={onCancel} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "오른쪽으로 이동" }));
|
||||
fireEvent.change(screen.getByRole("slider", { name: "확대 비율" }), { target: { value: "1.5" } });
|
||||
expect(screen.getByText("예상 결과 400 × 400px")).toBeInTheDocument();
|
||||
await waitFor(() => expect(onApply).toHaveBeenCalledWith(croppedFile));
|
||||
expect(renderCrop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
coordinates: { height: 300, left: 400, top: 100, width: 300 },
|
||||
expected: { height: 300, sourceX: 400, sourceY: 100, width: 300 },
|
||||
policy: squarePolicy,
|
||||
},
|
||||
{
|
||||
coordinates: { height: 1500, left: 1000, top: 500, width: 1060.5 },
|
||||
expected: { height: 1500, sourceX: 1000, sourceY: 500, width: 1061 },
|
||||
policy: { aspect: 210 / 297, maxWidth: 1000, noUpscale: true } as const,
|
||||
},
|
||||
])("Given an off-center fixed-ratio crop, when apply is selected, then it preserves the Cropper source coordinates", async ({ coordinates, expected, policy }) => {
|
||||
const sourceImage = policy.aspect === 1 ? image : { ...image, height: 3000, width: 4000 };
|
||||
const renderCrop = vi.fn<(request: CropRenderRequest) => Promise<File>>(() => Promise.resolve(new File(["crop"], "profile-crop.png", { type: "image/png" })));
|
||||
cropperFake.getCoordinates.mockReturnValue(coordinates);
|
||||
renderDialog({ image: sourceImage, onApply: vi.fn(), onCancel: vi.fn(), open: true, policy, renderCrop });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
await waitFor(() => expect(renderCrop).toHaveBeenCalledTimes(1));
|
||||
expect(calculateCropSourceRect(renderCrop.mock.calls[0]![0])).toEqual(expected);
|
||||
});
|
||||
|
||||
test("Given Cropper coordinates are not ready, when apply is selected, then it preserves the centered crop contract", async () => {
|
||||
const croppedFile = new File(["crop"], "profile-crop.png", { type: "image/png" });
|
||||
const onApply = vi.fn();
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => {
|
||||
expect(calculateCropSourceRect(request)).toEqual({ height: 600, sourceX: 300, sourceY: 0, width: 600 });
|
||||
return Promise.resolve(croppedFile);
|
||||
});
|
||||
cropperFake.getCoordinates.mockReturnValue(null);
|
||||
renderDialog({ image, onApply, onCancel: vi.fn(), open: true, policy: squarePolicy, renderCrop });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
await waitFor(() => expect(renderCrop).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(onApply).toHaveBeenCalledWith(croppedFile));
|
||||
});
|
||||
|
||||
test("Given a free aspect policy, when the dialog renders, then it gives the source ratio to the stencil and reports an 800 by 400 result", () => {
|
||||
cropperFake.getCoordinates.mockReturnValue({ height: 600, left: 0, top: 0, width: 1200 });
|
||||
renderDialog({ image, onApply: vi.fn(), onCancel: vi.fn(), open: true, policy: { aspect: "free", maxWidth: 800, noUpscale: true } });
|
||||
|
||||
expect(cropperFake.stencilAspectRatio).toBe(2);
|
||||
expect(screen.getByText("예상 결과 800 × 400px")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Given the focusable Cropper viewport, when keyboard controls and reset are used, then it delegates movement, zoom, and reset to CropperRef", () => {
|
||||
renderDialog({ image, onApply: vi.fn(), onCancel: vi.fn(), open: true, policy: squarePolicy });
|
||||
const viewport = screen.getByTestId("advanced-cropper");
|
||||
|
||||
viewport.focus();
|
||||
fireEvent.keyDown(viewport, { key: "ArrowUp" });
|
||||
fireEvent.keyDown(viewport, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(viewport, { key: "ArrowLeft" });
|
||||
fireEvent.keyDown(viewport, { key: "ArrowRight" });
|
||||
fireEvent.keyDown(viewport, { key: "+" });
|
||||
fireEvent.keyDown(viewport, { key: "=" });
|
||||
fireEvent.keyDown(viewport, { key: "-" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "초기화" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
await screen.findByText("예상 결과 600 × 600px");
|
||||
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 0, offsetY: 0, outputHeight: 600, outputWidth: 600, zoom: 1 }));
|
||||
expect(onApply).toHaveBeenCalledWith(expect.any(File));
|
||||
fireEvent.click(screen.getByRole("button", { name: "취소" }));
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
expect(cropperFake.moveImage).toHaveBeenNthCalledWith(1, 0, -10);
|
||||
expect(cropperFake.moveImage).toHaveBeenNthCalledWith(2, 0, 10);
|
||||
expect(cropperFake.moveImage).toHaveBeenNthCalledWith(3, -10, 0);
|
||||
expect(cropperFake.moveImage).toHaveBeenNthCalledWith(4, 10, 0);
|
||||
expect(cropperFake.zoomImage).toHaveBeenNthCalledWith(1, 1.1);
|
||||
expect(cropperFake.zoomImage).toHaveBeenNthCalledWith(2, 1.1);
|
||||
expect(cropperFake.zoomImage).toHaveBeenNthCalledWith(3, 0.9);
|
||||
expect(cropperFake.reset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("ImageCropDialog supports keyboard movement and no-upscale sizing", async () => {
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX}`], "crop.png", { type: "image/png" })));
|
||||
|
||||
render(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 2, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
|
||||
const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" });
|
||||
fireEvent.keyDown(preview, { key: "ArrowRight" });
|
||||
fireEvent.keyDown(preview, { key: "+" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
expect(await screen.findByText("예상 결과 545 × 272px")).toBeInTheDocument();
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 10, outputHeight: 272, outputWidth: 545, zoom: 1.1 }));
|
||||
});
|
||||
|
||||
test("ImageCropDialog sends preview frame dimensions with crop offsets", async () => {
|
||||
setPreviewFrameSize(256, 256);
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX}`], "crop.png", { type: "image/png" })));
|
||||
|
||||
render(<ImageCropDialog image={{ ...image, height: 3000, width: 4000 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 1, maxWidth: 1000, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "오른쪽으로 이동" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
await screen.findByText("예상 결과 1000 × 1000px");
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 10, previewFrameHeight: 256, previewFrameWidth: 256, sourceHeight: 3000, sourceWidth: 4000 }));
|
||||
});
|
||||
|
||||
test("ImageCropDialog measures the visible crop viewport instead of the transformed image", async () => {
|
||||
await withElementRects(async () => {
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX}`], "crop.png", { type: "image/png" })));
|
||||
|
||||
render(<ImageCropDialog image={{ ...image, height: 3000, width: 4000 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 210 / 297, maxWidth: 1000, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "오른쪽으로 이동" }));
|
||||
fireEvent.change(screen.getByRole("slider", { name: "확대 비율" }), { target: { value: "1.5" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
await screen.findByText("예상 결과 1000 × 1414px");
|
||||
expect(screen.getByLabelText("이미지 crop viewport")).toHaveStyle({ aspectRatio: `${210 / 297}` });
|
||||
expect(screen.getByAltText("선택한 이미지 미리보기")).toHaveClass("h-full", "w-auto", "max-w-none");
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ previewFrameHeight: 256, previewFrameWidth: 181, zoom: 1.5 }));
|
||||
});
|
||||
});
|
||||
|
||||
test("ImageCropDialog clamps movement on axes without crop overhang", async () => {
|
||||
await withElementRects(async () => {
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetY}`], "crop.png", { type: "image/png" })));
|
||||
|
||||
render(<ImageCropDialog image={{ ...image, height: 3000, width: 4000 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 210 / 297, maxWidth: 1000, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "아래로 이동" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
expect(screen.getByAltText("선택한 이미지 미리보기")).toHaveStyle({ transform: "translate(-50%, -50%) translate(0px, 0px) scale(1)" });
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetY: 0 }));
|
||||
});
|
||||
});
|
||||
|
||||
test("ImageCropDialog keeps apply single-flight and allows retry after render failure", async () => {
|
||||
test("Given an apply request in flight, when apply is repeated and rendering fails, then it stays single-flight, shows the error, and allows retry", async () => {
|
||||
const onApply = vi.fn();
|
||||
let rejectCrop: (error: Error) => void = () => undefined;
|
||||
const renderCrop = vi.fn(() => new Promise<File>((_resolve, reject) => {
|
||||
rejectCrop = reject;
|
||||
}));
|
||||
|
||||
render(<ImageCropDialog image={image} onApply={onApply} onCancel={vi.fn()} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
renderDialog({ image, onApply, onCancel: vi.fn(), open: true, policy: squarePolicy, renderCrop });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
await screen.findByRole("status");
|
||||
expect(renderCrop).toHaveBeenCalledTimes(1);
|
||||
|
||||
rejectCrop(new Error("render failed"));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("이미지 crop을 적용하지 못했습니다.");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
expect(renderCrop).toHaveBeenCalledTimes(2);
|
||||
expect(onApply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("ImageCropDialog changes zoom with a two pointer pinch", async () => {
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([String(request.zoom)], "crop.png", { type: "image/png" })));
|
||||
|
||||
render(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
|
||||
const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" });
|
||||
fireEvent.pointerDown(preview, { clientX: 100, clientY: 100, pointerId: 1 });
|
||||
fireEvent.pointerDown(preview, { clientX: 200, clientY: 100, pointerId: 2 });
|
||||
fireEvent.pointerMove(preview, { clientX: 250, clientY: 100, pointerId: 2 });
|
||||
fireEvent.pointerUp(preview, { pointerId: 1 });
|
||||
fireEvent.pointerUp(preview, { pointerId: 2 });
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
expect(await screen.findByText("예상 결과 400 × 400px")).toBeInTheDocument();
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ outputHeight: 400, outputWidth: 400, zoom: 1.5 }));
|
||||
expect(screen.getByRole("slider", { name: "확대 비율" })).toHaveValue("1.5");
|
||||
});
|
||||
|
||||
test("ImageCropDialog disables native touch gestures on the crop preview", () => {
|
||||
render(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} />);
|
||||
|
||||
expect(screen.getByRole("application", { name: "이미지 crop 미리보기" })).toHaveStyle({ touchAction: "none" });
|
||||
});
|
||||
|
||||
test("ImageCropDialog closes itself on Escape without bubbling to parent dialogs", () => {
|
||||
test("Given an open dialog inside a parent, when Escape is pressed, then it cancels once without bubbling", () => {
|
||||
const onCancel = vi.fn();
|
||||
const onParentEscape = vi.fn();
|
||||
|
||||
render(
|
||||
<div onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
onParentEscape();
|
||||
}
|
||||
}}>
|
||||
<ImageCropDialog image={image} onApply={vi.fn()} onCancel={onCancel} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} />
|
||||
<ImageCropDialog image={image} onApply={vi.fn()} onCancel={onCancel} open policy={squarePolicy} />
|
||||
</div>,
|
||||
);
|
||||
|
||||
@@ -201,24 +237,27 @@ test("ImageCropDialog closes itself on Escape without bubbling to parent dialogs
|
||||
expect(onParentEscape).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("ImageCropDialog supports free ratio output and pointer drag movement", async () => {
|
||||
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX},${request.offsetY}`], "crop.png", { type: "image/png" })));
|
||||
test("Given crop rendering is in progress, when Escape is pressed, then it keeps the pending result active", async () => {
|
||||
const croppedFile = new File(["crop"], "profile-crop.png", { type: "image/png" });
|
||||
const onApply = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
let resolveCrop: (file: File) => void = () => undefined;
|
||||
const renderCrop = vi.fn(() => new Promise<File>((resolve) => {
|
||||
resolveCrop = resolve;
|
||||
}));
|
||||
renderDialog({ image, onApply, onCancel, open: true, policy: squarePolicy, renderCrop });
|
||||
|
||||
render(<ImageCropDialog image={{ ...image, height: 600, width: 1200 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: "free", maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
|
||||
|
||||
const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" });
|
||||
fireEvent.change(screen.getByRole("slider", { name: "확대 비율" }), { target: { value: "1.5" } });
|
||||
fireEvent.pointerDown(preview, { clientX: 100, clientY: 100, pointerId: 1 });
|
||||
fireEvent.pointerMove(preview, { clientX: 130, clientY: 115, pointerId: 1 });
|
||||
fireEvent.pointerUp(preview, { pointerId: 1 });
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
await screen.findByRole("status");
|
||||
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
|
||||
|
||||
expect(await screen.findByText("예상 결과 800 × 400px")).toBeInTheDocument();
|
||||
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 30, offsetY: 15, outputHeight: 400, outputWidth: 800 }));
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
resolveCrop(croppedFile);
|
||||
await waitFor(() => expect(onApply).toHaveBeenCalledWith(croppedFile));
|
||||
});
|
||||
|
||||
test("ImageCropDialog renders nothing when closed", () => {
|
||||
render(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open={false} policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} />);
|
||||
test("Given a closed dialog, when it renders, then it returns no dialog", () => {
|
||||
renderDialog({ image, onApply: vi.fn(), onCancel: vi.fn(), open: false, policy: squarePolicy });
|
||||
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ test("PageState exposes accessible loading, empty, error, retry, and content sta
|
||||
const { rerender } = render(<PageState description="자료를 불러오는 중입니다." state="loading" title="불러오는 중" />);
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("불러오는 중");
|
||||
expect(screen.getByRole("heading", { name: "불러오는 중" })).toHaveClass("break-keep");
|
||||
expect(screen.getByText("자료를 불러오는 중입니다.")).toHaveClass("break-keep");
|
||||
|
||||
rerender(<PageState description="조건에 맞는 자료가 없습니다." state="empty" title="자료 없음" />);
|
||||
|
||||
|
||||
36
src/shared/ui/__tests__/tag-input.test.tsx
Normal file
36
src/shared/ui/__tests__/tag-input.test.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { TagInput } from "@/shared/ui/tag-input";
|
||||
|
||||
test("TagInput creates no chip from blank drafts", () => {
|
||||
// Given
|
||||
const onChange = vi.fn();
|
||||
render(<TagInput error={undefined} errorId="keyword-error" label="키워드" onChange={onChange} value="" />);
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("키워드"), { target: { value: " " } });
|
||||
fireEvent.keyDown(screen.getByLabelText("키워드"), { key: "Enter" });
|
||||
fireEvent.change(screen.getByLabelText("키워드"), { target: { value: "\t" } });
|
||||
fireEvent.keyDown(screen.getByLabelText("키워드"), { key: "," });
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("button", { name: /삭제/ })).not.toBeInTheDocument();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("TagInput emits only the remaining comma-separated value when removing a committed chip", () => {
|
||||
// Given
|
||||
const onChange = vi.fn();
|
||||
render(<TagInput error={undefined} errorId="keyword-error" label="키워드" onChange={onChange} value="달빛,상담" />);
|
||||
const removeButton = screen.getByRole("button", { name: "키워드 달빛 삭제" });
|
||||
|
||||
// When
|
||||
fireEvent.click(removeButton);
|
||||
|
||||
// Then
|
||||
expect(removeButton.querySelector("svg[aria-hidden='true']")).toBeInTheDocument();
|
||||
expect(removeButton).not.toHaveTextContent("삭제");
|
||||
expect(onChange).toHaveBeenCalledOnce();
|
||||
expect(onChange).toHaveBeenCalledWith("상담");
|
||||
});
|
||||
@@ -87,7 +87,7 @@ export function AdminAudioPlayer({ playerId, src, title }: AdminAudioPlayerProps
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label={`${title} 오디오 플레이어`} className="flex min-w-0 flex-col gap-3 rounded-lg border border-border bg-card p-4" onKeyDown={handleKeyDown} role="group" tabIndex={0}>
|
||||
<section aria-label={`${title} 오디오 플레이어`} className="flex min-w-0 flex-col gap-2 rounded-lg border border-border bg-card px-1 py-1 sm:px-3" onKeyDown={handleKeyDown} role="group" tabIndex={0}>
|
||||
<audio
|
||||
controlsList="nodownload"
|
||||
onDurationChange={(event) => setDuration(event.currentTarget.duration)}
|
||||
@@ -106,30 +106,26 @@ export function AdminAudioPlayer({ playerId, src, title }: AdminAudioPlayerProps
|
||||
ref={audioRef}
|
||||
src={src}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={togglePlay} type="button">
|
||||
{isPlaying ? "일시정지" : "재생"}
|
||||
<div aria-label="재생 제어" className="flex min-w-0 flex-wrap items-center gap-1 sm:gap-2" role="group">
|
||||
<button aria-label={isPlaying ? "일시정지" : "재생"} className="grid size-11 shrink-0 place-items-center rounded-full border border-input bg-primary text-primary-foreground hover:bg-[var(--button-bg-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:bg-[var(--button-bg-active)]" onClick={togglePlay} type="button">
|
||||
<svg aria-hidden="true" className="size-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
{isPlaying ? <path d="M7 5h4v14H7zm6 0h4v14h-4z" /> : <path d="m8 5 11 7-11 7z" />}
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm text-muted-foreground">{formatTime(currentTime)} / {formatTime(duration)}</span>
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
||||
재생 위치
|
||||
<input aria-label="재생 위치" max={duration || 0} min="0" onChange={(event) => changeCurrentTime(Number(event.currentTarget.value))} step="1" type="range" value={currentTime} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
||||
볼륨
|
||||
<input aria-label="볼륨" defaultValue="1" max="1" min="0" onChange={(event) => changeVolume(Number(event.currentTarget.value))} step="0.05" type="range" />
|
||||
</label>
|
||||
<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" onChange={(event) => changePlaybackRate(Number(event.currentTarget.value))} defaultValue="1">
|
||||
<input aria-label="재생 위치" className="h-11 min-w-11 flex-1 accent-primary" max={duration || 0} min="0" onChange={(event) => changeCurrentTime(Number(event.currentTarget.value))} step="1" type="range" value={currentTime} />
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">{formatTime(currentTime)}/{formatTime(duration)}</span>
|
||||
<select aria-label="재생 속도" className="min-h-11 w-11 shrink-0 rounded-md border border-input bg-card px-1 text-center text-base font-semibold sm:text-sm" defaultValue="1" onChange={(event) => changePlaybackRate(Number(event.currentTarget.value))}>
|
||||
<option value="0.75">0.75×</option>
|
||||
<option value="1">1×</option>
|
||||
<option value="1.25">1.25×</option>
|
||||
<option value="1.5">1.5×</option>
|
||||
<option value="2">2×</option>
|
||||
</select>
|
||||
</label>
|
||||
<svg aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M4 9v6h4l5 4V5L8 9zm11.5 3a3.5 3.5 0 0 0-1.5-2.87v5.74A3.5 3.5 0 0 0 15.5 12Zm-1.5-7.46v2.06a6 6 0 0 1 0 10.8v2.06a8 8 0 0 0 0-14.92Z" />
|
||||
</svg>
|
||||
<input aria-label="볼륨" className="h-11 min-w-11 basis-11 shrink grow-0 accent-primary" defaultValue="1" max="1" min="0" onChange={(event) => changeVolume(Number(event.currentTarget.value))} step="0.05" type="range" />
|
||||
</div>
|
||||
{hasError ? (
|
||||
<div className="flex min-w-0 flex-col gap-2 rounded-md border border-destructive bg-card p-3 text-sm text-destructive" role="alert">
|
||||
<p className="break-words">오디오를 재생할 수 없습니다. 페이지 새로고침 후 다시 시도하세요.</p>
|
||||
|
||||
33
src/shared/ui/can-price-field.tsx
Normal file
33
src/shared/ui/can-price-field.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useId } from "react";
|
||||
|
||||
export type CanPriceFieldProps = {
|
||||
readonly error: string | undefined;
|
||||
readonly errorId: string;
|
||||
readonly onChange: (value: string) => void;
|
||||
readonly value: string;
|
||||
};
|
||||
|
||||
export function CanPriceField({ error, errorId, onChange, value }: CanPriceFieldProps) {
|
||||
const inputId = useId();
|
||||
const unitId = `${inputId}-unit`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-semibold" htmlFor={inputId}>가격</label>
|
||||
<input
|
||||
aria-describedby={`${unitId}${error === undefined ? "" : ` ${errorId}`}`}
|
||||
aria-invalid={error === undefined ? undefined : true}
|
||||
className="rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground"
|
||||
id={inputId}
|
||||
inputMode="numeric"
|
||||
min="0"
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
step="1"
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground" id={unitId}>단위: 캔</p>
|
||||
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useId, useRef } from "react";
|
||||
import { useId, useLayoutEffect, useRef } from "react";
|
||||
|
||||
export type FileFieldProps = {
|
||||
readonly accept: string;
|
||||
@@ -16,8 +16,33 @@ export function FileField({ accept, acceptDescription, description, error, label
|
||||
const acceptId = useId();
|
||||
const errorId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const previewRef = useRef<HTMLImageElement>(null);
|
||||
const isImage = value !== null && value.type.startsWith("image/");
|
||||
const describedBy = [description === undefined ? null : descriptionId, acceptId, error === undefined ? null : errorId].filter((id): id is string => id !== null).join(" ");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (value === null || !value.type.startsWith("image/")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(value);
|
||||
const previewElement = previewRef.current;
|
||||
previewElement?.setAttribute("src", objectUrl);
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
previewElement?.removeAttribute("src");
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const input = inputRef.current;
|
||||
const selectedFile = input?.files?.[0];
|
||||
if (input !== null && selectedFile !== undefined && selectedFile !== value) {
|
||||
input.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = event.currentTarget.files;
|
||||
onChange(files === null ? null : files[0] ?? null);
|
||||
@@ -31,18 +56,23 @@ export function FileField({ accept, acceptDescription, description, error, label
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card p-4 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
|
||||
<label className="text-sm font-semibold" htmlFor={inputId}>{label}</label>
|
||||
{description === undefined ? null : <p className="text-sm text-muted-foreground" id={descriptionId}>{description}</p>}
|
||||
<p className="text-sm text-muted-foreground" id={acceptId}>{acceptDescription}</p>
|
||||
{description === undefined ? null : <p className="break-keep text-sm text-muted-foreground" id={descriptionId}>{description}</p>}
|
||||
<p className="break-keep text-sm text-muted-foreground" id={acceptId}>{acceptDescription}</p>
|
||||
<input accept={accept} aria-describedby={describedBy} aria-invalid={error === undefined ? undefined : true} className="sr-only" id={inputId} onChange={handleChange} ref={inputRef} type="file" />
|
||||
<button aria-label={`${label} 파일 선택`} className="w-fit rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => inputRef.current?.click()} type="button">파일 선택</button>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{value === null ? "선택된 파일 없음" : value.name}</span>
|
||||
{value === null ? null : (
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={clearSelection} type="button">선택 취소</button>
|
||||
<button aria-label={`${label} 선택 취소`} className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={clearSelection} type="button">선택 취소</button>
|
||||
)}
|
||||
</div>
|
||||
{isImage ? (
|
||||
<figure aria-label={`${label} 업로드 미리보기 영역`} className="aspect-video max-h-64 w-full max-w-md overflow-hidden rounded-md border border-border bg-muted p-2">
|
||||
<img alt={`${label} 업로드 미리보기`} className="h-full w-full object-contain" ref={previewRef} />
|
||||
</figure>
|
||||
) : null}
|
||||
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Cropper, ImageRestriction } from "react-advanced-cropper";
|
||||
import type { Coordinates, CropperRef } from "react-advanced-cropper";
|
||||
import "react-advanced-cropper/dist/style.css";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { calculateCropOutputSize, createCroppedImageFile } from "@/shared/lib/crop-image";
|
||||
import type { CropRenderRequest } from "@/shared/lib/crop-image";
|
||||
@@ -28,107 +31,42 @@ export type ImageCropDialogProps = {
|
||||
};
|
||||
|
||||
const MOVE_STEP = 10;
|
||||
const ZOOM_STEP = 0.1;
|
||||
|
||||
type PointerPoint = {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
};
|
||||
|
||||
type PinchState = {
|
||||
readonly distance: number;
|
||||
readonly zoom: number;
|
||||
};
|
||||
|
||||
type CropOffset = {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
};
|
||||
|
||||
type CropFrameSize = {
|
||||
readonly height: number;
|
||||
readonly width: number;
|
||||
};
|
||||
|
||||
export function ImageCropDialog({ image, onApply, onCancel, open, policy, renderCrop = createCroppedImageFile }: ImageCropDialogProps) {
|
||||
const [offsetX, setOffsetX] = useState(0);
|
||||
const [offsetY, setOffsetY] = useState(0);
|
||||
const [applyError, setApplyError] = useState<string | null>(null);
|
||||
const [coordinates, setCoordinates] = useState<Coordinates | null>(null);
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
const [viewportSize, setViewportSize] = useState<CropFrameSize | null>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const dragPointRef = useRef<{ readonly x: number; readonly y: number } | null>(null);
|
||||
const pinchRef = useRef<PinchState | null>(null);
|
||||
const cropViewportRef = useRef<HTMLDivElement>(null);
|
||||
const previewImageRef = useRef<HTMLImageElement>(null);
|
||||
const pointersRef = useRef(new Map<number, PointerPoint>());
|
||||
const cropperRef = useRef<CropperRef>(null);
|
||||
const { dialogRef, trapFocus } = useModalFocus<HTMLElement>(open);
|
||||
const outputSize = calculateCropOutputSize({ aspect: policy.aspect, maxWidth: policy.maxWidth, noUpscale: policy.noUpscale, sourceHeight: image.height, sourceWidth: image.width, zoom });
|
||||
const cropFrameAspect = policy.aspect === "free" ? image.width / image.height : policy.aspect;
|
||||
const sourceAspect = image.width / image.height;
|
||||
const coverImageClass = sourceAspect > cropFrameAspect ? "h-full w-auto max-w-none" : "h-auto w-full max-w-none";
|
||||
const clampedOffset = clampOffset({ x: offsetX, y: offsetY }, viewportSize);
|
||||
const setCropViewportNode = useCallback((node: HTMLDivElement | null) => {
|
||||
cropViewportRef.current = node;
|
||||
if (node === null) {
|
||||
setViewportSize(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = node.getBoundingClientRect();
|
||||
setViewportSize({ height: rect.height, width: rect.width });
|
||||
}, []);
|
||||
const resolvedAspect = policy.aspect === "free" ? sourceAspect : policy.aspect;
|
||||
const baseWidth = sourceAspect > resolvedAspect ? Math.round(image.height * resolvedAspect) : image.width;
|
||||
const baseHeight = sourceAspect > resolvedAspect ? image.height : Math.round(image.width / resolvedAspect);
|
||||
const zoom = coordinates === null ? 1 : baseWidth / coordinates.width;
|
||||
const outputSize = calculateCropOutputSize({
|
||||
aspect: policy.aspect,
|
||||
maxWidth: policy.maxWidth,
|
||||
noUpscale: policy.noUpscale,
|
||||
sourceHeight: image.height,
|
||||
sourceWidth: image.width,
|
||||
zoom,
|
||||
});
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function resetCrop() {
|
||||
setOffsetX(0);
|
||||
setOffsetY(0);
|
||||
setZoom(1);
|
||||
}
|
||||
|
||||
function move(deltaX: number, deltaY: number) {
|
||||
setOffsetX((current) => current + deltaX);
|
||||
setOffsetY((current) => current + deltaY);
|
||||
}
|
||||
|
||||
function changeZoom(nextZoom: number) {
|
||||
setZoom(Math.min(3, Math.max(1, Number(nextZoom.toFixed(1)))));
|
||||
}
|
||||
|
||||
function clampOffset(offset: CropOffset, frameSize: CropFrameSize | null): CropOffset {
|
||||
if (frameSize === null || frameSize.width <= 0 || frameSize.height <= 0) {
|
||||
return offset;
|
||||
}
|
||||
|
||||
const coverWidthRatio = sourceAspect > cropFrameAspect ? sourceAspect / cropFrameAspect : 1;
|
||||
const coverHeightRatio = sourceAspect > cropFrameAspect ? 1 : cropFrameAspect / sourceAspect;
|
||||
const maxX = Math.max(0, (frameSize.width * coverWidthRatio * zoom - frameSize.width) / 2);
|
||||
const maxY = Math.max(0, (frameSize.height * coverHeightRatio * zoom - frameSize.height) / 2);
|
||||
|
||||
return {
|
||||
x: Math.min(Math.max(offset.x, -maxX), maxX),
|
||||
y: Math.min(Math.max(offset.y, -maxY), maxY),
|
||||
};
|
||||
}
|
||||
|
||||
function getPinchDistance() {
|
||||
const points = Array.from(pointersRef.current.values());
|
||||
const first = points[0];
|
||||
const second = points[1];
|
||||
if (first === undefined || second === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.hypot(second.x - first.x, second.y - first.y);
|
||||
function updateCoordinates(cropper: CropperRef) {
|
||||
setCoordinates(cropper.getCoordinates());
|
||||
}
|
||||
|
||||
function handleDialogKeyDown(event: React.KeyboardEvent<HTMLElement>) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (isApplying) {
|
||||
return;
|
||||
}
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
@@ -136,103 +74,88 @@ export function ImageCropDialog({ image, onApply, onCancel, open, policy, render
|
||||
trapFocus(event);
|
||||
}
|
||||
|
||||
function handlePreviewKeyDown(event: React.KeyboardEvent<HTMLElement>) {
|
||||
function handleCropperKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||
const cropper = cropperRef.current;
|
||||
if (cropper === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
move(0, MOVE_STEP);
|
||||
cropper.moveImage(0, MOVE_STEP);
|
||||
return;
|
||||
case "ArrowLeft":
|
||||
event.preventDefault();
|
||||
move(-MOVE_STEP, 0);
|
||||
cropper.moveImage(-MOVE_STEP, 0);
|
||||
return;
|
||||
case "ArrowRight":
|
||||
event.preventDefault();
|
||||
move(MOVE_STEP, 0);
|
||||
cropper.moveImage(MOVE_STEP, 0);
|
||||
return;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
move(0, -MOVE_STEP);
|
||||
cropper.moveImage(0, -MOVE_STEP);
|
||||
return;
|
||||
case "+":
|
||||
case "=":
|
||||
event.preventDefault();
|
||||
changeZoom(zoom + ZOOM_STEP);
|
||||
cropper.zoomImage(1.1);
|
||||
return;
|
||||
case "-":
|
||||
event.preventDefault();
|
||||
changeZoom(zoom - ZOOM_STEP);
|
||||
cropper.zoomImage(0.9);
|
||||
return;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
function startDrag(event: React.PointerEvent<HTMLElement>) {
|
||||
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
if (pointersRef.current.size === 1) {
|
||||
dragPointRef.current = { x: event.clientX, y: event.clientY };
|
||||
return;
|
||||
}
|
||||
|
||||
const distance = getPinchDistance();
|
||||
if (distance !== null) {
|
||||
pinchRef.current = { distance, zoom };
|
||||
dragPointRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function drag(event: React.PointerEvent<HTMLElement>) {
|
||||
if (pointersRef.current.has(event.pointerId)) {
|
||||
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
}
|
||||
|
||||
const pinch = pinchRef.current;
|
||||
const distance = getPinchDistance();
|
||||
if (pinch !== null && distance !== null) {
|
||||
changeZoom(pinch.zoom * (distance / pinch.distance));
|
||||
return;
|
||||
}
|
||||
|
||||
const dragPoint = dragPointRef.current;
|
||||
if (dragPoint === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
move(event.clientX - dragPoint.x, event.clientY - dragPoint.y);
|
||||
dragPointRef.current = { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
|
||||
function stopDrag(event: React.PointerEvent<HTMLElement>) {
|
||||
pointersRef.current.delete(event.pointerId);
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
pinchRef.current = null;
|
||||
dragPointRef.current = null;
|
||||
function resetCrop() {
|
||||
cropperRef.current?.reset();
|
||||
}
|
||||
|
||||
async function applyCrop() {
|
||||
if (isApplying) {
|
||||
return;
|
||||
}
|
||||
|
||||
const freshCoordinates = cropperRef.current?.getCoordinates() ?? {
|
||||
height: baseHeight,
|
||||
left: (image.width - baseWidth) / 2,
|
||||
top: (image.height - baseHeight) / 2,
|
||||
width: baseWidth,
|
||||
};
|
||||
|
||||
const requestZoom = baseWidth / freshCoordinates.width;
|
||||
const renderedWidth = Math.round(baseWidth / requestZoom);
|
||||
const renderedHeight = Math.round(baseHeight / requestZoom);
|
||||
const centeredX = (image.width - renderedWidth) / 2;
|
||||
const centeredY = (image.height - renderedHeight) / 2;
|
||||
const requestOutputSize = calculateCropOutputSize({
|
||||
aspect: policy.aspect,
|
||||
maxWidth: policy.maxWidth,
|
||||
noUpscale: policy.noUpscale,
|
||||
sourceHeight: image.height,
|
||||
sourceWidth: image.width,
|
||||
zoom: requestZoom,
|
||||
});
|
||||
setApplyError(null);
|
||||
setIsApplying(true);
|
||||
const currentViewportRect = cropViewportRef.current?.getBoundingClientRect();
|
||||
const previewRect = previewImageRef.current?.getBoundingClientRect();
|
||||
const frameRect = currentViewportRect !== undefined && currentViewportRect.width > 0 && currentViewportRect.height > 0 ? currentViewportRect : previewRect;
|
||||
const cropOffset = clampOffset({ x: offsetX, y: offsetY }, frameRect === undefined ? null : { height: frameRect.height, width: frameRect.width });
|
||||
|
||||
try {
|
||||
const file = await renderCrop({
|
||||
aspect: policy.aspect,
|
||||
file: image.file,
|
||||
offsetX: cropOffset.x,
|
||||
offsetY: cropOffset.y,
|
||||
outputHeight: outputSize.height,
|
||||
outputWidth: outputSize.width,
|
||||
previewFrameHeight: frameRect?.height,
|
||||
previewFrameWidth: frameRect?.width,
|
||||
offsetX: (centeredX - freshCoordinates.left) * requestZoom,
|
||||
offsetY: (centeredY - freshCoordinates.top) * requestZoom,
|
||||
outputHeight: requestOutputSize.height,
|
||||
outputWidth: requestOutputSize.width,
|
||||
previewFrameHeight: baseHeight,
|
||||
previewFrameWidth: baseWidth,
|
||||
previewUrl: image.previewUrl,
|
||||
sourceHeight: image.height,
|
||||
sourceWidth: image.width,
|
||||
zoom,
|
||||
zoom: requestZoom,
|
||||
});
|
||||
onApply(file);
|
||||
} catch (error: unknown) {
|
||||
@@ -247,33 +170,32 @@ export function ImageCropDialog({ image, onApply, onCancel, open, policy, render
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-modal grid place-items-center bg-background/80 p-4">
|
||||
<section aria-label="이미지 crop" aria-modal="true" className="flex w-full max-w-lg flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={handleDialogKeyDown} ref={dialogRef} role="dialog">
|
||||
<section aria-label="이미지 crop" aria-modal="true" className="flex max-h-[calc(100dvh-2rem)] w-full max-w-lg flex-col gap-4 overflow-y-auto rounded-lg border border-border bg-card p-4 sm:p-6" onKeyDown={handleDialogKeyDown} ref={dialogRef} role="dialog">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-xl font-semibold">이미지 crop</h2>
|
||||
<p className="text-sm text-muted-foreground">버튼, 범위 입력, 방향키로 위치와 확대를 조정한 뒤 적용합니다.</p>
|
||||
</div>
|
||||
<div aria-label="이미지 crop 미리보기" className="overflow-hidden rounded-lg border border-border bg-muted p-4" onKeyDown={handlePreviewKeyDown} onPointerCancel={stopDrag} onPointerDown={startDrag} onPointerLeave={stopDrag} onPointerMove={drag} onPointerUp={stopDrag} role="application" style={{ touchAction: "none" }} tabIndex={0}>
|
||||
<div aria-label="이미지 crop viewport" className="relative mx-auto overflow-hidden rounded-md border border-info/70 bg-background" ref={setCropViewportNode} style={{ aspectRatio: String(cropFrameAspect), width: `${Math.min(16 * cropFrameAspect, 32)}rem`, maxWidth: "100%" }}>
|
||||
<img alt="선택한 이미지 미리보기" className={`absolute left-1/2 top-1/2 ${coverImageClass}`} ref={previewImageRef} src={image.previewUrl} style={{ transform: `translate(-50%, -50%) translate(${clampedOffset.x}px, ${clampedOffset.y}px) scale(${zoom})` }} />
|
||||
<p className="text-sm text-muted-foreground">이미지를 이동하거나 확대해 사용할 영역을 선택한 뒤 적용합니다.</p>
|
||||
</div>
|
||||
<div aria-label="이미지 crop viewport" className="overflow-hidden rounded-lg border border-border bg-muted p-2 sm:p-4" onKeyDown={handleCropperKeyDown} role="application" tabIndex={0}>
|
||||
<Cropper
|
||||
canvas={false}
|
||||
checkOrientation={false}
|
||||
className="h-[min(56vh,28rem)] min-h-64 w-full rounded-md bg-muted"
|
||||
disabled={isApplying}
|
||||
imageRestriction={ImageRestriction.stencil}
|
||||
onChange={updateCoordinates}
|
||||
onReady={updateCoordinates}
|
||||
ref={cropperRef}
|
||||
src={image.previewUrl}
|
||||
stencilProps={{ aspectRatio: resolvedAspect, grid: true }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-info">예상 결과 {outputSize.width} × {outputSize.height}px</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={() => move(0, -MOVE_STEP)} type="button">위로 이동</button>
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={() => move(0, MOVE_STEP)} type="button">아래로 이동</button>
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={() => move(-MOVE_STEP, 0)} type="button">왼쪽으로 이동</button>
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={() => move(MOVE_STEP, 0)} type="button">오른쪽으로 이동</button>
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
||||
확대 비율
|
||||
<input aria-label="확대 비율" disabled={isApplying} max="3" min="1" onChange={(event) => changeZoom(Number(event.currentTarget.value))} step="0.1" type="range" value={zoom} />
|
||||
</label>
|
||||
{applyError === null ? null : <p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">{applyError}</p>}
|
||||
{isApplying ? <p className="rounded-md border border-border bg-card p-3 text-sm font-semibold" role="status">이미지 crop을 적용하는 중</p> : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={resetCrop} type="button">초기화</button>
|
||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={onCancel} type="button">취소</button>
|
||||
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isApplying} onClick={() => void applyCrop()} type="button">적용</button>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<button className="min-h-11 rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={resetCrop} type="button">초기화</button>
|
||||
<button className="min-h-11 rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isApplying} onClick={onCancel} type="button">취소</button>
|
||||
<button className="min-h-11 rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isApplying} onClick={() => void applyCrop()} type="button">적용</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -37,22 +37,22 @@ export function PageState(props: PageStateProps) {
|
||||
case "loading":
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-card p-6" role="status">
|
||||
<h2 className="text-xl font-semibold">{props.title}</h2>
|
||||
{props.description === undefined ? null : <p className="mt-2 text-sm text-muted-foreground">{props.description}</p>}
|
||||
<h2 className="break-keep text-xl font-semibold">{props.title}</h2>
|
||||
{props.description === undefined ? null : <p className="mt-2 break-keep text-sm text-muted-foreground">{props.description}</p>}
|
||||
</section>
|
||||
);
|
||||
case "empty":
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-card p-6" role="status">
|
||||
<h2 className="text-xl font-semibold">{props.title}</h2>
|
||||
{props.description === undefined ? null : <p className="mt-2 text-sm text-muted-foreground">{props.description}</p>}
|
||||
<h2 className="break-keep text-xl font-semibold">{props.title}</h2>
|
||||
{props.description === undefined ? null : <p className="mt-2 break-keep text-sm text-muted-foreground">{props.description}</p>}
|
||||
</section>
|
||||
);
|
||||
case "error":
|
||||
return (
|
||||
<section className="rounded-lg border border-destructive bg-card p-6 text-destructive" role="alert">
|
||||
<h2 className="text-xl font-semibold">{props.title}</h2>
|
||||
{props.description === undefined ? null : <p className="mt-2 text-sm">{props.description}</p>}
|
||||
<h2 className="break-keep text-xl font-semibold">{props.title}</h2>
|
||||
{props.description === undefined ? null : <p className="mt-2 break-keep text-sm">{props.description}</p>}
|
||||
{props.onRetry === undefined ? null : (
|
||||
<button className="mt-4 rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={props.onRetry} type="button">
|
||||
다시 시도
|
||||
|
||||
59
src/shared/ui/tag-input.tsx
Normal file
59
src/shared/ui/tag-input.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useId, useState } from "react";
|
||||
|
||||
export type TagInputProps = {
|
||||
readonly error: string | undefined;
|
||||
readonly errorId: string;
|
||||
readonly label: string;
|
||||
readonly onChange: (value: string) => void;
|
||||
readonly value: string;
|
||||
};
|
||||
|
||||
export function TagInput({ error, errorId, label, onChange, value }: TagInputProps) {
|
||||
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}</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={`${label} ${tag} 삭제`} className="inline-flex min-h-11 min-w-11 items-center justify-center rounded-md 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">
|
||||
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16">
|
||||
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
|
||||
</svg>
|
||||
</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로 {label}를 추가하세요.</p>
|
||||
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ test("mobile supports list, search, and detail while hiding detail mutation acti
|
||||
await loginThroughMockMode(page);
|
||||
|
||||
// When
|
||||
await expectActionAbsentAcrossInteractiveRoles(page, "AI 캐릭터 생성");
|
||||
await expect(page.getByRole("link", { name: "AI 캐릭터 생성" })).toBeVisible();
|
||||
await page.getByLabel("검색어").fill("루나");
|
||||
await expect(page.getByRole("link", { name: "루나 선택" })).toBeVisible();
|
||||
await page.getByRole("link", { name: "루나 선택" }).click();
|
||||
@@ -204,8 +204,8 @@ test("keyboard-only path reaches the edit form and dirty-leave dialog", async ({
|
||||
// When
|
||||
await searchInput.focus();
|
||||
await page.keyboard.type("루나");
|
||||
await expect(page.getByRole("link", { name: "루나 선택" })).toBeVisible();
|
||||
await pressTabUntilFocused(page, page.getByRole("link", { name: "루나 선택" }));
|
||||
await expect(page.getByRole("link", { name: "루나", exact: true })).toBeVisible();
|
||||
await pressTabUntilFocused(page, page.getByRole("link", { name: "루나", exact: true }));
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("heading", { name: "루나", exact: true })).toBeVisible();
|
||||
await pressTabUntilFocused(page, page.getByRole("link", { name: "프로필" }));
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import type { Locator, Page, TestInfo } from "@playwright/test";
|
||||
|
||||
// allow: SIZE_OK - ordered Community browser states intentionally share one E2E narrative.
|
||||
|
||||
const interactiveActionRoles = ["button", "link", "menuitem"] as const;
|
||||
const tinyGif = Buffer.from("R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==", "base64");
|
||||
const tinyPng = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFElEQVQYV2NkYGD4z0AEYBxVSF+FAAfiAQELhYkNAAAAAElFTkSuQmCC", "base64");
|
||||
|
||||
async function captureScreenshot(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
const path = testInfo.outputPath(name);
|
||||
await page.screenshot({ fullPage: true, path });
|
||||
await testInfo.attach(name, { contentType: "image/png", path });
|
||||
}
|
||||
|
||||
async function expectNoHorizontalOverflow(page: Page): Promise<void> {
|
||||
const hasHorizontalOverflow = await page.evaluate(
|
||||
@@ -12,10 +22,17 @@ async function expectNoHorizontalOverflow(page: Page): Promise<void> {
|
||||
expect(hasHorizontalOverflow).toBe(false);
|
||||
}
|
||||
|
||||
async function zoomTo200Percent(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.zoom = "2";
|
||||
});
|
||||
async function expectNoClippedKoreanText(root: Locator): Promise<void> {
|
||||
const clippedText = await root.evaluate((element) => Array.from(element.querySelectorAll<HTMLElement>("*"))
|
||||
.filter((candidate) => candidate.childElementCount === 0 && /[가-힣]/.test(candidate.textContent ?? ""))
|
||||
.filter((candidate) => {
|
||||
const style = getComputedStyle(candidate);
|
||||
return style.display !== "none" && style.visibility !== "hidden" && candidate.clientWidth > 0 && candidate.clientHeight > 0;
|
||||
})
|
||||
.filter((candidate) => candidate.scrollWidth > candidate.clientWidth + 1 || candidate.scrollHeight > candidate.clientHeight + 1)
|
||||
.map((candidate) => candidate.textContent?.trim()));
|
||||
|
||||
expect(clippedText).toEqual([]);
|
||||
}
|
||||
|
||||
async function expectNoCriticalOrSeriousAxeViolations(page: Page): Promise<void> {
|
||||
@@ -119,9 +136,9 @@ test("mobile Community route stays read-only while list sheet and audio remain a
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test("mobile Community create route shows guidance instead of the create form", async ({ page }) => {
|
||||
test("mobile Community create route shows guidance instead of the create form", async ({ page }, testInfo) => {
|
||||
// Given
|
||||
await page.setViewportSize({ width: 320, height: 640 });
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await loginThroughMockMode(page);
|
||||
|
||||
// When
|
||||
@@ -132,10 +149,13 @@ test("mobile Community create route shows guidance instead of the create form",
|
||||
await expect(page.getByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" })).toBeHidden();
|
||||
await expectActionAbsentAcrossInteractiveRoles(page, "생성");
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await expectNoClippedKoreanText(page.locator("main"));
|
||||
await expectNoCriticalOrSeriousAxeViolations(page);
|
||||
await captureScreenshot(page, testInfo, "community-create-375-guidance.png");
|
||||
});
|
||||
|
||||
for (const width of [768, 1280] as const) {
|
||||
test(`tablet and desktop Community management remains available at ${width}px`, async ({ page }) => {
|
||||
test(`tablet and desktop Community management remains available at ${width}px`, async ({ page }, testInfo) => {
|
||||
// Given
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await loginThroughMockMode(page);
|
||||
@@ -156,8 +176,119 @@ for (const width of [768, 1280] as const) {
|
||||
await dialog.getByRole("button", { name: "닫기" }).click();
|
||||
|
||||
await page.getByRole("link", { name: "커뮤니티 게시글 생성" }).click();
|
||||
await expect(page.getByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" })).toBeVisible();
|
||||
const form = page.getByRole("form", { name: "커뮤니티 게시글 생성 입력 화면" });
|
||||
const imageInput = form.locator("input[type='file']").first();
|
||||
const audioInput = form.locator("input[type='file']").nth(1);
|
||||
const imageSelectButton = form.getByRole("button", { name: "게시글 이미지 파일 선택" });
|
||||
const contentInput = form.getByRole("textbox", { name: "내용" });
|
||||
const priceInput = form.getByRole("spinbutton", { name: "가격" });
|
||||
|
||||
await expect(form).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "생성" })).toBeVisible();
|
||||
await expect(audioInput).toBeHidden();
|
||||
await expect(priceInput).toHaveValue("0");
|
||||
expect(await form.evaluate((element) => {
|
||||
const image = element.querySelector("input[type='file']");
|
||||
const content = element.querySelector("textarea");
|
||||
return image !== null && content !== null && Boolean(image.compareDocumentPosition(content) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
})).toBe(true);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await expectNoClippedKoreanText(form);
|
||||
await expectNoCriticalOrSeriousAxeViolations(page);
|
||||
await captureScreenshot(page, testInfo, `community-create-${width}-initial.png`);
|
||||
|
||||
await pressTabUntilFocused(page, imageSelectButton);
|
||||
await expect(imageSelectButton).toBeFocused();
|
||||
expect(await imageSelectButton.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return style.outlineStyle !== "none" || style.boxShadow !== "none";
|
||||
})).toBe(true);
|
||||
|
||||
const imagePreview = form.getByRole("img", { name: "게시글 이미지 업로드 미리보기" });
|
||||
const cropDialog = page.getByRole("dialog", { name: "이미지 crop" });
|
||||
|
||||
await imageInput.setInputFiles({ buffer: tinyPng, mimeType: "image/png", name: "community-crop.png" });
|
||||
await expect(cropDialog).toBeVisible();
|
||||
await expect(imagePreview).toBeHidden();
|
||||
await expect(audioInput).toBeHidden();
|
||||
await captureScreenshot(page, testInfo, `community-create-${width}-crop.png`);
|
||||
|
||||
await cropDialog.getByRole("button", { name: "적용" }).click();
|
||||
await expect(cropDialog).toBeHidden();
|
||||
await expect(imagePreview).toBeVisible();
|
||||
expect(await imagePreview.evaluate((image) => image instanceof HTMLImageElement && image.complete && image.naturalWidth > 0)).toBe(true);
|
||||
await expect(audioInput).toBeVisible();
|
||||
await captureScreenshot(page, testInfo, `community-create-${width}-applied.png`);
|
||||
|
||||
await audioInput.setInputFiles({ buffer: Buffer.from("ID3"), mimeType: "audio/mpeg", name: "community-audio.mp3" });
|
||||
await imageInput.setInputFiles({ buffer: tinyPng, mimeType: "image/png", name: "community-replacement.png" });
|
||||
await expect(cropDialog).toBeVisible();
|
||||
await expect(imagePreview).toBeHidden();
|
||||
await expect(audioInput).toBeHidden();
|
||||
await captureScreenshot(page, testInfo, `community-create-${width}-replacement.png`);
|
||||
|
||||
await cropDialog.getByRole("button", { name: "취소" }).click();
|
||||
await expect(cropDialog).toBeHidden();
|
||||
await expect(imagePreview).toBeHidden();
|
||||
await expect(audioInput).toBeHidden();
|
||||
|
||||
await imageInput.setInputFiles({ buffer: Buffer.from("not-an-image"), mimeType: "image/png", name: "community-invalid.png" });
|
||||
const imageErrorAlert = form.getByRole("alert");
|
||||
await expect(cropDialog).toBeHidden();
|
||||
await expect(imagePreview).toBeHidden();
|
||||
await expect(audioInput).toBeHidden();
|
||||
await expect(imageErrorAlert).toContainText("이미지 미리보기를 불러오지 못했습니다.");
|
||||
await expect(imageErrorAlert).toBeVisible();
|
||||
await imageErrorAlert.scrollIntoViewIfNeeded();
|
||||
await captureScreenshot(page, testInfo, `community-create-${width}-error.png`);
|
||||
|
||||
await imageInput.setInputFiles({ buffer: tinyGif, mimeType: "image/gif", name: "community-final.gif" });
|
||||
await expect(imagePreview).toBeVisible();
|
||||
expect(await imagePreview.evaluate((image) => image instanceof HTMLImageElement && image.complete && image.naturalWidth > 0)).toBe(true);
|
||||
await expect(audioInput).toBeVisible();
|
||||
expect(await form.evaluate((element) => {
|
||||
const [image, audio] = element.querySelectorAll("input[type='file']");
|
||||
const content = element.querySelector("textarea");
|
||||
return image !== undefined && audio !== undefined && content !== null
|
||||
&& Boolean(image.compareDocumentPosition(audio) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
&& Boolean(audio.compareDocumentPosition(content) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
})).toBe(true);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await expectNoClippedKoreanText(form);
|
||||
await expectNoCriticalOrSeriousAxeViolations(page);
|
||||
await captureScreenshot(page, testInfo, `community-create-${width}-gif-ready.png`);
|
||||
|
||||
await audioInput.setInputFiles({ buffer: Buffer.from("ID3"), mimeType: "audio/mpeg", name: "community-audio.mp3" });
|
||||
await expect(audioInput).not.toHaveValue("");
|
||||
await form.getByRole("button", { name: "게시글 이미지 선택 취소" }).click();
|
||||
await expect(form.locator("img")).toBeHidden();
|
||||
await expect(audioInput).toBeHidden();
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await expectNoClippedKoreanText(form);
|
||||
await captureScreenshot(page, testInfo, `community-create-${width}-image-removed.png`);
|
||||
|
||||
await imageInput.setInputFiles({ buffer: tinyGif, mimeType: "image/gif", name: "community-final.gif" });
|
||||
await expect(audioInput).toBeVisible();
|
||||
await expect(audioInput).toHaveValue("");
|
||||
await contentInput.fill("가격 검증을 위한 커뮤니티 게시글입니다.");
|
||||
await priceInput.fill("");
|
||||
const createRequests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (request.method() === "POST" && request.url().includes("/community-posts")) {
|
||||
createRequests.push(request.url());
|
||||
}
|
||||
});
|
||||
await page.getByRole("button", { name: "생성" }).click();
|
||||
await expect(page.getByRole("alert")).toContainText("가격은 0 이상 99,999 이하 정수 캔으로 입력하세요.");
|
||||
await expect(priceInput).toBeFocused();
|
||||
await expect(priceInput).toHaveValue("");
|
||||
await expect(page).toHaveURL(/\/ai-characters\/101\/community-posts\/new$/);
|
||||
expect(createRequests).toEqual([]);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await expectNoClippedKoreanText(form);
|
||||
await expectNoCriticalOrSeriousAxeViolations(page);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await captureScreenshot(page, testInfo, `community-create-${width}-blank-price-error.png`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -191,20 +322,19 @@ test("Community sheet traps focus closes on Escape and returns focus", async ({
|
||||
await expect(opener).toBeFocused();
|
||||
});
|
||||
|
||||
test("Community list and create routes keep zoom overflow and axe coverage across capability widths", async ({ page }) => {
|
||||
test("Community list and create routes keep overflow and axe coverage across capability widths", async ({ page }) => {
|
||||
// Given
|
||||
await loginThroughMockMode(page);
|
||||
|
||||
for (const width of [320, 768, 1280] as const) {
|
||||
for (const width of [320, 375, 768, 1280] as const) {
|
||||
for (const route of ["/ai-characters/101/community-posts", "/ai-characters/101/community-posts/new"] as const) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto(route);
|
||||
|
||||
// When
|
||||
await zoomTo200Percent(page);
|
||||
|
||||
// Then
|
||||
await expectNoHorizontalOverflow(page);
|
||||
if (width !== 320) {
|
||||
await expectNoClippedKoreanText(page.locator("main"));
|
||||
}
|
||||
await expectNoCriticalOrSeriousAxeViolations(page);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ test("active mock resources complete the cross-domain journey without uncontract
|
||||
await loginThroughMockMode(page);
|
||||
|
||||
// When / Then
|
||||
await page.getByRole("link", { name: "루나 선택" }).click();
|
||||
await page.getByRole("link", { name: "루나", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "루나", exact: true })).toBeVisible();
|
||||
|
||||
await page.goto("/ai-characters/101/audio-contents");
|
||||
|
||||
@@ -143,7 +143,13 @@ test("desktop Series CRUD uses genre lookup, crop, edit initialization, and soft
|
||||
// When
|
||||
await page.getByLabel("제목").fill("새 시리즈");
|
||||
await page.getByLabel("소개").fill("새로운 시리즈 소개");
|
||||
await page.getByLabel("키워드").fill("달빛");
|
||||
const keywordInput = page.getByRole("textbox", { name: "키워드" });
|
||||
await keywordInput.fill("달빛");
|
||||
await keywordInput.press("Enter");
|
||||
await keywordInput.fill("상담");
|
||||
await keywordInput.press(",");
|
||||
await expect(page.getByRole("button", { name: "키워드 달빛 삭제" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "키워드 상담 삭제" })).toBeVisible();
|
||||
await page.getByRole("checkbox", { name: "월요일" }).check();
|
||||
await page.getByLabel("장르").selectOption("77");
|
||||
await setSeriesImageFile(page);
|
||||
|
||||
Reference in New Issue
Block a user