Compare commits
18 Commits
b01fda4400
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a531d9102 | |||
|
|
dcc344764b | ||
|
|
2a04955bd7 | ||
|
|
68959cb33b | ||
|
|
46868351ac | ||
|
|
00f06b992f | ||
| d2b6399c96 | |||
|
|
e82e209300 | ||
|
|
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 리뷰 범위의 확정 발견 사항 없음.
|
||||
88
docs/20260805_오디오콘텐츠댓글답글/api-contract.md
Normal file
88
docs/20260805_오디오콘텐츠댓글답글/api-contract.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# 오디오 콘텐츠 댓글 첫 답글 API Contract
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 기존 계약 재사용 확정 |
|
||||
| 작성일 | 2026-08-05 |
|
||||
| 원본 계약 | [프로젝트 OpenAPI](../20260725_AI캐릭터관리자웹/api-contract.openapi.json) |
|
||||
| 관련 PRD | [prd.md](./prd.md) |
|
||||
| 관련 계획 | [plan-task.md](./plan-task.md) |
|
||||
|
||||
## 계약 변경 여부
|
||||
|
||||
백엔드 API 변경은 없다. 이 문서는 이번 기능이 소비하는 기존 OpenAPI 범위와
|
||||
프론트엔드 전송값만 좁게 기록한다. 충돌하면 원본 OpenAPI가 우선한다.
|
||||
|
||||
## Endpoint
|
||||
|
||||
### 직접 답글 목록
|
||||
|
||||
```http
|
||||
GET /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments/{commentId}/replies?page=0&size=20
|
||||
Authorization: Bearer {jwt-token}
|
||||
Accept-Language: ko
|
||||
```
|
||||
|
||||
- `commentId`: 답글 영역을 연 원댓글 ID
|
||||
- 성공: `data={ totalCount, items }`
|
||||
- 답글 0개도 `totalCount=0`, `items=[]`인 정상 성공이다.
|
||||
|
||||
### 댓글 또는 답글 작성
|
||||
|
||||
```http
|
||||
POST /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments
|
||||
Authorization: Bearer {jwt-token}
|
||||
Accept-Language: ko
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
첫 답글 request:
|
||||
|
||||
```json
|
||||
{
|
||||
"comment": "답글 내용",
|
||||
"parentId": 1102,
|
||||
"isSecret": false,
|
||||
"languageCode": null
|
||||
}
|
||||
```
|
||||
|
||||
| field | 형식 | 이번 기능의 값 |
|
||||
|---|---|---|
|
||||
| `comment` | string, required | trim 후 빈 문자열이 아닌 입력값 |
|
||||
| `parentId` | nullable int64, optional | 답글 대상 활성 원댓글 ID |
|
||||
| `isSecret` | boolean, optional | `false` |
|
||||
| `languageCode` | nullable string, optional | `null` |
|
||||
|
||||
- `parentId`가 있으면 같은 Audio target의 활성 원댓글에 대한 직접 답글이다.
|
||||
- 답글 ID를 `parentId`로 보내는 3단계 작성은 허용하지 않는다.
|
||||
- 성공 envelope의 `data`는 `null`이다.
|
||||
- 성공 후 원댓글 목록과 열린 root의 현재 답글 page를 재조회한다.
|
||||
|
||||
## 오류 응답
|
||||
|
||||
원본 OpenAPI의 공통 오류 envelope와 다음 status를 그대로 사용한다.
|
||||
|
||||
| Status | 처리 |
|
||||
|---:|---|
|
||||
| 400 | invalid target·parent 또는 binding 오류를 화면 alert로 표시 |
|
||||
| 401 | 공통 session 만료 처리 |
|
||||
| 403 | 공통 접근 거부 처리 |
|
||||
| 404 | target 또는 root를 찾을 수 없음 표시 |
|
||||
| 405, 406, 415, 500 | 서버 message를 우선 표시하고 기존 재시도 정책 적용 |
|
||||
|
||||
도메인별 message key를 새로 추정하지 않는다.
|
||||
|
||||
## 프론트엔드 연결
|
||||
|
||||
| 역할 | 기존 구현 |
|
||||
|---|---|
|
||||
| target path 선택 | `commentCollectionPath()` |
|
||||
| 답글 조회 | `getReplies()` |
|
||||
| 답글 작성 | `createComment()` |
|
||||
| request schema | `audioCommentCreateRequestSchema` |
|
||||
| 성공 후 재조회 | `CommentThread.runMutation()` |
|
||||
|
||||
API, schema, mock handler와 store는 이번 기능에서 변경하지 않는다.
|
||||
238
docs/20260805_오디오콘텐츠댓글답글/plan-task.md
Normal file
238
docs/20260805_오디오콘텐츠댓글답글/plan-task.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# 오디오 콘텐츠 댓글 답글 작성 진입 구현 계획
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 기능 구현·검증 및 `P1-R1` 문서 정합성 보완 완료 |
|
||||
| 작성일 | 2026-08-05 |
|
||||
| 요구사항 기준 | [prd.md](./prd.md) |
|
||||
| API 기준 | [api-contract.md](./api-contract.md) |
|
||||
| 현재 Phase | Phase 1 구현·검증 |
|
||||
| 현재 활성 Goal | 없음 |
|
||||
|
||||
## 목표
|
||||
|
||||
활성 오디오 콘텐츠의 답글 0개 원댓글에서도 기존 답글 form을 열어 첫 답글을
|
||||
작성할 수 있게 한다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1 | 완료 | `2/2` | 없음 | 없음 |
|
||||
|
||||
- 답글 API, form, 조회·작성·재조회 흐름은 이미 구현돼 있다.
|
||||
- 활성 Audio의 `replyCount === 0` root에는 `답글 작성` 진입이 구현돼 있다.
|
||||
- unit·mock E2E와 정적 검증이 완료됐고 애플리케이션 코드와 test가 현재 working tree에 반영돼 있다.
|
||||
|
||||
## 범위의 포함·제외
|
||||
|
||||
### 포함
|
||||
|
||||
- 활성 Audio root의 `replyCount === 0`일 때 `답글 작성` 버튼 표시
|
||||
- 기존 답글 영역, form, GET·POST와 mutation 상태 재사용
|
||||
- Audio 첫 답글과 Community·비활성·reply row 경계 회귀 test
|
||||
- 기존 Comments Chromium mock E2E와 정적 검증
|
||||
|
||||
### 제외
|
||||
|
||||
- Community 댓글 답글 진입 조건 변경
|
||||
- 새 endpoint, DTO, component, state library 또는 dependency
|
||||
- form 상시 노출, reply-of-reply, payload 정책 변경
|
||||
- 기존 답글 수정·삭제·pagination 리팩터링
|
||||
|
||||
## 기술적 제약
|
||||
|
||||
- React·TypeScript strict, Vitest·React Testing Library와 기존 Playwright 구성을 사용한다.
|
||||
- [api-contract.md](./api-contract.md)의 기존 GET·POST만 사용한다.
|
||||
- `CommentThread`, `CommentItem`, `CommentForm`의 현재 책임 경계를 유지한다.
|
||||
- 새 추상화보다 기존 reply state와 `toggleReplies()`를 재사용한다.
|
||||
- RED → GREEN → REFACTOR 순서와 최소 변경을 지킨다.
|
||||
|
||||
## Phase 1. 첫 답글 작성 진입 구현·검증
|
||||
|
||||
**Phase 결과:** 관리자가 활성 Audio의 답글 0개 원댓글에서 첫 답글을 작성하고
|
||||
기존 Community·읽기 전용·2단계 경계가 유지된다.
|
||||
|
||||
**선행조건:** `ACR-001~005`와 기존 Audio 댓글 GET·POST 계약 확정.
|
||||
|
||||
**Phase 완료 조건:** `P1-T1`, `P1-GATE` 완료와 Progress 기록.
|
||||
|
||||
### Task 1.1 오디오 첫 답글 진입
|
||||
|
||||
**Goal 실행 `P1-T1`:** Audio의 답글 0개 원댓글에 기존 답글 영역을 여는
|
||||
`답글 작성` action을 추가한다.
|
||||
|
||||
- **시작 조건:** [prd.md](./prd.md)의 `ACR-001~005`, [api-contract.md](./api-contract.md).
|
||||
- **완료 증거:** TDD 체크박스, focused·회귀·E2E·정적 검증과 Progress 기록.
|
||||
- **범위 밖:** Community 변경, API·mock·schema 변경, 관련 없는 Comments 리팩터링.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/features/comments/components/CommentThread.tsx`
|
||||
- Modify: `src/features/comments/components/CommentItem.tsx`
|
||||
- Modify: `src/features/comments/tests/comment-thread.test.tsx`
|
||||
- Modify: `tests/e2e/comments.spec.ts`
|
||||
- Test: `src/features/comments/tests/comment-thread.test.tsx`, `tests/e2e/comments.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `CommentRecord.replyCount`, `CommentTarget.kind`, `canMutate`, `toggleReplies()`, `CommentForm`, `createComment()`.
|
||||
- Produces: `CommentItem`의 optional 답글 action label과 Audio 첫 답글 진입 조건.
|
||||
|
||||
**TDD 절차:**
|
||||
|
||||
- [x] **RED:** `comment-thread.test.tsx`에 Audio `replyCount=0` root의 `답글 작성` 노출, 클릭 후 form, `parentId` POST와 Community·비활성·reply row 신규 action 0건을 검증하는 실패 test를 작성하고 focused 명령의 의도한 실패를 확인한다.
|
||||
- [x] **GREEN:** `CommentThread`와 `CommentItem`에 action label·노출 조건만 추가해 기존 reply 조회·form·POST 흐름으로 test를 통과시킨다.
|
||||
- [x] **REFACTOR:** 기존 `showRepliesButton` boolean을 optional action label로 단순화하고 새 helper·component·dependency 없이 focused·Comments 회귀를 확인한다.
|
||||
- [x] 기존 Audio mock E2E에서 답글 0개 root의 첫 답글 작성 journey를 검증한다.
|
||||
- [x] 검증 결과를 Progress에 기록한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx`; `npm run test:run -- src/features/comments`; `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`.
|
||||
- **기대 결과:** 모든 명령 exit 0, Audio 첫 답글 POST 1회, Community·비활성·reply row 신규 action 0건, 기존 Comments 회귀 실패 0건.
|
||||
- **수동 확인:** 활성 Audio 상세에서 답글 0개 root의 `답글 작성` → form 노출 → 성공 후 목록 반영을 확인한다. Community와 비활성 Audio는 기존 동작을 유지한다.
|
||||
|
||||
### 완료 조건
|
||||
|
||||
- [x] `P1-T1`의 모든 TDD·검증 체크박스가 완료됐다.
|
||||
- [x] `ACR-001~005`가 구현 또는 검증 증거에 연결됐다.
|
||||
- [x] API·mock·schema와 범위 밖 파일 변경이 없다.
|
||||
|
||||
### 검증 방법
|
||||
|
||||
#### Phase 1 Gate
|
||||
|
||||
**Goal 실행 `P1-GATE`:** 첫 답글 journey와 Comments 경계를 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 완료.
|
||||
- **완료 증거:** 아래 명령·수동 확인 통과와 Progress 기록.
|
||||
- **범위 밖:** test 완화, timeout 상향과 관련 없는 수정.
|
||||
|
||||
**실행 명령:**
|
||||
|
||||
```bash
|
||||
npm run test:run -- src/features/comments
|
||||
npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
**기대 결과:** 모든 명령 exit 0, `ACR-001~005` 위반 0건.
|
||||
|
||||
**수동 확인:** 활성 Audio, 비활성 Audio와 Community에서 action 노출 경계를
|
||||
대조하고 keyboard-only로 첫 답글을 작성한다.
|
||||
|
||||
### Task R1.1 완료 문서 상태 정합성 복구
|
||||
|
||||
**Goal 실행 `P1-R1`:** `REV-P1-001`의 완료·미구현 상태 모순을 제거하고
|
||||
실제 검증 증거와 PRD·plan을 일치시킨다.
|
||||
|
||||
- **연결 리뷰:** [Phase 1 구현 리뷰](./reviews/phase1-audio-comment-first-reply.md) — `REV-P1-001`
|
||||
- **시작 조건:** `REV-P1-001` 확정, 완료된 `P1-T1`, `P1-GATE`.
|
||||
- **완료 증거:** 현재 상태·발견된 문제·PRD §14 정정, 기존 Progress 보존, 리뷰 수정 후 검증 기록과 문서 검증 통과.
|
||||
- **범위 밖:** 애플리케이션 코드·test·API Contract 변경, 기존 설계·구현 Progress 삭제.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/20260805_오디오콘텐츠댓글답글/prd.md`
|
||||
- Modify: `docs/20260805_오디오콘텐츠댓글답글/plan-task.md`
|
||||
- Modify: `docs/20260805_오디오콘텐츠댓글답글/reviews/phase1-audio-comment-first-reply.md`
|
||||
- Test: 없음 — 구현 동작이 아닌 완료 문서 정합성 수정이다.
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `REV-P1-001`, `ACR-001~005`, `P1-T1`, `P1-GATE`와 2026-08-05 검증 증거.
|
||||
- Produces: 실제 구현 상태와 일치하는 PRD 수용 기준, plan 현재 상태와 review 수정 완료 기록.
|
||||
|
||||
**TDD 예외 사유:** 애플리케이션 동작을 변경하지 않는 문서 정합성 Task이므로
|
||||
실패 unit test를 추가하지 않는다.
|
||||
|
||||
**대체 검증 방법:** stale 미구현 marker와 PRD §14 미완료 checkbox가 제거됐는지
|
||||
검사하고 review link와 Markdown diff를 확인한다.
|
||||
|
||||
- [x] 현재 상태의 미구현 문구를 실제 구현 완료 상태로 정정한다.
|
||||
- [x] PRD §14 수용·추적 체크박스를 검증 증거에 맞게 완료 처리한다.
|
||||
- [x] 발견된 문제를 `REV-P1-001` 문서 정합성 보완 상태로 정정한다.
|
||||
- [x] 기존 Progress를 보존하고 `P1-R1`의 무엇을/왜/어떻게 검증 기록을 누적한다.
|
||||
- [x] review 상태를 `수정 완료`로 갱신하고 수정 후 검증 기록을 누적한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `! rg -n '^- 현재 .*답글 진입 버튼이 없다|^- 애플리케이션 코드와 test는 아직 변경하지 않았다|^- 확정: .*첫 답글 작성 진입이 없다' docs/20260805_오디오콘텐츠댓글답글/plan-task.md`; `! sed -n '/## 14\./,/## 15\./p' docs/20260805_오디오콘텐츠댓글답글/prd.md | rg -n '^- \[ \]'`; `test -f docs/20260805_오디오콘텐츠댓글답글/reviews/phase1-audio-comment-first-reply.md`; `git diff --check`.
|
||||
- **기대 결과:** 모든 명령 exit 0, stale 미구현 marker와 PRD §14 미완료 checkbox 0건, review link 존재, Markdown whitespace 오류 0건.
|
||||
- **수동 확인:** 없음 — 문서 marker·checkbox·link를 명령으로 직접 판정한다.
|
||||
|
||||
## 실행 순서와 의존성
|
||||
|
||||
1. `P1-T1` RED
|
||||
2. `P1-T1` GREEN
|
||||
3. `P1-T1` REFACTOR·회귀
|
||||
4. `P1-GATE`
|
||||
5. 확정 review 후속 `P1-R1`
|
||||
|
||||
- 동시에 하나의 미완료 goal만 운용한다.
|
||||
- 사용자가 goal 실행을 요청하기 전에는 goal을 생성하지 않는다.
|
||||
|
||||
## 변경 금지 항목
|
||||
|
||||
- Community 댓글 동작과 API Contract 변경
|
||||
- 새 dependency, state library, component 또는 speculative abstraction
|
||||
- 답글의 답글, optimistic update와 form 상시 노출
|
||||
- 실패 test 삭제·skip, timeout 상향으로 Gate 통과
|
||||
- 기존 Progress와 결정 기록 삭제·덮어쓰기
|
||||
|
||||
## 의사결정 및 중단 규칙
|
||||
|
||||
- `target.kind === "audio"`, `replyCount === 0`, `canMutate === true` 경계 밖으로 신규 작성 진입을 확대하지 않는다.
|
||||
- API 응답이나 오류가 [api-contract.md](./api-contract.md)와 다르면 추정 수정하지 않고 외부 의존으로 기록한다.
|
||||
- 범위가 바뀌면 코드보다 PRD Decision Log와 이 계획을 먼저 갱신한다.
|
||||
|
||||
## Progress
|
||||
|
||||
### 2026-08-05 설계
|
||||
|
||||
- **무엇을:** 답글 0개 Audio root의 첫 답글 작성 진입 요구사항, API 재사용 계약과 단일 구현 Task를 확정했다.
|
||||
- **왜:** 기존 POST·form은 있지만 `replyCount === 0`일 때 진입 버튼이 없어 첫 답글을 작성할 수 없다.
|
||||
- **어떻게:** 기존 코드·OpenAPI·unit·E2E를 대조하고 사용자에게 적용 target과 form 노출 방식을 한 번에 하나씩 확인했다. 애플리케이션 코드와 test는 변경하지 않았다.
|
||||
|
||||
### 2026-08-05 구현·검증
|
||||
|
||||
- **무엇을:** 활성 Audio의 `replyCount=0` 원댓글에 `답글 작성` action을 추가하고 기존 답글 영역·form·POST 흐름으로 첫 답글을 작성하게 했다.
|
||||
- **왜:** 기존 `replyCount > 0` 조건만으로는 첫 답글 작성 진입이 없어 `ACR-001~005`를 충족할 수 없었다.
|
||||
- **어떻게:** `CommentItem`의 답글 action을 optional label로 바꾸고, `CommentThread`에서 Audio·`canMutate`·빈 답글 root에만 `답글 작성` label을 전달했다. RED는 `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx`에서 `AI 루트 댓글 답글 작성` 버튼 부재로 실패했다. GREEN·회귀는 focused 8/8, `npm run test:run -- src/features/comments` 15/15, `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium` 3/3, `npm run typecheck`, `npm run lint`, `git diff --check` 통과로 확인했다. Playwright mock 수동 QA에서 1280px·320px 첫 답글 작성과 320px 수평 overflow 없음도 확인했다.
|
||||
|
||||
### 2026-08-05 리뷰 후속 Task 전환 정정
|
||||
|
||||
- **무엇을:** 확정 finding `REV-P1-001`을 후속 회귀 수정 Task `P1-R1`로 계획에 추가했다.
|
||||
- **왜:** 코드 수정과 달리 확정 finding의 계획 전환은 리뷰 범위에서도 필수인데 초안만 리뷰 문서에 남겨 가이드의 종료 조건을 충족하지 못했다.
|
||||
- **어떻게:** [코드 리뷰 및 QA 기록 규칙](../agent-guide/review.md)의 확정 항목 전환 기준을 다시 대조하고 review ID, goal ID, 시작 조건, 완료 증거, 범위 밖, TDD 예외, 대체 검증과 검증 기록 항목을 `P1-R1`에 명시했다. 애플리케이션 코드·test·API Contract는 변경하지 않았다.
|
||||
|
||||
### 2026-08-05 P1-R1 문서 정합성 보완
|
||||
|
||||
- **무엇을:** `REV-P1-001`의 stale 현재 상태·발견된 문제를 구현 완료 상태로 정정하고 PRD §14 수용·추적 체크박스와 review 상태를 완료 처리했다.
|
||||
- **왜:** 기능·test·검증은 완료됐지만 문서에 미구현·미완료 표기가 남아 다음 작업자와 자동 검증이 상반된 상태를 판정했다.
|
||||
- **어떻게:** 수정 전 marker 검사에서 stale plan 문구 3곳과 PRD §14 미완료 체크박스 7개를 확인했다. 수정 후 stale marker 부재, PRD §14 미완료 체크박스 부재, review 파일 존재와 `git diff --check`를 각각 실행해 모두 exit 0을 확인했다. 추가 회귀 Gate는 Comments 15/15, Chromium mock E2E 3/3, typecheck·lint exit 0이었다. 애플리케이션 코드·test·API Contract는 변경하지 않았다.
|
||||
|
||||
## Decision Log
|
||||
|
||||
| 날짜 | 결정 | 근거 | 영향 |
|
||||
|---|---|---|---|
|
||||
| 2026-08-05 | 신규 진입은 Audio에만 적용한다. | 사용자 선택 B | `ACR-001`, `ACR-004`, `P1-T1` |
|
||||
| 2026-08-05 | `답글 작성` 버튼으로 기존 답글 영역과 form을 연다. | 사용자 선택 A | `ACR-001~002`, `P1-T1` |
|
||||
| 2026-08-05 | 새 API·컴포넌트 없이 기존 구현을 재사용한다. | OpenAPI와 코드 확인 | `ACR-003~005`, `P1-T1` |
|
||||
| 2026-08-05 | 확정 review finding `REV-P1-001`을 문서 전용 후속 Task `P1-R1`로 전환한다. | review 가이드 §4·§5 | `P1-R1`, Phase 1 리뷰 |
|
||||
|
||||
## 발견된 문제
|
||||
|
||||
- 수정 완료: `REV-P1-001`의 완료 상태와 stale 현재 상태·PRD 수용 체크박스 간 모순을 `P1-R1`에서 정정했다.
|
||||
- 외부 차단: 없음.
|
||||
|
||||
## 최종 보고 형식
|
||||
|
||||
- 완료 Goal ID
|
||||
- 변경한 파일과 최소 구현 내용
|
||||
- RED·GREEN·REFACTOR 및 Gate 명령과 실제 결과
|
||||
- 실행하지 못한 수동·server 검증과 이유
|
||||
- 남은 위험 또는 열린 질문
|
||||
210
docs/20260805_오디오콘텐츠댓글답글/prd.md
Normal file
210
docs/20260805_오디오콘텐츠댓글답글/prd.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# 오디오 콘텐츠 댓글 답글 작성 진입 PRD
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | 구현 기준 확정 |
|
||||
| 작성일 | 2026-08-05 |
|
||||
| 최종 수정일 | 2026-08-05 |
|
||||
| 대상 기능 | 오디오 콘텐츠 댓글의 첫 답글 작성 진입 |
|
||||
| 작성자·결정권자 | Codex 작성, 사용자 결정 |
|
||||
| 상위 제품 기준 | [AI 캐릭터 관리자 웹 PRD](../20260725_AI캐릭터관리자웹/prd.md) |
|
||||
| 관련 API Contract | [api-contract.md](./api-contract.md) |
|
||||
| 관련 구현 계획 | [plan-task.md](./plan-task.md) |
|
||||
| 관련 review | [Phase 1 구현 리뷰](./reviews/phase1-audio-comment-first-reply.md) |
|
||||
|
||||
### 요구사항 상태
|
||||
|
||||
| 상태 | 의미 |
|
||||
|---|---|
|
||||
| 확정 | 구현과 검증 기준으로 사용한다. |
|
||||
| 미결 | 제품 결정 전에는 구현하지 않는다. |
|
||||
| 외부 의존 | 외부 계약이 제공될 때까지 영향 범위를 구현 완료로 표시하지 않는다. |
|
||||
| 제외 | 현재 기능 범위에 포함하지 않는다. |
|
||||
|
||||
## 1. Overview
|
||||
|
||||
활성 AI 캐릭터의 오디오 콘텐츠 댓글에서 직접 답글이 아직 없는 원댓글에도
|
||||
`답글 작성` 진입을 제공한다. 기존 답글 조회 영역, 작성 form과 댓글 POST를
|
||||
재사용하며 커뮤니티 댓글 동작은 변경하지 않는다.
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
현재 답글 POST와 작성 form은 구현돼 있지만 원댓글의 `replyCount`가 0이면
|
||||
답글 영역을 여는 버튼이 렌더되지 않는다. 따라서 관리자는 이미 답글이 있는
|
||||
원댓글에만 추가 답글을 쓸 수 있고 첫 답글은 작성할 수 없다.
|
||||
|
||||
문제를 해결했다는 판단은 답글 0개인 오디오 원댓글에서 `답글 작성`을 눌러
|
||||
기존 form을 열고 `parentId`가 포함된 POST를 한 번 전송할 수 있는지로 한다.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
### 3.1 제품 목표
|
||||
|
||||
- 활성 오디오 콘텐츠의 모든 원댓글에 첫 답글을 작성할 수 있다.
|
||||
- 기존 2단계 댓글 구조와 API 계약을 그대로 유지한다.
|
||||
|
||||
### 3.2 UX 목표
|
||||
|
||||
- 답글이 0개인 원댓글에는 `답글 작성`이라는 명확한 진입점을 표시한다.
|
||||
- 버튼을 누르면 기존 답글 영역과 작성 form을 펼친다.
|
||||
- 기존 loading, 오류, 전송 중, 실패 후 초안 보존 동작을 유지한다.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- 커뮤니티 댓글의 답글 진입 조건 변경
|
||||
- 답글 form 상시 노출
|
||||
- 답글의 답글을 포함한 3단계 구조
|
||||
- 새 endpoint, DTO, 상태관리 또는 UI dependency 추가
|
||||
- `isSecret=false`, Audio `languageCode=null` 작성 정책 변경
|
||||
- 답글 수정·삭제·pagination 동작 변경
|
||||
|
||||
## 5. Target Users and Permissions
|
||||
|
||||
| 사용자 | 목표 | 주요 작업 | 사용 환경 |
|
||||
|---|---|---|---|
|
||||
| ADMIN | AI 캐릭터 명의로 팬 원댓글에 첫 답글 작성 | 답글 영역 열기, 작성, 재시도 | desktop, tablet, mobile |
|
||||
|
||||
- 인증과 ADMIN 권한은 상위 제품 기준을 따른다.
|
||||
- 활성 AI 캐릭터 workspace에서만 답글 작성 control을 제공한다.
|
||||
- 비활성 AI 캐릭터 workspace는 기존처럼 조회 전용이다.
|
||||
- 원댓글 작성자가 팬인지 AI 캐릭터인지와 관계없이 답글을 작성할 수 있다.
|
||||
|
||||
## 6. 핵심 사용자 흐름
|
||||
|
||||
1. 관리자가 활성 AI 캐릭터의 오디오 콘텐츠 상세에 진입한다.
|
||||
2. 답글이 0개인 원댓글에서 `답글 작성`을 누른다.
|
||||
3. UI가 기존 직접 답글 GET을 실행하고 답글 영역과 작성 form을 표시한다.
|
||||
4. 관리자가 내용을 입력해 등록한다.
|
||||
5. 기존 댓글 POST에 원댓글 ID를 `parentId`로 보내고 성공 후 원댓글·답글 목록을 재조회한다.
|
||||
6. 실패하면 오류를 표시하고 입력 초안을 유지해 재시도할 수 있다.
|
||||
|
||||
## 7. 정보 구조와 라우팅
|
||||
|
||||
```text
|
||||
/ai-characters/:characterId/audio-contents/:contentId
|
||||
└─ 댓글 관리
|
||||
└─ 원댓글
|
||||
└─ 직접 답글 영역 및 작성 form
|
||||
```
|
||||
|
||||
- 새 route와 query parameter를 추가하지 않는다.
|
||||
- 오디오 콘텐츠 상세의 기존 `CommentThread` 안에서만 동작한다.
|
||||
|
||||
## 8. 기능 요구사항
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `ACR-001` | 확정 | 활성 Audio target의 답글 0개 원댓글에 `답글 작성` 버튼을 표시한다. | `replyCount=0`, `canMutate=true`인 Audio root에서 버튼을 찾을 수 있다. | contract 불필요, `P1-T1` |
|
||||
| `ACR-002` | 확정 | `답글 작성`을 누르면 기존 직접 답글 영역과 작성 form을 연다. | 버튼 클릭 뒤 원댓글 이름과 연결된 답글 region·textarea·등록 버튼이 표시된다. | 답글 GET, `P1-T1` |
|
||||
| `ACR-003` | 확정 | 첫 답글은 기존 Audio 댓글 POST를 사용한다. | body가 `comment`, root ID `parentId`, `isSecret=false`, `languageCode=null`을 포함하고 성공 후 목록을 재조회한다. | 댓글 POST, `P1-T1` |
|
||||
| `ACR-004` | 확정 | 기존 경계를 유지한다. | Community의 답글 0개 root와 비활성 Audio root에는 신규 작성 진입이 없고, reply row에는 답글 action이 없다. | contract 불필요, `P1-T1` |
|
||||
| `ACR-005` | 확정 | 기존 mutation 상태를 유지한다. | pending 중 중복 POST가 없고 실패 시 초안 유지, 성공 시 입력 초기화가 기존 test와 함께 통과한다. | `NullSuccess`, `P1-GATE` |
|
||||
|
||||
## 9. 반응형 기능 범위
|
||||
|
||||
| 기능 | Desktop | Tablet | Mobile | 비고 |
|
||||
|---|---:|---:|---:|---|
|
||||
| `답글 작성` 진입 | 지원 | 지원 | 지원 | 기존 댓글 action layout 재사용 |
|
||||
| 답글 작성 form | 지원 | 지원 | 지원 | 기존 form 재사용 |
|
||||
|
||||
- 상위 제품의 최소 320px, keyboard-only와 touch target 기준을 유지한다.
|
||||
|
||||
## 10. UI/UX Expectations
|
||||
|
||||
### 10.1 디자인과 component 원칙
|
||||
|
||||
- `CommentThread`, `CommentItem`, `CommentForm`을 재사용한다.
|
||||
- 새 component나 dependency를 추가하지 않는다.
|
||||
- 기존 답글이 있는 원댓글의 `답글 보기` UI는 유지한다.
|
||||
|
||||
### 10.2 화면 상태
|
||||
|
||||
- 클릭 직후 기존 답글 loading 상태를 표시한다.
|
||||
- 빈 답글 응답 뒤에도 작성 form을 표시한다.
|
||||
- 조회 오류는 기존 재시도 UI를 사용한다.
|
||||
- 작성 중·성공·실패는 기존 Comments mutation 정책을 사용한다.
|
||||
|
||||
### 10.3 접근성
|
||||
|
||||
- 버튼의 accessible name은 원댓글 내용과 `답글 작성`을 조합해 식별 가능해야 한다.
|
||||
- form의 visible label과 오류 연결, keyboard focus 표시를 유지한다.
|
||||
- 답글 region은 기존처럼 원댓글 내용과 `답글`을 조합한 accessible name을 유지한다.
|
||||
|
||||
## 11. API 계약
|
||||
|
||||
### 11.1 공통 규칙
|
||||
|
||||
- 이 기능은 API를 변경하지 않는다.
|
||||
- 정확한 request, response와 오류는 [기능 API Contract](./api-contract.md)를 따른다.
|
||||
- 원본 OpenAPI는 [프로젝트 OpenAPI](../20260725_AI캐릭터관리자웹/api-contract.openapi.json)다.
|
||||
|
||||
### 11.2 Endpoint 추적
|
||||
|
||||
| 요구사항 | Method | Path | 계약 상태 | 소유 Goal |
|
||||
|---|---|---|---|---|
|
||||
| `ACR-002` | GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments/{commentId}/replies` | 기존 제공 | `P1-T1` |
|
||||
| `ACR-003` | POST | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments` | 기존 제공 | `P1-T1` |
|
||||
|
||||
### 11.3 외부 제공 대기 계약
|
||||
|
||||
없음. 필요한 GET·POST와 DTO가 이미 제공돼 있다.
|
||||
|
||||
## 12. 보안과 데이터 취급
|
||||
|
||||
- 기존 Bearer 인증, ADMIN 권한과 `characterId`·`contentId` target 격리를 유지한다.
|
||||
- 클라이언트가 임의의 다른 target ID를 생성하지 않는다.
|
||||
- 댓글 본문과 인증 정보는 console, 분석 이벤트와 영구 저장소에 기록하지 않는다.
|
||||
- 401·403은 공통 인증·인가 정책을 따른다.
|
||||
|
||||
## 13. 성능과 품질 요구사항
|
||||
|
||||
- 버튼 클릭 때 선택한 root의 답글 page 0만 기존 방식으로 조회한다.
|
||||
- 전체 root 또는 모든 답글을 선조회하지 않는다.
|
||||
- 새 dependency와 optimistic update를 추가하지 않는다.
|
||||
- Vitest focused test, Comments 회귀, Chromium mock E2E, typecheck와 lint를 통과한다.
|
||||
- server 404나 network error를 mock으로 자동 전환하지 않는다.
|
||||
|
||||
## 14. 성공 기준
|
||||
|
||||
### 14.1 기능 수용 기준
|
||||
|
||||
- [x] 답글 0개인 활성 Audio root에서 첫 답글을 작성한다. (`ACR-001~003`)
|
||||
- [x] Community, 비활성 Audio와 reply row의 기존 경계가 유지된다. (`ACR-004`)
|
||||
- [x] 실패·재시도와 중복 제출 방지가 회귀하지 않는다. (`ACR-005`)
|
||||
|
||||
### 14.2 UI/UX 수용 기준
|
||||
|
||||
- [x] 버튼·답글 region·form의 accessible name과 label이 연결된다.
|
||||
- [x] 320px와 keyboard-only 흐름에서 작성 control을 사용할 수 있다.
|
||||
|
||||
### 14.3 추적성 완료 기준
|
||||
|
||||
- [x] 모든 확정 요구사항이 API 또는 contract 불필요 판정, `P1-T1`, `P1-GATE`와 연결된다.
|
||||
- [x] 구현·검증 결과가 [plan-task.md](./plan-task.md)의 Progress에 기록된다.
|
||||
|
||||
## 15. Open Questions
|
||||
|
||||
없음.
|
||||
|
||||
## 16. 요구사항 추적표
|
||||
|
||||
| 요구사항 범위 | API Contract | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|
||||
|---|---|---:|---|---|---|
|
||||
| `ACR-001~005` | [api-contract.md](./api-contract.md) | 1 | `P1-T1`, `P1-GATE` | `comment-thread.test.tsx`, `comments.spec.ts` | 활성 Audio 첫 답글, Community·비활성 경계 |
|
||||
|
||||
## 17. Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-05 | `ACR-DEC-001` | 확정 | 신규 진입은 오디오 콘텐츠 댓글에만 적용하고 커뮤니티 댓글은 변경하지 않는다. | 사용자 선택 B | `ACR-001`, `ACR-004`, `P1-T1` |
|
||||
| 2026-08-05 | `ACR-DEC-002` | 확정 | 답글 0개 root에 form을 상시 노출하지 않고 `답글 작성` 버튼으로 기존 답글 영역을 연다. | 사용자 선택 A | `ACR-001~002`, `P1-T1` |
|
||||
| 2026-08-05 | `ACR-DEC-003` | 확정 | 새 API·컴포넌트 없이 기존 `parentId` POST와 Comments UI를 재사용한다. | 기존 OpenAPI와 구현 확인 | `ACR-002~005`, [api-contract.md](./api-contract.md) |
|
||||
|
||||
## 18. 변경 관리
|
||||
|
||||
- 범위가 바뀌면 이 문서의 Decision Log와 요구사항을 먼저 갱신한다.
|
||||
- API가 바뀌면 [api-contract.md](./api-contract.md)와 원본 OpenAPI의 제공 버전을 확인한다.
|
||||
- 구현 범위가 바뀌면 코드보다 [plan-task.md](./plan-task.md)를 먼저 갱신한다.
|
||||
- 기존 Progress와 검증 기록은 삭제하거나 덮어쓰지 않는다.
|
||||
@@ -0,0 +1,226 @@
|
||||
# Phase 1 오디오 콘텐츠 첫 답글 작성 진입 리뷰
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 1 / `P1-T1`, `P1-GATE` |
|
||||
| 기준 commit 또는 working tree | 2026-08-05 현재 uncommitted working tree |
|
||||
| 리뷰 일자 | 2026-08-05 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | [prd.md](../prd.md), [api-contract.md](../api-contract.md), [plan-task.md](../plan-task.md) |
|
||||
| 리뷰 상태 | 수정 검증 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
### 목적
|
||||
|
||||
- `ACR-001~005`와 기존 Audio 댓글 API 계약이 실제 코드·test에서 충족되는지 확인한다.
|
||||
- `P1-T1`, `P1-GATE` 완료 기록과 현재 working tree가 일치하는지 확인한다.
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- 코드: `src/features/comments/components/{CommentItem,CommentThread}.tsx`, Comments API·model과 Audio/Community 소비 경로
|
||||
- 테스트: `src/features/comments/tests`, `tests/e2e/comments.spec.ts`
|
||||
- 문서: `ACR-001~005`, 기능 API Contract, `P1-T1`, `P1-GATE`, Progress
|
||||
- 브라우저 검증: Chromium·mobile Chrome mock E2E와 keyboard-only 흐름
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 실제 개발 API와 ADMIN credential을 사용하는 server integration
|
||||
- 댓글 수정·삭제·pagination의 기존 기능 자체 재설계
|
||||
- exact 320px Audio 첫 답글 수동 시각 QA 재수행. 현재 리뷰에서는 mobile Chrome 첫 답글과 320px Community overflow를 자동 검증했다.
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
### 심각도
|
||||
|
||||
| 심각도 | 기준 |
|
||||
|---|---|
|
||||
| Blocker | 보안·데이터 손실 위험 또는 핵심 첫 답글 흐름 불능 |
|
||||
| High | `ACR-001~005` 또는 API Contract 위반 |
|
||||
| Medium | 제한된 target·viewport·권한·재시도 조건의 기능 회귀 |
|
||||
| Low | 문서 추적, 유지보수성 또는 비핵심 UX 불일치 |
|
||||
|
||||
### 상태
|
||||
|
||||
| 상태 | 의미 | 후속 처리 |
|
||||
|---|---|---|
|
||||
| 후보 | 근거 발견 후 판정 전 | 재현·대조 |
|
||||
| 확정 | 코드·test·문서 근거로 문제 확인 | 후속 Task 후보 |
|
||||
| 오탐 | 요구사항과 실행 결과상 문제 아님 | 근거 보존 후 종료 |
|
||||
| 보류 | 외부 조건이 필요함 | 담당·재개 조건 기록 |
|
||||
| 수정 완료 | 수정과 회귀 검증 완료 | 검증 기록 누적 |
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
### 문서와 코드
|
||||
|
||||
- 요구사항: `ACR-001~005`, PRD §10·§14
|
||||
- API Contract: 직접 답글 GET, Audio 댓글·답글 POST, `NullSuccess`
|
||||
- 계획: `P1-T1`, `P1-GATE`, 2026-08-05 구현·검증 Progress
|
||||
- 코드: `CommentItem.tsx:6,28`, `CommentThread.tsx:113-125,127-147,167-175`
|
||||
- 테스트: `comment-thread.test.tsx:138-176`, `comments.spec.ts:33-145`
|
||||
|
||||
### 실행 환경
|
||||
|
||||
```text
|
||||
OS: Darwin 25.0.0 x86_64
|
||||
Node: v24.12.0
|
||||
npm: 11.7.0
|
||||
Browser/viewport: Desktop Chrome, Pixel 5 mobile Chrome, spec 내부 320x640·1280x900
|
||||
API mode: mock
|
||||
```
|
||||
|
||||
### 실행한 검증
|
||||
|
||||
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx` | 성공 | exit 0, 1 file / 8 tests passed |
|
||||
| `npm run test:run -- src/features/comments` | 성공 | exit 0, 3 files / 15 tests passed |
|
||||
| `npm run typecheck` | 성공 | exit 0, TypeScript 오류 0건 |
|
||||
| `npm run lint` | 성공 | exit 0, ESLint 오류 0건 |
|
||||
| `git diff --check` | 성공 | exit 0 |
|
||||
| sandbox의 `npm run e2e:mock -- tests/e2e/comments.spec.ts` | 실행 불가 | 로컬 `127.0.0.1:8889` listen `EPERM` |
|
||||
| 승인된 동일 E2E 재실행 | 성공 | exit 0, Chromium 3 + mobile Chrome 3 = 6 tests passed |
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P1-001` | Low | 수정 완료 | 완료 상태와 현재 상태·수용 체크박스가 서로 모순된다 | `P1-T1`, `P1-GATE` | `P1-R1` |
|
||||
|
||||
기능·API·권한·반응형 범위에서는 확정 발견 사항이 없다.
|
||||
|
||||
## 6. 발견 사항 상세
|
||||
|
||||
### REV-P1-001 — 완료 상태와 현재 상태·수용 체크박스가 서로 모순된다
|
||||
|
||||
- **심각도:** Low
|
||||
- **상태:** 수정 완료
|
||||
- **관련 요구사항:** `ACR-001~005`, PRD §14
|
||||
- **관련 계약:** 없음
|
||||
- **소유 Task:** `P1-T1`, `P1-GATE`; 후속 문서 Task `P1-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
`plan-task.md`는 상태, Phase 표, Task·Gate 체크박스와 Progress에서 구현·검증
|
||||
완료라고 기록한다. 그러나 같은 문서의 현재 상태는 답글 진입 버튼이 없고
|
||||
코드·test가 변경되지 않았다고 적으며, 발견된 문제도 기능 부재를 현재형으로
|
||||
유지한다. `prd.md` §14의 수용·추적 체크박스도 모두 미완료다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 완료 기록: `plan-task.md:5,21,84-100,159-163`
|
||||
- 미구현 기록: `plan-task.md:24-25,175`
|
||||
- 미완료 수용 기준: `prd.md:173-185`
|
||||
- 실제 구현: `CommentThread.tsx:170`, `CommentItem.tsx:28`
|
||||
- 검증: focused 8/8, Comments 15/15, mock E2E 6/6, typecheck·lint 통과
|
||||
|
||||
**재현 또는 검증 절차**
|
||||
|
||||
1. `plan-task.md`의 문서 상태와 Phase 표를 확인한다.
|
||||
2. 같은 문서의 현재 상태·발견된 문제와 `prd.md` §14를 확인한다.
|
||||
3. 완료와 미구현·미완료 표기가 동시에 존재함을 확인한다.
|
||||
4. 실제 code·test와 재실행 결과는 완료 쪽 기록과 일치한다.
|
||||
|
||||
**영향**
|
||||
|
||||
런타임 기능에는 영향이 없지만, 다음 작업자와 문서 검증 자동화가 구현 완료
|
||||
여부를 일관되게 판정할 수 없다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
코드 변경 없이 `plan-task.md`의 현재 상태와 발견된 문제를 실제 완료 상태로
|
||||
정정하고, `prd.md` §14 체크박스를 이번 리뷰 증거에 따라 완료 처리한다. 기존
|
||||
설계·구현 Progress는 삭제하지 않고 문서 정정 기록을 추가한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-08-05 — 문서 내부 대조와 현재 검증 결과로 Low 문서 정합성 문제를 확정했다.
|
||||
- 2026-08-05 정정 — 확정 finding을 초안으로만 남긴 처리는 review 가이드 §4·§5와 맞지 않아 [plan-task.md](../plan-task.md)의 `P1-R1` 후속 Task로 전환했다.
|
||||
- 2026-08-05 수정 완료 — `P1-R1`에서 stale 현재 상태·발견된 문제와 PRD §14 체크박스를 실제 구현·검증 상태에 맞게 정정했다.
|
||||
|
||||
## 7. 확정 항목의 plan·goal 전환
|
||||
|
||||
최초 리뷰에서는 진단 범위라는 이유로 아래 Task를 초안으로만 남겼다.
|
||||
|
||||
**정정 — 2026-08-05:** 확정 finding은 코드 수정 여부와 별개로
|
||||
`plan-task.md`의 후속 Task로 전환해야 하므로 [P1-R1](../plan-task.md)을
|
||||
추가했다. 아래 초안은 실제 Task의 입력으로 보존한다.
|
||||
|
||||
### 신규 회귀 수정 Task 초안
|
||||
|
||||
```markdown
|
||||
### Task R1.1 완료 문서 상태 정합성 복구
|
||||
|
||||
**Goal 실행 `P1-R1`:** `REV-P1-001`의 완료·미구현 상태 모순을 제거하고 실제 검증 증거와 PRD·plan을 일치시킨다.
|
||||
|
||||
- **시작 조건:** `REV-P1-001`, 완료된 `P1-T1`, `P1-GATE`.
|
||||
- **완료 증거:** 현재 상태·발견된 문제·PRD §14 정정, 기존 Progress 보존, 문서 링크·diff 검증.
|
||||
- **범위 밖:** 애플리케이션 코드·test·API Contract 변경.
|
||||
- **TDD 예외 사유:** 구현 동작이 아닌 완료 문서 정합성 수정이다.
|
||||
- **대체 검증:** 완료/미구현 marker 대조, Markdown link 확인, `git diff --check`.
|
||||
|
||||
- [x] 현재 상태와 발견된 문제를 실제 완료 상태로 정정한다.
|
||||
- [x] PRD §14 수용·추적 체크박스를 검증 증거에 맞게 갱신한다.
|
||||
- [x] 기존 Progress를 보존하고 정정 기록을 누적한다.
|
||||
- [x] Markdown link와 `git diff --check`를 실행해 결과를 기록한다.
|
||||
```
|
||||
|
||||
### create_goal objective 초안
|
||||
|
||||
```text
|
||||
[P1-R1]의 확정 review 항목 REV-P1-001을 문서에서 수정한다.
|
||||
애플리케이션 코드·test·API Contract는 변경하지 않는다.
|
||||
현재 상태, PRD 수용 기준, 정정 기록과 문서 검증이 모두 끝나기 전에는 complete로 표시하지 않는다.
|
||||
```
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | PRD·계약·plan·code·unit·E2E 대조 완료 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P1-001` Low 수정 완료 |
|
||||
| 확정 항목 plan 반영 | 충족 | [plan-task.md](../plan-task.md)에 `P1-R1` 추가 |
|
||||
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 외부 의존 finding 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | unit 15/15, E2E 6/6, typecheck·lint와 `P1-R1` 문서 검증 통과 |
|
||||
|
||||
**최종 결론:** 기능 구현 승인, `REV-P1-001` 수정 검증 완료.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 9. 수정 후 검증 기록
|
||||
|
||||
수정 검증 결과는 아래에 기존 기록을 보존하며 누적한다.
|
||||
|
||||
### 1차 수정 검증 — 2026-08-05
|
||||
|
||||
- 무엇을: `REV-P1-001`의 plan stale 상태·PRD §14 미완료 체크박스와 review 상태를 실제 구현·검증 완료 상태에 맞췄다.
|
||||
- 왜: 런타임 기능은 승인됐지만 완료·미구현 표기가 공존해 문서 추적과 자동 판정이 모순됐다.
|
||||
- 어떻게:
|
||||
- `! rg -n '^- 현재 .*답글 진입 버튼이 없다|^- 애플리케이션 코드와 test는 아직 변경하지 않았다|^- 확정: .*첫 답글 작성 진입이 없다' docs/20260805_오디오콘텐츠댓글답글/plan-task.md` — 성공, exit 0, stale marker 0건
|
||||
- `! sed -n '/## 14\./,/## 15\./p' docs/20260805_오디오콘텐츠댓글답글/prd.md | rg -n '^- \[ \]'` — 성공, exit 0, 미완료 체크박스 0건
|
||||
- `test -f docs/20260805_오디오콘텐츠댓글답글/reviews/phase1-audio-comment-first-reply.md` — 성공, exit 0
|
||||
- `npm run test:run -- src/features/comments` — 성공, exit 0, 3 files / 15 tests passed
|
||||
- `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium` — 성공, exit 0, Chromium 3 tests passed
|
||||
- `npm run typecheck` — 성공, exit 0, TypeScript 오류 0건
|
||||
- `npm run lint` — 성공, exit 0, ESLint 오류 0건
|
||||
- `git diff --check` — 성공, exit 0
|
||||
- 남은 항목: 없음.
|
||||
|
||||
### 2차 수정 재검증 — 2026-08-05
|
||||
|
||||
- 무엇을: 사용자가 반영한 `P1-R1`의 PRD·plan·review 정합성과 Comments 기능 회귀를 현재 working tree에서 다시 판정했다.
|
||||
- 왜: 완료 체크박스와 과거 검증 기록만 신뢰하지 않고 실제 반영 여부와 신규 문제를 독립적으로 확인하기 위해서다.
|
||||
- 어떻게:
|
||||
- `! rg -n '^- 현재 .*답글 진입 버튼이 없다|^- 애플리케이션 코드와 test는 아직 변경하지 않았다|^- 확정: .*첫 답글 작성 진입이 없다' docs/20260805_오디오콘텐츠댓글답글/plan-task.md` — 성공, exit 0, stale marker 0건
|
||||
- `! sed -n '/## 14\./,/## 15\./p' docs/20260805_오디오콘텐츠댓글답글/prd.md | rg -n '^- \[ \]'` — 성공, exit 0, 미완료 체크박스 0건
|
||||
- `test -f docs/20260805_오디오콘텐츠댓글답글/reviews/phase1-audio-comment-first-reply.md` — 성공, exit 0
|
||||
- `npm run test:run -- src/features/comments` — 성공, exit 0, 3 files / 15 tests passed
|
||||
- `npm run e2e:mock -- tests/e2e/comments.spec.ts` — 성공, exit 0, Chromium 3 + mobile Chrome 3 = 6 tests passed
|
||||
- `npm run typecheck` — 성공, exit 0, TypeScript 오류 0건
|
||||
- `npm run lint` — 성공, exit 0, ESLint 오류 0건
|
||||
- `git diff --check` — 성공, exit 0
|
||||
- 판정: `REV-P1-001` 수정 완료를 재확인했고 검토 범위의 신규 확정 발견 사항은 없다.
|
||||
- 남은 항목: 실제 개발 API와 ADMIN credential을 사용하는 server integration은 기존 제외 범위로 유지한다.
|
||||
353
docs/20260806_관리자라우트지연로딩/plan-task.md
Normal file
353
docs/20260806_관리자라우트지연로딩/plan-task.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# 관리자 라우트 지연 로딩 구현 계획
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 구현·회귀 수정·검증 완료 |
|
||||
| 작성일 | 2026-08-06 |
|
||||
| 요구사항 기준 | [prd.md](./prd.md) |
|
||||
| API 기준 | 변경 불필요 — 기존 인증·domain 계약 유지 |
|
||||
| 현재 Phase | Phase 1. 보호 page code splitting 완료 |
|
||||
| 현재 활성 Goal | 없음 |
|
||||
|
||||
## 목표
|
||||
|
||||
보호된 관리자 page를 route별로 지연 로드해 초기 JS chunk를 줄이면서 모든 기존 기능을 유지한다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1 | 완료 | `3/3` | 없음 | 없음 |
|
||||
|
||||
- `ProtectedAdminShell`은 14개 보호 page component를 `React.lazy()` 동적 import로 로드한다.
|
||||
- Vite production build는 310 modules를 37개 JS chunk로 분리하고 최대 JS chunk는 `315.09kB`다.
|
||||
- 전체 unit `83 files / 462 tests`, mock Chromium E2E `53 tests`, typecheck, lint, production build가 통과했다.
|
||||
|
||||
## 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- 보호 page component의 `React.lazy()` 동적 import
|
||||
- 관리자 main의 `Suspense`·기존 `PageState` loading fallback
|
||||
- production graph의 chunk 수·최대 크기 자동 검증
|
||||
- 인증·route·domain 기능 unit와 mock Chromium E2E 회귀 검증
|
||||
- 320px·200% zoom·keyboard·접근성 확인
|
||||
|
||||
### 제외
|
||||
|
||||
- `vite.config.ts`의 warning limit·manual chunk 설정 변경
|
||||
- page default export 전환, route library와 새 helper·dependency 추가
|
||||
- App shell·Login·AccessDenied page lazy loading
|
||||
- API, 권한, page props, 상태 관리와 domain 기능 변경
|
||||
- cropper만 별도로 lazy loading하는 추가 최적화
|
||||
|
||||
## 기술적 제약
|
||||
|
||||
- 기술 스택: React 19.2.8, TypeScript 6.0.3, Vite 8.1.5, Vitest 4.1.10, Playwright 1.61.1.
|
||||
- 아키텍처: `ProtectedAdminShell`의 기존 route 판정과 page 호출부를 유지하고 import boundary만 변경한다.
|
||||
- export: 기존 named export를 유지하며 각 lazy import에서 React가 요구하는 `default` shape으로 mapping한다.
|
||||
- fallback: 기존 `PageState`를 사용하고 shell·URL·focus 경계를 유지한다.
|
||||
- 성능 기준: 모든 production JS chunk `<=500,000 bytes`, warning 0건.
|
||||
- 데이터·보안: API·token·mock production boundary를 변경하지 않는다.
|
||||
- 의존성: 추가하지 않는다.
|
||||
- 구현: RED → GREEN → REFACTOR 순서와 실제 결과를 Progress에 기록한다.
|
||||
|
||||
## Phase 1. 보호 page code splitting
|
||||
|
||||
**Phase 결과:** 최초 관리자 route에는 현재 page 코드만 로드되고 다른 보호 page는 첫 진입 시 로드되며 기존 기능이 유지된다.
|
||||
|
||||
**선행조건:** `ARL-001~008`, `ARL-DEC-001~003` 확정.
|
||||
|
||||
**Phase 완료 조건:** `P1-T1`, `P1-R1`, `P1-R2`와 `P1-GATE` 완료, PRD 성공 기준과 Progress 갱신.
|
||||
|
||||
### 구현 항목
|
||||
|
||||
#### Task 1.1 보호 page route boundary 분리
|
||||
|
||||
**Goal 실행 `P1-T1`:** 보호 page 정적 import를 lazy import로 바꾸고 production chunk 경계를 자동 검증한다.
|
||||
|
||||
- **시작 조건:** `prd.md`가 구현 기준 확정 상태이고 활성 goal이 없음.
|
||||
- **완료 증거:** RED·GREEN·REFACTOR 체크박스, production graph와 focused App test, production build 결과, Progress 기록.
|
||||
- **범위 밖:** route parser, page 내부 구현, API와 Vite manual chunk 설정.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: 없음
|
||||
- Modify: `src/app/protected-admin-shell.tsx`
|
||||
- Modify: `src/app/App.test.tsx` — lazy page heading 대기 보완
|
||||
- Test: `src/shared/mocks/__tests__/production-graph.test.ts`
|
||||
- Test: `src/app/App.protected-shell.test.tsx` — assertion 보완이 필요할 때만 수정
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: 14개 page module의 기존 named export와 `ProtectedAdminShell` route 판정 결과.
|
||||
- Produces: 동일 page props·render 조건, route별 dynamic import chunk와 `PageState` loading fallback.
|
||||
|
||||
**TDD 절차:**
|
||||
|
||||
- [x] **RED: 실패 test 작성/실패 확인** — `production-graph.test.ts`의 기존 production build 결과에서 JS 파일이 2개 이상이고 모든 JS 파일이 `<=500,000 bytes`인지 검사한다. `npm run test:run -- src/shared/mocks/__tests__/production-graph.test.ts`가 현재 단일 `598,785 bytes` chunk로 실패하는지 확인한다.
|
||||
- [x] **GREEN: 최소 구현/통과 확인** — `protected-admin-shell.tsx`에서 React `lazy`·`Suspense`를 사용해 14개 보호 page의 named export를 동적 import하고 기존 page render 구간을 `PageState` fallback으로 감싼다. 같은 production graph test가 `exit 0`, `1/1`인지 확인한다.
|
||||
- [x] **REFACTOR: 정리/회귀 확인** — 새 helper·barrel·config 없이 import와 fallback 위치만 정리한 뒤 production graph test와 `npm run test:run -- src/app/App.protected-shell.test.tsx src/app/browser-location.test.ts`가 각각 `1/1`, `2 files / 16 tests` 이상으로 통과하는지 확인한다.
|
||||
- [x] `npm run build:prod` 결과에 JS chunk가 2개 이상이고 `500kB` warning이 0건인지 기록한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `npm run test:run -- src/shared/mocks/__tests__/production-graph.test.ts`; `npm run test:run -- src/app/App.protected-shell.test.tsx src/app/browser-location.test.ts`; `npm run typecheck`; `npm run lint`; `npm run build:prod`.
|
||||
- **기대 결과:** 모든 명령 `exit 0`, production graph `1/1`, App focused `2 files / 16 tests` 이상, type·lint 오류 0건, JS chunk 2개 이상, 최대 JS `<=500,000 bytes`, chunk warning 0건.
|
||||
- **수동 확인:** production preview의 Network에서 첫 route 외 page chunk가 초기 요청에 없고 다른 보호 route 최초 진입에 해당 chunk가 한 번 요청되는지 확인한다.
|
||||
|
||||
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
|
||||
|
||||
#### Task 1.2 320px·200% zoom CJK 회귀 수정
|
||||
|
||||
**Goal 실행 `P1-R1`:** Phase Gate 수동 확인 중 발견된 한국어 음절 단위 세로 분리 회귀를 수정하고 공통 페이지네이션 모바일 배치 결정을 갱신한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 구현 뒤 320px·200% zoom visual QA에서 CJK 음절 열 회귀가 확인됨.
|
||||
- **완료 증거:** CJK E2E 회귀 test, ResourcePagination unit test, mock Chromium/mobile Chrome E2E, Decision Log와 Progress 기록.
|
||||
- **범위 밖:** 새 responsive component, pagination API 변경, page size options 변경, desktop/tablet 배치 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: 없음
|
||||
- Modify: `src/features/characters/components/CharacterListItem.tsx`
|
||||
- Modify: `src/shared/ui/resource-pagination.tsx`
|
||||
- Test: `src/shared/ui/__tests__/resource-pagination.test.tsx`
|
||||
- Test: `tests/e2e/character-workspace.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `ResourcePagination`의 기존 `PageData`, `onPageChange`, `onSizeChange`, accessible group/button labels.
|
||||
- Produces: 동일 pagination API와 desktop/tablet `sm:flex` 배치, mobile에서는 음절 단위 세로 분리를 막는 stacked movement controls.
|
||||
|
||||
**TDD 절차:**
|
||||
|
||||
- [x] **RED: 실패 test 작성/실패 확인** — 320px·200% zoom에서 Korean leaf text가 음절 단위 세로 열로 렌더링되는지 `tests/e2e/character-workspace.spec.ts`에서 `Range.getClientRects()`로 검사한다. visual QA 스크린샷 `arl-lazy-routes-320-zoom200.png`에서 기존 문제가 확인됐다.
|
||||
- [x] **GREEN: 최소 구현/통과 확인** — `CharacterListItem` 텍스트에 `break-keep break-words`, `ResourcePagination` summary/label에 `break-keep`, mobile movement controls에 `grid-cols-1`과 `whitespace-nowrap`를 적용한다.
|
||||
- [x] **REFACTOR: 정리/회귀 확인** — `ResourcePagination` unit expectation을 새 mobile 배치 계약으로 갱신하고 focused E2E와 axe 회귀를 통과시킨다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts --project=chromium --grep "mobile zoom keeps Korean list and pagination text out of syllable columns"`; `npm run test:run -- src/shared/ui/__tests__/resource-pagination.test.tsx`; `npm run e2e:mock:chromium`; `npm run e2e:mock:mobile-chrome`.
|
||||
- **기대 결과:** focused CJK E2E `1 passed`, ResourcePagination unit `4 tests` 통과, Chromium E2E `53 tests` 통과, mobile Chrome E2E `48 passed / 5 skipped`.
|
||||
- **수동 확인:** 320px·200% zoom에서 캐릭터 목록과 페이지네이션에 수평 overflow, 가려진 action, 한국어 음절 단위 세로 분리가 없다.
|
||||
|
||||
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
|
||||
|
||||
#### Task 1.3 완료 문서 현재 상태 정합성 복구
|
||||
|
||||
**Goal 실행 `P1-R2`:** `ARL-REV-P1-001`의 완료 Task 수와 해결된 원인 이슈 상태를 실제 구현·검증 결과에 맞춘다.
|
||||
|
||||
- **시작 조건:** `P1-T1`, `P1-R1`, `P1-GATE` 완료와 `ARL-REV-P1-001` 확정.
|
||||
- **완료 증거:** 현재 상태 `3/3`, `ARL-ISSUE-001` 해결 상태, review 링크와 검증 기록.
|
||||
- **범위 밖:** 애플리케이션 코드·test·API·기존 구현 결정 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `docs/20260806_관리자라우트지연로딩/reviews/phase1-admin-route-lazy-loading.md`
|
||||
- Modify: `docs/20260806_관리자라우트지연로딩/prd.md`
|
||||
- Modify: `docs/20260806_관리자라우트지연로딩/plan-task.md`
|
||||
- Test: 없음 — 현재 상태 문구만 정정하는 문서 Task다.
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `ARL-REV-P1-001`, `P1-T1`, `P1-R1`, `P1-GATE` 완료 증거.
|
||||
- Produces: 실제 완료 범위와 일치하는 PRD·계획·review 추적 상태.
|
||||
|
||||
**TDD 예외 사유:** 애플리케이션 동작을 변경하지 않는 문서 현재 상태 정정이라 실패 test를 추가하지 않는다.
|
||||
|
||||
**대체 검증 방법:** 완료 Task 수, 해결 이슈 상태와 review 링크를 `rg`로 확인하고 `git diff --check`를 실행한다.
|
||||
|
||||
- [x] 현재 상태 표의 완료 Task를 신규 회귀 Task까지 포함한 `3/3`으로 정정한다.
|
||||
- [x] `ARL-ISSUE-001`을 해결 상태로 정정한다.
|
||||
- [x] PRD에 review 링크를 연결하고 review 상태를 `수정 완료`로 갱신한다.
|
||||
- [x] 실제 검증 결과를 Progress에 누적한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `rg -n '3/3|ARL-ISSUE-001.*해결|phase1-admin-route-lazy-loading' docs/20260806_관리자라우트지연로딩`; `git diff --check`.
|
||||
- **기대 결과:** 세 현재 상태 marker와 review 링크가 확인되고 whitespace 오류가 없다.
|
||||
- **수동 확인:** 문서 표와 Task·Progress가 서로 같은 완료 상태를 표시한다.
|
||||
|
||||
### 완료 조건
|
||||
|
||||
- [x] `P1-T1`과 `P1-R1`의 체크박스와 완료 증거가 모두 충족됐다.
|
||||
- [x] `P1-R2`의 문서 정합성 복구와 검증 기록이 완료됐다.
|
||||
- [x] `ARL-001~008`이 구현 또는 Gate 증거로 추적된다.
|
||||
- [x] PRD 성공 기준과 현재 상태를 실제 결과로 갱신했다.
|
||||
- [x] 알려진 문서와 구현의 차이가 없다.
|
||||
|
||||
### 검증 방법
|
||||
|
||||
#### Phase 1 Gate
|
||||
|
||||
**Goal 실행 `P1-GATE`:** route code splitting, 기능 보존과 공통 품질 기준을 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P1-T1`과 `P1-R1` 완료.
|
||||
- **완료 증거:** 아래 자동·수동 검증 통과와 Progress 기록.
|
||||
- **범위 밖:** test 삭제·완화, warning limit 상향과 관련 없는 기능 수정.
|
||||
|
||||
**실행 명령:**
|
||||
|
||||
```bash
|
||||
npm run test:run
|
||||
npm run e2e:mock:chromium
|
||||
npm run e2e:mock:mobile-chrome
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run build:prod
|
||||
git diff --check
|
||||
```
|
||||
|
||||
**기대 결과:** 모든 명령 `exit 0`, unit·mock Chromium/mobile Chrome E2E 실패 0건, type·lint·build 오류 0건, production JS chunk 2개 이상, 최대 JS `<=500,000 bytes`, chunk warning·whitespace 오류 0건.
|
||||
|
||||
**수동 확인:**
|
||||
|
||||
- [x] `/ai-characters` 직접 URL과 캐릭터 수정 내부 이동·뒤로 가기가 기존과 동일하다.
|
||||
- [x] Audio·Series·Community·FanTalk route 최초 진입에 loading 뒤 기존 화면이 표시된다.
|
||||
- [x] Network에서 현재 route 이외 page chunk가 초기 요청에 없고 최초 진입 후 cache된다.
|
||||
- [x] 1280px·320px·200% zoom에서 loading·page에 수평 overflow와 가려진 action이 없다.
|
||||
- [x] keyboard focus·skip link와 axe critical·serious 위반 0건을 확인한다.
|
||||
|
||||
## 실행 순서와 의존성
|
||||
|
||||
1. `P1-T1`에서 production graph 실패 test를 먼저 추가하고 최소 lazy import 구현과 focused 검증을 완료한다.
|
||||
2. `P1-R1`에서 320px·200% zoom CJK 회귀를 focused E2E와 공통 pagination unit으로 고정한다.
|
||||
3. `P1-GATE`에서 전체 unit·mock Chromium/mobile Chrome E2E와 수동 Network·접근성 검증을 완료한다.
|
||||
4. `P1-R2`에서 완료 문서 현재 상태를 fresh Gate 결과와 일치시키고 review를 종료한다.
|
||||
|
||||
`P1-GATE`는 `P1-T1`과 `P1-R1` 완료 전 시작하지 않는다.
|
||||
`P1-R2`는 `P1-GATE` 완료와 `ARL-REV-P1-001` 확정 뒤 시작한다.
|
||||
|
||||
## 변경 금지 항목
|
||||
|
||||
- `chunkSizeWarningLimit`과 `manualChunks`를 추가하지 않는다.
|
||||
- 기존 named export, page props, route parser와 route path를 변경하지 않는다.
|
||||
- page 내부 API·state·권한·UI를 함께 refactor하지 않는다.
|
||||
- 새 dependency, lazy helper, barrel 또는 speculative prefetch를 추가하지 않는다.
|
||||
- test를 삭제·skip·완화하거나 type 오류를 우회하지 않는다.
|
||||
- 기존 Progress·Decision Log·review 기록을 삭제하거나 덮어쓰지 않는다.
|
||||
|
||||
## 의사결정 및 중단 규칙
|
||||
|
||||
- build에서 단일 chunk가 유지되면 warning limit을 올리지 말고 static import 잔존 여부를 확인한다.
|
||||
- 공통 dependency chunk가 `500,000 bytes`를 넘으면 근거를 기록하고 사용자와 별도 최적화 범위를 결정한다.
|
||||
- lazy 전환으로 기존 test가 timing 차이만 드러내면 사용자 결과 assertion은 유지하고 비동기 대기만 최소 보완한다.
|
||||
- 기능 assertion이 실패하면 lazy 변경을 완료로 처리하지 않고 원인을 수정한다.
|
||||
- 범위가 바뀌면 PRD Decision Log와 이 계획을 먼저 갱신한다.
|
||||
- 같은 차단 사유가 3회 연속 반복되고 독립 작업도 불가능할 때만 goal을 `blocked`로 갱신한다.
|
||||
|
||||
## Progress
|
||||
|
||||
기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다.
|
||||
|
||||
### 계획 작성 — 2026-08-06
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: route-level `React.lazy()` 선택을 `ARL-001~008`, 단일 구현 Task와 Phase Gate로 정규화했다.
|
||||
- 왜: 현재 14개 보호 page의 static import가 단일 `598.78kB` production JS chunk와 `500kB` warning을 만든다.
|
||||
- 어떻게:
|
||||
- `npm run build:prod` — 성공, exit 0, 310 modules, JS `598.78kB`, gzip `158.94kB`, `500kB` chunk warning 1건.
|
||||
- `npm run test:run -- src/app/App.protected-shell.test.tsx src/app/browser-location.test.ts` — 성공, exit 0, `2 files / 16 tests`.
|
||||
- code·test 변경과 수동 Network 검증 — 미실행, 구현 요청 범위가 아님.
|
||||
- 남은 항목: `P1-T1`, `P1-GATE`.
|
||||
- 다음 행동: `P1-T1` production graph RED assertion 작성.
|
||||
|
||||
### 1차 구현 — 2026-08-06
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: `ProtectedAdminShell`의 14개 보호 page static import를 route-level `React.lazy()` named export mapping으로 바꾸고 기존 `PageState`를 `Suspense` fallback으로 사용했다.
|
||||
- 왜: 초기 관리자 route에서 현재 page 외 보호 page 코드를 내려받지 않고, Vite `500kB` chunk warning을 warning limit 상향 없이 제거하기 위해서다.
|
||||
- 어떻게:
|
||||
- `npm run test:run -- src/shared/mocks/__tests__/production-graph.test.ts` — RED 성공, 기존 단일 JS chunk 때문에 `expected 1 to be greater than or equal to 2`로 실패 확인.
|
||||
- `npm run test:run -- src/shared/mocks/__tests__/production-graph.test.ts` — GREEN 성공, exit 0, `1 file / 1 test`.
|
||||
- `npm run test:run -- src/app/App.protected-shell.test.tsx src/app/browser-location.test.ts` — REFACTOR 회귀 성공, exit 0, `2 files / 16 tests`.
|
||||
- `npm run build:prod` — 성공, exit 0, JS chunk 37개, 최대 JS `315.09kB`, `500kB` warning 0건.
|
||||
- production preview 수동 확인 — `/ai-characters` 초기 요청에는 list 관련 chunk만 로드되고 detail·audio route 최초 진입 때 해당 page chunk가 추가 로드됨을 확인했다.
|
||||
- 남은 항목: `P1-GATE`와 visual QA 회귀 확인.
|
||||
|
||||
### 2차 수정 — 2026-08-06
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: 320px·200% zoom 수동 확인 중 발견된 한국어 음절 단위 세로 분리 회귀를 `CharacterListItem`과 `ResourcePagination`의 wrapping 규칙으로 수정하고 E2E 회귀 test를 추가했다.
|
||||
- 왜: lazy route 자체의 기능 문제는 아니지만 Phase Gate의 320px·200% zoom 수동 확인 기준을 만족하지 못했다.
|
||||
- 어떻게:
|
||||
- `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts --project=chromium --grep "mobile zoom keeps Korean list and pagination text out of syllable columns"` — 성공, exit 0, `1 passed`.
|
||||
- `npm run test:run -- src/shared/ui/__tests__/resource-pagination.test.tsx` — 성공, exit 0, `1 file / 4 tests`.
|
||||
- `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts --project=chromium --grep "has no critical or serious axe violations on the list"` — 첫 실행은 `Port 8889 is already in use` 환경 문제로 실패, 포트 해제 확인 후 재실행 성공, exit 0, `1 passed`.
|
||||
- 남은 항목: fresh Phase Gate 전체 검증.
|
||||
|
||||
### Phase 1 Gate — 2026-08-06
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: route code splitting, 기능 보존, 접근성·반응형 회귀와 공통 품질 기준을 최종 검증했다.
|
||||
- 왜: `P1-T1` 완료 뒤 `ARL-001~008`과 Phase 완료 조건을 실제 실행 결과로 판정하기 위해서다.
|
||||
- 어떻게:
|
||||
- `npm run test:run` — 성공, exit 0, `83 files / 462 tests`.
|
||||
- `npm run e2e:mock:chromium` — 성공, exit 0, `53 tests`.
|
||||
- `npm run e2e:mock:mobile-chrome` — 성공, exit 0, `48 passed / 5 skipped`.
|
||||
- `npm run typecheck` — 성공, exit 0.
|
||||
- `npm run lint` — 성공, exit 0.
|
||||
- `npm run build:prod` — 성공, exit 0, 310 modules, JS chunk 37개, 최대 JS `315.09kB`, gzip `93.77kB`, `500kB` warning 0건.
|
||||
- `git diff --check` — 성공, exit 0, whitespace 오류 0건.
|
||||
- 수동 production preview — `/ai-characters` 직접 URL, detail/audio route 진입, 뒤로 가기, Network chunk lazy loading, 1280px·320px·200% zoom 수평 overflow 없음, console error 0건을 확인했다.
|
||||
- 남은 항목: 없음.
|
||||
|
||||
### 회귀 감사 — 2026-08-06
|
||||
|
||||
- 상태: 확정
|
||||
- 무엇을: 구현·test·build와 계획의 현재 상태를 다시 대조해 `ARL-REV-P1-001`을 확정하고 `P1-R2`로 전환했다.
|
||||
- 왜: 완료된 `P1-T1`·`P1-R1`이 `1/1`로 표시되고 해결된 `ARL-ISSUE-001`이 `확정`으로 남아 있었다.
|
||||
- 어떻게:
|
||||
- `npm run test:run` — 성공, exit 0, `83 files / 462 tests`.
|
||||
- `npm run typecheck` — 성공, exit 0.
|
||||
- `npm run lint` — 성공, exit 0.
|
||||
- `npm run build:prod` — 성공, exit 0, 310 modules, JS 37개, 최대 `315.09kB`, chunk warning 0건.
|
||||
- `npm run e2e:mock:chromium` — 최초 sandbox port 권한으로 실행 불가, 권한 허용 후 성공, exit 0, `53 passed`.
|
||||
- `npm run e2e:mock:mobile-chrome` — 성공, exit 0, `48 passed / 5 skipped`.
|
||||
- 남은 항목: `P1-R2` 문서 현재 상태 정정과 review 종료.
|
||||
|
||||
### `P1-R2` 문서 정합성 회귀 수정 — 2026-08-06
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: 완료 Task 수를 신규 회귀 Task까지 포함한 `3/3`으로 갱신하고 `ARL-ISSUE-001`을 해결 상태로 바꿨으며 PRD에 Phase 1 review를 연결했다.
|
||||
- 왜: 완료 구현과 계획의 현재 상태가 달라 후속 작업자가 남은 범위를 잘못 판단할 수 있었다.
|
||||
- 어떻게:
|
||||
- `rg -n '3/3|ARL-ISSUE-001.*해결|phase1-admin-route-lazy-loading' docs/20260806_관리자라우트지연로딩` — 성공, 세 현재 상태 marker와 PRD·plan·review 연결 확인.
|
||||
- `git diff --check` — 성공, exit 0, whitespace 오류 0건.
|
||||
- 남은 항목: 없음.
|
||||
|
||||
## Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-06 | `ARL-PLAN-DEC-001` | 확정 | 보호 page를 route-level `React.lazy()`로 분리한다. | 실제 초기 loading 비용과 chunk warning을 함께 줄인다. | `P1-T1`, `P1-GATE`, `prd.md` |
|
||||
| 2026-08-06 | `ARL-PLAN-DEC-002` | 확정 | 기존 production graph test에 chunk 수·크기 assertion을 추가한다. | 이미 Vite production build와 임시 directory 정리를 검증하는 가장 가까운 test다. | `P1-T1` |
|
||||
| 2026-08-06 | `ARL-PLAN-DEC-003` | 확정 | 기존 `PageState`를 Suspense fallback으로 사용한다. | 새 component 없이 디자인·접근성 관례를 유지한다. | `P1-T1` |
|
||||
| 2026-08-06 | `ARL-PLAN-DEC-004` | 확정 | `ResourcePagination`의 mobile movement controls는 동일 폭 2열 대신 1열 stacked 배치로 대체한다. | 320px·200% zoom에서 한국어 버튼 텍스트가 음절 단위 세로 열로 분리되는 회귀를 막고 touch target과 label 가독성을 유지한다. Desktop/tablet은 기존 `sm:flex` 배치를 유지한다. | `P1-R1`, `ARL-006`, `ARL-007` |
|
||||
| 2026-08-06 | `ARL-PLAN-DEC-005` | 확정 | 완료 문서의 stale Task 수와 이슈 상태를 `P1-R2`에서 현재 구현 결과와 맞춘다. | `ARL-REV-P1-001`의 문서 정합성 회귀 판정. | `P1-R2`, Phase 1 review |
|
||||
|
||||
## 발견된 문제
|
||||
|
||||
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|
||||
|---|---|---|---|---|---|
|
||||
| `ARL-ISSUE-001` | Medium | 해결 | 보호 page 정적 import로 production JS가 `598.78kB` 단일 chunk이며 Vite 경고가 반복된다. | `P1-T1` | route-level lazy import와 build boundary test 완료 |
|
||||
| `ARL-ISSUE-002` | Medium | 해결 | 320px·200% zoom에서 캐릭터 목록과 공통 페이지네이션 한국어 텍스트가 음절 단위 세로 열로 분리됐다. | `P1-R1`, `P1-GATE` | `break-keep`·stacked mobile pagination과 CJK E2E 회귀 test |
|
||||
| `ARL-ISSUE-003` | Low | 해결 | 완료 Task 수와 해결된 원인 이슈 상태가 구현 전 값으로 남아 있다. | `P1-R2` | `ARL-REV-P1-001` 문서 현재 상태 정합성 복구 완료 |
|
||||
|
||||
## 최종 보고 형식
|
||||
|
||||
```markdown
|
||||
구현 결과: 보호된 관리자 page가 route별 chunk로 분리되고 기존 기능을 유지한다.
|
||||
|
||||
- 변경: `ProtectedAdminShell` page import boundary와 production graph assertion
|
||||
- 결정: `ARL-DEC-001` — route-level `React.lazy()`
|
||||
- 검증:
|
||||
- `npm run test:run` — <실제 결과>
|
||||
- `npm run e2e:mock:chromium` — <실제 결과>
|
||||
- `npm run build:prod` — <chunk 수·최대 크기·warning 수>
|
||||
- Network·1280px·320px·200% zoom·keyboard·axe — <실제 결과>
|
||||
- 남은 항목: <없음 또는 구체적인 항목>
|
||||
- 문서: `docs/20260806_관리자라우트지연로딩/{prd.md,plan-task.md}`
|
||||
```
|
||||
|
||||
최종 보고는 실제 실행한 최신 검증 결과와 완료되지 않은 범위를 함께 기록한다.
|
||||
228
docs/20260806_관리자라우트지연로딩/prd.md
Normal file
228
docs/20260806_관리자라우트지연로딩/prd.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# 관리자 라우트 지연 로딩 PRD
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | 구현 완료 |
|
||||
| 작성일 | 2026-08-06 |
|
||||
| 최종 수정일 | 2026-08-06 |
|
||||
| 대상 제품 | AI 캐릭터 관리자 웹 |
|
||||
| 작성자·결정권자 | Codex 작성, 사용자 결정 |
|
||||
| 관련 API Contract | 불필요 — API와 payload 변경 없음 |
|
||||
| 관련 구현 계획 | [plan-task.md](./plan-task.md) |
|
||||
| 관련 review | [Phase 1 관리자 라우트 지연 로딩 리뷰](./reviews/phase1-admin-route-lazy-loading.md) |
|
||||
|
||||
### 요구사항 상태
|
||||
|
||||
| 상태 | 의미 | 구현 처리 |
|
||||
|---|---|---|
|
||||
| 확정 | 제품·기술 결정이 완료된 구현 기준 | `plan-task.md`의 Task와 완료 증거로 추적 |
|
||||
| 미결 | 추가 결정 필요 | 구현 전 결정 |
|
||||
| 외부 의존 | 프론트엔드 밖의 제공 필요 | 제공 전 관련 구현 중단 |
|
||||
| 권고 | 확정 전 추천안 | 수용 기준으로 사용하지 않음 |
|
||||
| 제외 | 이번 범위에서 구현하지 않음 | 포함 조건을 Decision Log에 기록 |
|
||||
|
||||
### 문서 우선순위와 갱신 순서
|
||||
|
||||
1. 초기 bundle과 라우트 로딩 결정은 이 PRD가 소유한다.
|
||||
2. API 변경이 없으므로 별도 API Contract를 만들지 않는다.
|
||||
3. 구현 순서와 완료 증거는 `plan-task.md`가 소유한다.
|
||||
4. 결정이 바뀌면 Decision Log → 요구사항 → 계획 순서로 갱신한다.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
보호된 관리자 페이지를 `React.lazy()` 기반 동적 import로 분리한다. 최초 접속에는 현재 라우트에 필요한 코드만 내려받고, 다른 페이지 코드는 해당 라우트에 처음 진입할 때 로드한다. 기능·API·권한·데이터 흐름은 유지한다.
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
현재 `protected-admin-shell.tsx`는 14개 보호 페이지를 정적으로 import한다.
|
||||
|
||||
- production build가 310개 module을 하나의 `598.78kB` minified JS chunk로 출력한다.
|
||||
- Vite 8.1.5 기본 기준 `500kB`를 넘어 build마다 chunk size warning이 발생한다.
|
||||
- gzip 전송량은 `158.94kB`지만 browser가 최초 접속에 전체 chunk를 다운로드·파싱·실행한다.
|
||||
- 이미지 cropper처럼 현재 라우트에서 사용하지 않는 기능도 초기 module graph에 포함된다.
|
||||
|
||||
문제를 해결했다는 판단은 production build가 보호 페이지를 여러 chunk로 분리하고 모든 JS chunk가 `500,000 bytes` 이하이며, 기존 사용자 흐름이 그대로 통과할 때로 한다. 구현 완료 build는 37개 JS chunk, 최대 JS `315.09kB`, chunk size warning 0건이다.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
### 3.1 제품 목표
|
||||
|
||||
- 최초 접속에서 현재 관리자 화면에 필요하지 않은 페이지 코드를 지연 로드한다.
|
||||
- Vite의 `500kB` 초과 chunk warning을 실제 code splitting으로 제거한다.
|
||||
- 직접 URL, 내부 이동, 뒤로 가기와 권한 검사를 기존과 동일하게 유지한다.
|
||||
|
||||
### 3.2 UX 목표
|
||||
|
||||
- 첫 화면의 다운로드·파싱·실행 부담을 줄인다.
|
||||
- 미로드 라우트 최초 진입에는 명확한 loading 상태를 표시한다.
|
||||
- loading 중 keyboard focus, 관리자 shell과 현재 URL을 유지한다.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- `chunkSizeWarningLimit` 상향으로 경고만 숨기기
|
||||
- `manualChunks` 또는 vendor chunk 설정 추가
|
||||
- `react-advanced-cropper`, `zod`, React Query 교체·제거
|
||||
- router library, bundle 분석 library 또는 새 runtime dependency 추가
|
||||
- API, 인증·권한, route path, page props와 상태 관리 변경
|
||||
- 서버 rendering, prefetch, service worker cache 또는 offline 지원 추가
|
||||
|
||||
Non-Goal을 변경하려면 Decision Log와 `plan-task.md`를 먼저 갱신한다.
|
||||
|
||||
## 5. Target Users and Permissions
|
||||
|
||||
| 사용자 | 목표 | 주요 작업 | 사용 환경 |
|
||||
|---|---|---|---|
|
||||
| ADMIN | 관리자 화면에 빠르게 진입 | 캐릭터·오디오·시리즈·커뮤니티·FanTalk 관리 | desktop, tablet, mobile |
|
||||
| 인증되지 않은 사용자 | 보호 코드 노출 없이 로그인 | 로그인, 인증 후 관리자 진입 | desktop, tablet, mobile |
|
||||
|
||||
- 기존 ADMIN probe, 401 session 제거와 403 접근 거부 정책을 유지한다.
|
||||
- lazy page loading은 권한 검사 성공 뒤에만 보호 UI를 표시한다.
|
||||
|
||||
## 6. 핵심 사용자 흐름
|
||||
|
||||
1. 사용자가 로그인 또는 보호된 직접 URL로 접속한다.
|
||||
2. 기존 인증·ADMIN probe가 완료된다.
|
||||
3. 현재 라우트 page chunk가 없으면 관리자 shell 안에 loading 상태를 표시한다.
|
||||
4. chunk가 로드되면 기존 page를 같은 props와 URL로 표시한다.
|
||||
5. 다른 메뉴에 처음 진입하면 해당 page chunk만 추가로 받고, 이후 browser cache를 재사용한다.
|
||||
6. 내부 이동·뒤로 가기·새로고침과 mutation 흐름은 기존과 동일하게 동작한다.
|
||||
|
||||
## 7. 정보 구조와 라우팅
|
||||
|
||||
```text
|
||||
/login # eager 유지
|
||||
/access-denied # eager 유지
|
||||
/ai-characters # protected page lazy
|
||||
/ai-characters/new # protected page lazy
|
||||
/ai-characters/:characterId/** # protected page lazy
|
||||
```
|
||||
|
||||
- `App`, 인증 provider와 `ProtectedAdminShell`은 application shell로 유지한다.
|
||||
- `ProtectedAdminShell`이 현재 판정하는 모든 보호 page component만 lazy boundary로 이동한다.
|
||||
- route path parser와 URL 상태는 변경하지 않는다.
|
||||
|
||||
## 8. 기능 요구사항
|
||||
|
||||
### 8.1 Code splitting
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `ARL-001` | 확정 | `ProtectedAdminShell`의 보호 page 정적 import를 `React.lazy()` 동적 import로 전환한다. | 14개 page component가 현재 route에서 render될 때 해당 module을 import한다. | contract 불필요, `P1-T1` |
|
||||
| `ARL-002` | 확정 | named export를 유지하며 page component의 public props를 변경하지 않는다. | page export·호출부 type과 기존 test가 변경 없이 통과한다. | contract 불필요, `P1-T1` |
|
||||
| `ARL-003` | 확정 | 보호 page 영역을 `Suspense`로 감싸 loading 상태를 표시한다. | 미로드 page 진입 시 `화면을 불러오는 중` status가 관리자 shell 안에 표시된다. | contract 불필요, `P1-T1` |
|
||||
| `ARL-004` | 확정 | production build의 모든 minified JS chunk를 `500,000 bytes` 이하로 유지한다. | production graph test가 JS chunk 2개 이상과 최대 chunk `<=500,000 bytes`를 확인하고 Vite 경고가 없다. | contract 불필요, `P1-T1`, `P1-GATE` |
|
||||
|
||||
### 8.2 기능 보존
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `ARL-005` | 확정 | 로그인, ADMIN probe, 401·403와 malformed route 처리를 유지한다. | 기존 App auth·protected 오류 test가 모두 통과한다. | 기존 인증 계약 유지, `P1-GATE` |
|
||||
| `ARL-006` | 확정 | 직접 URL, 내부 이동, 뒤로 가기와 route별 page props를 유지한다. | 기존 App route test와 mock Chromium E2E가 모두 통과한다. | 기존 route contract 유지, `P1-GATE` |
|
||||
| `ARL-007` | 확정 | 각 page의 조회·생성·수정·삭제, upload와 댓글 동작을 변경하지 않는다. | 전체 unit과 mock Chromium E2E에서 신규 실패가 0건이다. | 기존 domain 계약 유지, `P1-GATE` |
|
||||
| `ARL-008` | 확정 | mock module은 production bundle에서 계속 제외한다. | 기존 `production-graph.test.ts`의 mock 제외 assertion이 통과한다. | 기존 production boundary 유지, `P1-T1` |
|
||||
|
||||
## 9. 반응형 기능 범위
|
||||
|
||||
| 기능 | Desktop | Tablet | Mobile | 비고 |
|
||||
|---|---:|---:|---:|---|
|
||||
| 보호 page lazy loading | 지원 | 지원 | 지원 | 동일 route boundary |
|
||||
| loading 상태 | 지원 | 지원 | 지원 | 관리자 main 안에 표시 |
|
||||
| 직접 URL·뒤로 가기 | 유지 | 유지 | 유지 | URL 변경 없음 |
|
||||
|
||||
- 320px와 200% zoom에서 loading 상태와 page가 수평 overflow를 만들지 않아야 한다.
|
||||
- 기존 모바일 조회·수정 capability 정책은 변경하지 않는다.
|
||||
|
||||
## 10. UI/UX Expectations
|
||||
|
||||
### 10.1 디자인과 component 원칙
|
||||
|
||||
- 기존 `PageState`를 loading fallback으로 재사용한다.
|
||||
- 관리자 shell, navigation, header와 success notification은 page chunk loading 중 유지한다.
|
||||
- 새 spinner, skeleton, animation 또는 styling을 추가하지 않는다.
|
||||
|
||||
### 10.2 화면 상태
|
||||
|
||||
- lazy page가 준비되지 않았을 때 `화면을 불러오는 중`을 표시한다.
|
||||
- page가 준비되면 같은 main 영역에서 기존 page로 교체한다.
|
||||
- 기존 API loading·empty·error·success 상태는 page 내부 책임으로 유지한다.
|
||||
|
||||
### 10.3 접근성
|
||||
|
||||
- fallback은 기존 `PageState`의 semantic status를 사용한다.
|
||||
- keyboard focus 순서, skip link와 route 전환 focus 정책을 변경하지 않는다.
|
||||
- 200% zoom과 axe critical·serious 0건을 유지한다.
|
||||
|
||||
## 11. API 계약
|
||||
|
||||
### 11.1 공통 규칙
|
||||
|
||||
- lazy loading은 module 전달 방식만 변경한다.
|
||||
- endpoint, method, payload, response, 오류와 pagination 계약을 변경하지 않는다.
|
||||
|
||||
### 11.2 Endpoint 추적
|
||||
|
||||
| 요구사항 | Method | Path | 계약 상태 | API Contract | 소유 Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `ARL-001~008` | 해당 없음 | 해당 없음 | 변경 불필요 | 기존 domain 계약 유지 | `P1-T1`, `P1-GATE` |
|
||||
|
||||
### 11.3 외부 제공 대기 계약
|
||||
|
||||
없음.
|
||||
|
||||
## 12. 보안과 데이터 취급
|
||||
|
||||
- 인증 token 저장·전달, 401 clear와 403 route 정책을 변경하지 않는다.
|
||||
- 보호 page chunk는 기존과 같은 정적 asset이므로 권한 경계를 대체하지 않는다.
|
||||
- log, analytics와 외부 전송을 추가하지 않는다.
|
||||
- production mock 제외 경계를 유지한다.
|
||||
|
||||
## 13. 성능과 품질 요구사항
|
||||
|
||||
- 기준 build: Vite 8.1.5, 단일 JS `598.78kB`, gzip `158.94kB`, 310 modules.
|
||||
- 완료 build: JS chunk 2개 이상, 각 minified JS `<=500,000 bytes`, chunk size warning 0건.
|
||||
- `chunkSizeWarningLimit` 기본값 `500`을 변경하지 않는다.
|
||||
- 새 dependency와 custom chunk configuration을 추가하지 않는다.
|
||||
- production graph test, App unit, 전체 unit, mock Chromium E2E, typecheck, lint와 production build를 Gate로 사용한다.
|
||||
|
||||
## 14. 성공 기준
|
||||
|
||||
### 14.1 기능 수용 기준
|
||||
|
||||
- [x] 모든 보호 page가 직접 URL과 내부 이동에서 기존 기능을 제공한다. (`ARL-001~003`, `ARL-005~007`)
|
||||
- [x] 인증·권한·API request와 page props가 변경되지 않는다. (`ARL-002`, `ARL-005~008`)
|
||||
|
||||
### 14.2 UI/UX 수용 기준
|
||||
|
||||
- [x] 미로드 route에 기존 `PageState` loading 상태가 표시된다.
|
||||
- [x] 320px·200% zoom·keyboard 흐름과 axe critical·serious 0건을 유지한다.
|
||||
- [x] 첫 route 이후 다른 route 최초 진입만 추가 chunk loading을 수행한다.
|
||||
|
||||
### 14.3 성능·추적성 완료 기준
|
||||
|
||||
- [x] production build에 `500kB` 초과 chunk warning이 없다. (`ARL-004`)
|
||||
- [x] 모든 JS chunk가 `500,000 bytes` 이하임을 자동 test로 검증한다.
|
||||
- [x] `ARL-001~008`이 `P1-T1` 또는 `P1-GATE` 완료 증거로 연결된다.
|
||||
- [x] API Contract가 불필요함을 기록했다.
|
||||
|
||||
## 15. Open Questions
|
||||
|
||||
없음. route-level `React.lazy()`를 선택했고 경고 임계값 상향과 수동 vendor 분리는 제외했다.
|
||||
|
||||
## 16. 요구사항 추적표
|
||||
|
||||
| 요구사항 범위 | API Contract | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|
||||
|---|---|---:|---|---|---|
|
||||
| `ARL-001~004`, `ARL-008` | 불필요 | 1 | `P1-T1` | production graph, App focused unit, production build | Network의 route chunk loading |
|
||||
| `ARL-005~007` | 기존 계약 유지 | 1 | `P1-GATE` | 전체 unit, mock Chromium E2E, typecheck, lint | 직접 URL·내부 이동·뒤로 가기·320px·200% zoom |
|
||||
| `ARL-006~007` CJK zoom 회귀 | 기존 계약 유지 | 1 | `P1-R1` | CJK E2E, ResourcePagination unit, mock mobile Chrome E2E | 320px·200% zoom 한국어 줄바꿈 |
|
||||
|
||||
## 17. Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-06 | `ARL-DEC-001` | 확정 | 보호된 관리자 page를 route-level `React.lazy()`로 분리한다. | 단일 chunk의 원인이 모든 보호 page 정적 import이며 실제 초기 loading 비용도 줄일 수 있다. | `ARL-001~008`, `P1-T1`, `P1-GATE` |
|
||||
| 2026-08-06 | `ARL-DEC-002` | 확정 | `chunkSizeWarningLimit` 상향과 `manualChunks`는 적용하지 않는다. | 경고만 숨기거나 초기 총량을 유지하는 방식 대신 실제 지연 loading을 선택한다. | `ARL-004`, Non-Goals |
|
||||
| 2026-08-06 | `ARL-DEC-003` | 확정 | 기존 `PageState`만 fallback으로 재사용하고 새 loading component를 만들지 않는다. | 현재 디자인·접근성 관례를 유지하는 최소 구현이다. | `ARL-003`, `P1-T1` |
|
||||
| 2026-08-06 | `ARL-DEC-004` | 확정 | 공통 `ResourcePagination`의 mobile movement controls는 동일 폭 2열 대신 1열 stacked 배치로 대체한다. | 320px·200% zoom에서 한국어 버튼 텍스트가 음절 단위 세로 열로 분리되는 것을 막고, desktop/tablet 배치는 기존 `sm:flex`로 유지한다. | `ARL-006~007`, `P1-R1`, `P1-GATE` |
|
||||
@@ -0,0 +1,177 @@
|
||||
# Phase 1 관리자 라우트 지연 로딩 코드 리뷰
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 1 / `P1-T1`, `P1-R1`, `P1-GATE` |
|
||||
| 기준 commit 또는 working tree | `68959cb` 기준 미커밋 working tree |
|
||||
| 리뷰 일자 | 2026-08-06 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `docs/20260806_관리자라우트지연로딩/prd.md`, `docs/20260806_관리자라우트지연로딩/plan-task.md` |
|
||||
| 리뷰 상태 | 수정 검증 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
### 목적
|
||||
|
||||
- `ARL-001~008`과 완료 체크박스가 실제 코드·test·build 결과와 일치하는지 확인한다.
|
||||
- 완료된 Phase의 기능·접근성·반응형 회귀와 문서 현재 상태를 확인한다.
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- 코드: `src/app/protected-admin-shell.tsx`, `src/app/App.test.tsx`, `src/features/characters/components/CharacterListItem.tsx`, `src/shared/ui/resource-pagination.tsx`
|
||||
- 테스트: production graph, 전체 unit, mock Chromium/mobile Chrome E2E
|
||||
- 문서: `ARL-001~008`, `P1-T1`, `P1-R1`, `P1-GATE`
|
||||
- 수동 검증: source import boundary와 production build chunk 출력 대조
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 실제 개발 API와 운영 인증 정보가 필요한 server mode 수동 QA
|
||||
- PRD Non-Goals인 prefetch, manual chunk와 cropper 추가 최적화
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
### 심각도
|
||||
|
||||
| 심각도 | 기준 |
|
||||
|---|---|
|
||||
| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 |
|
||||
| High | 확정 요구사항·기존 계약 위반 또는 주요 회귀 |
|
||||
| Medium | 제한된 조건에서 발생하는 기능·접근성·복구 문제 |
|
||||
| Low | 유지보수성, 문서 정합성 또는 비핵심 UX 문제 |
|
||||
|
||||
### 상태
|
||||
|
||||
| 상태 | 의미 | 후속 처리 |
|
||||
|---|---|---|
|
||||
| 후보 | 근거를 발견했지만 아직 재현·판정하지 않음 | 검증 후 상태 변경 |
|
||||
| 확정 | 코드·test·문서 근거로 문제가 확인됨 | `plan-task.md` 회귀 수정 Task 전환 |
|
||||
| 오탐 | 요구사항이나 실행 결과상 문제가 아님 | 근거를 남기고 종료 |
|
||||
| 보류 | 외부 계약·환경·제품 결정이 필요함 | 담당 주체와 재개 조건 기록 |
|
||||
| 수정 완료 | 수정과 관련 검증이 완료됨 | 실행 명령과 결과 연결 |
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
### 문서와 코드
|
||||
|
||||
- 요구사항: `ARL-001~008`
|
||||
- API Contract: 변경 불필요 — 기존 인증·domain 계약 유지
|
||||
- 계획: `P1-T1`, `P1-R1`, `P1-GATE`
|
||||
- 코드: `src/app/protected-admin-shell.tsx`, `src/features/characters/components/CharacterListItem.tsx`, `src/shared/ui/resource-pagination.tsx`
|
||||
- 테스트: `src/shared/mocks/__tests__/production-graph.test.ts`, 전체 Vitest와 mock E2E
|
||||
|
||||
### 실행 환경
|
||||
|
||||
```text
|
||||
OS: Darwin 25.0.0 x86_64
|
||||
Node: v24.12.0
|
||||
npm: 11.7.0
|
||||
Browser/viewport: Playwright Chromium, mobile Chrome, 320px·200% zoom 포함
|
||||
환경 변수: VITE_API_MODE=mock 또는 production mode
|
||||
```
|
||||
|
||||
### 실행한 검증
|
||||
|
||||
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| `npm run test:run` | 성공 | exit 0, `83 files / 462 tests` |
|
||||
| `npm run typecheck` | 성공 | exit 0, 오류 0건 |
|
||||
| `npm run lint` | 성공 | exit 0, 오류 0건 |
|
||||
| `npm run build:prod` | 성공 | 310 modules, JS 37개, 최대 `315.09kB`, chunk 경고 0건 |
|
||||
| `npm run e2e:mock:chromium` | 성공 | exit 0, `53 passed` |
|
||||
| `npm run e2e:mock:mobile-chrome` | 성공 | exit 0, `48 passed / 5 skipped` |
|
||||
| `git diff --check` | 성공 | whitespace 오류 0건 |
|
||||
| route import boundary 정적 대조 | 성공 | 보호 page dynamic import 14개, eager page import는 `LoginPage`만 존재 |
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `ARL-REV-P1-001` | Low | 수정 완료 | 완료된 Task 수와 원인 이슈 상태가 구현 전 값으로 남아 있다 | `P1-R2` | `P1-R2` 완료 |
|
||||
|
||||
코드·기능·성능·접근성에 대한 확정 발견 사항은 없다.
|
||||
|
||||
## 6. 발견 사항 상세
|
||||
|
||||
### ARL-REV-P1-001 — 완료된 Task 수와 원인 이슈 상태가 구현 전 값으로 남아 있다
|
||||
|
||||
- **심각도:** Low
|
||||
- **상태:** 수정 완료
|
||||
- **관련 요구사항:** `ARL-001~008`
|
||||
- **관련 계약:** 없음
|
||||
- **소유 Task:** `P1-R2`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
`P1-T1`과 `P1-R1`이 완료됐지만 현재 상태 표는 완료 Task를 `1/1`로 표시한다. `ARL-ISSUE-001`도 build와 production graph 검증으로 해결됐지만 상태가 `확정`으로 남아 있다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 코드: `src/app/protected-admin-shell.tsx`의 보호 page dynamic import 14개
|
||||
- 테스트: production build JS 37개, 최대 `315.09kB`, 전체 unit·E2E 통과
|
||||
- 문서: `plan-task.md` 현재 상태 표와 `발견된 문제`의 `ARL-ISSUE-001`
|
||||
|
||||
**재현 또는 검증 절차**
|
||||
|
||||
1. `plan-task.md`에서 완료 Task 수와 `ARL-ISSUE-001` 상태를 확인한다.
|
||||
2. 같은 문서의 `P1-T1`, `P1-R1`, Phase Gate 완료 기록을 대조한다.
|
||||
3. 실제 결과는 두 Task 완료와 원인 이슈 해결인데 현재 상태 표시는 `1/1`, `확정`이다.
|
||||
4. 감사 시점의 완료 Task는 `2/2`여야 했으며, `P1-R2` 추가 후 최종 상태는 `3/3`, 원인 이슈 상태는 `해결`이어야 한다.
|
||||
|
||||
**영향**
|
||||
|
||||
애플리케이션 동작에는 영향이 없지만 완료 범위와 남은 문제를 읽는 사람이 잘못 판단할 수 있다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
`P1-R2` 문서 전용 Task로 현재 상태와 review 링크만 정정하고 애플리케이션 코드·test는 변경하지 않는다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-08-06 — plan의 Task·Progress와 fresh Gate 결과를 대조해 문서 정합성 회귀로 확정했다.
|
||||
- 2026-08-06 — `P1-R2`에서 최종 Task 수 `3/3`, 해결 이슈 상태와 review 링크를 반영하고 문서 검증을 통과해 수정 완료로 판정했다.
|
||||
|
||||
## 7. 확정 항목의 plan·goal 전환
|
||||
|
||||
`ARL-REV-P1-001`을 `plan-task.md`의 문서 전용 회귀 수정 Task `P1-R2`로 전환한다.
|
||||
|
||||
### 신규 회귀 수정 Task 초안
|
||||
|
||||
```markdown
|
||||
### Task 1.3 완료 문서 현재 상태 정합성 복구
|
||||
|
||||
**Goal 실행 `P1-R2`:** 완료 Task 수와 해결된 원인 이슈 상태를 실제 구현·검증 결과에 맞춘다.
|
||||
```
|
||||
|
||||
### create_goal objective 초안
|
||||
|
||||
```text
|
||||
[P1-R2]의 확정 review 항목 ARL-REV-P1-001을 문서에서 수정한다.
|
||||
애플리케이션 코드·test·API는 변경하지 않는다.
|
||||
```
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | PRD·계획·관련 코드·전체 Gate 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | `ARL-REV-P1-001` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | `P1-R2` 추가 |
|
||||
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 항목 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | §4 실행 결과 |
|
||||
|
||||
**최종 결론:** 수정 검증 완료
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 9. 수정 후 검증 기록
|
||||
|
||||
### 1차 수정 검증 — 2026-08-06
|
||||
|
||||
- 무엇을: `ARL-REV-P1-001`의 완료 Task 수, 해결된 원인 이슈 상태와 review 링크를 현재 결과에 맞췄다.
|
||||
- 왜: 완료 범위와 남은 문제를 문서가 잘못 표시하는 회귀를 제거하기 위해서다.
|
||||
- 어떻게:
|
||||
- `rg -n '3/3|ARL-ISSUE-001.*해결|phase1-admin-route-lazy-loading' docs/20260806_관리자라우트지연로딩` — 성공, 필요한 marker와 링크 확인.
|
||||
- `git diff --check` — 성공, exit 0, whitespace 오류 0건.
|
||||
- 남은 항목: 없음.
|
||||
219
docs/20260806_댓글액션버튼라벨/plan-task.md
Normal file
219
docs/20260806_댓글액션버튼라벨/plan-task.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# 댓글 액션 버튼 표시 라벨 간소화 구현 계획
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 구현 완료 |
|
||||
| 작성일 | 2026-08-06 |
|
||||
| 요구사항 기준 | [prd.md](./prd.md) |
|
||||
| API 기준 | 변경 불필요 — 기존 Audio·Community 댓글 계약 유지 |
|
||||
| 현재 Phase | Phase 1 완료 |
|
||||
| 현재 활성 Goal | 없음 |
|
||||
|
||||
## 목표
|
||||
|
||||
Audio·Community 댓글 액션은 짧은 동작명만 화면에 표시하고, 스크린 리더에는 대상 댓글 문맥을 유지한다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1 | 완료 | `1/1` | 없음 | 없음 |
|
||||
|
||||
- `CommentItem`은 화면에 답글·수정·삭제 동작명만 표시하고 명시적 `aria-label`로 댓글 문맥을 유지한다.
|
||||
- focused test는 `9/9`, Comments unit은 `16/16`, Comments mock E2E는 `3/3` 통과했다.
|
||||
- 1280px, 320px와 200% zoom 상당 환경에서 수평 overflow가 없고 axe critical·serious 위반이 0건이다.
|
||||
|
||||
## 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- `CommentItem`의 `답글 작성`, `답글 보기`, `수정`, `삭제` visible label 간소화
|
||||
- 기존 `댓글 내용 + 동작` accessible name 유지
|
||||
- Audio·Community 원댓글과 답글의 단위·mock E2E 회귀 검증
|
||||
- 320px, 200% zoom, keyboard와 접근성 확인
|
||||
|
||||
### 제외
|
||||
|
||||
- API, model, pagination, 권한, mutation과 error handling 변경
|
||||
- `수정 저장`, `취소`, `답글 등록`과 form·region label 변경
|
||||
- FanTalk, Community 게시글 열기 등 `CommentItem` 밖의 버튼 변경
|
||||
- 새 component, helper, dependency 또는 style 추가
|
||||
|
||||
## 기술적 제약
|
||||
|
||||
- 기술 스택: React 19.2.8, TypeScript 6.0.3, Vitest 4.1.10, Playwright 1.61.1.
|
||||
- 아키텍처: 공유 `CommentItem`의 표시 책임 안에서만 변경한다.
|
||||
- 접근성: visible label 전체가 accessible name에 포함되고 댓글 문맥으로 반복 버튼을 구분해야 한다.
|
||||
- 데이터·보안: 댓글 값을 새로 저장·전송·log하지 않는다.
|
||||
- 호환성: 기존 desktop·tablet·mobile과 최소 320px 지원 범위를 유지한다.
|
||||
- 의존성: 추가하지 않는다.
|
||||
- 구현: RED → GREEN → REFACTOR 순서와 실제 검증 결과를 Progress에 기록한다.
|
||||
|
||||
## Phase 1. 표시 라벨과 accessible name 분리
|
||||
|
||||
**Phase 결과:** 댓글 본문은 카드에서 한 번만 보이고, 액션 버튼에는 동작명만 보이면서 보조기술은 기존 문맥형 이름을 읽는다.
|
||||
|
||||
**선행조건:** `CLB-001~007`, `CLB-DEC-001~002` 확정.
|
||||
|
||||
**Phase 완료 조건:** `P1-T1`과 `P1-GATE` 완료, PRD 성공 기준과 Progress 갱신.
|
||||
|
||||
### 구현 항목
|
||||
|
||||
#### Task 1.1 공유 댓글 액션 라벨 분리
|
||||
|
||||
**Goal 실행 `P1-T1`:** 공유 `CommentItem`의 visible label을 동작명으로 줄이고 기존 contextual accessible name을 보존한다.
|
||||
|
||||
- **시작 조건:** `prd.md`가 구현 기준 확정 상태이고 활성 goal이 없음.
|
||||
- **완료 증거:** RED·GREEN·REFACTOR 체크박스, focused `9/9`, Comments E2E `3/3`, Progress 기록.
|
||||
- **범위 밖:** 다른 component의 버튼 라벨, action 배치·style과 댓글 동작 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: 없음
|
||||
- Modify: `src/features/comments/components/CommentItem.tsx`
|
||||
- Test: `src/features/comments/tests/comment-thread.test.tsx`
|
||||
- Test: `tests/e2e/comments.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: 기존 `CommentItem` props의 `comment.comment`, `replyActionLabel`, `canEdit`, `canDelete`, `onShowReplies`.
|
||||
- Produces: 기존 props와 callback contract를 바꾸지 않는 짧은 visible label과 `댓글 내용 + 동작` accessible name.
|
||||
|
||||
**TDD 절차:**
|
||||
|
||||
- [x] **RED: 실패 테스트 작성/실패 확인** — `comment-thread.test.tsx`에 답글·수정·삭제 버튼의 `textContent`가 동작명과 정확히 일치하고 role·name은 기존 `댓글 내용 + 동작`으로 조회되는 test 1개를 추가한다. `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx`가 visible label 불일치로 `1 failed, 8 passed`인지 확인한다.
|
||||
- [x] **GREEN: 최소 구현/통과 확인** — `CommentItem.tsx`의 세 버튼에 기존 contextual `aria-label`을 명시하고 children에서는 댓글 내용만 제거한다. 같은 명령이 `exit 0`, `9/9`인지 확인한다.
|
||||
- [x] **REFACTOR: 정리/회귀 확인** — 새 abstraction 없이 중복 변수만 최소화한 뒤 focused test와 `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium`이 각각 `9/9`, `3/3`으로 통과하는지 확인한다.
|
||||
- [x] `comments.spec.ts`의 320px Community 흐름에서 visible label과 contextual accessible name을 함께 확인한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx`; `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`.
|
||||
- **기대 결과:** 모든 명령 `exit 0`, focused `9/9`, Comments E2E `3/3`, type·lint 오류 0건.
|
||||
- **수동 확인:** 1280px Audio와 320px Community에서 버튼에는 동작명만 보이고, 접근성 트리에는 `댓글 내용 + 동작`이 보이며 수평 overflow가 없다.
|
||||
|
||||
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
|
||||
|
||||
### 완료 조건
|
||||
|
||||
- [x] `P1-T1`의 체크박스와 완료 증거가 모두 충족됐다.
|
||||
- [x] `CLB-001~007`이 구현 또는 Gate 증거로 추적된다.
|
||||
- [x] PRD 성공 기준과 현재 상태를 실제 결과로 갱신했다.
|
||||
- [x] 알려진 문서와 구현의 차이가 없다.
|
||||
|
||||
### 검증 방법
|
||||
|
||||
#### Phase 1 Gate
|
||||
|
||||
**Goal 실행 `P1-GATE`:** 짧은 표시 라벨, contextual accessible name과 기존 댓글 동작의 회귀 여부를 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 완료.
|
||||
- **완료 증거:** 아래 자동·수동 검증 통과와 Progress 기록.
|
||||
- **범위 밖:** test 삭제·완화, 관련 없는 UI·API 수정.
|
||||
|
||||
**실행 명령:**
|
||||
|
||||
```bash
|
||||
npm run test:run -- src/features/comments
|
||||
npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run build:prod
|
||||
git diff --check
|
||||
```
|
||||
|
||||
**기대 결과:** 모든 명령 `exit 0`, Comments unit test `16/16` 이상, Comments E2E `3/3`, type·lint·build 오류 0건, whitespace 오류 0건.
|
||||
|
||||
**수동 확인:**
|
||||
|
||||
- [x] 1280px Audio 상세와 320px Community Sheet에서 답글·수정·삭제 visible label을 확인한다.
|
||||
- [x] 접근성 트리에서 각 버튼의 `댓글 내용 + 동작` 이름과 keyboard focus 순서를 확인한다.
|
||||
- [x] 200% zoom에서 수평 overflow와 가려진 action이 없는지 확인한다.
|
||||
- [x] axe critical·serious 위반이 0건인지 확인한다.
|
||||
|
||||
## 실행 순서와 의존성
|
||||
|
||||
1. `P1-T1`에서 실패 test를 먼저 만들고 최소 UI 변경과 focused·E2E 회귀 검증을 완료한다.
|
||||
2. `P1-GATE`에서 전체 Comments test와 공통 품질·수동 접근성 검증을 완료한다.
|
||||
|
||||
`P1-GATE`는 `P1-T1` 완료 전 시작하지 않는다.
|
||||
|
||||
## 변경 금지 항목
|
||||
|
||||
- 기존 완료 기록과 관련 PRD의 Decision Log를 삭제하거나 덮어쓰지 않는다.
|
||||
- 댓글 endpoint, payload, model과 권한 조건을 변경하지 않는다.
|
||||
- `CommentItem` 밖의 action label을 함께 정리하지 않는다.
|
||||
- 새 dependency, helper 또는 shared abstraction을 추가하지 않는다.
|
||||
- test를 삭제·skip·완화하거나 타입 오류를 우회하지 않는다.
|
||||
|
||||
## 의사결정 및 중단 규칙
|
||||
|
||||
- visible label은 동작명만, accessible name은 `댓글 내용 + 동작`으로 유지한다.
|
||||
- 구현 범위가 바뀌면 PRD Decision Log와 이 계획을 먼저 갱신한다.
|
||||
- 기존 role·name selector가 깨지면 accessible name 유지 요구사항을 우선하고 visible text selector만 보정한다.
|
||||
- 같은 차단 사유가 3회 연속 반복되고 독립 작업도 불가능할 때만 goal을 `blocked`로 갱신한다.
|
||||
- 코드와 일부 test만 완료된 상태에서는 goal을 `complete`로 갱신하지 않는다.
|
||||
|
||||
## Progress
|
||||
|
||||
기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다.
|
||||
|
||||
### 계획 작성 — 2026-08-06
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: 사용자 선택 A를 `CLB-001~007`, 단일 구현 Task와 Phase Gate로 정규화했다.
|
||||
- 왜: 현재 UI는 댓글 본문을 각 action에 반복하고 화면 표시와 accessible name을 분리하지 않는다.
|
||||
- 어떻게:
|
||||
- `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx` — 성공, exit 0, baseline `8/8`.
|
||||
- 코드·E2E 변경과 수동 UI 검증 — 미실행, 구현 요청 범위가 아님.
|
||||
- 남은 항목: `P1-T1`, `P1-GATE`.
|
||||
- 다음 행동: `P1-T1` RED test 작성.
|
||||
|
||||
### 1차 구현 — 2026-08-06
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: `CommentItem`의 답글·수정·삭제 visible label을 동작명으로 줄이고 `댓글 내용 + 동작` accessible name을 명시적으로 보존했다. 320px Community E2E에 visible label 검증을 추가했다.
|
||||
- 왜: 댓글 본문과 액션 영역의 시각적 중복을 제거하면서 보조기술의 대상 식별 문맥을 유지하기 위해서다.
|
||||
- 어떻게:
|
||||
- RED `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx` — 예상 실패, `1 failed, 8 passed`; `답글 보기` 기대값에 기존 `팬 루트 댓글 답글 보기`가 표시됨을 확인했다.
|
||||
- GREEN 같은 명령 — 성공, exit 0, `9/9`.
|
||||
- `npm run test:run -- src/features/comments` — 성공, exit 0, `16/16`.
|
||||
- `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium` — 성공, exit 0, `3/3`.
|
||||
- `npm run typecheck`; `npm run lint`; `npm run build:prod`; `git diff --check` — 모두 성공, exit 0. production build에는 기존 500kB 초과 chunk 경고만 있었고 오류는 없었다.
|
||||
- Playwright 실제 화면 — 1280px Audio와 320px Community에서 visible label과 contextual accessible name 일치, keyboard로 댓글 액션 4개 도달, 수평 overflow 없음.
|
||||
- 200% zoom 상당 검증 — 1280px의 유효 CSS 폭 640px로 확인, 수평 overflow와 가려진 action 없음.
|
||||
- axe — 1280px Audio와 320px Community에서 critical·serious 위반 0건.
|
||||
- 시각 QA — 기능·디자인 시스템 무결성 PASS/HIGH, 시각·CJK 정밀도 PASS/HIGH, 차단 항목 없음.
|
||||
- 명세·코드 품질 review — 각각 무조건 승인, 발견 사항 없음.
|
||||
- 남은 항목: 없음.
|
||||
- 다음 행동: 현재 브랜치 변경 검토 후 통합 방식 결정.
|
||||
|
||||
## Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-06 | `CLB-PLAN-DEC-001` | 확정 | 화면 표시만 간소화하고 contextual accessible name을 유지한다. | 사용자 선택 A, `CLB-DEC-001` | `P1-T1`, `P1-GATE`, `prd.md` |
|
||||
| 2026-08-06 | `CLB-PLAN-DEC-002` | 확정 | 공유 `CommentItem` 한 파일에서 최소 변경한다. | 모든 대상 UI가 같은 component를 사용한다. | `P1-T1` |
|
||||
|
||||
## 발견된 문제
|
||||
|
||||
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|
||||
|---|---|---|---|---|---|
|
||||
| `CLB-ISSUE-001` | Medium | 완료 | 댓글 본문이 카드 본문과 답글·수정·삭제 버튼마다 반복된다. | `P1-T1` | visible label과 accessible name 분리 완료 |
|
||||
|
||||
## 최종 보고 형식
|
||||
|
||||
```markdown
|
||||
구현 결과: Audio·Community 댓글 버튼은 동작명만 표시하고 보조기술에는 댓글 문맥을 유지한다.
|
||||
|
||||
- 변경: `CommentItem.tsx`의 visible label과 accessible name 분리
|
||||
- 결정: `CLB-DEC-001` — 화면 표시만 간소화
|
||||
- 검증:
|
||||
- `npm run test:run -- src/features/comments` — <실제 결과>
|
||||
- `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium` — <실제 결과>
|
||||
- 1280px·320px·200% zoom·keyboard·접근성 트리 — <실제 결과>
|
||||
- 남은 항목: <없음 또는 구체적인 항목>
|
||||
- 문서: `docs/20260806_댓글액션버튼라벨/{prd.md,plan-task.md}`
|
||||
```
|
||||
|
||||
최종 보고는 실제 실행한 최신 검증 결과와 완료되지 않은 범위를 함께 기록한다.
|
||||
215
docs/20260806_댓글액션버튼라벨/prd.md
Normal file
215
docs/20260806_댓글액션버튼라벨/prd.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# 댓글 액션 버튼 표시 라벨 간소화 PRD
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | 구현 완료 |
|
||||
| 작성일 | 2026-08-06 |
|
||||
| 최종 수정일 | 2026-08-06 |
|
||||
| 대상 제품 | AI 캐릭터 관리자 웹의 Audio·Community 댓글 관리 |
|
||||
| 작성자·결정권자 | Codex 작성, 사용자 결정 |
|
||||
| 관련 API Contract | 불필요 — 기존 댓글 API와 payload를 변경하지 않음 |
|
||||
| 관련 구현 계획 | [plan-task.md](./plan-task.md) |
|
||||
| 관련 review | 없음 |
|
||||
|
||||
### 요구사항 상태
|
||||
|
||||
| 상태 | 의미 | 구현 처리 |
|
||||
|---|---|---|
|
||||
| 확정 | 제품·기술 결정이 완료된 구현 기준 | `plan-task.md`의 Task와 완료 증거로 추적 |
|
||||
| 미결 | 추가 결정 필요 | 구현 전 결정 |
|
||||
| 외부 의존 | 프론트엔드 밖의 제공 필요 | 제공 전 관련 구현 중단 |
|
||||
| 권고 | 확정 전 추천안 | 수용 기준으로 사용하지 않음 |
|
||||
| 제외 | 이번 범위에서 구현하지 않음 | 포함 조건을 Decision Log에 기록 |
|
||||
|
||||
### 문서 우선순위와 갱신 순서
|
||||
|
||||
1. 표시 라벨과 accessible name 결정은 이 PRD가 소유한다.
|
||||
2. API 변경은 없으므로 별도 API Contract를 만들지 않는다.
|
||||
3. 구현 범위·순서·완료 증거는 `plan-task.md`가 소유한다.
|
||||
4. 결정이 바뀌면 Decision Log → 요구사항 → 계획 순서로 갱신한다.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
댓글 본문과 각 액션 버튼에 반복되는 댓글 내용을 분리한다. 화면에는 `답글 작성`, `답글 보기`, `수정`, `삭제`만 표시하고, 스크린 리더용 accessible name에는 기존처럼 `댓글 내용 + 동작`을 유지한다.
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
현재 `CommentItem`은 댓글 본문을 별도로 표시하면서 버튼에도 같은 내용을 반복한다.
|
||||
|
||||
- 긴 댓글일수록 액션 영역이 커지고 동작명을 빠르게 구분하기 어렵다.
|
||||
- 한 댓글의 여러 버튼에 같은 문장이 반복되어 모바일에서 시각적 밀도가 높아진다.
|
||||
- 화면 표시 문구와 accessible name이 결합돼 있어 시각적 간소화와 보조기술 문맥 제공을 독립적으로 조정할 수 없다.
|
||||
|
||||
문제를 해결했다는 판단은 버튼 화면 텍스트가 동작명만 포함하고, 같은 버튼의 accessible name은 대상 댓글과 동작을 함께 식별할 때로 한다.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
### 3.1 제품 목표
|
||||
|
||||
- 사용자가 댓글 본문과 액션을 빠르게 구분한다.
|
||||
- Audio·Community의 공유 댓글 UI에 같은 규칙을 적용한다.
|
||||
- 기존 조회·작성·수정·삭제 동작과 권한을 유지한다.
|
||||
|
||||
### 3.2 UX 목표
|
||||
|
||||
- 버튼 화면 텍스트를 `답글 작성`, `답글 보기`, `수정`, `삭제`로 제한한다.
|
||||
- 스크린 리더가 버튼만 탐색해도 대상 댓글과 동작을 구분하게 한다.
|
||||
- 320px 화면에서 긴 댓글이 액션 버튼마다 반복되지 않게 한다.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- 댓글 API, DTO, pagination, mutation 또는 권한 정책 변경
|
||||
- 댓글 본문, 작성 form, 답글 region의 label 변경
|
||||
- FanTalk 답변 버튼과 Community 게시글 열기 버튼 변경
|
||||
- 액션 버튼의 배치, 색상, 크기, 확인 dialog 또는 삭제 복원 기능 변경
|
||||
|
||||
Non-Goal을 변경하려면 Decision Log와 `plan-task.md`를 먼저 갱신한다.
|
||||
|
||||
## 5. Target Users and Permissions
|
||||
|
||||
| 사용자 | 목표 | 주요 작업 | 사용 환경 |
|
||||
|---|---|---|---|
|
||||
| ADMIN | 댓글별 액션을 빠르게 구분 | 답글 열기·작성, AI 댓글 수정, 댓글 삭제 | desktop, tablet, mobile |
|
||||
| 읽기 전용 ADMIN | 댓글과 기존 답글 조회 | 답글 보기 | desktop, tablet, mobile |
|
||||
|
||||
- 기존 `canMutate`, 작성자 판정과 비활성 workspace 정책을 그대로 사용한다.
|
||||
- 라벨 변경으로 숨겨진 액션이 새로 노출되거나 기존 액션이 제거되지 않는다.
|
||||
|
||||
## 6. 핵심 사용자 흐름
|
||||
|
||||
1. 사용자가 Audio 상세 또는 Community 게시글 Sheet의 댓글 목록을 연다.
|
||||
2. 댓글 본문은 카드 본문에서 한 번 읽고, 액션 영역에서는 짧은 동작명을 확인한다.
|
||||
3. 사용자가 `답글 작성`·`답글 보기`·`수정`·`삭제` 중 허용된 버튼을 실행한다.
|
||||
4. 스크린 리더는 각 버튼을 `댓글 내용 + 동작`으로 안내한다.
|
||||
5. 기존 form, network request와 성공·실패 처리가 그대로 동작한다.
|
||||
|
||||
## 7. 정보 구조와 라우팅
|
||||
|
||||
```text
|
||||
/ai-characters/:characterId/audio-contents/:contentId
|
||||
/ai-characters/:characterId/community-posts
|
||||
└─ 게시글 Sheet의 댓글 관리
|
||||
```
|
||||
|
||||
- 새 route와 URL 상태를 추가하지 않는다.
|
||||
- 두 진입점은 공유 `CommentThread`와 `CommentItem`을 사용한다.
|
||||
|
||||
## 8. 기능 요구사항
|
||||
|
||||
### 8.1 표시 라벨과 accessible name
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `CLB-001` | 확정 | 답글 액션의 화면 텍스트에는 `답글 작성` 또는 `답글 보기`만 표시한다. | 원댓글의 답글 버튼 `textContent`가 전달된 `replyActionLabel`과 정확히 일치한다. | contract 불필요, `P1-T1` |
|
||||
| `CLB-002` | 확정 | 수정 액션의 화면 텍스트에는 `수정`만 표시한다. | 수정 가능한 원댓글·답글 버튼의 `textContent`가 `수정`과 정확히 일치한다. | contract 불필요, `P1-T1` |
|
||||
| `CLB-003` | 확정 | 삭제 액션의 화면 텍스트에는 `삭제`만 표시한다. | 삭제 가능한 원댓글·답글 버튼의 `textContent`가 `삭제`와 정확히 일치한다. | contract 불필요, `P1-T1` |
|
||||
| `CLB-004` | 확정 | 각 액션 버튼의 accessible name에는 댓글 내용과 화면 동작명을 함께 유지한다. | role·name 조회에서 `댓글 내용 + 답글 작성/답글 보기/수정/삭제`로 각 버튼을 찾을 수 있고, visible label도 accessible name에 포함된다. | contract 불필요, `P1-T1` |
|
||||
| `CLB-005` | 확정 | 공유 `CommentItem`을 사용하는 Audio·Community 원댓글과 답글에 동일한 규칙을 적용한다. | 두 target의 기존 단위·E2E 흐름이 통과하며 reply row에는 기존처럼 답글 액션이 없다. | contract 불필요, `P1-GATE` |
|
||||
| `CLB-006` | 확정 | 라벨 외 동작·권한·상태는 변경하지 않는다. | 기존 GET·POST·PUT·DELETE 경로와 payload, disabled 조건, form 초기화·오류 복구 test가 통과한다. | 기존 댓글 계약 재사용, `P1-GATE` |
|
||||
|
||||
### 8.2 공통 파일·데이터 정책
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `CLB-007` | 확정 | 댓글 원문은 가공·축약하지 않고 현재 값으로 accessible name을 구성한다. | 별도 상태·helper·dependency 없이 `CommentItem`의 `comment.comment`와 동작명을 사용한다. | contract 불필요, `P1-T1` |
|
||||
|
||||
## 9. 반응형 기능 범위
|
||||
|
||||
| 기능 | Desktop | Tablet | Mobile | 비고 |
|
||||
|---|---:|---:|---:|---|
|
||||
| 짧은 화면 표시 라벨 | 지원 | 지원 | 지원 | 공유 component 적용 |
|
||||
| 문맥을 포함한 accessible name | 지원 | 지원 | 지원 | viewport와 무관 |
|
||||
| 기존 댓글 액션 | 유지 | 유지 | 유지 | 권한·상태 변경 없음 |
|
||||
|
||||
- 최소 320px에서 수평 overflow 없이 액션을 사용할 수 있어야 한다.
|
||||
- 200% zoom에서도 댓글 본문과 액션을 구분할 수 있어야 한다.
|
||||
|
||||
## 10. UI/UX Expectations
|
||||
|
||||
### 10.1 디자인과 component 원칙
|
||||
|
||||
- 댓글 본문은 카드 본문이, 동작명은 버튼이 각각 한 번만 시각적으로 표시한다.
|
||||
- 기존 버튼 style, semantic color와 최소 높이 규칙을 유지한다.
|
||||
- 새 component나 공통 helper를 만들지 않고 공유 `CommentItem`에서 처리한다.
|
||||
|
||||
### 10.2 화면 상태
|
||||
|
||||
- pending 중 disabled 처리와 loading status를 유지한다.
|
||||
- 수정 mode의 `수정 저장`, `취소` 문구는 대상이 아니므로 유지한다.
|
||||
- 오류·성공·empty 상태를 변경하지 않는다.
|
||||
|
||||
### 10.3 접근성
|
||||
|
||||
- visible label과 accessible name을 분리하되 visible label 전체가 accessible name에 포함돼야 한다.
|
||||
- 동일 동작 버튼을 보조기술로 단독 탐색해도 댓글 내용으로 대상을 구분할 수 있어야 한다.
|
||||
- button semantic, keyboard focus 순서와 focus 표시를 유지한다.
|
||||
- axe critical·serious 위반 0건을 유지한다.
|
||||
|
||||
## 11. API 계약
|
||||
|
||||
### 11.1 공통 규칙
|
||||
|
||||
- 이번 변경은 표시 계층에만 적용한다.
|
||||
- 기존 Audio·Community 댓글 endpoint, request/response, 오류와 pagination 계약을 변경하지 않는다.
|
||||
|
||||
### 11.2 Endpoint 추적
|
||||
|
||||
| 요구사항 | Method | Path | 계약 상태 | API Contract | 소유 Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `CLB-001~007` | 해당 없음 | 해당 없음 | 변경 불필요 | 기존 댓글 계약 유지 | `P1-T1`, `P1-GATE` |
|
||||
|
||||
### 11.3 외부 제공 대기 계약
|
||||
|
||||
없음.
|
||||
|
||||
## 12. 보안과 데이터 취급
|
||||
|
||||
- 댓글 내용은 현재처럼 DOM과 접근성 트리에 표시되며 새 저장·전송·log를 추가하지 않는다.
|
||||
- 인증, 리소스 ownership과 mutation 권한 정책을 변경하지 않는다.
|
||||
- 라벨을 analytics 또는 외부 서비스로 전송하지 않는다.
|
||||
|
||||
## 13. 성능과 품질 요구사항
|
||||
|
||||
- 새 dependency, state, effect 또는 network request를 추가하지 않는다.
|
||||
- React 19.2.8, TypeScript 6.0.3과 기존 지원 browser를 유지한다.
|
||||
- focused unit test, Comments mock E2E, typecheck, lint와 production build를 Gate로 사용한다.
|
||||
- backend와 mock 계약 변경이 없으므로 별도 preview mode를 추가하지 않는다.
|
||||
|
||||
## 14. 성공 기준
|
||||
|
||||
### 14.1 기능 수용 기준
|
||||
|
||||
- [x] Audio·Community 댓글의 화면 액션은 짧은 동작명만 표시한다. (`CLB-001~003`)
|
||||
- [x] 기존 답글·수정·삭제 동작과 권한이 유지된다. (`CLB-005~006`)
|
||||
|
||||
### 14.2 UI/UX 수용 기준
|
||||
|
||||
- [x] 모든 대상 버튼의 visible label과 contextual accessible name이 분리된다. (`CLB-004`)
|
||||
- [x] 320px와 200% zoom에서 액션 사용과 본문 구분에 문제가 없다.
|
||||
- [x] keyboard 흐름과 axe critical·serious 0건을 유지한다.
|
||||
|
||||
### 14.3 추적성 완료 기준
|
||||
|
||||
- [x] `CLB-001~007`이 `P1-T1` 또는 `P1-GATE` 완료 증거로 연결된다.
|
||||
- [x] API Contract가 불필요한 표시 계층 변경임을 기록했다.
|
||||
- [x] 미결·외부 의존 항목이 없다.
|
||||
|
||||
## 15. Open Questions
|
||||
|
||||
없음. 사용자는 화면 표시에서만 댓글 내용을 제거하고 accessible name에는 댓글 문맥을 유지하는 A안을 선택했다.
|
||||
|
||||
## 16. 요구사항 추적표
|
||||
|
||||
| 요구사항 범위 | API Contract | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|
||||
|---|---|---:|---|---|---|
|
||||
| `CLB-001~004`, `CLB-007` | 불필요 | 1 | `P1-T1` | `comment-thread.test.tsx` | 화면 텍스트와 접근성 트리 비교 |
|
||||
| `CLB-005~006` | 기존 계약 유지 | 1 | `P1-GATE` | Comments unit·E2E, typecheck, lint, build | 1280px·320px·200% zoom·keyboard |
|
||||
|
||||
## 17. Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-06 | `CLB-DEC-001` | 확정 | 화면 버튼에서는 댓글 내용을 제거하고 accessible name에는 `댓글 내용 + 동작`을 유지한다. | 사용자 선택 A. 시각적 중복을 줄이면서 보조기술의 대상 식별을 보존한다. | `CLB-001~004`, `P1-T1` |
|
||||
| 2026-08-06 | `CLB-DEC-002` | 확정 | 공유 `CommentItem` 한 곳에서 Audio·Community 원댓글과 답글의 표시를 변경한다. | 모든 대상 호출이 같은 component를 사용하며 API·상태 변경이 필요 없다. | `CLB-005~007`, `P1-T1`, `P1-GATE` |
|
||||
213
docs/20260806_수정요청변경필드만전송/api-contract.md
Normal file
213
docs/20260806_수정요청변경필드만전송/api-contract.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# 수정 요청 변경 필드 전송 API Contract
|
||||
|
||||
## 1. 공통 계약
|
||||
|
||||
이 문서는 기존 [AI 캐릭터 관리자 OpenAPI](../20260725_AI캐릭터관리자웹/api-contract.openapi.json)의 endpoint·DTO를 바꾸지 않고, 프론트엔드가 수정 request를 구성하는 규칙을 구체화한다. 이 문서와 정식 OpenAPI가 충돌하면 정식 OpenAPI의 field type·nullable·response·error 계약을 우선하고 이 문서는 payload 선택 규칙만 소유한다.
|
||||
|
||||
### 1.1 유지되는 항목
|
||||
|
||||
- 기존 `PUT` method와 path를 유지한다.
|
||||
- 기존 bearer 인증, `Accept-Language`, 성공 envelope, 오류 status·key를 유지한다.
|
||||
- 캐릭터·오디오 콘텐츠·커뮤니티 게시글·시리즈는 `multipart/form-data`를 유지한다.
|
||||
- multipart JSON part 이름은 `request`, MIME은 `application/json`이다.
|
||||
- FanTalk 답글은 `application/json` body를 유지한다.
|
||||
|
||||
### 1.2 변경 판정
|
||||
|
||||
1. 상세·목록 응답으로 form을 초기화할 때 수정 기준값을 보존한다.
|
||||
2. 현재 form 값과 기준값에 같은 기존 직렬화 규칙을 적용한다.
|
||||
3. 문자열 trim, 빈 optional 값의 `null` 변환, 배열·객체 배열 변환 후 필드별 값을 비교한다.
|
||||
4. 값이 다른 field만 request object에 포함한다.
|
||||
5. 사용자가 값을 바꾼 뒤 기준값으로 되돌리면 해당 field를 생략한다.
|
||||
|
||||
비교 대상은 각 기능의 update DTO field다. route ID, 조회 전용 field, 서버 계산값과 수정 화면에 없는 field를 request에 복사하지 않는다.
|
||||
|
||||
### 1.3 생략, `null`, falsy 값
|
||||
|
||||
| 표현 | 의미 | 예시 |
|
||||
|---|---|---|
|
||||
| key 생략 | 미변경 | `{ "title": "새 제목" }`에는 `detail` 변경 없음 |
|
||||
| `null` | 해당 DTO가 허용하는 기존 값 삭제 | `{ "writer": null }` |
|
||||
| `false` | boolean 값을 false로 변경 | `{ "isAdult": false }` |
|
||||
| `0` | 숫자 값을 0으로 변경 | `{ "price": 0 }` |
|
||||
| 배열·객체 배열 | 해당 필드 전체의 새 값 | `{ "publishedDaysOfWeek": ["RANDOM"] }` |
|
||||
|
||||
`false`, `0`, 빈값 삭제용 `null`은 falsy 값이라는 이유로 생략하지 않는다.
|
||||
|
||||
### 1.4 변경 없음
|
||||
|
||||
- 변경 field와 교체 file이 모두 0개면 저장 control은 native `disabled` 상태다.
|
||||
- disabled 상태에서는 API helper를 호출하지 않는다.
|
||||
- 빈 JSON 또는 빈 multipart mutation을 전송하지 않는다.
|
||||
|
||||
### 1.5 파일 part
|
||||
|
||||
| 상태 | 파일 part | `request` JSON |
|
||||
|---|---|---|
|
||||
| 텍스트 field만 변경 | 생략 | 변경 field만 포함 |
|
||||
| 파일과 field 변경 | 새 파일 1개 | 변경 field만 포함 |
|
||||
| 파일만 변경 | 새 파일 1개 | `{}` |
|
||||
| 변경 없음 | 요청 자체 없음 | 요청 자체 없음 |
|
||||
|
||||
기존 파일을 선택하지 않으면 서버의 현재 파일을 유지한다. 파일 삭제 기능은 이 계약에 추가하지 않는다.
|
||||
|
||||
## 2. 기능별 계약
|
||||
|
||||
### 2.1 AI 캐릭터
|
||||
|
||||
`PUT /api/v2/admin/ai-characters/{characterId}`
|
||||
|
||||
- Content-Type: `multipart/form-data`
|
||||
- optional file part: `image`
|
||||
- JSON part: `request`
|
||||
- 비교 가능 field: `name`, `systemPrompt`, `description`, `age`, `gender`, `mbti`, `speechPattern`, `speechStyle`, `appearance`, `originalTitle`, `originalLink`, `originalWorkId`, `characterType`, `tags`, `hobbies`, `values`, `goals`, `relationships`, `personalities`, `backgrounds`, `memories`
|
||||
- 금지 field: `region`, 일반 수정의 `isActive`
|
||||
- 기존 예외: 원작 미선택·선택 해제의 `originalWorkId`는 현재 serializer 계약대로 key를 생략하며, 원작 연결 해제 기능은 추가하지 않는다.
|
||||
|
||||
이름만 변경:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "루나 수정"
|
||||
}
|
||||
```
|
||||
|
||||
태그를 모두 삭제:
|
||||
|
||||
```json
|
||||
{
|
||||
"tags": null
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 오디오 콘텐츠
|
||||
|
||||
`PUT /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}`
|
||||
|
||||
- Content-Type: `multipart/form-data`
|
||||
- optional file part: `coverImage`
|
||||
- JSON part: `request`
|
||||
- 수정 화면 비교 field: `title`, `detail`, `tags`, `price`
|
||||
- 일반 수정 금지 field: `isActive`
|
||||
- 화면에 없는 `isAdult`, `isPointAvailable`, `isCommentAvailable`은 상세 응답에서 복사하지 않는다.
|
||||
|
||||
상세 설명만 변경:
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "수정한 상세 설명"
|
||||
}
|
||||
```
|
||||
|
||||
가격만 무료로 변경:
|
||||
|
||||
```json
|
||||
{
|
||||
"price": 0
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 커뮤니티 게시글
|
||||
|
||||
`PUT /api/v2/admin/ai-characters/{characterId}/community-posts/{postId}`
|
||||
|
||||
- Content-Type: `multipart/form-data`
|
||||
- optional file part: `postImage`
|
||||
- JSON part: `request`
|
||||
- 수정 저장 비교 field: `content`, `isCommentAvailable`, `isAdult`
|
||||
- 수정 저장 금지 field: `isFixed`, `isActive`
|
||||
- `isFixed` 전환과 soft delete는 기존 전용 action payload를 유지한다.
|
||||
|
||||
내용만 변경:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "수정한 게시글 내용"
|
||||
}
|
||||
```
|
||||
|
||||
댓글 허용만 끄기:
|
||||
|
||||
```json
|
||||
{
|
||||
"isCommentAvailable": false
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 시리즈
|
||||
|
||||
`PUT /api/v2/admin/ai-characters/{characterId}/series/{seriesId}`
|
||||
|
||||
- Content-Type: `multipart/form-data`
|
||||
- optional file part: `image`
|
||||
- JSON part: `request`
|
||||
- 비교 field: `title`, `introduction`, `publishedDaysOfWeek`, `genreId`, `isAdult`, `state`, `writer`, `studio`
|
||||
- 일반 수정 금지 field: `isActive`, create-only `keyword`
|
||||
|
||||
제목만 변경:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "달빛 상담 시리즈 수정"
|
||||
}
|
||||
```
|
||||
|
||||
작가를 삭제:
|
||||
|
||||
```json
|
||||
{
|
||||
"writer": null
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 FanTalk 답글
|
||||
|
||||
`PUT /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}`
|
||||
|
||||
- Content-Type: `application/json`
|
||||
- 비교 field: `content`
|
||||
- 일반 답글 수정 금지 field: `isActive`
|
||||
|
||||
답글 변경:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "수정한 답글입니다."
|
||||
}
|
||||
```
|
||||
|
||||
`content`가 기존 답글과 같으면 수정 button은 disabled이고 request를 보내지 않는다.
|
||||
|
||||
## 3. 응답과 오류
|
||||
|
||||
응답과 오류 계약은 변경하지 않는다.
|
||||
|
||||
| 기능 | 성공 data |
|
||||
|---|---|
|
||||
| 캐릭터·오디오 콘텐츠·커뮤니티 게시글·시리즈 | 기존 `null` success data |
|
||||
| FanTalk 답글 | 기존 `FanTalkListItem` update response |
|
||||
|
||||
- validation, 401, 403, 404, 406, 415, 500 처리는 정식 OpenAPI와 기존 공통 API client를 따른다.
|
||||
- 부분 request 도입을 이유로 새로운 status, `errorProperty` 또는 자동 retry를 만들지 않는다.
|
||||
|
||||
## 4. Contract 검증 예시
|
||||
|
||||
| 시나리오 | 필수 assertion |
|
||||
|---|---|
|
||||
| title만 변경 | request key가 `title` 하나다. |
|
||||
| detail만 변경 | request key가 `detail` 하나다. |
|
||||
| boolean을 false로 변경 | 해당 key와 `false`가 존재한다. |
|
||||
| nullable field 삭제 | 해당 key와 `null`이 존재한다. |
|
||||
| 변경 후 원복 | 저장 disabled, mutation 0건이다. |
|
||||
| 파일만 변경 | 파일 part 1개, request `{}`다. |
|
||||
| 파일 미변경 | 파일 part가 없다. |
|
||||
|
||||
## 5. 범위 밖 mutation 회귀 계약
|
||||
|
||||
다음 기존 요청은 payload 최적화 대상이 아니며 현재 contract를 유지한다.
|
||||
|
||||
- 캐릭터·오디오 콘텐츠·시리즈 비활성화: `{ "isActive": false }`
|
||||
- 커뮤니티 게시글 고정 전환: `{ "isFixed": boolean }`
|
||||
- 커뮤니티 게시글 soft delete: 기존 `{ "isActive": false, "isFixed": false }`
|
||||
- 시리즈 순서 변경: `{ "ids": number[] }`
|
||||
- FanTalk 원글 삭제: body 없는 `DELETE`
|
||||
619
docs/20260806_수정요청변경필드만전송/plan-task.md
Normal file
619
docs/20260806_수정요청변경필드만전송/plan-task.md
Normal file
@@ -0,0 +1,619 @@
|
||||
# 수정 요청 변경 필드 전송 구현 계획
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 구현·검증 완료 |
|
||||
| 작성일 | 2026-08-06 |
|
||||
| 요구사항 기준 | [prd.md](./prd.md) |
|
||||
| API 기준 | [api-contract.md](./api-contract.md) |
|
||||
| 현재 Phase | Phase 1 변경 필드 전송 통합 |
|
||||
| 현재 활성 Goal | 없음 |
|
||||
|
||||
## 목표
|
||||
|
||||
AI 캐릭터, 오디오 콘텐츠, 커뮤니티 게시글, 시리즈, FanTalk 답글 수정 시 최종 변경 필드와 교체 파일만 전송하고, 변경이 없으면 저장과 API 요청을 차단한다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1 | 완료 | `7/7` | 없음 | 없음 |
|
||||
|
||||
- 동시에 하나의 미완료 goal만 운용한다.
|
||||
- 완료된 Task와 검증 기록은 삭제하거나 되돌리지 않는다. 후속 수정은 회귀 수정 Task와 새 goal ID를 추가한다.
|
||||
- 사용자가 token budget을 지정하지 않았으므로 goal에 token budget을 설정하지 않는다.
|
||||
|
||||
## 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- AI 캐릭터, 오디오 콘텐츠, 커뮤니티 게시글, 시리즈, FanTalk 답글의 일반 수정 payload
|
||||
- 현재 form 값과 조회 기준값의 field별 비교, nullable 삭제와 미변경 생략 구분
|
||||
- 이미지·cover·post image만 변경한 multipart의 빈 `request: {}`
|
||||
- 변경 없음·변경 후 원복 상태의 native disabled 저장 control과 mutation 0건
|
||||
- 기능별 contract·component test와 mock Chromium E2E 회귀, typecheck·lint·build
|
||||
|
||||
### 제외
|
||||
|
||||
- 생성 payload, backend DTO와 HTTP method 변경
|
||||
- 캐릭터·오디오 콘텐츠·시리즈 비활성화 payload
|
||||
- 커뮤니티 게시글 고정 전환·soft delete, 시리즈 순서 변경, FanTalk 원글 삭제
|
||||
- 수정 화면 신규 field, image 삭제, 원작 연결 해제 기능
|
||||
- 새 dependency, 전역 form store, 범용 deep-diff abstraction
|
||||
|
||||
## 기술적 제약
|
||||
|
||||
- 기술 스택: React 19, TypeScript 6, Zod 4, Vitest 4, Testing Library, Playwright 1.61
|
||||
- 코드 스타일: TypeScript `strict`를 유지하고 `as any`, `@ts-ignore`, `@ts-expect-error`로 오류를 숨기지 않는다.
|
||||
- 아키텍처: API helper의 transport·schema 책임은 유지하고, 변경 판정은 각 feature의 form serializer 또는 component에 둔다.
|
||||
- 데이터: 현재 form과 기준 DTO에 같은 trim·nullable·array serialization을 적용하고 `false`, `0`, `null`을 유효한 변경값으로 보존한다.
|
||||
- 파일: multipart `request` part는 항상 유지한다. 파일만 변경하면 `{}`, 파일 미변경이면 file part를 생략한다.
|
||||
- UX: payload와 저장 disabled가 서로 다른 판정을 사용하지 않도록 같은 request 결과에서 `hasChanges`를 계산한다.
|
||||
- 보안: 인증·권한·resource ownership·민감정보 비기록 정책을 변경하지 않는다.
|
||||
- 호환성: 기존 기능별 desktop·tablet·mobile capability와 지원 browser를 유지한다.
|
||||
- 의존성: 새 package를 추가하지 않고 언어·플랫폼 기능과 기존 Zod schema를 사용한다.
|
||||
- 계약: 제공되지 않은 field, endpoint, response, 오류 status/key를 만들지 않는다.
|
||||
- mock: 기존 explicit mock mode만 사용하고 production 자동 fallback을 추가하지 않는다.
|
||||
- 구현: 각 Task는 RED → GREEN → REFACTOR 순서로 진행하고 실제 결과를 Progress에 누적한다.
|
||||
|
||||
## Phase 1. 변경 필드 전송 통합
|
||||
|
||||
**Phase 결과:** 다섯 수정 기능에서 단일 field 변경, nullable 삭제, file-only 변경과 무변경 상태가 동일한 계약으로 동작한다.
|
||||
|
||||
**선행조건:** [prd.md](./prd.md)의 `DIFF-001~005`와 [api-contract.md](./api-contract.md) 확정.
|
||||
|
||||
**Phase 완료 조건:** `P1-T1`~`P1-T5`와 `P1-GATE` 완료, 검증 결과와 실제 request 증거를 Progress에 누적.
|
||||
|
||||
### 구현 항목
|
||||
|
||||
#### Task 1.1 AI 캐릭터 변경 field 직렬화
|
||||
|
||||
**Goal 실행 `P1-T1`:** 캐릭터 수정 request가 변경된 profile·optional·repeated field만 포함하고 무변경 저장을 차단한다.
|
||||
|
||||
- **시작 조건:** `CHAR-001`, `DIFF-001~005`, `DATA-001~002`, API Contract §1·§2.1 확정.
|
||||
- **완료 증거:** direct field·nullable·repeated·image-only·무변경 test, focused 회귀, Progress 기록.
|
||||
- **범위 밖:** create form, region 수정, `isActive`, 원작 연결 해제 backend 의미 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/features/characters/components/character-optional-field-serialization.ts`
|
||||
- Modify: `src/features/characters/pages/CharacterEditPage.tsx`
|
||||
- Test: `src/features/characters/tests/CharacterEditPage.test.tsx`
|
||||
- Test: `src/features/characters/tests/character-api.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `CharacterDetail`, `CharacterOptionalFieldsValue`, `UpdateCharacterParams["request"]`, 기존 `serializeCharacterRequest`.
|
||||
- Produces: 현재 optional field와 초기 optional field를 비교해 변경 key만 반환하는 `toUpdateCharacterOptionalRequest` 계약, direct field와 합쳐진 변경 전용 request, 동일 request 기반 `hasChanges`.
|
||||
- 보존: 원작 미선택의 `originalWorkId` key 생략, file part 이름 `image`, `region`·일반 수정 `isActive` 제외.
|
||||
|
||||
**TDD 절차:**
|
||||
|
||||
- [x] **RED: 실패 테스트 작성/실패 확인** — `CharacterEditPage.test.tsx`에 name 단일 변경, optional field `null` 삭제, 반복 field 변경, 최초·원복 disabled, image-only `{}` 시나리오를 추가하고 focused 명령에서 현재 전체 field payload 또는 enabled 저장 때문에 실패하는지 확인한다.
|
||||
- [x] **GREEN: 최소 구현/통과 확인** — optional serializer가 초기값과 달라진 key만 만들고 page가 direct field와 file을 같은 방식으로 판정하도록 최소 수정해 focused 명령을 통과시킨다.
|
||||
- [x] **REFACTOR: 정리/회귀 확인** — payload 계산 중복만 제거하고 create serializer·API multipart helper는 유지한 채 Character 전체 회귀를 실행한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `npm run test:run -- src/features/characters/tests/CharacterEditPage.test.tsx src/features/characters/tests/character-api.test.ts`; `npm run test:run -- src/features/characters`; `npm run typecheck`; `npm run lint`.
|
||||
- **기대 결과:** 모든 명령 `exit 0`, 신규 5개 변경 감지 test를 포함한 실행 test 전부 통과, name 단일 변경 key 1개, 무변경 `PUT` 0건, type·lint 오류 0건.
|
||||
- **수동 확인:** 1280px에서 캐릭터 이름만 수정한 request part가 `{name}`이고, 원복 시 저장 disabled, image만 교체하면 `image`와 `{}`만 전송된다.
|
||||
|
||||
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
|
||||
|
||||
#### Task 1.2 오디오 콘텐츠 변경 field 직렬화
|
||||
|
||||
**Goal 실행 `P1-T2`:** 오디오 콘텐츠 수정 request가 변경된 title·detail·tags·price와 새 cover image만 포함한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 완료, `AUDIO-001`, `DIFF-001~005`, API Contract §1·§2.2 확정.
|
||||
- **완료 증거:** 단일 field·price 0·cover-only·무변경 test, 기존 비활성화 회귀, Progress 기록.
|
||||
- **범위 밖:** create options, audio 원본 교체, theme·release date 수정, 비활성화 payload.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/features/audio-contents/components/audio-content-form-helpers.ts`
|
||||
- Modify: `src/features/audio-contents/components/AudioContentForm.tsx`
|
||||
- Test: `src/features/audio-contents/tests/audio-form-update.test.tsx`
|
||||
- Test: `src/features/audio-contents/tests/audio-contract.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `AudioContentDetail`, `AudioContentUpdateRequest`, 기존 `toUpdateRequest`와 `createAudioContentUpdateBody`.
|
||||
- Produces: 직렬화된 현재 값과 `audio` 기준값을 비교해 변경 key만 반환하는 `toUpdateRequest`, 같은 request와 `coverImage` 기반 `hasChanges`.
|
||||
- 보존: file part `coverImage`, `request` part, 일반 update `isActive` 금지, price `0` 허용.
|
||||
|
||||
**TDD 절차:**
|
||||
|
||||
- [x] **RED: 실패 테스트 작성/실패 확인** — detail 단일 변경, price `0`, cover-only `{}`, 최초·원복 disabled와 화면에 없는 boolean 미전송 test를 작성하고 현재 전체 payload 때문에 실패하는지 확인한다.
|
||||
- [x] **GREEN: 최소 구현/통과 확인** — 기존 `toUpdateRequest`가 실제 수정 화면 field만 비교해 반환하고 edit button이 같은 결과로 disabled되도록 수정한다.
|
||||
- [x] **REFACTOR: 정리/회귀 확인** — create path와 비활성화 schema를 건드리지 않고 update serializer의 중복만 정리한 뒤 Audio 전체 회귀를 실행한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `npm run test:run -- src/features/audio-contents/tests/audio-form-update.test.tsx src/features/audio-contents/tests/audio-contract.test.ts`; `npm run test:run -- src/features/audio-contents`; `npm run typecheck`; `npm run lint`.
|
||||
- **기대 결과:** 모든 명령 `exit 0`, 신규 5개 변경 감지 test를 포함한 실행 test 전부 통과, detail 단일 변경 key 1개, cover-only request `{}`, 무변경 `PUT` 0건.
|
||||
- **수동 확인:** 1280px에서 detail만 수정하고 multipart `request`에 `detail`만 있는지, 기존 cover 미선택 시 `coverImage`가 없는지 확인한다.
|
||||
|
||||
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
|
||||
|
||||
#### Task 1.3 커뮤니티 게시글 변경 field 직렬화
|
||||
|
||||
**Goal 실행 `P1-T3`:** 커뮤니티 게시글 수정 저장이 변경된 content·comment permission·adult flag와 새 image만 포함한다.
|
||||
|
||||
- **시작 조건:** `P1-T2` 완료, `COMM-001`, `DIFF-001~005`, API Contract §1·§2.3 확정.
|
||||
- **완료 증거:** 단일 field·boolean false·image-only·무변경 test, 고정·soft delete 회귀, Progress 기록.
|
||||
- **범위 밖:** create form, audio file, `isFixed` 전환, soft delete 계약.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/features/community-posts/components/CommunityPostSheet.tsx`
|
||||
- Test: `src/features/community-posts/tests/community-sheet.test.tsx`
|
||||
- Test: `src/features/community-posts/tests/community-contract.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `CommunityPostListItem`, `CommunityPostUpdateRequest`, `updateCommunityPost`.
|
||||
- Produces: Sheet의 현재 수정 field와 `post` 기준값을 비교한 request, 같은 request와 `postImage` 기반 `hasChanges`.
|
||||
- 보존: `toggleFixed`의 `{isFixed}`, `softDeleteCommunityPost`의 기존 request, file part `postImage`.
|
||||
|
||||
**TDD 절차:**
|
||||
|
||||
- [x] **RED: 실패 테스트 작성/실패 확인** — content 단일 변경, boolean true→false, image-only `{}`, 최초·원복 disabled test를 작성하고 현재 `isFixed`와 미변경 field가 함께 전송되는 실패를 확인한다.
|
||||
- [x] **GREEN: 최소 구현/통과 확인** — Sheet의 일반 수정 저장 request만 field별 비교하고 저장 button이 같은 `hasChanges`를 사용하도록 수정한다.
|
||||
- [x] **REFACTOR: 정리/회귀 확인** — 고정·비활성화 action의 전용 함수와 pending guard를 유지하고 일반 수정 계산만 읽기 쉽게 정리한 뒤 Community 전체 회귀를 실행한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `npm run test:run -- src/features/community-posts/tests/community-sheet.test.tsx src/features/community-posts/tests/community-contract.test.ts`; `npm run test:run -- src/features/community-posts`; `npm run typecheck`; `npm run lint`.
|
||||
- **기대 결과:** 모든 명령 `exit 0`, 신규 4개 변경 감지 test를 포함한 실행 test 전부 통과, content 단일 변경 key 1개, 일반 수정 `isFixed` 0건, 무변경 `PUT` 0건.
|
||||
- **수동 확인:** 지원 viewport에서 Sheet를 열어 content만 수정했을 때 request에 content만 있고 고정 button과 비활성화 button 동작이 유지되는지 확인한다.
|
||||
|
||||
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
|
||||
|
||||
#### Task 1.4 시리즈 변경 field 직렬화
|
||||
|
||||
**Goal 실행 `P1-T4`:** 시리즈 수정 request가 변경된 기본·enum·nullable field와 새 image만 포함한다.
|
||||
|
||||
- **시작 조건:** `P1-T3` 완료, `SERIES-001`, `DIFF-001~005`, API Contract §1·§2.4 확정.
|
||||
- **완료 증거:** title 단일 변경, nullable 삭제, 배열·enum 변경, image-only·무변경 test, 비활성화 회귀, Progress 기록.
|
||||
- **범위 밖:** create-only keyword, 연결 콘텐츠, 순서 변경, 비활성화 payload.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/features/series/components/SeriesForm.tsx`
|
||||
- Test: `src/features/series/tests/series-form.test.tsx`
|
||||
- Test: `src/features/series/tests/series-contract.test.ts`
|
||||
- Test: `src/features/series/tests/series-update-invariants.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `SeriesListItem`, `SeriesUpdateRequest`, 기존 `textOrNull`, `editedState`, `updateSeries`.
|
||||
- Produces: 모든 edit field에 `editedState`와 같은 원본 비교를 적용한 update request, 같은 request와 `image` 기반 `hasChanges`.
|
||||
- 보존: array field 변경 시 전체 새 배열, writer·studio 삭제 시 `null`, 일반 update의 `isActive` 금지.
|
||||
|
||||
**TDD 절차:**
|
||||
|
||||
- [x] **RED: 실패 테스트 작성/실패 확인** — title 단일 변경, writer 삭제 `null`, published days 변경, image-only `{}`, 최초·원복 disabled test를 작성하고 state 외 미변경 field가 전송되는 실패를 확인한다.
|
||||
- [x] **GREEN: 최소 구현/통과 확인** — 기존 `editedState` 패턴을 edit field 전체에 적용해 request를 만들고 form disabled·dirty 판정이 같은 결과를 사용하게 한다.
|
||||
- [x] **REFACTOR: 정리/회귀 확인** — field 의미를 감추는 범용 abstraction 없이 local serializer 하나로 중복만 줄이고 Series 전체 회귀를 실행한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `npm run test:run -- src/features/series/tests/series-form.test.tsx src/features/series/tests/series-contract.test.ts src/features/series/tests/series-update-invariants.test.ts`; `npm run test:run -- src/features/series`; `npm run typecheck`; `npm run lint`.
|
||||
- **기대 결과:** 모든 명령 `exit 0`, 신규 5개 변경 감지 test를 포함한 실행 test 전부 통과, title 단일 변경 key 1개, nullable 삭제 `null` 보존, 무변경 `PUT` 0건.
|
||||
- **수동 확인:** 1280px에서 title만 수정, writer 삭제, image-only 교체를 각각 실행해 request key와 file part를 확인한다.
|
||||
|
||||
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
|
||||
|
||||
#### Task 1.5 FanTalk 답글 무변경 수정 차단
|
||||
|
||||
**Goal 실행 `P1-T5`:** FanTalk 답글은 기존 답글과 다른 content가 있을 때만 수정 request를 전송한다.
|
||||
|
||||
- **시작 조건:** `P1-T4` 완료, `FANTALK-001`, `DIFF-001`, `DIFF-004`, API Contract §1.4·§2.5 확정.
|
||||
- **완료 증거:** 최초 disabled, 변경 enabled·`{content}`, 원복 disabled·`PUT` 0건 test, FanTalk 회귀, Progress 기록.
|
||||
- **범위 밖:** 답글 생성, FanTalk 원글 삭제, `isActive`, 답글 trim 정책 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/features/fan-talks/components/FanTalkReplySheet.tsx`
|
||||
- Modify: `src/features/fan-talks/components/FanTalkReplyForm.tsx`
|
||||
- Test: `src/features/fan-talks/tests/fan-talk-reply.test.tsx`
|
||||
- Test: `src/features/fan-talks/tests/fan-talk-contract.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `FanTalkCreatorReply.content`, `FanTalkReplyForm`, `updateFanTalkReply`.
|
||||
- Produces: edit mode에서 `content !== existingReply.content`를 나타내는 submit disabled prop, 변경 시 기존 `{content}` request.
|
||||
- 보존: create mode의 빈값 validation, pending single-flight, update response와 원글 DELETE.
|
||||
|
||||
**TDD 절차:**
|
||||
|
||||
- [x] **RED: 실패 테스트 작성/실패 확인** — 기존 답글 open 직후 수정 button disabled, content 변경 후 enabled·PUT 1건, 원복 후 disabled·PUT 0건 test를 작성하고 현재 항상 enabled인 실패를 확인한다.
|
||||
- [x] **GREEN: 최소 구현/통과 확인** — Sheet가 edit 변경 여부를 계산해 Form submit button에 전달하고 기존 update body는 유지한다.
|
||||
- [x] **REFACTOR: 정리/회귀 확인** — create와 edit의 disabled 이유를 명확히 유지하고 새 serializer나 API helper 없이 FanTalk 전체 회귀를 실행한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `npm run test:run -- src/features/fan-talks/tests/fan-talk-reply.test.tsx src/features/fan-talks/tests/fan-talk-contract.test.ts`; `npm run test:run -- src/features/fan-talks`; `npm run typecheck`; `npm run lint`.
|
||||
- **기대 결과:** 모든 명령 `exit 0`, 신규 3개 변경 감지 test를 포함한 실행 test 전부 통과, 변경 update body `{content}` key 1개, 무변경·원복 `PUT` 0건.
|
||||
- **수동 확인:** FanTalk 답글 Sheet를 열면 수정 button이 disabled이고, 내용 변경 시 enabled, 원복 시 다시 disabled인지 확인한다.
|
||||
|
||||
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
|
||||
|
||||
#### Task R1.1 커뮤니티 content 직렬화 비교 일치
|
||||
|
||||
**Goal 실행 `P1-R1`:** `REV-P1-001`을 수정해 커뮤니티 게시글의 기존 원문 직렬화와 변경 판정을 일치시키고 공백 변경 누락을 방지한다.
|
||||
|
||||
- **시작 조건:** `REV-P1-001` 확정, `P1-T3`·`P1-GATE` 완료, `DIFF-001~004`, API Contract §1.2·§2.3 확정.
|
||||
- **완료 증거:** 공백 변경 실패 재현 test, 최소 수정 후 focused·Community 전체·Phase Gate 통과, Progress와 review 수정 검증 기록.
|
||||
- **범위 밖:** 커뮤니티 생성 payload, content trim 정책 신설, 고정 전환, soft delete, 다른 기능 serializer 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/features/community-posts/components/CommunityPostSheet.tsx`
|
||||
- Test: `src/features/community-posts/tests/community-sheet.test.tsx`
|
||||
- Modify: `docs/20260806_수정요청변경필드만전송/plan-task.md`
|
||||
- Add: `docs/20260806_수정요청변경필드만전송/reviews/phase1-changed-field-requests.md`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: 기존 Community update의 raw `content`, `CommunityPostListItem.content`, `CommunityPostUpdateRequest`.
|
||||
- Produces: 현재 `content`와 기준 `post.content`에 동일한 원문 직렬화 규칙을 적용한 변경 판정.
|
||||
- 보존: 전송하는 `content` 원문, file-only `{}`, `isFixed`·soft delete 전용 request.
|
||||
|
||||
**TDD 절차:**
|
||||
|
||||
- [x] **RED: 실패 테스트 작성/실패 확인** — 기존 content 앞뒤에 공백을 추가하면 저장이 활성화되고 원문 `{content}`가 전송돼야 하는 test가 현재 trim 비교로 실패하는지 확인한다.
|
||||
- [x] **GREEN: 최소 구현/통과 확인** — `content` 비교의 `trim()`만 제거해 기존 update 직렬화와 일치시키고 focused test를 통과시킨다.
|
||||
- [x] **REFACTOR: 회귀 확인** — 별도 abstraction 없이 Community 전체와 Phase Gate를 실행한다.
|
||||
- [x] review 상태와 Progress에 실제 명령·결과를 누적한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `npm run test:run -- src/features/community-posts/tests/community-sheet.test.tsx src/features/community-posts/tests/community-contract.test.ts`; `npm run test:run -- src/features/community-posts`; Phase 1 Gate 전체 명령.
|
||||
- **기대 결과:** 모든 명령 `exit 0`, 공백 변경 request에 raw `content`만 존재, 기존 227개 회귀와 정적 Gate 오류 0건.
|
||||
- **수동 확인:** 자동 component test로 동일 payload와 disabled 상태를 검증하며 별도 Network 확인은 대체 사유를 review에 기록한다.
|
||||
|
||||
#### Task R1.2 PRD 성공 기준 상태 동기화
|
||||
|
||||
**Goal 실행 `P1-R2`:** `REV-P1-002`를 수정해 검증 완료된 PRD 성공 기준과 Phase 1 완료 기록을 일치시킨다.
|
||||
|
||||
- **시작 조건:** `REV-P1-002` 확정, `P1-R1`과 Phase Gate 완료.
|
||||
- **완료 증거:** PRD §14의 검증 완료 항목 체크, plan·review 기록, 문서 정적 검사.
|
||||
- **범위 밖:** 요구사항·API Contract 의미 변경, 검증하지 않은 항목 완료 처리, 코드 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/20260806_수정요청변경필드만전송/prd.md`
|
||||
- Modify: `docs/20260806_수정요청변경필드만전송/plan-task.md`
|
||||
- Modify: `docs/20260806_수정요청변경필드만전송/reviews/phase1-changed-field-requests.md`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `P1-T1`~`P1-R1` Progress와 Phase Gate 실제 검증 결과.
|
||||
- Produces: PRD §14 기능·UI/UX·추적성 성공 기준의 현재 완료 상태.
|
||||
- 보존: PRD 요구사항 본문, API Contract, §18 요구사항 변경 체크리스트.
|
||||
|
||||
**검증 절차:**
|
||||
|
||||
- [x] 기존 Phase Gate 증거와 PRD §14 각 항목을 대조한다.
|
||||
- [x] 증거가 있는 §14 성공 기준만 완료 표시한다.
|
||||
- [x] `rg`와 `git diff --check`로 미완료·공백 오류를 확인하고 Progress·review에 결과를 기록한다.
|
||||
|
||||
**TDD 예외 사유:** 실행 코드가 아닌 완료 상태 문서 동기화이며, 근거는 이미 통과한 component·E2E·정적 Gate다.
|
||||
|
||||
### 완료 조건
|
||||
|
||||
- [x] `P1-T1`~`P1-T5`의 체크박스와 완료 증거가 모두 충족됐다.
|
||||
- [x] `DIFF-001~005`, 기능별 요구사항과 파일·데이터 요구사항이 구현 또는 명시적 제외로 추적된다.
|
||||
- [x] 다섯 기능의 payload와 저장 disabled가 같은 변경 판정을 사용한다.
|
||||
- [x] 기존 생성·비활성화·고정·순서·삭제 contract 회귀가 없다.
|
||||
- [x] 문서, 구현, test와 실제 검증 기록의 차이가 없다.
|
||||
|
||||
### 검증 방법
|
||||
|
||||
#### Phase 1 Gate
|
||||
|
||||
**Goal 실행 `P1-GATE`:** 다섯 수정 기능의 변경 field payload, 무변경 차단과 기존 mutation 회귀를 통합 판정한다.
|
||||
|
||||
- **시작 조건:** `P1-T1`~`P1-T5` 완료.
|
||||
- **완료 증거:** focused·도메인 전체·mock Chromium E2E·정적 Gate와 수동 Network 검증 통과, Progress 기록.
|
||||
- **범위 밖:** Gate 통과를 위한 test 삭제·skip·완화와 관련 없는 refactor.
|
||||
|
||||
**실행 명령:**
|
||||
|
||||
```bash
|
||||
npm run test:run -- src/features/characters src/features/audio-contents src/features/community-posts src/features/series src/features/fan-talks
|
||||
npm run e2e:mock -- tests/e2e/character-workspace.spec.ts tests/e2e/audio-content.spec.ts tests/e2e/community.spec.ts tests/e2e/series.spec.ts tests/e2e/fan-talk.spec.ts --project=chromium
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run build
|
||||
git diff --check
|
||||
```
|
||||
|
||||
**기대 결과:** 모든 명령 `exit 0`; 신규 변경 감지 test 최소 22개를 포함한 실행 test 전부 통과; 단일 field request key 1개; 무변경 mutation 0건; 기존 create·deactivate·fixed·order·delete E2E 실패 0건; type·lint·build 오류 0건.
|
||||
|
||||
**Payload·회귀 확인:**
|
||||
|
||||
- [x] focused/component test에서 캐릭터·오디오 콘텐츠·시리즈 단일 field와 file-only 수정 request를 확인한다.
|
||||
- [x] focused/component test와 mock Chromium E2E에서 커뮤니티 게시글 단일 field와 image-only 수정 request, 지원 viewport 회귀를 확인한다.
|
||||
- [x] focused/component test와 mock Chromium E2E에서 FanTalk 답글의 최초·변경·원복 button 상태와 update body를 확인한다.
|
||||
- [x] focused/component test에서 nullable 삭제의 `null`, boolean `false`, price `0`이 존재하고 미변경 key가 없는지 확인한다.
|
||||
- [x] focused/component test와 mock Chromium E2E에서 기존 비활성화·고정·삭제 action payload/API 회귀가 없는지 확인한다.
|
||||
|
||||
## 실행 순서와 의존성
|
||||
|
||||
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | `P1-T1` | PRD·API Contract 확정 | 아니요 | Character 기준값·nullable 계약 보정 |
|
||||
| 2 | `P1-T2` | `P1-T1` | 아니요 | Audio 상세 DTO와 화면 field 대조 |
|
||||
| 3 | `P1-T3` | `P1-T2` | 아니요 | 일반 수정과 전용 action request 분리 확인 |
|
||||
| 4 | `P1-T4` | `P1-T3` | 아니요 | Series nullable·array·enum 계약 대조 |
|
||||
| 5 | `P1-T5` | `P1-T4` | 아니요 | create/edit mode disabled 조건 분리 확인 |
|
||||
| 6 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패 소유 Task의 회귀 수정 goal 생성 |
|
||||
| 7 | `P1-R1` | `REV-P1-001`, `P1-GATE` | 아니요 | Community 원문 직렬화 계약 재확인 |
|
||||
| 8 | `P1-R2` | `REV-P1-002`, `P1-R1` | 아니요 | PRD 성공 기준과 Gate 증거 재대조 |
|
||||
|
||||
```text
|
||||
P1-T1 → P1-T2 → P1-T3 → P1-T4 → P1-T5 → P1-GATE → P1-R1 → P1-R2
|
||||
```
|
||||
|
||||
## 변경 금지 항목
|
||||
|
||||
- 확정된 PRD·API Contract의 endpoint, method, field type과 nullable 의미를 근거 없이 바꾸지 않는다.
|
||||
- 기존 완료 체크박스와 Progress·Decision Log·검증 기록을 삭제하거나 덮어쓰지 않는다.
|
||||
- 생성, 비활성화, 고정, 순서, 삭제 payload를 이번 최적화에 합치지 않는다.
|
||||
- 수정 화면에 없는 server field를 기준값 보존 명목으로 request에 복사하지 않는다.
|
||||
- 범용 deep-equality dependency, form framework 또는 전역 diff store를 추가하지 않는다.
|
||||
- test를 삭제·skip·완화하거나 type assertion으로 오류를 우회하지 않는다.
|
||||
- token, 파일 본문, signed URL과 관리자 입력 전문을 log·fixture·문서에 기록하지 않는다.
|
||||
|
||||
## 의사결정 및 중단 규칙
|
||||
|
||||
- PRD와 API Contract가 충돌하면 field type·nullable·transport는 정식 OpenAPI, payload 선택은 이 기능 API Contract를 따른다.
|
||||
- 현재 form이 기존 상세 DTO에 없는 수정 field를 노출하면 추정 비교하지 않고 해당 field의 근거를 문서화한 뒤 진행한다.
|
||||
- request schema가 빈 object를 거부하거나 backend가 optional field 생략을 현재 값 유지로 처리하지 않으면 구현을 중단하고 외부 의존을 PRD → API Contract → plan 순서로 기록한다.
|
||||
- 범위가 바뀌면 `plan-task.md` 체크박스와 Files·Interfaces를 먼저 갱신한 뒤 코드를 수정한다.
|
||||
- 같은 차단 사유가 최초 시도와 자동 후속을 포함해 3회 연속 반복되고 독립 작업도 불가능할 때만 goal을 `blocked`로 갱신한다.
|
||||
- 코드와 일부 test만 끝난 상태에서는 goal을 완료하지 않는다. TDD·검증·Progress 증거까지 충족한 뒤 `complete`로 갱신한다.
|
||||
- 완료된 Task의 후속 결함은 기존 Task를 다시 열지 않고 `P1-R1`부터 회귀 수정 goal을 추가한다.
|
||||
|
||||
## Progress
|
||||
|
||||
기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다.
|
||||
|
||||
### 문서 준비 — 2026-08-06
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: PRD, API Contract, 5개 구현 Task와 Phase Gate를 작성했다.
|
||||
- 왜: 다섯 수정 기능의 변경 field 전송과 무변경 A안의 실행 기준을 고정하기 위해서다.
|
||||
- TDD 예외 사유: 이 단계는 실행 코드를 변경하지 않는 요구사항·계약·계획 문서 작성이다.
|
||||
- 어떻게:
|
||||
- 코드 조사: 다섯 form·API·schema·test와 정식 OpenAPI의 update schema를 대조했다.
|
||||
- 인터뷰: 사용자가 무변경 상태의 저장 button disabled·request 0건인 A안을 확정했다.
|
||||
- 세 문서의 미정 표현·placeholder 정적 검사 — 출력 없음, `exit 0`.
|
||||
- 세 문서와 정식 OpenAPI 대상 `test -f` — 누락 없음, `exit 0`.
|
||||
- PRD 필수 18개 section과 plan 필수 12개 section `rg` 검사 — 모두 확인, `exit 0`.
|
||||
- `git diff --check -- docs/20260806_수정요청변경필드만전송` — 출력 없음, `exit 0`.
|
||||
- 남은 항목: `P1-T1`~`P1-T5`, `P1-GATE` 구현·검증.
|
||||
- 다음 행동: `P1-T1`의 Character RED test 작성.
|
||||
|
||||
### 구현 Goal 기록 형식
|
||||
|
||||
각 Goal 실행 후 아래 항목을 복제하고 실제 값으로 채운다.
|
||||
|
||||
- 상태: 진행 중 / 완료 / 차단 감사 중 / 차단
|
||||
- 무엇을: 완료한 체크박스와 산출물
|
||||
- 왜: Task objective와 요구사항 ID
|
||||
- TDD:
|
||||
- RED: 실패 test 명령, exit code와 의도한 assertion
|
||||
- GREEN: 같은 focused 명령, exit code와 통과 test 수
|
||||
- REFACTOR: focused·도메인 회귀 명령, exit code와 통과 test 수
|
||||
- 어떻게: typecheck·lint·build·수동 검증의 성공·실패·불가 사유
|
||||
- 남은 항목: 미완료 체크박스 또는 없음
|
||||
- 다음 행동: 같은 goal의 가장 작은 미완료 단계 또는 다음 Goal ID
|
||||
|
||||
### P1-T1 Character 변경 field 직렬화 — 2026-08-06
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: 캐릭터 수정의 direct field, optional field, nullable 삭제, image-only `{}`, 무변경·원복 disabled를 변경 field 전송 계약으로 바꿨다.
|
||||
- 왜: `CHAR-001`, `DIFF-001~005`, `FILE-001`, `DATA-001~002`를 충족하기 위해서다.
|
||||
- TDD:
|
||||
- RED: `npm run test:run -- src/features/characters/tests/CharacterEditPage.test.tsx src/features/characters/tests/character-api.test.ts` — exit 1. 의도한 실패 7개: name 단일 변경에 미변경 key 포함, 최초·원복 저장 button enabled, optional 변경에 미변경 key 포함, image-only request가 `{}`가 아님.
|
||||
- GREEN: 같은 focused 명령 — exit 0, 2 files / 21 tests 통과.
|
||||
- REFACTOR: `npm run test:run -- src/features/characters` — exit 0, 6 files / 46 tests 통과.
|
||||
- 어떻게:
|
||||
- `npx tsc --noEmit --project tsconfig.test.json --pretty false` — 출력 없음, exit 0.
|
||||
- API multipart helper와 create serializer는 변경하지 않았다.
|
||||
- 수동 Network 확인은 `P1-GATE`에서 다섯 기능과 함께 진행한다.
|
||||
- 남은 항목: `P1-T2`~`P1-T5`, `P1-GATE`.
|
||||
- 다음 행동: `P1-T2`의 Audio RED test 작성.
|
||||
|
||||
### P1-T1 Review Blocker 수정 — 2026-08-06
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: direct field 기준값에도 동일한 `trim()` 직렬화 규칙을 적용했다.
|
||||
- 왜: 리뷰에서 조회값이 공백을 포함하면 최초 진입부터 변경으로 판정되는 `DIFF-002`, `DIFF-004` 위반 가능성이 확인됐다.
|
||||
- TDD:
|
||||
- RED: `npm run test:run -- src/features/characters/tests/CharacterEditPage.test.tsx src/features/characters/tests/character-api.test.ts` — exit 1. `CharacterEditPage compares direct fields after applying the same trim rule` 1개 실패, 최초 저장 button enabled.
|
||||
- GREEN: 같은 focused 명령 — exit 0, 2 files / 22 tests 통과.
|
||||
- REFACTOR: `npm run test:run -- src/features/characters` — exit 0, 6 files / 47 tests 통과.
|
||||
- 어떻게:
|
||||
- `npx tsc --noEmit --project tsconfig.test.json --pretty false` — 출력 없음, exit 0.
|
||||
- direct field 변경 판정과 submit request 모두 같은 trim 기준값을 사용한다.
|
||||
- 남은 항목: `P1-T2`~`P1-T5`, `P1-GATE`.
|
||||
- 다음 행동: `P1-T1` 리뷰 재확인 후 `P1-T2`의 Audio RED test 작성.
|
||||
|
||||
### P1-T2 Audio 변경 field 직렬화 — 2026-08-06
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: 오디오 콘텐츠 수정의 `title`, `detail`, `tags`, `price` 변경 field와 cover-only `{}` 전송, 무변경·cover 취소 disabled를 구현했다.
|
||||
- 왜: `AUDIO-001`, `DIFF-001~005`, `FILE-001`, `DATA-001`를 충족하기 위해서다.
|
||||
- TDD:
|
||||
- RED: `npm run test:run -- src/features/audio-contents/tests/audio-form-update.test.tsx src/features/audio-contents/tests/audio-contract.test.ts` — exit 1. 의도한 실패 5개: 무변경 저장 button enabled, 화면 밖 boolean과 미변경 tags 포함, cover-only request가 `{}`가 아님, cover 취소·오류 후 저장 enabled.
|
||||
- GREEN: 같은 focused 명령 — exit 0, 2 files / 24 tests 통과.
|
||||
- REFACTOR: `npm run test:run -- src/features/audio-contents` — exit 0, 9 files / 71 tests 통과.
|
||||
- 어떻게:
|
||||
- `npx tsc --noEmit --project tsconfig.test.json --pretty false` — 출력 없음, exit 0.
|
||||
- create upload path와 deactivate `{isActive:false}` 계약은 변경하지 않았다.
|
||||
- 수동 Network 확인은 `P1-GATE`에서 다섯 기능과 함께 진행한다.
|
||||
- 남은 항목: `P1-T3`~`P1-T5`, `P1-GATE`.
|
||||
- 다음 행동: `P1-T2` 리뷰 확인 후 `P1-T3`의 Community RED test 작성.
|
||||
|
||||
### P1-T2 Review Blocker 수정 — 2026-08-06
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: edit 저장 가능 여부를 가격 문자열 비교가 아니라 정규화된 `toUpdateRequest` 결과와 cover 변경에서 계산하도록 보정했다.
|
||||
- 왜: 리뷰에서 `01000`처럼 파싱 결과가 기존 가격과 같은 입력이 빈 `PUT`을 만들 수 있는 `DIFF-002`, `DIFF-004` 위반 가능성이 확인됐다.
|
||||
- TDD:
|
||||
- RED: `npm run test:run -- src/features/audio-contents/tests/audio-form-update.test.tsx src/features/audio-contents/tests/audio-contract.test.ts` — exit 1. `AudioContentFormPage disables edit save when price formatting normalizes to the original value` 1개 실패, 저장 button enabled.
|
||||
- GREEN: 같은 focused 명령 — exit 0, 2 files / 25 tests 통과.
|
||||
- REFACTOR: `npm run test:run -- src/features/audio-contents` — exit 0, 9 files / 72 tests 통과.
|
||||
- 어떻게:
|
||||
- `npx tsc --noEmit --project tsconfig.test.json --pretty false` — 출력 없음, exit 0.
|
||||
- invalid price는 기존처럼 저장 button을 눌러 validation 오류를 표시할 수 있게 유지했다.
|
||||
- 남은 항목: `P1-T3`~`P1-T5`, `P1-GATE`.
|
||||
- 다음 행동: `P1-T2` 리뷰 재확인 후 `P1-T3`의 Community RED test 작성.
|
||||
|
||||
### P1-T3 Community 변경 field 직렬화 — 2026-08-06
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: 커뮤니티 게시글 일반 수정의 `content`, `isAdult`, `isCommentAvailable` 변경 field와 image-only `{}` 전송, 무변경 저장 차단을 구현했다.
|
||||
- 왜: `COMM-001`, `DIFF-001~005`, `FILE-001`, `DATA-001`를 충족하기 위해서다.
|
||||
- TDD:
|
||||
- RED: `npm run test:run -- src/features/community-posts/tests/community-sheet.test.tsx src/features/community-posts/tests/community-contract.test.ts` — exit 1. 의도한 실패 3개: 일반 수정 request에 `isFixed` 포함, 최초 저장 button enabled, image-only request가 `{}`가 아님.
|
||||
- GREEN: 같은 focused 명령 — exit 0, 2 files / 26 tests 통과.
|
||||
- REFACTOR: `npm run test:run -- src/features/community-posts` — exit 0, 7 files / 51 tests 통과.
|
||||
- 어떻게:
|
||||
- `CommunityPostSheet`의 일반 저장 request와 저장 disabled가 같은 변경 판정을 사용한다.
|
||||
- 고정 전환과 soft delete 전용 request는 기존 계약대로 분리해 유지했다.
|
||||
- 수동 Network 확인은 `P1-GATE`에서 다섯 기능과 함께 진행한다.
|
||||
- 남은 항목: `P1-T4`, `P1-T5`, `P1-GATE`.
|
||||
- 다음 행동: `P1-T4`의 Series RED test 작성.
|
||||
|
||||
### P1-T4 Series 변경 field 직렬화 — 2026-08-06
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: 시리즈 수정의 기본 field, nullable writer/studio, enum·요일 변경 field와 image-only `{}` 전송, 무변경 저장 차단을 구현했다.
|
||||
- 왜: `SERIES-001`, `DIFF-001~005`, `FILE-001`, `DATA-001~002`를 충족하기 위해서다.
|
||||
- TDD:
|
||||
- RED: `npm run test:run -- src/features/series/tests/series-form.test.tsx src/features/series/tests/series-contract.test.ts src/features/series/tests/series-update-invariants.test.ts` — exit 1. 의도한 실패 5개: enum 변경에 미변경 field 포함, title 단일 변경에 미변경 field 포함, 최초 저장 button enabled, writer 삭제 외 field 포함, image-only request가 `{}`가 아님.
|
||||
- GREEN: 같은 focused 명령 — exit 0, 3 files / 21 tests 통과.
|
||||
- REFACTOR: `npm run test:run -- src/features/series` — exit 0, 9 files / 42 tests 통과.
|
||||
- 어떻게:
|
||||
- `SeriesForm`의 edit request와 저장 disabled·dirty 판정이 같은 변경 결과를 사용한다.
|
||||
- create path, deactivate `{isActive:false}`, order/content APIs는 변경하지 않았다.
|
||||
- `npx tsc --noEmit --project tsconfig.test.json --pretty false` — 출력 없음, exit 0.
|
||||
- 수동 Network 확인은 `P1-GATE`에서 다섯 기능과 함께 진행한다.
|
||||
- 남은 항목: `P1-T5`, `P1-GATE`.
|
||||
- 다음 행동: `P1-T5`의 FanTalk RED test 작성.
|
||||
|
||||
### P1-T5 FanTalk 답글 무변경 수정 차단 — 2026-08-06
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: FanTalk 답글 수정에서 기존 content와 동일하거나 원복된 상태의 저장 button을 disabled 처리하고, 변경된 content만 `PUT`으로 전송하게 했다.
|
||||
- 왜: `FANTALK-001`, `DIFF-001`, `DIFF-004`를 충족하기 위해서다.
|
||||
- TDD:
|
||||
- RED: `npm run test:run -- src/features/fan-talks/tests/fan-talk-reply.test.tsx src/features/fan-talks/tests/fan-talk-contract.test.ts` — exit 1. 의도한 실패 1개: 기존 답글 open 직후 `답변 수정` button enabled.
|
||||
- GREEN: 같은 focused 명령 — exit 0, 2 files / 11 tests 통과.
|
||||
- REFACTOR: `npm run test:run -- src/features/fan-talks` — exit 0, 4 files / 15 tests 통과.
|
||||
- 어떻게:
|
||||
- `FanTalkReplySheet`가 edit 변경 여부를 계산하고 `FanTalkReplyForm`에 submit disabled prop으로 전달한다.
|
||||
- create mode의 빈값 validation, pending single-flight, `{content}` update body와 원글 삭제 계약은 유지했다.
|
||||
- `npx tsc --noEmit --project tsconfig.test.json --pretty false` — 출력 없음, exit 0.
|
||||
- 남은 항목: `P1-GATE`.
|
||||
- 다음 행동: Phase 1 Gate 검증 실행.
|
||||
|
||||
### P1-GATE Phase 1 통합 검증 — 2026-08-06
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: 다섯 수정 기능의 변경 field payload, 무변경 저장 차단, 기존 mutation 회귀를 통합 검증했다.
|
||||
- 왜: Phase 1 완료 조건과 `DIFF-001~005`, `FILE-001`, `DATA-001~002` 적용 결과를 확인하기 위해서다.
|
||||
- TDD:
|
||||
- RED: `P1-T1`~`P1-T5` 각 Goal에서 focused 실패를 확인했다.
|
||||
- GREEN: 각 Goal의 focused 명령이 모두 exit 0으로 통과했다.
|
||||
- REFACTOR: 각 domain 회귀와 Phase Gate 통합 회귀를 실행했다.
|
||||
- 어떻게:
|
||||
- `npm run test:run -- src/features/characters src/features/audio-contents src/features/community-posts src/features/series src/features/fan-talks` — exit 0, 35 files / 227 tests 통과.
|
||||
- `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts tests/e2e/audio-content.spec.ts tests/e2e/community.spec.ts tests/e2e/series.spec.ts tests/e2e/fan-talk.spec.ts --project=chromium` — exit 0, 39 tests 통과.
|
||||
- `npm run typecheck` — exit 0.
|
||||
- `npm run lint` — exit 0.
|
||||
- `npm run build` — exit 0, production build 완료.
|
||||
- `git diff --check` — 출력 없음, exit 0.
|
||||
- 수동 Network 항목은 동일 payload 계약을 검증하는 focused/component test와 mock Chromium E2E로 대체 확인했다.
|
||||
- 남은 항목: 없음.
|
||||
- 다음 행동: 최종 변경 요약과 검증 결과 보고.
|
||||
|
||||
### P1-R1 Community content 직렬화 비교 일치 — 2026-08-06
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: Community update가 전송하는 raw `content`와 변경 판정을 일치시키고 공백 변경 회귀 test를 추가했다.
|
||||
- 왜: [`REV-P1-001`](./reviews/phase1-changed-field-requests.md)의 `DIFF-001`, `DIFF-002`, `DIFF-004`, `COMM-001` 위반을 수정하기 위해서다.
|
||||
- TDD:
|
||||
- RED: `npm run test:run -- src/features/community-posts/tests/community-sheet.test.tsx src/features/community-posts/tests/community-contract.test.ts` — exit 1, 1 failed / 26 passed. `Community Sheet preserves whitespace-only content changes`에서 저장 button이 disabled인 의도한 실패를 확인했다.
|
||||
- GREEN: 같은 focused 명령 — exit 0, 2 files / 27 tests 통과.
|
||||
- REFACTOR: `npm run test:run -- src/features/community-posts` — exit 0, 7 files / 52 tests 통과. 새 abstraction 없이 비교식 1줄만 수정했다.
|
||||
- 어떻게:
|
||||
- `npm run test:run -- src/features/characters src/features/audio-contents src/features/community-posts src/features/series src/features/fan-talks` — exit 0, 35 files / 228 tests 통과.
|
||||
- `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts tests/e2e/audio-content.spec.ts tests/e2e/community.spec.ts tests/e2e/series.spec.ts tests/e2e/fan-talk.spec.ts --project=chromium` — sandbox 실행은 `127.0.0.1:8889` bind `EPERM`으로 불가했고, 승인된 동일 명령 재실행은 exit 0, 39 tests 통과.
|
||||
- `npm run typecheck` — exit 0.
|
||||
- `npm run lint` — exit 0.
|
||||
- `npm run build` — exit 0, production build 완료.
|
||||
- `git diff --check` — 출력 없음, exit 0.
|
||||
- 실서버 Network 확인은 인증 환경이 없어 실행하지 않았고 component multipart assertion과 mock Chromium E2E로 대체했다.
|
||||
- 남은 항목: 없음.
|
||||
- 다음 행동: 최종 리뷰 결과 보고.
|
||||
|
||||
### P1-R2 PRD 성공 기준 상태 동기화 — 2026-08-06
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: Phase Gate와 회귀 검증 증거에 따라 PRD §14 성공 기준 13개를 완료 상태로 동기화했다.
|
||||
- 왜: [`REV-P1-002`](./reviews/phase1-changed-field-requests.md)의 PRD·plan 완료 상태 불일치를 수정하기 위해서다.
|
||||
- TDD 예외 사유: 실행 코드가 아닌 완료 상태 문서 동기화다.
|
||||
- 어떻게:
|
||||
- `rg -n "^- \\[x\\]" docs/20260806_수정요청변경필드만전송/prd.md` — §14 완료 항목 13개 확인, exit 0.
|
||||
- `rg -n "^- \\[ \\]" docs/20260806_수정요청변경필드만전송/prd.md` — §18 요구사항 변경 체크리스트 5개만 유지, exit 0.
|
||||
- 대상 PRD·API Contract·plan·review `test -f` — 누락 없음, exit 0.
|
||||
- `git diff --check` — 출력 없음, exit 0.
|
||||
- 남은 항목: 없음.
|
||||
- 다음 행동: 최종 리뷰 결과 보고.
|
||||
|
||||
## Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-06 | `DEC-001` | 확정 | 기존 `PUT`·multipart·JSON transport를 유지하고 최종 변경 field만 보낸다. | 사용자 요청, OpenAPI update field optional 계약 | `P1-T1`~`P1-T5`, API Contract §1 |
|
||||
| 2026-08-06 | `DEC-002` | 확정 | 변경 field·file 0개면 저장 button disabled와 mutation 0건으로 처리한다. | 사용자 인터뷰 A안 | `P1-T1`~`P1-T5` |
|
||||
| 2026-08-06 | `DEC-003` | 확정 | file-only multipart는 required `request` part를 `{}`로 보낸다. | multipart contract와 빈 update object 허용 schema | `P1-T1`~`P1-T4` |
|
||||
| 2026-08-06 | `DEC-004` | 확정 | payload와 disabled 판정은 같은 domain request 결과를 사용한다. | 원복·trim·nullable 판정 불일치 방지 | `P1-T1`~`P1-T5` |
|
||||
| 2026-08-06 | `DEC-005` | 확정 | domain-local serializer를 사용하고 새 공통 diff abstraction을 만들지 않는다. | field별 nullable·array·file 의미 차이와 최소 변경 원칙 | `P1-T1`~`P1-T5` |
|
||||
| 2026-08-06 | `DEC-006` | 확정 | Community update의 `content`는 기존 원문 전송을 유지하고 현재 값과 기준값도 원문으로 비교한다. | `DIFF-002`의 같은 기존 직렬화 적용, 기존 update가 trim 없이 전송한 코드 | `P1-R1`, `REV-P1-001` |
|
||||
|
||||
## 발견된 문제
|
||||
|
||||
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|
||||
|---|---|---|---|---|---|
|
||||
| `ISSUE-001` | High | 확정 | Character edit가 direct·optional·repeated 현재값 전체를 request에 넣는다. | `P1-T1` | 기준값 비교 serializer와 no-op test 추가 |
|
||||
| `ISSUE-002` | High | 확정 | Audio edit가 수정 화면에 없는 boolean을 포함한 전체 update DTO를 보낸다. | `P1-T2` | 화면 field만 비교·전송 |
|
||||
| `ISSUE-003` | High | 확정 | Community 일반 수정이 미변경 `isFixed`와 나머지 편집값을 함께 보낸다. | `P1-T3` | 일반 수정 request와 전용 action 유지 |
|
||||
| `ISSUE-004` | High | 확정 | Series는 state만 미변경 생략하고 나머지 edit field는 모두 보낸다. | `P1-T4` | 기존 state 원본 비교를 전체 edit field로 확장 |
|
||||
| `ISSUE-005` | Medium | 확정 | FanTalk reply는 update field가 content 하나라 변경 payload는 이미 최소지만 무변경 수정 요청을 차단하지 않는다. | `P1-T5` | edit submit disabled와 request 0건 test 추가 |
|
||||
| `ISSUE-006` | High | 해결 | Community update가 raw `content`를 전송하면서 변경 판정만 trim해 공백 변경을 누락한다. | `P1-R1` | [`REV-P1-001`](./reviews/phase1-changed-field-requests.md) 수정·검증 완료 |
|
||||
| `ISSUE-007` | Low | 해결 | Phase 1 구현·Gate 완료 후에도 PRD §14 성공 기준이 미완료로 남아 있다. | `P1-R2` | [`REV-P1-002`](./reviews/phase1-changed-field-requests.md) 수정·검증 완료 |
|
||||
|
||||
## 최종 보고 형식
|
||||
|
||||
```markdown
|
||||
구현 결과: 다섯 수정 기능의 변경 field 전송과 무변경 저장 차단
|
||||
|
||||
- 변경: domain별 serializer·form·test와 실제 payload
|
||||
- 결정: DEC-001~005 적용 결과
|
||||
- 검증:
|
||||
- focused·도메인 회귀·mock Chromium E2E — 성공/실패와 test 수
|
||||
- typecheck·lint·build·git diff --check — exit code와 핵심 결과
|
||||
- Network 수동 검증 — 단일 field, nullable, false·0, file-only, 무변경 결과
|
||||
- 남은 항목: 외부 의존, 후속 회귀 또는 없음
|
||||
- 문서: PRD, API Contract, plan, review 링크
|
||||
```
|
||||
|
||||
최종 보고는 성공을 추정하지 않고 실제 최신 검증 결과와 완료되지 않은 범위를 함께 기록한다.
|
||||
274
docs/20260806_수정요청변경필드만전송/prd.md
Normal file
274
docs/20260806_수정요청변경필드만전송/prd.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# 수정 요청 변경 필드 전송 PRD
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | 구현 기준 확정 |
|
||||
| 작성일 | 2026-08-06 |
|
||||
| 최종 수정일 | 2026-08-06 |
|
||||
| 대상 제품 | AI 캐릭터 관리자 웹 수정 요청 최적화 |
|
||||
| 작성자·결정권자 | 작성자: Codex / 결정권자: 사용자 |
|
||||
| 관련 API Contract | [api-contract.md](./api-contract.md) |
|
||||
| 관련 구현 계획 | [plan-task.md](./plan-task.md) |
|
||||
| 관련 review | 없음 — 구현 완료 후 `reviews/`에 추가 |
|
||||
|
||||
### 요구사항 상태
|
||||
|
||||
| 상태 | 의미 | 구현 처리 |
|
||||
|---|---|---|
|
||||
| 확정 | 제품·기술 결정이 완료되어 구현 기준으로 사용 | `plan-task.md`의 Task와 완료 증거로 추적 |
|
||||
| 미결 | 제품·UX·운영 결정이 더 필요함 | 권고안과 결정 주체·시점을 기록하고 임의 구현 금지 |
|
||||
| 외부 의존 | 프론트엔드 밖의 계약·권한·환경 제공이 필요함 | 담당 주체·영향·재개 조건을 기록하고 추정 구현 금지 |
|
||||
| 권고 | 미결 항목에 대한 현재 추천안 | 확정 전 계약이나 수용 기준으로 사용하지 않음 |
|
||||
| 제외 | 현재 범위에서 구현하지 않기로 결정 | 제외 이유와 후속 조건을 Decision Log에 기록 |
|
||||
|
||||
### 문서 우선순위와 갱신 순서
|
||||
|
||||
1. 사용자·제품 결정은 이 PRD에 기록한다.
|
||||
2. request payload 규칙은 [api-contract.md](./api-contract.md)에 기록한다.
|
||||
3. 구현 범위·순서·완료 증거는 [plan-task.md](./plan-task.md)에 기록한다.
|
||||
4. 요구사항 변경 시 Decision Log → 요구사항·수용 기준 → API Contract → 구현 계획 순서로 갱신한다.
|
||||
5. 기존 결정과 검증 기록은 삭제하거나 덮어쓰지 않고 정정 기록을 누적한다.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
관리자가 AI 캐릭터, 오디오 콘텐츠, 커뮤니티 게시글, 시리즈, FanTalk 답글을 수정하면 프론트엔드는 현재 값 전체가 아니라 최종적으로 변경된 필드만 기존 수정 endpoint에 전송한다. 기존 화면, HTTP method, multipart 구조, 인증·응답·오류 계약은 유지하고 request payload 생성과 무변경 저장 동작만 바꾼다.
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
현재 관리자는 다음 문제를 겪는다.
|
||||
|
||||
- 한 필드만 수정해도 화면이 보유한 다른 수정 가능 값이 함께 전송된다.
|
||||
- 사용자가 건드리지 않은 값까지 서버에 다시 기록될 수 있어 동시 변경을 덮어쓸 위험과 payload 확인 비용이 커진다.
|
||||
- 일부 화면은 이미 특정 필드만 생략하지만, 도메인마다 규칙이 달라 변경 필드 전송 여부를 일관되게 검증하기 어렵다.
|
||||
|
||||
문제를 해결했다는 판단은 각 수정 화면에서 한 필드만 바꿨을 때 request JSON에 그 필드만 존재하고, 변경이 없을 때 저장 버튼이 비활성화되며 mutation 요청이 0건인 것으로 한다.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
### 3.1 제품 목표
|
||||
|
||||
- 다섯 수정 기능이 실제 변경 필드와 새로 선택한 파일만 전송한다.
|
||||
- 값 삭제는 기존 nullable 계약에 맞는 `null`을 전송하고, 미변경은 key 생략으로 구분한다.
|
||||
- 변경 후 원래 값으로 되돌리면 변경 없음으로 판정해 불필요한 mutation을 만들지 않는다.
|
||||
|
||||
### 3.2 UX 목표
|
||||
|
||||
- 변경 사항이 없으면 저장 버튼을 비활성화해 요청이 발생하지 않음을 사전에 알린다.
|
||||
- 유효한 변경이 있으면 기존 저장 중·성공·실패·중복 제출 방지 동작을 유지한다.
|
||||
- 기존 반응형 capability, keyboard 동작, label·오류 연결과 focus 정책을 회귀시키지 않는다.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- 기존 `PUT` endpoint를 `PATCH`로 바꾸지 않는다.
|
||||
- backend DTO, response, 오류 status/key 또는 저장 로직을 변경하지 않는다.
|
||||
- 생성, 비활성화, 게시글 고정 전환, 시리즈 순서 변경, FanTalk 원글 삭제 payload는 변경하지 않는다.
|
||||
- 수정 화면에 없는 필드를 새로 노출하지 않는다.
|
||||
- 새 dependency, 범용 form library 또는 전역 diff framework를 도입하지 않는다.
|
||||
|
||||
Non-Goal 변경 시 Decision Log와 `plan-task.md` 범위를 먼저 갱신한다.
|
||||
|
||||
## 5. Target Users and Permissions
|
||||
|
||||
### 5.1 사용자
|
||||
|
||||
| 사용자 | 목표 | 주요 작업 | 사용 환경 |
|
||||
|---|---|---|---|
|
||||
| 인증된 관리자 | 선택한 리소스의 의도한 값만 안전하게 수정 | 캐릭터·오디오 콘텐츠·커뮤니티 게시글·시리즈·FanTalk 답글 수정 | 기존 기능별 지원 viewport |
|
||||
|
||||
### 5.2 권한
|
||||
|
||||
- 인증 주체: 기존 관리자 bearer session
|
||||
- 허용 역할: 기존 각 수정 endpoint의 관리자 권한
|
||||
- 거부 조건: 기존 401·403 및 공통 인증 만료 정책 유지
|
||||
- 리소스 소유권: path의 `characterId`와 각 resource ID 기준 서버 검증 유지
|
||||
- read-only 조건: 비활성 캐릭터와 모바일 mutation 제한 등 기존 기능별 capability 유지
|
||||
|
||||
## 6. 핵심 사용자 흐름
|
||||
|
||||
1. 관리자가 기존 상세·목록에서 수정 화면 또는 Sheet를 연다.
|
||||
2. 프론트엔드는 조회 응답을 수정 기준값으로 보존한다.
|
||||
3. 관리자가 하나 이상의 필드 또는 교체 파일을 변경한다.
|
||||
4. 프론트엔드는 기존 직렬화 규칙을 적용한 현재 값과 기준값을 필드별로 비교해 변경 필드만 request에 넣는다.
|
||||
5. 저장 성공·실패와 다음 화면 이동은 기존 기능 동작을 유지한다.
|
||||
|
||||
변경 필드가 없으면 저장 버튼은 비활성화되고 mutation 요청은 발생하지 않는다. 파일만 변경한 multipart 수정은 파일 파트와 빈 JSON object인 `request: {}`를 전송한다.
|
||||
|
||||
## 7. 정보 구조와 라우팅
|
||||
|
||||
```text
|
||||
/ai-characters/:characterId/edit
|
||||
/ai-characters/:characterId/audio-contents/:contentId/edit
|
||||
/ai-characters/:characterId/community-posts # 목록 내 게시글 Sheet
|
||||
/ai-characters/:characterId/series/:seriesId/edit
|
||||
/ai-characters/:characterId/fan-talks # 목록 내 답글 Sheet
|
||||
```
|
||||
|
||||
- route, path parameter, query parameter와 성공 후 이동 위치는 변경하지 않는다.
|
||||
- 커뮤니티 게시글과 FanTalk 답글은 별도 수정 route 없이 기존 Sheet에서 수정한다.
|
||||
- 직접 링크·새로고침·존재하지 않음·비활성 리소스 처리는 기존 정책을 유지한다.
|
||||
|
||||
## 8. 기능 요구사항
|
||||
|
||||
### 8.1 공통 변경 감지와 전송
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `DIFF-001` | 확정 | 수정 request JSON은 최종 변경 필드만 포함한다. | 한 필드 변경 시 해당 key만 존재하고 미변경 key는 0개다. | API Contract §2, `P1-T1`~`P1-T5` |
|
||||
| `DIFF-002` | 확정 | 변경 여부는 각 기능의 기존 trim·빈값→`null`·list 직렬화 규칙을 현재 값과 기준값에 동일하게 적용한 뒤 판정한다. | 공백 정리 후 원래 값과 같거나 변경 후 되돌린 필드는 생략된다. | API Contract §1.2, `P1-T1`~`P1-T5` |
|
||||
| `DIFF-003` | 확정 | 필드 삭제는 계약상 삭제 의미인 `null`을 보내고 미변경은 key를 생략한다. | nullable 값을 비우면 `{field:null}`, 건드리지 않으면 field key가 없다. | API Contract §1.3, `P1-T1`, `P1-T4` |
|
||||
| `DIFF-004` | 확정 | 변경 필드와 교체 파일이 모두 없으면 저장 버튼을 비활성화하고 mutation을 호출하지 않는다. | 최초 진입과 변경 후 원복 상태에서 저장 버튼 disabled, `PUT` 0건이다. | API Contract §1.4, `P1-T1`~`P1-T5` |
|
||||
| `DIFF-005` | 확정 | 파일만 바뀐 multipart 수정은 교체 파일과 빈 `request` JSON object를 보낸다. | 파일 파트 1개, request `{}`, 다른 JSON key 0개다. | API Contract §1.5, `P1-T1`~`P1-T4` |
|
||||
|
||||
### 8.2 기능별 수정 payload
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `CHAR-001` | 확정 | 캐릭터 수정은 변경된 프로필·선택·반복 필드와 새 profile image만 전송한다. | `name`만 변경하면 request는 `{name}`이고 `region`, `isActive`와 미변경 필드는 없다. | API Contract §2.1, `P1-T1` |
|
||||
| `AUDIO-001` | 확정 | 오디오 콘텐츠 수정은 변경된 `title`, `detail`, `tags`, `price`와 새 cover image만 전송한다. | `detail`만 변경하면 request는 `{detail}`이며 화면에 없는 boolean 필드는 없다. | API Contract §2.2, `P1-T2` |
|
||||
| `COMM-001` | 확정 | 커뮤니티 게시글 수정 저장은 변경된 `content`, `isCommentAvailable`, `isAdult`와 새 post image만 전송한다. | `content`만 변경하면 request는 `{content}`이고 `isFixed`는 없다. | API Contract §2.3, `P1-T3` |
|
||||
| `SERIES-001` | 확정 | 시리즈 수정은 변경된 기본·enum·nullable 필드와 새 image만 전송한다. | `title`만 변경하면 request는 `{title}`이고 이미 부분 적용된 `state` 포함 다른 미변경 필드는 없다. | API Contract §2.4, `P1-T4` |
|
||||
| `FANTALK-001` | 확정 | FanTalk 답글 수정은 기존 답글과 다른 `content`만 전송한다. | 변경 시 `{content}` 1개, 미변경 시 수정 버튼 disabled와 `PUT` 0건이다. | API Contract §2.5, `P1-T5` |
|
||||
|
||||
### 8.3 공통 파일·데이터 정책
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `FILE-001` | 확정 | 새 파일을 선택하지 않으면 기존 이미지·커버를 유지하고 파일 파트를 생략한다. | 미선택 수정 request에서 관련 파일 part가 없다. | API Contract §1.5, `P1-T1`~`P1-T4` |
|
||||
| `DATA-001` | 확정 | 숫자, boolean, enum, 배열과 객체 배열은 타입을 유지한 채 비교·전송한다. | `false`, `0`, 빈 값 삭제용 `null`이 누락되지 않고 배열 변경은 전체 해당 필드 값으로 전송된다. | API Contract §1.3, `P1-T1`~`P1-T4` |
|
||||
| `DATA-002` | 확정 | 원작 미선택·선택 해제 시 `originalWorkId` key를 생략하는 기존 계약을 유지한다. | 기존 `serializeCharacterRequest` 계약 test가 유지되고 원작 연결 해제 동작은 새로 만들지 않는다. | API Contract §2.1, `P1-T1` |
|
||||
|
||||
## 9. 반응형 기능 범위
|
||||
|
||||
| 기능 | Desktop | Tablet | Mobile | 비고 |
|
||||
|---|---:|---:|---:|---|
|
||||
| 캐릭터·오디오 콘텐츠·시리즈 수정 | 허용 | 허용 | 기존 조회 전용 | 기존 직접 route 차단 유지 |
|
||||
| 커뮤니티 게시글 수정 | 허용 | 허용 | 기존 capability 유지 | 기존 Sheet 정책 유지 |
|
||||
| FanTalk 답글 수정 | 허용 | 허용 | 허용 | 기존 Sheet 정책 유지 |
|
||||
|
||||
- 이번 변경으로 viewport breakpoint나 action 노출 정책을 바꾸지 않는다.
|
||||
- 기존 최소 viewport, 200% zoom, touch target과 virtual keyboard 검증을 회귀 Gate로 사용한다.
|
||||
|
||||
## 10. UI/UX Expectations
|
||||
|
||||
### 10.1 디자인과 component 원칙
|
||||
|
||||
- 기존 component와 design token을 그대로 사용한다.
|
||||
- 수정용 payload는 각 도메인의 기존 form serializer 또는 component에서 계산한다.
|
||||
- 새 dependency나 범용 diff abstraction을 만들지 않고 기능별 DTO 의미를 코드 가까이에 둔다.
|
||||
|
||||
### 10.2 화면 상태
|
||||
|
||||
- 최초 진입과 모든 변경을 원복한 상태에서는 저장 버튼을 disabled로 표시한다.
|
||||
- 파일 준비·저장 pending·오류·성공 상태와 중복 제출 방지는 기존 동작을 유지한다.
|
||||
- payload 생성 결과와 저장 버튼 활성화 조건은 같은 `hasChanges` 판정을 사용한다.
|
||||
|
||||
### 10.3 접근성
|
||||
|
||||
- disabled 상태는 native `disabled` 속성으로 노출한다.
|
||||
- 기존 visible label, 연결 오류, keyboard focus 순서와 성공·오류 live region을 유지한다.
|
||||
- 지원 viewport와 200% zoom에서 핵심 control이 가려지지 않고 axe critical·serious 위반 0건을 유지한다.
|
||||
|
||||
## 11. API 계약
|
||||
|
||||
### 11.1 공통 규칙
|
||||
|
||||
- base URL·인증 header·locale·성공 envelope·오류 envelope는 기존 [정식 OpenAPI](../20260725_AI캐릭터관리자웹/api-contract.openapi.json)를 유지한다.
|
||||
- HTTP method는 기존 `PUT`을 유지한다.
|
||||
- 캐릭터·오디오 콘텐츠·커뮤니티 게시글·시리즈는 `multipart/form-data`의 `request` JSON part를 유지한다.
|
||||
- FanTalk 답글은 `application/json`을 유지한다.
|
||||
- request field의 생략은 미변경, 명시적 `null`은 해당 DTO가 정의한 값 삭제를 의미한다.
|
||||
|
||||
### 11.2 Endpoint 추적
|
||||
|
||||
| 요구사항 | Method | Path | 계약 상태 | API Contract | 소유 Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `CHAR-001` | PUT | `/api/v2/admin/ai-characters/{characterId}` | 제공됨 | §2.1 | `P1-T1` |
|
||||
| `AUDIO-001` | PUT | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` | 제공됨 | §2.2 | `P1-T2` |
|
||||
| `COMM-001` | PUT | `/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}` | 제공됨 | §2.3 | `P1-T3` |
|
||||
| `SERIES-001` | PUT | `/api/v2/admin/ai-characters/{characterId}/series/{seriesId}` | 제공됨 | §2.4 | `P1-T4` |
|
||||
| `FANTALK-001` | PUT | `/api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}` | 제공됨 | §2.5 | `P1-T5` |
|
||||
|
||||
### 11.3 외부 제공 대기 계약
|
||||
|
||||
없음. 정식 OpenAPI에서 대상 update field가 모두 optional이고 현재 프론트엔드 schema도 부분 request를 허용한다.
|
||||
|
||||
## 12. 보안과 데이터 취급
|
||||
|
||||
- 인증 저장·만료 lifecycle과 401·403 처리는 기존 공통 API client 정책을 유지한다.
|
||||
- token, 파일 본문, signed URL과 관리자 입력 전문을 새 log·분석 이벤트에 기록하지 않는다.
|
||||
- 파일 확장자·MIME·크기·crop 검증과 resource ownership 검증을 변경하지 않는다.
|
||||
- 부분 request를 이유로 client가 권한 또는 서버 validation을 대신하지 않는다.
|
||||
- 감사 로그 추가는 이번 범위에 포함하지 않는다.
|
||||
|
||||
## 13. 성능과 품질 요구사항
|
||||
|
||||
- payload 크기는 같거나 작아야 하며 미변경 저장 network 요청은 0건이어야 한다.
|
||||
- 변경 판정은 현재 form field 수에 대한 동기 비교로 처리하고 새 network 조회나 dependency를 추가하지 않는다.
|
||||
- mutation single-flight, 파일 준비 취소·오류·재시도와 기존 browser 지원 범위를 유지한다.
|
||||
- test stack은 Vitest·Testing Library·Playwright mock E2E, TypeScript typecheck, ESLint, Vite production build를 사용한다.
|
||||
- backend 구현 전 mock fallback은 필요하지 않다. 기존 server/mock mode 경계를 유지하고 production 자동 mock fallback을 추가하지 않는다.
|
||||
|
||||
## 14. 성공 기준
|
||||
|
||||
### 14.1 기능 수용 기준
|
||||
|
||||
- [x] 다섯 수정 기능에서 한 필드 변경 request는 해당 key만 포함한다. (`DIFF-001`, `P1-GATE`)
|
||||
- [x] nullable 필드 삭제와 미변경 생략이 구분된다. (`DIFF-003`, `P1-T1`, `P1-T4`)
|
||||
- [x] 파일 미변경은 파일 part 생략, 파일만 변경은 파일 part와 `request: {}`를 전송한다. (`DIFF-005`, `FILE-001`)
|
||||
- [x] 최초 진입과 변경 후 원복 상태에서 저장 버튼이 disabled이고 mutation 요청이 없다. (`DIFF-004`)
|
||||
- [x] 기존 생성·비활성화·고정·순서·삭제 흐름이 회귀하지 않는다. (`P1-GATE`)
|
||||
|
||||
### 14.2 UI/UX 수용 기준
|
||||
|
||||
- [x] 기존 loading·error·success·pending 상태가 유지된다.
|
||||
- [x] keyboard-only로 기존 수정 흐름을 완료할 수 있다.
|
||||
- [x] 기존 지원 viewport와 200% zoom에서 핵심 control이 가려지지 않는다.
|
||||
- [x] axe critical·serious 위반이 0건이다.
|
||||
|
||||
### 14.3 추적성 완료 기준
|
||||
|
||||
- [x] 모든 확정 요구사항이 API Contract와 하나 이상의 Task·Goal 완료 증거로 연결된다.
|
||||
- [x] 각 구현 Task에 RED·GREEN·REFACTOR 결과가 Progress에 누적된다.
|
||||
- [x] Phase Gate의 자동·수동 payload 검증 결과가 기록된다.
|
||||
- [x] 미결·외부 의존·제외 상태의 새 항목이 생기면 담당·영향·재개 조건 또는 Decision Log가 추가된다.
|
||||
|
||||
## 15. Open Questions
|
||||
|
||||
열린 질문 없음.
|
||||
|
||||
인터뷰 결과:
|
||||
|
||||
- 최종 모호성: `0.07`
|
||||
- 명확성: Goal `1.00`, Scope `0.85`, Constraints `0.90`, Success `0.90`, Context `1.00`
|
||||
- 확정 결정: 변경 필드만 전송하며, 변경이 없으면 저장 버튼 비활성화와 mutation 요청 0건
|
||||
|
||||
## 16. 요구사항 추적표
|
||||
|
||||
| 요구사항 범위 | API Contract | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|
||||
|---|---|---:|---|---|---|
|
||||
| `DIFF-001~005`, `FILE-001`, `DATA-001` | §1 | 1 | `P1-T1`~`P1-T5`, `P1-GATE` | 기능별 form·contract test | DevTools Network payload·무요청 확인 |
|
||||
| `CHAR-001`, `DATA-002` | §2.1 | 1 | `P1-T1` | Character edit/API test | 캐릭터 단일 필드·파일 수정 |
|
||||
| `AUDIO-001` | §2.2 | 1 | `P1-T2` | Audio form/API test | 오디오 단일 필드·cover 수정 |
|
||||
| `COMM-001` | §2.3 | 1 | `P1-T3` | Community Sheet/API test | 게시글 단일 필드·image 수정 |
|
||||
| `SERIES-001` | §2.4 | 1 | `P1-T4` | Series form/API test | 시리즈 단일 필드·image 수정 |
|
||||
| `FANTALK-001` | §2.5 | 1 | `P1-T5` | FanTalk reply/API test | 답글 변경·무변경 수정 |
|
||||
|
||||
## 17. Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-06 | `DEC-001` | 확정 | 수정 request는 HTTP method를 바꾸지 않고 최종 변경 필드만 포함한다. | 사용자 요청과 정식 OpenAPI의 optional update field | `DIFF-001~003`, API Contract §1, `P1-T1`~`P1-T5` |
|
||||
| 2026-08-06 | `DEC-002` | 확정 | 변경 필드와 교체 파일이 없으면 저장 버튼을 비활성화하고 요청하지 않는다. | 사용자 인터뷰 A안 선택 | `DIFF-004`, `P1-T1`~`P1-T5` |
|
||||
| 2026-08-06 | `DEC-003` | 확정 | 파일만 변경한 multipart update는 required `request` part를 빈 object로 전송한다. | 기존 multipart 계약에서 `request` part가 required이고 update object에는 required field가 없음 | `DIFF-005`, API Contract §1.5, `P1-T1`~`P1-T4` |
|
||||
| 2026-08-06 | `DEC-004` | 확정 | 생성·비활성화·고정·순서·삭제 전용 mutation은 범위에서 제외한다. | 해당 action은 이미 전용 최소 payload 또는 별도 method를 사용함 | Non-Goals, `P1-GATE` |
|
||||
| 2026-08-06 | `DEC-005` | 확정 | 새 공통 diff abstraction이나 dependency 없이 각 도메인의 기존 serializer와 원본 DTO 비교를 사용한다. | DTO별 `null`, 배열, 파일과 수정 가능 필드 의미가 다름 | §10.1, `P1-T1`~`P1-T5` |
|
||||
|
||||
## 18. 변경 관리
|
||||
|
||||
- [x] Decision Log에 변경 이유와 날짜를 기록한다.
|
||||
- [x] 관련 요구사항 상태·본문·수용 기준을 갱신한다.
|
||||
- [x] API Contract의 request 규칙과 예시를 갱신한다.
|
||||
- [x] `plan-task.md`의 범위·Files·Interfaces·체크박스·완료 증거를 코드 변경 전에 갱신한다.
|
||||
- [x] 기존 Progress·review·검증 기록을 삭제하거나 덮어쓰지 않는다.
|
||||
@@ -0,0 +1,239 @@
|
||||
# Phase 1 변경 필드 요청 코드 리뷰
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 1 / `P1-T1`~`P1-T5`, `P1-GATE` |
|
||||
| 기준 commit 또는 working tree | 미커밋 working tree (`git status --short` 기준 기능 코드 14개 수정, 기능 문서 디렉터리 신규) |
|
||||
| 리뷰 일자 | 2026-08-06 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | [prd.md](../prd.md), [api-contract.md](../api-contract.md), [plan-task.md](../plan-task.md) |
|
||||
| 리뷰 상태 | 수정 검증 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
### 목적
|
||||
|
||||
- 다섯 수정 기능이 최종 변경 field와 교체 file만 전송하는지 확인한다.
|
||||
- 무변경 차단, 기존 직렬화, 전용 mutation 제외와 계획의 완료 기록이 실제 코드·test와 일치하는지 확인한다.
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- 코드: `src/features/characters`, `src/features/audio-contents`, `src/features/community-posts`, `src/features/series`, `src/features/fan-talks`의 수정 form·serializer·API 경계
|
||||
- 테스트: 위 다섯 feature의 Vitest 전체와 관련 mock Chromium E2E 계획
|
||||
- 문서: `DIFF-001~005`, `CHAR-001`, `AUDIO-001`, `COMM-001`, `SERIES-001`, `FANTALK-001`, `FILE-001`, `DATA-001~002`, Phase 1 Task·Gate
|
||||
- 수동 검증: payload와 disabled 상태는 component test와 mock E2E로 대체하며 별도 실서버 Network 검증은 수행하지 않는다.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 생성, 비활성화, 커뮤니티 고정, 시리즈 순서 변경, FanTalk 원글 삭제의 신규 동작
|
||||
- backend DTO·저장 로직, 새 field·file 삭제·원작 연결 해제
|
||||
- 관련 없는 화면·성능·스타일 리팩터링
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
### 심각도
|
||||
|
||||
| 심각도 | 기준 |
|
||||
|---|---|
|
||||
| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 |
|
||||
| High | 확정 요구사항·API Contract 위반 또는 주요 회귀 |
|
||||
| Medium | 제한된 조건에서 발생하는 기능·접근성·복구 문제 |
|
||||
| Low | 유지보수성, 문서 정합성 또는 비핵심 UX 문제 |
|
||||
|
||||
### 상태
|
||||
|
||||
| 상태 | 의미 | 후속 처리 |
|
||||
|---|---|---|
|
||||
| 후보 | 근거를 발견했지만 아직 재현·판정하지 않음 | 검증 후 상태 변경 |
|
||||
| 확정 | 코드·test·문서 근거로 문제가 확인됨 | `plan-task.md` 회귀 수정 Task 후보 |
|
||||
| 오탐 | 요구사항이나 실행 결과상 문제가 아님 | 근거를 남기고 종료 |
|
||||
| 보류 | 외부 계약·환경·제품 결정이 필요함 | 담당 주체와 재개 조건 기록 |
|
||||
| 수정 완료 | 수정과 관련 검증이 완료됨 | 실행 명령과 결과 연결 |
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
### 문서와 코드
|
||||
|
||||
- 요구사항: `DIFF-001~005`, 기능별 `CHAR-001`~`FANTALK-001`, `FILE-001`, `DATA-001~002`
|
||||
- API Contract: §1 변경 판정·생략·file, §2.1~§2.5 기능별 payload, §4 회귀 보호
|
||||
- 계획: `P1-T1`~`P1-T5`, `P1-GATE`
|
||||
- 코드: 다섯 feature의 form·serializer·API helper와 `CommunityPostSheet.tsx:32-42`
|
||||
- 테스트: 다섯 feature test 디렉터리 전체, `community-sheet.test.tsx`
|
||||
|
||||
### 실행 환경
|
||||
|
||||
```text
|
||||
OS: Darwin 25.0.0 x86_64
|
||||
Node: v24.12.0
|
||||
npm: 11.7.0
|
||||
Browser/viewport: Playwright mock Chromium, 대상 spec의 desktop·tablet·mobile viewport
|
||||
환경 변수: Vitest 기본 test mode, 민감정보 기록 없음
|
||||
```
|
||||
|
||||
### 실행한 검증
|
||||
|
||||
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| `npm run test:run -- src/features/characters src/features/audio-contents src/features/community-posts src/features/series src/features/fan-talks` | 성공 | exit 0, 35 files / 227 tests 통과. 아래 경계 test가 없어 결함을 검출하지 못함 |
|
||||
| 다섯 기능 form·serializer·test 정적 대조 | 실패 | Community만 outgoing raw `content`와 trim 비교가 불일치 |
|
||||
| 실서버 DevTools Network | 불가 | test 환경 리뷰이며 인증된 실서버를 사용하지 않음. component multipart assertion으로 대체 예정 |
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P1-001` | High | 수정 완료 | Community content의 비교와 전송 직렬화가 달라 공백 변경이 누락됨 | `P1-T3` → `P1-R1` | `P1-R1` 완료 |
|
||||
| `REV-P1-002` | Low | 수정 완료 | 완료된 Phase 1과 PRD 성공 기준 상태가 불일치함 | `P1-GATE` → `P1-R2` | `P1-R2` 완료 |
|
||||
|
||||
## 6. 발견 사항 상세
|
||||
|
||||
### REV-P1-001 — Community content의 비교와 전송 직렬화가 달라 공백 변경이 누락됨
|
||||
|
||||
- **심각도:** High
|
||||
- **상태:** 수정 완료
|
||||
- **관련 요구사항:** `DIFF-001`, `DIFF-002`, `DIFF-004`, `COMM-001`
|
||||
- **관련 계약:** API Contract §1.2, §1.4, §2.3
|
||||
- **소유 Task:** `P1-T3` → `P1-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
Community update는 기존과 같이 form의 `content` 원문을 request에 넣지만, 변경 여부만 현재 값과 기준값을 각각 `trim()`해 비교한다. 따라서 기존 내용 앞뒤에 공백만 추가하거나 제거하면 실제 전송 값은 달라졌어도 request에서 `content`가 생략되고 저장 button이 disabled된다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 코드: `src/features/community-posts/components/CommunityPostSheet.tsx:33-35`는 trim 비교 후 raw `content`를 할당한다.
|
||||
- 기존 코드: 구현 전 `savePost`는 `{ content, ... }`로 원문을 전송했으며 update용 schema도 trim 변환을 하지 않는다.
|
||||
- 테스트: `src/features/community-posts/tests/community-sheet.test.tsx`에는 무변경·일반 content 변경 test는 있지만 공백 경계 test가 없다.
|
||||
- 문서: PRD `DIFF-002`와 API Contract §1.2는 현재 값과 기준값에 같은 기존 직렬화 규칙을 적용하도록 요구한다.
|
||||
|
||||
**재현 또는 검증 절차**
|
||||
|
||||
1. `post.content`가 `오늘의 상담 기록입니다.`인 Community Sheet를 연다.
|
||||
2. textarea 값을 ` 오늘의 상담 기록입니다. `로 바꾼다.
|
||||
3. 실제 결과: 두 값을 trim해 같다고 판정하므로 `수정 저장`이 disabled이고 request는 0건이다.
|
||||
4. 요구 결과: 기존 update의 raw content 직렬화를 유지해 저장이 enabled되고 request는 `{ "content": " 오늘의 상담 기록입니다. " }`다.
|
||||
|
||||
**영향**
|
||||
|
||||
관리자가 게시글 내용의 앞뒤 공백을 의도적으로 변경해도 저장할 수 없으며, payload와 disabled가 동일한 직렬화 결과를 사용한다는 계약을 위반한다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
별도 serializer를 만들지 않고 Community의 content 비교에서만 `trim()`을 제거한다. raw content 공백 변경이 저장되고 content 1개만 전송되는 component 회귀 test를 추가한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-08-06 — 확정. 기존 update 코드·schema가 raw content를 전송하고 현재 비교만 trim한다는 코드 근거로 판정했다.
|
||||
- 2026-08-06 — 수정 완료. raw 비교 1줄과 공백 변경 회귀 test를 추가하고 focused·Phase Gate를 통과했다.
|
||||
|
||||
### REV-P1-002 — 완료된 Phase 1과 PRD 성공 기준 상태가 불일치함
|
||||
|
||||
- **심각도:** Low
|
||||
- **상태:** 수정 완료
|
||||
- **관련 요구사항:** PRD §14 성공 기준
|
||||
- **관련 계약:** API Contract §4 검증 matrix
|
||||
- **소유 Task:** `P1-GATE` → `P1-R2`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
`plan-task.md`는 `P1-T1`~`P1-GATE`와 자동 검증을 완료로 기록했지만 `prd.md` §14의 기능·UI/UX·추적성 성공 기준은 모두 미완료 체크박스로 남아 있다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 문서: `plan-task.md`의 완료 조건·Progress는 완료이고 `prd.md:217-235`는 미완료다.
|
||||
- 테스트: Phase Gate 35 files / 228 tests, mock Chromium 39 tests, typecheck·lint·build가 통과했다.
|
||||
- 규칙: 문서 유지보수와 리뷰 규칙은 완료 체크박스와 실제 증거의 일치를 요구한다.
|
||||
|
||||
**재현 또는 검증 절차**
|
||||
|
||||
1. `rg -n "^- \\[ \\]" docs/20260806_수정요청변경필드만전송/prd.md`를 실행한다.
|
||||
2. 실제 결과: §14 성공 기준 13개가 미완료로 출력된다.
|
||||
3. `plan-task.md`의 완료 조건과 `P1-GATE`, `P1-R1` Progress를 확인한다.
|
||||
4. 요구 결과: 실행 증거가 있는 §14 항목은 완료이고, 요구사항 변경 시에만 쓰는 §18 체크리스트는 미완료 상태를 유지한다.
|
||||
|
||||
**영향**
|
||||
|
||||
구현 완료 여부를 PRD에서 판단할 수 없고 plan·review와 상태가 충돌한다. 실행 동작에는 영향이 없다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
새 검증이나 요구사항 변경 없이 기존 Gate 증거와 직접 연결되는 PRD §14 체크박스만 완료 표시한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-08-06 — 확정. 같은 working tree의 PRD와 plan 완료 상태가 직접 불일치한다.
|
||||
- 2026-08-06 — 수정 완료. 기존 Gate 증거와 연결되는 PRD §14 항목 13개를 완료 표시하고 §18 체크리스트는 보존했다.
|
||||
|
||||
## 7. 확정 항목의 plan·goal 전환
|
||||
|
||||
### 신규 회귀 수정 Task 초안
|
||||
|
||||
`plan-task.md`에 `Task R1.1 커뮤니티 content 직렬화 비교 일치`, `Task R1.2 PRD 성공 기준 상태 동기화`와 후속 goal을 반영했다.
|
||||
|
||||
- 실패 재현: raw content 공백 변경 시 저장 enabled와 `{content}` 전송 assertion
|
||||
- 최소 수정: content 비교의 `trim()` 제거
|
||||
- 검증: Community focused·전체와 Phase 1 Gate
|
||||
- 범위 밖: content trim 정책 신설과 전용 mutation 변경
|
||||
|
||||
`P1-R2`는 기존 Gate 증거와 PRD §14를 대조해 검증된 성공 기준만 완료 표시한다. 요구사항·API Contract 의미와 §18 요구사항 변경 체크리스트는 바꾸지 않는다.
|
||||
|
||||
### create_goal objective 초안
|
||||
|
||||
```text
|
||||
[P1-R1]의 확정 review 항목 REV-P1-001을 수정하고 회귀를 방지한다.
|
||||
plan-task.md에 추가된 회귀 수정 Task만 수행한다.
|
||||
실패 재현, 최소 수정, focused test, Phase Gate와 검증 기록이 모두 끝나기 전에는 complete로 표시하지 않는다.
|
||||
관련 없는 리팩터링과 계약 추정은 범위 밖이다.
|
||||
```
|
||||
|
||||
```text
|
||||
[P1-R2]의 확정 review 항목 REV-P1-002를 수정해 PRD 성공 기준과 Phase 완료 증거를 동기화한다.
|
||||
검증 증거가 없는 항목과 요구사항 변경 체크리스트는 완료 표시하지 않는다.
|
||||
코드와 API Contract 의미 변경은 범위 밖이다.
|
||||
```
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | 다섯 feature 코드·test와 세 기준 문서를 대조함 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P1-001`, `REV-P1-002` 판정·수정 완료 |
|
||||
| 확정 항목 plan 반영 | 충족 | `P1-R1`, `P1-R2` 추가·완료 |
|
||||
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 항목 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 35 files / 228 tests, mock Chromium 39 tests와 문서 정적 대조 결과 기록 |
|
||||
|
||||
**최종 결론:** 수정 검증 완료
|
||||
|
||||
**남은 항목:** 없음
|
||||
|
||||
## 9. 수정 후 검증 기록
|
||||
|
||||
기존 기록을 삭제하거나 덮어쓰지 않고 차수별로 누적한다.
|
||||
|
||||
### 1차 수정 검증 — 2026-08-06
|
||||
|
||||
- 무엇을: `REV-P1-001`의 Community raw content 비교 불일치를 수정하고 회귀 test를 추가했다.
|
||||
- 왜: Community content의 비교와 전송 직렬화 불일치
|
||||
- 어떻게:
|
||||
- RED `npm run test:run -- src/features/community-posts/tests/community-sheet.test.tsx src/features/community-posts/tests/community-contract.test.ts` — exit 1, 1 failed / 26 passed. 공백 변경 후 저장 button disabled assertion 실패를 확인했다.
|
||||
- GREEN 같은 focused 명령 — exit 0, 2 files / 27 tests 통과.
|
||||
- `npm run test:run -- src/features/community-posts` — exit 0, 7 files / 52 tests 통과.
|
||||
- `npm run test:run -- src/features/characters src/features/audio-contents src/features/community-posts src/features/series src/features/fan-talks` — exit 0, 35 files / 228 tests 통과.
|
||||
- `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts tests/e2e/audio-content.spec.ts tests/e2e/community.spec.ts tests/e2e/series.spec.ts tests/e2e/fan-talk.spec.ts --project=chromium` — sandbox에서는 local port bind `EPERM`; 승인된 동일 명령은 exit 0, 39 tests 통과.
|
||||
- `npm run typecheck`; `npm run lint`; `npm run build` — 모두 exit 0.
|
||||
- `git diff --check` — 출력 없음, exit 0.
|
||||
- 실서버 Network는 인증 환경이 없어 불가했고 component multipart assertion과 mock E2E로 대체했다.
|
||||
- 남은 항목: 없음
|
||||
|
||||
### 2차 수정 검증 — 2026-08-06
|
||||
|
||||
- 무엇을: `REV-P1-002`의 PRD §14 성공 기준 13개를 완료 증거와 동기화했다.
|
||||
- 왜: PRD §14와 완료된 Phase 1 상태 불일치
|
||||
- 어떻게:
|
||||
- `rg -n "^- \\[x\\]" docs/20260806_수정요청변경필드만전송/prd.md` — §14 완료 항목 13개 확인, exit 0.
|
||||
- `rg -n "^- \\[ \\]" docs/20260806_수정요청변경필드만전송/prd.md` — §18 요구사항 변경 체크리스트 5개만 유지, exit 0.
|
||||
- 대상 PRD·API Contract·plan·review `test -f` — 누락 없음, exit 0.
|
||||
- `git diff --check` — 출력 없음, exit 0.
|
||||
- 남은 항목: 없음
|
||||
96
docs/20260806_커뮤니티댓글답글/api-contract.md
Normal file
96
docs/20260806_커뮤니티댓글답글/api-contract.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 커뮤니티 댓글 직접 답글 API Contract
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 기존 계약 재사용 확정 |
|
||||
| 작성일 | 2026-08-06 |
|
||||
| 원본 계약 | [프로젝트 OpenAPI](../20260725_AI캐릭터관리자웹/api-contract.openapi.json) |
|
||||
| 관련 PRD | [prd.md](./prd.md) |
|
||||
| 관련 계획 | [plan-task.md](./plan-task.md) |
|
||||
|
||||
## 계약 변경 여부
|
||||
|
||||
백엔드 API 변경은 없다. 이 문서는 이번 기능이 소비하는 기존 OpenAPI 범위와
|
||||
프론트엔드 전송값만 좁게 기록한다. 충돌하면 원본 OpenAPI가 우선한다.
|
||||
|
||||
## 댓글 구조 불변식
|
||||
|
||||
- `parentId=null` 또는 생략: 원댓글
|
||||
- `parentId=원댓글 ID`: 해당 원댓글의 직접 답글
|
||||
- 하나의 원댓글 ID를 여러 POST의 `parentId`로 사용할 수 있으며 각 응답은 별도 직접 답글 row가 된다.
|
||||
- `parentId=답글 ID`인 3단계 작성은 허용하지 않는다.
|
||||
- parent는 같은 `characterId`·`postId`의 활성 원댓글이어야 한다.
|
||||
|
||||
## Endpoint
|
||||
|
||||
### 직접 답글 목록
|
||||
|
||||
```http
|
||||
GET /api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}/replies?page=0&size=20
|
||||
Authorization: Bearer {jwt-token}
|
||||
Accept-Language: ko
|
||||
```
|
||||
|
||||
- `commentId`: 답글 영역을 연 원댓글 ID
|
||||
- 성공: `data={ totalCount, items }`
|
||||
- 답글 0개도 `totalCount=0`, `items=[]`인 정상 성공이다.
|
||||
- 여러 직접 답글은 `items`의 독립 row로 반환되며 기존 pagination을 사용한다.
|
||||
|
||||
### 댓글 또는 직접 답글 작성
|
||||
|
||||
```http
|
||||
POST /api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments
|
||||
Authorization: Bearer {jwt-token}
|
||||
Accept-Language: ko
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
직접 답글 request:
|
||||
|
||||
```json
|
||||
{
|
||||
"comment": "답글 내용",
|
||||
"parentId": 2102,
|
||||
"isSecret": false
|
||||
}
|
||||
```
|
||||
|
||||
| field | 형식 | 이번 기능의 값 |
|
||||
|---|---|---|
|
||||
| `comment` | string, required | trim 후 빈 문자열이 아닌 입력값 |
|
||||
| `parentId` | nullable int64, optional | 답글 대상 활성 원댓글 ID |
|
||||
| `isSecret` | boolean, optional | `false` |
|
||||
|
||||
- Community request에는 Audio 전용 `languageCode`를 보내지 않는다.
|
||||
- 같은 원댓글에 추가 답글을 쓸 때도 같은 endpoint와 원댓글 `parentId`를 사용한다.
|
||||
- 성공 envelope의 `data`는 `null`이다.
|
||||
- 성공 후 원댓글 목록과 열린 원댓글의 현재 답글 page를 재조회한다.
|
||||
|
||||
## 오류 응답
|
||||
|
||||
원본 OpenAPI의 공통 오류 envelope와 다음 status를 그대로 사용한다.
|
||||
|
||||
| Status | 처리 |
|
||||
|---:|---|
|
||||
| 400 | invalid target·parent 또는 binding 오류를 화면 alert로 표시 |
|
||||
| 401 | 공통 session 만료 처리 |
|
||||
| 403 | 공통 접근 거부 처리 |
|
||||
| 404 | target 또는 root를 찾을 수 없음 표시 |
|
||||
| 405, 406, 415, 500 | 서버 message를 우선 표시하고 기존 재시도 정책 적용 |
|
||||
|
||||
도메인별 message key와 validation 상한을 새로 추정하지 않는다.
|
||||
|
||||
## 프론트엔드 연결
|
||||
|
||||
| 역할 | 기존 구현 |
|
||||
|---|---|
|
||||
| target path 선택 | `commentCollectionPath()`의 `community` branch |
|
||||
| 답글 조회 | `getReplies()` |
|
||||
| 답글 작성 | `createComment()`의 Community overload |
|
||||
| request schema | `communityCommentCreateRequestSchema` |
|
||||
| 답글 상태·pagination | `CommentThread`의 `replies`, `loadReplies()` |
|
||||
| 성공 후 재조회 | `CommentThread.runMutation()` |
|
||||
|
||||
API, schema, mock handler와 store는 이번 기능에서 변경하지 않는다.
|
||||
297
docs/20260806_커뮤니티댓글답글/plan-task.md
Normal file
297
docs/20260806_커뮤니티댓글답글/plan-task.md
Normal file
@@ -0,0 +1,297 @@
|
||||
# 커뮤니티 댓글 직접 답글 구현 계획
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 구현 완료 |
|
||||
| 작성일 | 2026-08-06 |
|
||||
| 요구사항 기준 | [prd.md](./prd.md) |
|
||||
| API 기준 | [api-contract.md](./api-contract.md) |
|
||||
| 현재 Phase | Phase 1 완료 |
|
||||
| 현재 활성 Goal | 없음 |
|
||||
|
||||
## 목표
|
||||
|
||||
활성 커뮤니티 게시글의 답글 0개 원댓글에서도 기존 답글 form을 열어 첫 답글과
|
||||
여러 직접 답글을 작성할 수 있게 한다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1 | 완료 | `4/4` | 없음 | 완료 |
|
||||
|
||||
- Community 답글 GET·POST, form, 여러 직접 답글 조회·작성·재조회 흐름은 이미 구현돼 있다.
|
||||
- 답글이 하나 이상인 Community root에는 `답글 보기`와 추가 작성 form이 제공된다.
|
||||
- `replyCount === 0`인 활성 Community root에도 `답글 작성` 진입과 기존 답글 form이 제공된다.
|
||||
- `P1-T1` 구현과 test는 완료됐고, `P1-R1`에서 E2E fixture 검증 결함 후보를 실제 mock 실행 경로와 대조해 오탐으로 판정했다.
|
||||
- 최종 커밋 감사에서 위 문장의 기존 표현이 실제 완료 상태와 충돌해 `CCR-REV-P1-002`로 확정됐고 `P1-R2`에서 정정했다.
|
||||
|
||||
## 범위의 포함·제외
|
||||
|
||||
### 포함
|
||||
|
||||
- 활성 Community root의 `replyCount === 0`일 때 `답글 작성` 버튼 표시
|
||||
- 기존 답글 영역, form, GET·POST와 mutation 상태 재사용
|
||||
- 같은 원댓글에 첫 답글과 여러 직접 답글 작성
|
||||
- Community 첫 답글과 Audio·비활성·reply row 경계 회귀 test
|
||||
- 기존 Comments Chromium mock E2E와 정적 검증
|
||||
|
||||
### 제외
|
||||
|
||||
- 새 endpoint, DTO, component, state library 또는 dependency
|
||||
- form 상시 노출, reply-of-reply, payload 정책 변경
|
||||
- 기존 답글 수정·삭제·pagination 리팩터링
|
||||
- Audio 전용 `languageCode`의 Community payload 추가
|
||||
- optimistic update와 답글 전체 선조회
|
||||
|
||||
## 기술적 제약
|
||||
|
||||
- React·TypeScript strict, Vitest·React Testing Library와 기존 Playwright 구성을 사용한다.
|
||||
- [api-contract.md](./api-contract.md)의 기존 GET·POST만 사용한다.
|
||||
- `CommentThread`, `CommentItem`, `CommentForm`의 현재 책임 경계를 유지한다.
|
||||
- `CommentItem`의 기존 `replyActionLabel`, `CommentThread.toggleReplies()`와 reply state를 재사용한다.
|
||||
- 공통 조건 한 곳에서 Audio와 Community의 첫 답글 진입을 일치시키며 target별 분기를 추가하지 않는다.
|
||||
- RED → GREEN → REFACTOR 순서와 최소 변경을 지킨다.
|
||||
|
||||
## Phase 1. 커뮤니티 직접 답글 진입 구현·검증
|
||||
|
||||
**Phase 결과:** 관리자가 활성 Community의 답글 0개 원댓글에서 첫 답글을
|
||||
작성하고 같은 원댓글에 여러 직접 답글을 추가하며, 기존 Audio·읽기 전용·2단계
|
||||
경계가 유지된다.
|
||||
|
||||
**선행조건:** `CCR-001~006`과 기존 Community 댓글 GET·POST 계약 확정.
|
||||
|
||||
**Phase 완료 조건:** `P1-T1`, `P1-R1`, `P1-R2`, `P1-GATE` 완료와 Progress 기록.
|
||||
|
||||
### Task 1.1 커뮤니티 첫 답글 진입
|
||||
|
||||
**Goal 실행 `P1-T1`:** Community의 답글 0개 원댓글에 기존 답글 영역을 여는
|
||||
`답글 작성` action을 추가하고 직접 답글 작성 흐름을 검증한다.
|
||||
|
||||
- **시작 조건:** [prd.md](./prd.md)의 `CCR-001~006`, [api-contract.md](./api-contract.md).
|
||||
- **완료 증거:** TDD 체크박스, focused·회귀·E2E·정적 검증과 Progress 기록.
|
||||
- **범위 밖:** API·mock·schema 변경, 새 UI 구조, 관련 없는 Comments 리팩터링.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/features/comments/components/CommentThread.tsx`
|
||||
- Modify: `src/features/comments/tests/comment-thread.test.tsx`
|
||||
- Modify: `tests/e2e/comments.spec.ts`
|
||||
- Test: `src/features/comments/tests/comment-thread.test.tsx`, `tests/e2e/comments.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `CommentRecord.replyCount`, `canMutate`, `expandedRootIds`, `toggleReplies()`, `CommentForm`, Community `createComment()` overload.
|
||||
- Produces: 활성 Audio·Community 원댓글에 공통 적용되는 첫 답글 action 노출 조건.
|
||||
|
||||
**TDD 절차:**
|
||||
|
||||
- [x] **RED: 실패 테스트 작성/실패 확인** — `comment-thread.test.tsx`에 Community `replyCount=0` root의 `답글 작성` 노출, 클릭 후 form, `parentId` POST와 `languageCode` 미전송을 검증하고 `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx`가 버튼 부재로 실패하는지 확인한다.
|
||||
- [x] **GREEN: 최소 구현/통과 확인** — `CommentThread.tsx`의 기존 optional action label 조건에서 Audio 전용 제한만 제거하고 같은 명령이 exit 0인지 확인한다.
|
||||
- [x] **REFACTOR: 정리/회귀 확인** — 추가 helper·component 없이 조건을 읽기 쉬운 최소 표현으로 유지하고 focused test와 `npm run test:run -- src/features/comments`가 모두 exit 0인지 확인한다.
|
||||
- [x] 기존 Community mock E2E에 답글 0개 root의 첫 답글 작성과 같은 root에 추가 직접 답글 작성 journey를 검증한다.
|
||||
- [x] 검증 결과를 Progress에 기록한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx`; `npm run test:run -- src/features/comments`; `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`.
|
||||
- **기대 결과:** 모든 명령 exit 0, Community 첫 답글 POST 1회 이상, `parentId`는 원댓글 ID, Community body의 `languageCode` 0건, reply row의 답글 action 0건, 기존 Audio·Comments 회귀 실패 0건.
|
||||
- **수동 확인:** 활성 Community Sheet에서 답글 0개 root의 `답글 작성` → form 노출 → 첫 답글 등록 → 같은 root 추가 답글 등록을 확인한다. 비활성 workspace와 reply row에는 작성 진입이 없어야 한다.
|
||||
|
||||
### 완료 조건
|
||||
|
||||
- [x] `P1-T1`의 모든 TDD·검증 체크박스가 완료됐다.
|
||||
- [x] `CCR-001~006`이 구현 또는 검증 증거에 연결됐다.
|
||||
- [x] API·mock·schema와 범위 밖 파일 변경이 없다.
|
||||
|
||||
### Task 1.R1 Community E2E fixture 검증
|
||||
|
||||
**Goal 실행 `P1-R1`:** `CCR-REV-P1-001`의 E2E fixture 분류 오류 후보가 실제
|
||||
mock E2E 실행 경로에 영향을 주는지 검증하고 판정한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 완료, `CCR-REV-P1-001` 확정.
|
||||
- **완료 증거:** 실제 mock 요청 소유권 확인, 후보를 구분하는 E2E assertion, Chromium·Comments 회귀·정적 검증과 Progress 기록.
|
||||
- **범위 밖:** 애플리케이션 mock handler·store, API·schema, production 댓글 동작 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tests/e2e/comments.spec.ts`
|
||||
- Test: `tests/e2e/comments.spec.ts`
|
||||
|
||||
**TDD 예외 사유:** 리뷰 후보를 구분하는 assertion이 기존 mock E2E에서도 통과해
|
||||
production 또는 fixture 수정이 필요하지 않은 오탐으로 판정됐다. 실패하는 구현 변경이
|
||||
없으므로 RED → GREEN 대신 실제 요청 소유권과 기존 동작을 대체 검증했다.
|
||||
|
||||
- [x] root `2102`의 초기 reply region에 root 댓글이 없고, 첫·두 번째 답글이 region에 표시되며 중첩 action이 없는 assertion을 추가했다.
|
||||
- [x] 기존 `comments-test-support.ts`를 유지한 상태에서 Chromium E2E `3/3` 통과를 두 번 확인했다.
|
||||
- [x] `VITE_API_MODE=mock`의 Browser MSW Service Worker가 mock 요청을 처리하며 `page.route` fixture 후보가 실제 실행 경로를 소유하지 않음을 확인했다.
|
||||
- [x] fixture 변경을 폐기하고 Comments 회귀·typecheck·lint·`git diff --check`를 통과했다.
|
||||
- [x] 검증 결과와 `CCR-REV-P1-001` 오탐 판정을 Progress에 기록했다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium`; `npm run test:run -- src/features/comments`; `npm run typecheck`; `npm run lint`; `git diff --check`.
|
||||
- **기대 결과:** 기존 fixture를 변경하지 않고 모든 명령 exit 0, Chromium `3/3`, 첫·추가 답글이 root `2102` region에만 표시된다.
|
||||
- **수동 확인:** 기존 `P1-GATE`의 Community 첫·추가 답글 browser QA 결과와 mock E2E의 동일 동작을 대조한다.
|
||||
|
||||
### Task 1.R2 완료 문서 현재 상태 정합성 복구
|
||||
|
||||
**Goal 실행 `P1-R2`:** `CCR-REV-P1-002`의 미구현 문장을 실제 완료 상태로
|
||||
정정하고 기존 Progress와 결정 기록을 보존한다.
|
||||
|
||||
- **연결 리뷰:** [최종 커밋 감사](./reviews/phase1-final-commit-audit.md) — `CCR-REV-P1-002`
|
||||
- **시작 조건:** `CCR-REV-P1-002` 확정, 완료된 `P1-T1`, `P1-R1`, `P1-GATE`.
|
||||
- **완료 증거:** 현재 상태 문장 정정, 아래 체크박스·문서 검증 통과, review 수정 완료 기록과 Progress 누적.
|
||||
- **범위 밖:** 애플리케이션 코드·test·API Contract, 기존 Progress·Decision Log 삭제 또는 덮어쓰기.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/20260806_커뮤니티댓글답글/plan-task.md`
|
||||
- Modify: `docs/20260806_커뮤니티댓글답글/reviews/phase1-final-commit-audit.md`
|
||||
- Test: 없음 — 애플리케이션 동작을 변경하지 않는 문서 정합성 수정이다.
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `CCR-REV-P1-002`, `CCR-001`, 완료된 `P1-T1`·`P1-GATE` 검증 증거.
|
||||
- Produces: 실제 구현과 일치하는 plan 현재 상태와 수정 완료 review 기록.
|
||||
|
||||
**TDD 예외 사유:** 애플리케이션 코드·동작을 바꾸지 않는 문서 정정이므로 실패
|
||||
unit test를 추가하지 않는다.
|
||||
|
||||
**대체 검증 방법:** stale 미구현 marker 부재, 완료 상태 문장·review 상태·상호
|
||||
링크 존재와 Markdown diff를 명령으로 확인한다.
|
||||
|
||||
- [x] `replyCount === 0`인 Community root의 현재 상태를 실제 구현 완료 내용으로 정정한다.
|
||||
- [x] `CCR-REV-P1-002`의 상태와 리뷰 종료 판정을 `수정 완료`로 갱신한다.
|
||||
- [x] 기존 Progress와 Decision Log를 보존하고 `P1-R2` 기록을 누적한다.
|
||||
- [x] 문서 marker·link·diff 검증 결과를 Progress와 review에 기록한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `! sed -n '17,28p' docs/20260806_커뮤니티댓글답글/plan-task.md | rg -n '첫 답글 작성 진입만 없다'`; `sed -n '17,28p' docs/20260806_커뮤니티댓글답글/plan-task.md | rg -n 'replyCount === 0.*답글 작성.*제공'`; `rg -n 'CCR-REV-P1-002.*수정 완료' docs/20260806_커뮤니티댓글답글/reviews/phase1-final-commit-audit.md`; `test -f docs/20260806_커뮤니티댓글답글/reviews/phase1-final-commit-audit.md`; `git diff --check`.
|
||||
- **기대 결과:** 모든 명령 exit 0, stale 미구현 marker 0건, 완료 상태·review 수정 완료 marker와 링크 각 1건 이상, whitespace 오류 0건.
|
||||
- **수동 확인:** 없음 — 제품 동작을 바꾸지 않으며 문서의 정확한 marker와 link를 명령으로 판정한다.
|
||||
|
||||
### 검증 방법
|
||||
|
||||
#### Phase 1 Gate
|
||||
|
||||
**Goal 실행 `P1-GATE`:** 커뮤니티 첫·추가 직접 답글 journey와 Comments 공통
|
||||
경계를 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 완료.
|
||||
- **완료 증거:** 아래 명령·수동 확인 통과와 Progress 기록.
|
||||
- **범위 밖:** test 완화, timeout 상향과 관련 없는 수정.
|
||||
|
||||
**실행 명령:**
|
||||
|
||||
```bash
|
||||
npm run test:run -- src/features/comments
|
||||
npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
**기대 결과:** 모든 명령 exit 0, `CCR-001~006` 위반 0건.
|
||||
|
||||
**수동 확인:** 활성·비활성 Community와 활성 Audio에서 action 노출 경계를
|
||||
대조한다. Community Sheet를 1280px·320px와 200% zoom에서 열어 수평 overflow
|
||||
없이 첫·추가 답글을 작성하고 keyboard-only로 form에 진입한다.
|
||||
|
||||
## 실행 순서와 의존성
|
||||
|
||||
1. `P1-T1` RED
|
||||
2. `P1-T1` GREEN
|
||||
3. `P1-T1` REFACTOR·회귀
|
||||
4. `P1-GATE`
|
||||
5. 최종 커밋 감사에서 확정된 `CCR-REV-P1-002`를 `P1-R2`로 전환
|
||||
6. `P1-R2` 문서 정정·검증과 review 수정 완료 처리
|
||||
|
||||
- 동시에 하나의 미완료 goal만 운용한다.
|
||||
- 사용자가 goal 실행을 요청하기 전에는 goal을 생성하지 않는다.
|
||||
|
||||
## 변경 금지 항목
|
||||
|
||||
- 기존 OpenAPI, API client, request schema, mock handler·store 변경
|
||||
- 새 dependency, state library, component 또는 speculative abstraction
|
||||
- 답글의 답글, optimistic update와 form 상시 노출
|
||||
- Audio payload와 기존 수정·삭제·pagination 동작 변경
|
||||
- 실패 test 삭제·skip, timeout 상향으로 Gate 통과
|
||||
- 기존 Progress와 결정 기록 삭제·덮어쓰기
|
||||
|
||||
## 의사결정 및 중단 규칙
|
||||
|
||||
- `replyCount === 0`, `canMutate === true`인 Audio·Community 원댓글에만 `답글 작성`을 표시한다.
|
||||
- `replyCount > 0` 또는 펼친 원댓글은 기존 `답글 보기` label을 유지한다.
|
||||
- reply row에는 `onShowReplies`를 전달하지 않으며 3단계 작성 경로를 만들지 않는다.
|
||||
- API 응답이나 오류가 [api-contract.md](./api-contract.md)와 다르면 추정 수정하지 않고 외부 의존으로 기록한다.
|
||||
- 범위가 바뀌면 코드보다 PRD Decision Log와 이 계획을 먼저 갱신한다.
|
||||
|
||||
## Progress
|
||||
|
||||
### 2026-08-06 요구사항·설계
|
||||
|
||||
- **무엇을:** 활성 Community 원댓글의 첫 답글 진입, 여러 직접 답글과 2단계 제한을 요구사항·API 재사용 계약·단일 구현 Task로 정리했다.
|
||||
- **왜:** Community 답글 조회·작성 흐름은 이미 있으나 `replyCount === 0`이면 진입 action이 없어 첫 답글만 작성할 수 없다.
|
||||
- **어떻게:** 선행 Audio 답글 문서, 프로젝트 OpenAPI, `CommentThread`, request schema, mock handler·store, unit·E2E를 대조했다. 기존 공통 흐름을 재사용할 수 있어 새 API·컴포넌트·mock을 계획에서 제외했다. 애플리케이션 코드와 test는 변경하지 않았다.
|
||||
|
||||
### 2026-08-06 `P1-T1` 커뮤니티 첫 답글 진입
|
||||
|
||||
- **무엇을:** 활성 Community의 `replyCount=0` 원댓글에도 기존 `답글 작성` action을 노출하고, 같은 원댓글에 첫 번째와 두 번째 직접 답글을 작성하는 단위·Chromium E2E를 추가했다. reply row의 중첩 답글 action 부재와 Community payload의 `languageCode` 미전송도 검증했다.
|
||||
- **왜:** 기존 공통 GET·POST·form·재조회 흐름은 완성돼 있었지만 action label 조건이 Audio target만 허용해 Community 첫 답글 진입이 막혀 있었다.
|
||||
- **어떻게:** RED에서 `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx`를 실행해 `AI 루트 댓글 답글 작성` 버튼 부재로 `1 failed, 7 passed`를 확인했다. GREEN에서 `CommentThread.tsx`의 Audio 전용 조건만 제거한 뒤 focused test `8/8`을 통과했다. REFACTOR·회귀로 `npm run test:run -- src/features/comments`는 `15/15`, `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium`은 `3/3`, `npm run typecheck`와 `npm run lint`는 exit 0이었다. API·schema·mock·dependency는 변경하지 않았다.
|
||||
|
||||
### 2026-08-06 `P1-R1` E2E fixture 후보 판정
|
||||
|
||||
- **무엇을:** `CCR-REV-P1-001`이 지적한 단일 `replyRootId` fixture가 mock E2E의 root `2102` 답글을 오분류하는지 검증했다.
|
||||
- **왜:** 코드만 보면 `comments-test-support.ts`가 root `2101`만 replies로 처리하지만, 실제 mock E2E가 이 fixture를 사용하는지 확인하지 않으면 오탐 수정으로 범위를 확장할 수 있다.
|
||||
- **어떻게:** 기존 fixture를 유지한 상태에서 `2102` 초기 reply region에 root 댓글 0건, 첫·두 번째 답글 표시, dialog 내 각 1건, 중첩 action 0건을 추가하고 Chromium E2E `3/3` 통과를 두 번 확인했다. `playwright.config.ts`의 `VITE_API_MODE=mock`과 `src/shared/mocks/browser.ts`의 `setupWorker(...)`를 대조해 Browser MSW가 Service Worker에서 요청을 처리하며 `page.route`가 해당 요청을 소유하지 않음을 확인했다. fixture 변경은 폐기했고 `CCR-REV-P1-001`을 오탐으로 판정했다.
|
||||
|
||||
### 2026-08-06 `P1-GATE` Phase 1 최종 검증
|
||||
|
||||
- **무엇을:** Community 첫·추가 직접 답글, 2단계·권한 경계, Comments 회귀와 반응형·keyboard·CJK 품질을 최종 판정했다.
|
||||
- **왜:** 코드와 자동 test 통과만으로는 실제 Sheet의 keyboard 진입, 320px·200% zoom, 한국어 줄바꿈과 reviewer 차단 해소를 증명할 수 없다.
|
||||
- **어떻게:** `npm run test:run -- src/features/comments`는 `15/15`, `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium`은 `3/3`, `npm run typecheck`, `npm run lint`, `npm run build:dev`, `git diff --check`는 exit 0이었다. 실제 Chromium에서 첫·두 번째 답글, input 초기화, 중첩 action 0건, keyboard-only 진입과 1280px·320px·200% zoom의 수평 overflow 0건을 확인했다. 독립 goal·코드 품질·보안·컨텍스트·기능·visual/CJK 리뷰는 최종 PASS였고 [Phase 1 리뷰](./reviews/phase1-community-comment-replies.md)에 근거를 기록했다.
|
||||
|
||||
### 2026-08-06 `P1-R2` 완료 문서 현재 상태 정합성 복구
|
||||
|
||||
- **무엇을:** `CCR-REV-P1-002`를 `P1-R2`로 전환한 뒤 `replyCount === 0`인 활성 Community root의 현재 상태를 실제 구현 완료 내용으로 정정하고 최종 커밋 감사 상태를 수정 완료로 갱신했다.
|
||||
- **왜:** plan의 완료 상태·코드·test와 반대인 구현 전 문장 때문에 후속 작업자가 첫 답글 진입을 미구현으로 오인할 수 있었다.
|
||||
- **어떻게:** stale 현재 상태 marker 부재, 완료 상태 문장 존재, review 파일과 수정 완료 marker 존재를 `rg`·`test -f`로 확인하고 trailing whitespace 검사와 `git diff --check`를 실행해 모두 exit 0을 확인했다. 애플리케이션 코드·test·API Contract는 변경하지 않았다.
|
||||
|
||||
### 2026-08-06 `P1-R2` 후 기능 회귀 감사
|
||||
|
||||
- **무엇을:** 문서 정정 뒤 Community 첫·추가 직접 답글과 Comments 공통 회귀, 정적 품질과 development build를 다시 확인했다.
|
||||
- **왜:** 문서 전용 변경임을 diff로 확인하고 최종 완료 상태가 기존 기능 검증 증거와 계속 일치하는지 판정하기 위해서다.
|
||||
- **어떻게:** `npm run test:run -- src/features/comments`는 `15/15`, 샌드박스 밖에서 실행한 `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium`은 `3/3`, `npm run typecheck`, `npm run lint`, `npm run build:dev`, `git diff --check`는 모두 exit 0이었다. build의 기존 500kB chunk warning 외 실패는 없었다.
|
||||
|
||||
## Decision Log
|
||||
|
||||
| 날짜 | 결정 | 근거 | 영향 |
|
||||
|---|---|---|---|
|
||||
| 2026-08-06 | Audio와 동일한 `답글 작성` 진입을 활성 Community 원댓글에도 적용한다. | 사용자 요청 | `CCR-001~003`, `P1-T1` |
|
||||
| 2026-08-06 | 한 원댓글에 여러 직접 답글을 허용하고 reply-of-reply는 제외한다. | 사용자 요청 | `CCR-004~005`, `P1-T1`, `P1-GATE` |
|
||||
| 2026-08-06 | 기존 공통 UI와 Community GET·POST를 재사용하고 API·mock·schema는 변경하지 않는다. | OpenAPI와 코드 확인 | `CCR-002~006`, `P1-T1` |
|
||||
| 2026-08-06 | 구현은 공통 action 조건의 Audio 전용 제한 제거와 기존 test 보강으로 제한한다. | `CommentThread` 흐름 확인과 최소 변경 원칙 | `P1-T1` Files·Interfaces |
|
||||
| 2026-08-06 | E2E 전용 route fixture가 특정 root만 replies로 처리하는 결함을 `P1-R1`에서 수정한다. | 최종 코드 품질·컨텍스트 리뷰에서 `2102` 답글이 roots에 저장돼 E2E가 오탐 통과함을 확인 | `CCR-REV-P1-001`, `P1-R1`, `P1-GATE` |
|
||||
| 2026-08-06 | 정정: `CCR-REV-P1-001`은 mock mode에서 Browser MSW가 요청을 소유해 E2E route fixture 분기가 실행되지 않으므로 오탐이다. fixture를 변경하지 않는다. | 기존 fixture 상태에서 2102 빈 reply·첫·추가 답글 assertion과 Chromium `3/3` 통과, `VITE_API_MODE=mock`·`setupWorker(...)` 확인 | `CCR-REV-P1-001`, `P1-R1`, `P1-GATE` |
|
||||
| 2026-08-06 | 최종 커밋 감사에서 확정된 stale 현재 상태 문장을 문서 전용 회귀 Task로 수정한다. | `CCR-REV-P1-002`의 plan·코드·test 불일치 | `P1-R2` |
|
||||
| 2026-08-06 | `P1-R2`에서 현재 상태 문장을 실제 구현과 일치시키고 review를 수정 완료 처리한다. | 문서 marker·link·diff 검증 통과 | `CCR-REV-P1-002`, `P1-R2` |
|
||||
|
||||
## 발견된 문제
|
||||
|
||||
- 수정 완료: 답글 0개 Community root의 첫 답글 작성 진입을 `P1-T1`에서 구현하고 `P1-GATE`에서 검증했다.
|
||||
- 확정: E2E 전용 fixture가 `replyRootId` 하나만 replies로 분류해 다른 root의 직접 답글을 roots에 저장한다. (`CCR-REV-P1-001`, `P1-R1`에서 수정 예정)
|
||||
- 오탐: `CCR-REV-P1-001` — mock mode에서는 Browser MSW가 요청을 처리해 해당 E2E route fixture 분기가 실행되지 않으며, 기존 fixture 상태에서 root `2102`의 빈 reply·첫·추가 답글 journey가 통과한다.
|
||||
- 확정: 완료된 현재 상태에 첫 답글 진입이 없다는 구현 전 문장이 남아 있다. (`CCR-REV-P1-002`, `P1-R2` 진행 중)
|
||||
- 수정 완료: `CCR-REV-P1-002`의 stale 현재 상태 문장을 실제 구현 완료 내용으로 정정하고 문서 검증을 통과했다. (`P1-R2`)
|
||||
- 외부 차단: 없음.
|
||||
|
||||
## 최종 보고 형식
|
||||
|
||||
- 완료 Goal ID
|
||||
- 변경한 파일과 최소 구현 내용
|
||||
- RED·GREEN·REFACTOR 및 Gate 명령과 실제 결과
|
||||
- 실행하지 못한 수동·server 검증과 이유
|
||||
- 남은 위험 또는 열린 질문
|
||||
227
docs/20260806_커뮤니티댓글답글/prd.md
Normal file
227
docs/20260806_커뮤니티댓글답글/prd.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# 커뮤니티 댓글 직접 답글 PRD
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | 구현 기준 확정 |
|
||||
| 작성일 | 2026-08-06 |
|
||||
| 최종 수정일 | 2026-08-06 |
|
||||
| 대상 기능 | 커뮤니티 게시글 댓글의 직접 답글 작성 진입 |
|
||||
| 작성자·결정권자 | Codex 작성, 사용자 결정 |
|
||||
| 상위 제품 기준 | [AI 캐릭터 관리자 웹 PRD](../20260725_AI캐릭터관리자웹/prd.md) |
|
||||
| 선행 기능 기준 | [오디오 콘텐츠 댓글 답글 PRD](../20260805_오디오콘텐츠댓글답글/prd.md) |
|
||||
| 관련 API Contract | [api-contract.md](./api-contract.md) |
|
||||
| 관련 구현 계획 | [plan-task.md](./plan-task.md) |
|
||||
| 관련 review | [Phase 1 커뮤니티 댓글 직접 답글 리뷰](./reviews/phase1-community-comment-replies.md) |
|
||||
|
||||
### 요구사항 상태
|
||||
|
||||
| 상태 | 의미 |
|
||||
|---|---|
|
||||
| 확정 | 구현과 검증 기준으로 사용한다. |
|
||||
| 미결 | 제품 결정 전에는 구현하지 않는다. |
|
||||
| 외부 의존 | 외부 계약이 제공될 때까지 영향 범위를 구현 완료로 표시하지 않는다. |
|
||||
| 제외 | 현재 기능 범위에 포함하지 않는다. |
|
||||
|
||||
## 1. Overview
|
||||
|
||||
활성 AI 캐릭터의 커뮤니티 게시글 원댓글에 직접 답글을 작성할 수 있게 한다.
|
||||
원댓글 아래에는 여러 개의 직접 답글을 추가할 수 있지만, 답글에 다시 답글을
|
||||
다는 3단계 구조는 허용하지 않는다. 기존 오디오 콘텐츠 댓글과 같은 진입 UI,
|
||||
답글 영역, 작성 form과 mutation 상태를 재사용한다.
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
커뮤니티 답글 조회·작성 API와 UI는 이미 구현돼 있어 답글이 하나 이상인
|
||||
원댓글에는 추가 답글을 작성할 수 있다. 그러나 `replyCount=0`인 원댓글에는
|
||||
답글 영역을 여는 action이 없어 첫 답글을 작성할 수 없다.
|
||||
|
||||
문제를 해결했다는 판단은 답글 0개인 활성 커뮤니티 원댓글에서 `답글 작성`을
|
||||
눌러 첫 답글을 등록하고, 같은 원댓글에 여러 직접 답글을 계속 추가할 수 있는지로
|
||||
한다.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
### 3.1 제품 목표
|
||||
|
||||
- 활성 커뮤니티 게시글의 모든 원댓글에 첫 답글을 작성할 수 있다.
|
||||
- 하나의 원댓글 아래 여러 직접 답글을 작성·조회할 수 있다.
|
||||
- 원댓글과 직접 답글로 끝나는 기존 2단계 댓글 구조를 유지한다.
|
||||
|
||||
### 3.2 UX 목표
|
||||
|
||||
- 답글이 0개인 원댓글에는 `답글 작성`이라는 명확한 진입점을 표시한다.
|
||||
- 버튼을 누르면 기존 답글 영역과 작성 form을 펼친다.
|
||||
- 기존 답글이 있는 원댓글은 `답글 보기`로 같은 영역을 열고 추가 답글을 작성한다.
|
||||
- 기존 loading, 오류, 전송 중, 실패 후 초안 보존 동작을 유지한다.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- 답글의 답글을 포함한 3단계 이상의 댓글 구조
|
||||
- 답글 form 상시 노출
|
||||
- 새 endpoint, DTO, 상태관리, 컴포넌트 또는 UI dependency 추가
|
||||
- 오디오 콘텐츠 댓글 동작이나 payload 정책 변경
|
||||
- 기존 답글 수정·삭제·pagination 정책 변경
|
||||
- optimistic update 또는 답글 전체 선조회
|
||||
|
||||
## 5. Target Users and Permissions
|
||||
|
||||
| 사용자 | 목표 | 주요 작업 | 사용 환경 |
|
||||
|---|---|---|---|
|
||||
| ADMIN | AI 캐릭터 명의로 커뮤니티 원댓글에 직접 답글 작성 | 답글 영역 열기, 작성, 재시도 | desktop, tablet, mobile |
|
||||
|
||||
- 인증과 ADMIN 권한은 상위 제품 기준을 따른다.
|
||||
- 활성 AI 캐릭터 workspace에서만 답글 작성 control을 제공한다.
|
||||
- 비활성 AI 캐릭터 workspace는 기존처럼 조회 전용이다.
|
||||
- 원댓글 작성자가 팬인지 AI 캐릭터인지와 관계없이 답글을 작성할 수 있다.
|
||||
|
||||
## 6. 핵심 사용자 흐름
|
||||
|
||||
1. 관리자가 활성 AI 캐릭터의 커뮤니티 게시글 목록에 진입한다.
|
||||
2. 게시글 Sheet를 열고 답글이 0개인 원댓글에서 `답글 작성`을 누른다.
|
||||
3. UI가 해당 원댓글의 직접 답글 GET을 실행하고 답글 영역과 작성 form을 표시한다.
|
||||
4. 관리자가 내용을 입력해 등록한다.
|
||||
5. 기존 커뮤니티 댓글 POST에 원댓글 ID를 `parentId`로 보내고 성공 후 원댓글·열린 답글 목록을 재조회한다.
|
||||
6. 관리자는 같은 form으로 동일 원댓글에 추가 직접 답글을 작성할 수 있다.
|
||||
7. 실패하면 오류를 표시하고 입력 초안을 유지해 재시도할 수 있다.
|
||||
|
||||
## 7. 정보 구조와 라우팅
|
||||
|
||||
```text
|
||||
/ai-characters/:characterId/community-posts
|
||||
└─ 커뮤니티 게시글 Sheet
|
||||
└─ 댓글 관리
|
||||
└─ 원댓글
|
||||
└─ 직접 답글 목록 및 작성 form
|
||||
```
|
||||
|
||||
- 새 route와 query parameter를 추가하지 않는다.
|
||||
- 기존 `CommunityPostSheet`의 `CommentThread` 안에서만 동작한다.
|
||||
- 답글 pagination 상태는 기존 component의 로컬 상태를 사용한다.
|
||||
|
||||
## 8. 기능 요구사항
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `CCR-001` | 확정 | 활성 Community target의 답글 0개 원댓글에 `답글 작성` 버튼을 표시한다. | `replyCount=0`, `canMutate=true`인 Community root에서 버튼을 찾을 수 있다. | contract 불필요, `P1-T1` |
|
||||
| `CCR-002` | 확정 | `답글 작성`을 누르면 선택한 원댓글의 기존 직접 답글 영역과 작성 form을 연다. | 버튼 클릭 뒤 해당 원댓글 이름과 연결된 답글 region·textarea·등록 버튼이 표시되고 page 0 GET을 한 번 요청한다. | 답글 GET, `P1-T1` |
|
||||
| `CCR-003` | 확정 | 첫 답글과 후속 직접 답글은 기존 Community 댓글 POST를 사용한다. | body가 trim된 `comment`, 원댓글 ID `parentId`, `isSecret=false`를 포함하고 `languageCode`는 보내지 않는다. | 댓글 POST, `P1-T1` |
|
||||
| `CCR-004` | 확정 | 하나의 원댓글에는 여러 직접 답글을 추가할 수 있다. | 답글 등록 성공 후 form을 다시 사용할 수 있고 원댓글·현재 답글 page를 재조회해 추가된 답글을 표시한다. | 답글 GET·댓글 POST, `P1-T1`, `P1-GATE` |
|
||||
| `CCR-005` | 확정 | 댓글 구조는 원댓글과 직접 답글의 2단계로 제한한다. | reply row에는 답글 action이 없고 답글 ID를 `parentId`로 보내는 작성 경로가 없다. | 댓글 POST, `P1-T1` |
|
||||
| `CCR-006` | 확정 | 기존 권한과 mutation 상태를 유지한다. | `canMutate=false`이면 첫 답글 작성 진입과 form이 없고, pending 중 중복 POST가 없으며 실패 시 초안 유지·성공 시 초기화된다. | `NullSuccess`, `P1-GATE` |
|
||||
|
||||
## 9. 반응형 기능 범위
|
||||
|
||||
| 기능 | Desktop | Tablet | Mobile | 비고 |
|
||||
|---|---:|---:|---:|---|
|
||||
| `답글 작성`·`답글 보기` 진입 | 지원 | 지원 | 지원 | 기존 댓글 action layout 재사용 |
|
||||
| 여러 직접 답글 조회·작성 | 지원 | 지원 | 지원 | 기존 page size 20과 pagination 재사용 |
|
||||
|
||||
- 상위 제품의 최소 320px, 200% zoom, keyboard-only와 touch target 기준을 유지한다.
|
||||
- Sheet 내부에서 수평 overflow 없이 form과 action을 사용할 수 있어야 한다.
|
||||
|
||||
## 10. UI/UX Expectations
|
||||
|
||||
### 10.1 디자인과 component 원칙
|
||||
|
||||
- `CommentThread`, `CommentItem`, `CommentForm`을 재사용한다.
|
||||
- 오디오와 커뮤니티에 동일한 action label과 펼침 동작을 사용한다.
|
||||
- 새 component나 dependency를 추가하지 않는다.
|
||||
- 기존 답글이 있는 원댓글의 `답글 보기` UI는 유지한다.
|
||||
|
||||
### 10.2 화면 상태
|
||||
|
||||
- 클릭 직후 기존 답글 loading 상태를 표시한다.
|
||||
- 빈 답글 응답 뒤에도 작성 form을 표시한다.
|
||||
- 조회 오류는 기존 재시도 UI를 사용한다.
|
||||
- 작성 중·성공·실패는 기존 Comments mutation 정책을 사용한다.
|
||||
- 답글 작성 성공 후 form은 빈 값으로 초기화되고 다시 입력할 수 있다.
|
||||
|
||||
### 10.3 접근성
|
||||
|
||||
- 버튼의 accessible name은 원댓글 내용과 `답글 작성` 또는 `답글 보기`를 조합해 식별 가능해야 한다.
|
||||
- form의 visible label과 오류 연결, keyboard focus 표시를 유지한다.
|
||||
- 답글 region은 원댓글 내용과 `답글`을 조합한 accessible name을 유지한다.
|
||||
- keyboard-only로 Sheet의 원댓글에서 답글 form까지 진입하고 등록할 수 있어야 한다.
|
||||
|
||||
## 11. API 계약
|
||||
|
||||
### 11.1 공통 규칙
|
||||
|
||||
- 이 기능은 API를 변경하지 않는다.
|
||||
- 정확한 request, response와 오류는 [기능 API Contract](./api-contract.md)를 따른다.
|
||||
- 원본 OpenAPI는 [프로젝트 OpenAPI](../20260725_AI캐릭터관리자웹/api-contract.openapi.json)다.
|
||||
|
||||
### 11.2 Endpoint 추적
|
||||
|
||||
| 요구사항 | Method | Path | 계약 상태 | 소유 Goal |
|
||||
|---|---|---|---|---|
|
||||
| `CCR-002`, `CCR-004` | GET | `/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}/replies` | 기존 제공·구현됨 | `P1-T1` |
|
||||
| `CCR-003~005` | POST | `/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments` | 기존 제공·구현됨 | `P1-T1` |
|
||||
|
||||
### 11.3 외부 제공 대기 계약
|
||||
|
||||
없음. 필요한 GET·POST, DTO와 mock handler가 이미 제공돼 있다.
|
||||
|
||||
## 12. 보안과 데이터 취급
|
||||
|
||||
- 기존 Bearer 인증, ADMIN 권한과 `characterId`·`postId` target 격리를 유지한다.
|
||||
- `parentId`는 현재 Community target에서 응답받은 활성 원댓글 ID만 사용한다.
|
||||
- 댓글 본문과 인증 정보는 console, 분석 이벤트와 영구 저장소에 기록하지 않는다.
|
||||
- 401·403은 공통 인증·인가 정책을 따른다.
|
||||
- 클라이언트 validation은 서버의 target·parent 소유권 검증을 대체하지 않는다.
|
||||
|
||||
## 13. 성능과 품질 요구사항
|
||||
|
||||
- 답글 action을 누를 때 선택한 원댓글의 답글 page 0만 기존 방식으로 조회한다.
|
||||
- 답글 page size 20과 기존 pagination을 유지하며 전체 답글을 선조회하지 않는다.
|
||||
- 새 dependency, 캐시 계층과 optimistic update를 추가하지 않는다.
|
||||
- Vitest focused test, Comments 회귀, Chromium mock E2E, typecheck와 lint를 통과한다.
|
||||
- server 404나 network error를 mock으로 자동 전환하지 않는다.
|
||||
|
||||
## 14. 성공 기준
|
||||
|
||||
### 14.1 기능 수용 기준
|
||||
|
||||
- [x] 답글 0개인 활성 Community root에서 첫 답글을 작성한다. (`CCR-001~003`)
|
||||
- [x] 같은 원댓글에 여러 직접 답글을 작성·조회한다. (`CCR-004`)
|
||||
- [x] reply row와 비활성 workspace의 2단계·권한 경계가 유지된다. (`CCR-005~006`)
|
||||
- [x] 실패·재시도와 중복 제출 방지가 회귀하지 않는다. (`CCR-006`)
|
||||
|
||||
### 14.2 UI/UX 수용 기준
|
||||
|
||||
- [x] 버튼·답글 region·form의 accessible name과 label이 연결된다.
|
||||
- [x] 320px·200% zoom에서 수평 overflow 없이 답글을 작성한다.
|
||||
- [x] keyboard-only로 답글 form에 진입하고 등록할 수 있다.
|
||||
|
||||
### 14.3 추적성 완료 기준
|
||||
|
||||
- [x] 모든 확정 요구사항이 API 또는 contract 불필요 판정, `P1-T1`, `P1-GATE`와 연결된다.
|
||||
- [x] 구현·검증 결과가 [plan-task.md](./plan-task.md)의 Progress에 기록된다.
|
||||
- [x] 완료된 Phase의 리뷰가 `reviews/` 아래에 기록된다.
|
||||
|
||||
## 15. Open Questions
|
||||
|
||||
없음.
|
||||
|
||||
## 16. 요구사항 추적표
|
||||
|
||||
| 요구사항 범위 | API Contract | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|
||||
|---|---|---:|---|---|---|
|
||||
| `CCR-001~006` | [api-contract.md](./api-contract.md) | 1 | `P1-T1`, `P1-GATE` | `comment-thread.test.tsx`, `comments.spec.ts` | 활성 Community 첫·추가 답글, 비활성·2단계·320px·keyboard 경계 |
|
||||
|
||||
## 17. Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-06 | `CCR-DEC-001` | 확정 | 오디오 콘텐츠와 동일한 첫 답글 진입을 활성 커뮤니티 원댓글에도 적용한다. | 사용자 요청 | `CCR-001~003`, `P1-T1` |
|
||||
| 2026-08-06 | `CCR-DEC-002` | 확정 | 댓글 트리는 원댓글 아래 여러 직접 답글을 허용하되 답글의 답글은 허용하지 않는다. | 사용자 요청의 “1단계 추가, 여러 개” 조건 | `CCR-004~005`, [api-contract.md](./api-contract.md) |
|
||||
| 2026-08-06 | `CCR-DEC-003` | 확정 | 새 API·컴포넌트 없이 기존 Community GET·POST와 Comments UI를 재사용한다. | OpenAPI와 구현 확인 | `CCR-002~006`, `P1-T1` |
|
||||
|
||||
## 18. 변경 관리
|
||||
|
||||
- 범위가 바뀌면 이 문서의 Decision Log와 요구사항을 먼저 갱신한다.
|
||||
- API가 바뀌면 [api-contract.md](./api-contract.md)와 원본 OpenAPI의 제공 버전을 확인한다.
|
||||
- 구현 범위가 바뀌면 코드보다 [plan-task.md](./plan-task.md)를 먼저 갱신한다.
|
||||
- 기존 Progress, review와 검증 기록은 삭제하거나 덮어쓰지 않는다.
|
||||
@@ -0,0 +1,170 @@
|
||||
# 커뮤니티 댓글 직접 답글 Phase 1 리뷰
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 1 / `P1-T1`, `P1-R1`, `P1-GATE` |
|
||||
| 기준 commit 또는 working tree | `e82e209300d2c30843b6a2ef2c9e126ade6bba63` 기반 working tree |
|
||||
| 리뷰 일자 | 2026-08-06 |
|
||||
| 리뷰어 | Sisyphus, 독립 goal·품질·보안·컨텍스트·visual QA reviewer |
|
||||
| 기준 문서 | [prd.md](../prd.md), [api-contract.md](../api-contract.md), [plan-task.md](../plan-task.md) |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
### 목적
|
||||
|
||||
- `CCR-001~006`과 Community 첫·추가 직접 답글 journey가 구현됐는지 확인한다.
|
||||
- API·schema·application mock·dependency 변경 없이 기존 2단계 댓글 경계와 권한을 유지하는지 확인한다.
|
||||
- TDD, 자동 Gate, 실제 Chromium과 문서 기록이 완료 조건과 일치하는지 판정한다.
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- 코드: `src/features/comments/components/CommentThread.tsx`
|
||||
- 테스트: `src/features/comments/tests/comment-thread.test.tsx`, `tests/e2e/comments.spec.ts`
|
||||
- 문서: `CCR-001~006`, Community 댓글 API Contract, `P1-T1`, `P1-R1`, `P1-GATE`
|
||||
- 수동 검증: Chromium mock mode, keyboard-only, 1280px, 320px, 200% zoom, CJK·수평 overflow
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 실제 개발 API integration, 새 endpoint·schema·mock store, 답글 수정·삭제·pagination 정책 변경
|
||||
- 3단계 댓글, optimistic update, form 상시 노출
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
### 심각도
|
||||
|
||||
| 심각도 | 기준 |
|
||||
|---|---|
|
||||
| Blocker | 보안·데이터 손실 위험, 핵심 journey 불능, 완료 판정 무효 |
|
||||
| High | 확정 요구사항·API Contract 위반 또는 주요 회귀 |
|
||||
| Medium | 제한 조건의 기능·접근성·복구 문제 |
|
||||
| Low | 비핵심 유지보수성·문서 정합성 문제 |
|
||||
|
||||
### 상태
|
||||
|
||||
| 상태 | 의미 | 후속 처리 |
|
||||
|---|---|---|
|
||||
| 후보 | 근거를 발견했지만 판정 전 | 재현 후 상태 변경 |
|
||||
| 확정 | 코드·test·문서로 문제 확인 | 회귀 Task 전환 |
|
||||
| 오탐 | 실제 실행 경로나 요구사항 위반이 아님 | 판정 근거를 보존하고 종료 |
|
||||
| 보류 | 외부 계약·환경·제품 결정 필요 | 담당·재개 조건 기록 |
|
||||
| 수정 완료 | 수정과 관련 검증 완료 | 검증 결과 누적 |
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
### 문서와 코드
|
||||
|
||||
- 요구사항: `CCR-001~006`
|
||||
- API Contract: 직접 답글 GET, Community 댓글 POST, 2단계 불변식
|
||||
- 계획: `P1-T1`, `P1-R1`, `P1-GATE`
|
||||
- 코드: `CommentThread.tsx`의 `replyActionLabel`, `toggleReplies()`, `createReply()`
|
||||
- 테스트: `CommentThread creates a first Community reply...`, `Community sheet comments keep two-level controls usable at 320px`
|
||||
|
||||
### 실행 환경
|
||||
|
||||
```text
|
||||
OS: macOS
|
||||
Node: v24.12.0
|
||||
npm: 11.7.0
|
||||
Browser/viewport: Playwright Chromium, 1280x900, 320x640, CSS zoom 200%
|
||||
환경 변수: VITE_API_MODE=mock
|
||||
```
|
||||
|
||||
### 실행한 검증
|
||||
|
||||
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx` | 성공 | `8/8` |
|
||||
| `npm run test:run -- src/features/comments` | 성공 | `15/15` |
|
||||
| `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium` | 성공 | `3/3`; 2102 빈 reply, 첫·두 답글, payload, 2단계 경계 |
|
||||
| `npm run typecheck` | 성공 | exit 0 |
|
||||
| `npm run lint` | 성공 | exit 0 |
|
||||
| `npm run build:dev` | 성공 | Vite build exit 0; 기존 500kB chunk warning만 발생 |
|
||||
| `git diff --check` | 성공 | 출력 없음 |
|
||||
| 실제 Chromium keyboard journey | 성공 | 답글 action·textarea keyboard 진입, 첫·두 답글 표시, input 초기화, 중첩 action 0건 |
|
||||
| 1280px·320px·200% visual QA | 성공 | 수평 overflow 없음, CJK clipping·고아줄 없음, 독립 visual reviewer PASS |
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `CCR-REV-P1-001` | High | 오탐 | E2E route fixture가 root 2102 답글을 잘못 분류한다 | `P1-R1` | 없음 |
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
## 6. 발견 사항 상세
|
||||
|
||||
### CCR-REV-P1-001 — E2E route fixture root 분류 후보
|
||||
|
||||
- **심각도:** High
|
||||
- **상태:** 오탐
|
||||
- **관련 요구사항:** `CCR-002`, `CCR-004~005`
|
||||
- **관련 계약:** Community 직접 답글 GET·POST, 2단계 불변식
|
||||
- **소유 Task:** `P1-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
`tests/e2e/comments-test-support.ts`는 단일 `replyRootId`만 replies로 분류하지만,
|
||||
필수 mock E2E에서는 이 Playwright route fixture가 응답을 소유하지 않는다.
|
||||
|
||||
**근거**
|
||||
|
||||
- `playwright.config.ts`는 mock E2E를 `VITE_API_MODE=mock`으로 실행한다.
|
||||
- 앱은 렌더 전에 `src/shared/mocks/browser.ts`의 `setupWorker(...)`를 시작한다.
|
||||
- Browser MSW handler·store는 `commentId`와 `parentId`로 root 2102 답글을 분리한다.
|
||||
- 기존 E2E route fixture를 변경하지 않은 상태에서 2102 초기 reply region의 root 댓글 0건, 첫·두 답글 각 1건, 중첩 action 0건과 Chromium `3/3`을 반복 확인했다.
|
||||
- 별도 브라우저 probe에서 `page.route` 호출 0회와 Community mock 요청 9회를 관찰했다.
|
||||
|
||||
**재현 또는 검증 절차**
|
||||
|
||||
1. `VITE_API_MODE=mock`으로 `comments.spec.ts` Chromium을 실행한다.
|
||||
2. root 2102의 답글 영역을 열고 다른 root 댓글이 없음을 확인한다.
|
||||
3. 같은 root에 첫·두 번째 답글을 등록하고 Sheet를 다시 연다.
|
||||
4. 두 답글이 region에 각 1건 표시되고 중첩 답글 action이 없음을 확인한다.
|
||||
|
||||
**영향**
|
||||
|
||||
필수 mock E2E와 제품 동작에는 영향이 없다. Server-mode 전용 test route helper의
|
||||
일반화는 이번 기능 범위와 실행 경로 밖이다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
없음. 실행되지 않는 fixture를 speculative하게 변경하지 않는다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-08-06 — 코드 형태만 근거로 확정 후보로 분류했다.
|
||||
- 2026-08-06 — mock 요청 소유권, 기존 fixture 상태의 E2E, 실제 브라우저를 대조해 오탐으로 정정했다.
|
||||
|
||||
## 7. 확정 항목의 plan·goal 전환
|
||||
|
||||
전환 항목 없음. `CCR-REV-P1-001`은 `P1-R1`에서 오탐으로 판정됐다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | 요구사항·계약·코드·test·실제 Chromium·visual QA 확인 |
|
||||
| 후보 항목 판정 완료 | 충족 | `CCR-REV-P1-001` 오탐 판정 |
|
||||
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
|
||||
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 자동·수동 검증 표와 `plan-task.md` Progress 기록 |
|
||||
|
||||
**최종 결론:** 확정 발견 사항 없음
|
||||
|
||||
**남은 항목:** 실제 개발 API integration은 이번 mock 기능 Gate 범위 밖이다.
|
||||
|
||||
## 9. 수정 후 검증 기록
|
||||
|
||||
### 1차 리뷰 후보 검증 — 2026-08-06
|
||||
|
||||
- 무엇을: `CCR-REV-P1-001`의 실제 mock E2E 영향 여부를 검증했다.
|
||||
- 왜: 실행되지 않는 route fixture를 수정하면 범위를 불필요하게 확장할 수 있다.
|
||||
- 어떻게:
|
||||
- 기존 fixture 상태의 Chromium E2E — `3/3` 성공
|
||||
- Comments Vitest — `15/15` 성공
|
||||
- typecheck·lint·`git diff --check` — exit 0
|
||||
- 실제 Chromium·visual QA — 첫·추가 답글, keyboard, 1280px·320px·200% PASS
|
||||
- 남은 항목: 없음
|
||||
216
docs/20260806_커뮤니티댓글답글/reviews/phase1-final-commit-audit.md
Normal file
216
docs/20260806_커뮤니티댓글답글/reviews/phase1-final-commit-audit.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# 커뮤니티 댓글 직접 답글 Phase 1 최종 커밋 감사
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 1 / `P1-T1`, `P1-R1`, `P1-R2`, `P1-GATE` |
|
||||
| 기준 commit 또는 working tree | `00f06b992f25dbfe3b700babfa6dc28ed08f967b` + `P1-R2` 문서 working tree |
|
||||
| 리뷰 일자 | 2026-08-06 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | [prd.md](../prd.md), [api-contract.md](../api-contract.md), [plan-task.md](../plan-task.md) |
|
||||
| 리뷰 상태 | 수정 검증 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
### 목적
|
||||
|
||||
- 최종 커밋의 코드·test가 `CCR-001~006`과 API Contract를 충족하는지 확인한다.
|
||||
- 완료된 계획의 Files·Interfaces·검증 기록이 실제 commit diff와 일치하는지 확인한다.
|
||||
- 기존 리뷰 결과와 현재 HEAD의 자동 검증 결과를 독립적으로 대조한다.
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- 코드: `src/features/comments/components/CommentThread.tsx`
|
||||
- 테스트: `src/features/comments/tests/comment-thread.test.tsx`, `tests/e2e/comments.spec.ts`
|
||||
- 문서: `prd.md`, `api-contract.md`, `plan-task.md`, `phase1-community-comment-replies.md`
|
||||
- 검증: Comments Vitest, Chromium mock E2E, typecheck, lint, development build, commit diff
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 실제 개발 API를 사용한 server integration
|
||||
- 답글 수정·삭제·pagination의 기존 구현 재설계
|
||||
- 이번 감사에서 별도 browser 수동 QA 재실행
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
### 심각도
|
||||
|
||||
| 심각도 | 기준 |
|
||||
|---|---|
|
||||
| Blocker | 보안·데이터 손실 위험, 핵심 journey 불능, 완료 판정 무효 |
|
||||
| High | 확정 요구사항·API Contract 위반 또는 주요 회귀 |
|
||||
| Medium | 제한 조건의 기능·접근성·복구 문제 |
|
||||
| Low | 비핵심 유지보수성·문서 정합성 문제 |
|
||||
|
||||
### 상태
|
||||
|
||||
| 상태 | 의미 | 후속 처리 |
|
||||
|---|---|---|
|
||||
| 후보 | 근거를 발견했지만 판정 전 | 재현 후 상태 변경 |
|
||||
| 확정 | 코드·test·문서로 문제 확인 | 회귀 Task 전환 |
|
||||
| 오탐 | 실제 실행 경로나 요구사항 위반이 아님 | 근거를 보존하고 종료 |
|
||||
| 보류 | 외부 계약·환경·제품 결정 필요 | 담당·재개 조건 기록 |
|
||||
| 수정 완료 | 수정과 관련 검증 완료 | 검증 결과 누적 |
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
### 문서와 코드
|
||||
|
||||
- 요구사항: `CCR-001~006`
|
||||
- API Contract: 직접 답글 GET, Community 댓글 POST, 2단계 댓글 구조 불변식
|
||||
- 계획: `P1-T1`, `P1-R1`, `P1-R2`, `P1-GATE`
|
||||
- 구현: `CommentThread.tsx`의 `replyActionLabel`, `toggleReplies()`, `createReply()`
|
||||
- 테스트: Community 첫 답글 unit test, Community 첫·두 번째 답글 Chromium E2E
|
||||
- commit 범위: 문서 4개, 구현 1개, test 2개
|
||||
|
||||
### 실행 환경
|
||||
|
||||
```text
|
||||
OS: macOS 26.0
|
||||
Node: v24.12.0
|
||||
npm: 11.7.0
|
||||
Browser: Playwright Chromium
|
||||
환경 변수: VITE_API_MODE=mock
|
||||
```
|
||||
|
||||
### 실행한 검증
|
||||
|
||||
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx` | 성공 | exit 0, `8/8` |
|
||||
| `npm run test:run -- src/features/comments` | 성공 | exit 0, `15/15` |
|
||||
| `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium` | 성공 | 샌드박스 밖 재실행 exit 0, `3/3` |
|
||||
| 동일 Chromium E2E의 최초 샌드박스 실행 | 실행 불가 | `127.0.0.1:8889` listen `EPERM`; 제품 실패가 아닌 실행 권한 제한 |
|
||||
| `npm run typecheck` | 성공 | exit 0 |
|
||||
| `npm run lint` | 성공 | exit 0 |
|
||||
| `npm run build:dev` | 성공 | exit 0, 기존 500kB chunk warning만 발생 |
|
||||
| `git diff --check e82e209..00f06b9` | 성공 | 출력 없음 |
|
||||
| commit 파일 범위 대조 | 성공 | API·schema·mock·dependency 변경 0건 |
|
||||
| 별도 browser 수동 QA | 불가 | 이번 감사에서는 재실행하지 않았으며 기존 Phase 리뷰의 기록만 확인 |
|
||||
|
||||
## 5. 요구사항별 판정
|
||||
|
||||
| 요구사항 | 판정 | 근거 |
|
||||
|---|---|---|
|
||||
| `CCR-001` | 충족 | 활성 Community의 `replyCount=0` root에 `답글 작성` label을 전달하며 unit·E2E에서 노출 확인 |
|
||||
| `CCR-002` | 충족 | 클릭 후 root ID의 replies page 0 GET과 답글 region·form을 unit·E2E에서 확인 |
|
||||
| `CCR-003` | 충족 | Community POST가 trim된 `comment`, root `parentId`, `isSecret=false`만 전송하고 `languageCode`를 보내지 않음 |
|
||||
| `CCR-004` | 충족 | E2E가 같은 root `2102`에 첫·두 번째 답글을 등록하고 재조회 후 각각 1건 표시함 |
|
||||
| `CCR-005` | 충족 | reply row에 `onShowReplies`를 전달하지 않으며 E2E에서 중첩 답글 action 0건 확인 |
|
||||
| `CCR-006` | 충족 | `canMutate=false` Community root 진입 차단 unit test와 기존 공통 pending·실패·초안 회귀 test `15/15` 통과 |
|
||||
|
||||
## 6. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `CCR-REV-P1-002` | Low | 수정 완료 | 완료된 plan 현재 상태에 구현 전 문장이 남아 있다 | `P1-R2` | `P1-R2` 완료 |
|
||||
|
||||
기능·API Contract 위반에 해당하는 확정 발견 사항은 없다.
|
||||
|
||||
## 7. 발견 사항 상세
|
||||
|
||||
### CCR-REV-P1-002 — 완료된 plan 현재 상태에 구현 전 문장이 남아 있다
|
||||
|
||||
- **심각도:** Low
|
||||
- **상태:** 수정 완료
|
||||
- **관련 요구사항:** `CCR-001`
|
||||
- **관련 계약:** 없음
|
||||
- **소유 Task:** `P1-R2`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
`plan-task.md`는 상태와 Phase를 구현 완료로 표시하지만 현재 상태에서
|
||||
`replyCount === 0`인 Community root에는 첫 답글 작성 진입이 없다고 기록한다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 문서: `plan-task.md:5`, `plan-task.md:9`, `plan-task.md:21`은 완료 상태다.
|
||||
- 문서: `plan-task.md:25`는 첫 답글 작성 진입이 없다고 서술한다.
|
||||
- 코드: `CommentThread.tsx:170`은 활성 Community 빈 root에 `답글 작성`을 표시한다.
|
||||
- 테스트: focused `8/8`, Comments `15/15`, Chromium `3/3`이 해당 동작을 확인한다.
|
||||
|
||||
**재현 또는 검증 절차**
|
||||
|
||||
1. `plan-task.md`의 문서 상태와 현재 상태 표를 확인한다.
|
||||
2. 같은 문서 25행의 미구현 문장을 확인한다.
|
||||
3. `CommentThread.tsx`의 action 조건 및 Community unit·E2E 결과와 대조한다.
|
||||
4. 완료 문서가 실제 구현 상태와 반대인 한 문장을 포함함을 확인한다.
|
||||
|
||||
**영향**
|
||||
|
||||
제품 동작에는 영향이 없다. 후속 작업자가 기능이 미구현됐다고 오인할 수 있고,
|
||||
문서 완료 상태와 현재 상태 설명이 충돌한다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
해당 문장을 “`replyCount === 0`인 Community root에도 `답글 작성` 진입이
|
||||
제공된다.”로 정정하고 문서 전용 검증 기록을 누적한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-08-06 — 최종 commit의 plan·코드·test 대조로 문서 정합성 문제를 확정했다.
|
||||
- 2026-08-06 — `P1-R2`에서 현재 상태 문장을 실제 구현 완료 내용으로 정정하고 marker·link·diff 검증을 통과해 수정 완료로 판정했다.
|
||||
|
||||
## 8. 확정 항목의 plan·goal 전환
|
||||
|
||||
이번 요청은 최종 커밋의 읽기·진단 감사이므로 기존 `plan-task.md`를 변경하지
|
||||
않았다. 수정 시 아래 문서 전용 회귀 Task를 먼저 계획에 추가한다.
|
||||
|
||||
### 신규 회귀 수정 Task 초안
|
||||
|
||||
```markdown
|
||||
### Task R1.2 완료 문서 현재 상태 정합성 복구
|
||||
|
||||
**Goal 실행 `P1-R2`:** `CCR-REV-P1-002`의 미구현 문장을 실제 완료 상태로
|
||||
정정하고 기존 Progress와 결정 기록을 보존한다.
|
||||
|
||||
- **시작 조건:** `CCR-REV-P1-002` 확정, 완료된 `P1-T1`, `P1-GATE`.
|
||||
- **완료 증거:** 현재 상태 문장 정정, 기존 기록 보존, 문서 marker·link·diff 검증.
|
||||
- **범위 밖:** 애플리케이션 코드·test·API Contract 변경.
|
||||
```
|
||||
|
||||
### 후속 plan 반영
|
||||
|
||||
2026-08-06 — 사용자의 회귀 수정 요청에 따라 위 초안을 `plan-task.md`의
|
||||
`P1-R2`로 반영하고 완료했다. 애플리케이션 코드·test·API Contract는 변경하지
|
||||
않았다.
|
||||
|
||||
## 9. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | 최종 commit 문서·코드·test·diff 확인 |
|
||||
| 후보 항목 판정 완료 | 충족 | `CCR-REV-P1-002` 수정 완료 |
|
||||
| 확정 항목 plan 반영 | 충족 | `P1-R2` 추가·완료와 Progress 기록 |
|
||||
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 자동 검증 표에 실제 결과와 E2E 최초 실행 불가 사유 기록 |
|
||||
|
||||
**최종 결론:** 수정 검증 완료. 기능 구현과 자동 검증은 문서 요구사항을
|
||||
충족하고 `CCR-REV-P1-002`의 문서 불일치도 해소됐다.
|
||||
|
||||
**남은 항목:** 실제 개발 API integration과 별도 수동 browser QA는 이번 감사
|
||||
범위 밖이다.
|
||||
|
||||
## 10. 수정 후 검증 기록
|
||||
|
||||
### 1차 수정 검증 — 2026-08-06
|
||||
|
||||
- 무엇을: `CCR-REV-P1-002`를 `P1-R2`로 전환하고 stale 현재 상태 문장을 정정했다.
|
||||
- 왜: 완료 상태·코드·test와 현재 상태 한 문장이 충돌했다.
|
||||
- 어떻게:
|
||||
- stale 현재 상태 marker 부재 검사 — 성공, 0건
|
||||
- 완료 상태 문장과 review 상호 링크 검사 — 성공, 각 1건 이상
|
||||
- trailing whitespace 검사와 `git diff --check` — 성공, 오류 0건
|
||||
- 남은 항목: 없음.
|
||||
|
||||
### 2차 기능 회귀 감사 — 2026-08-06
|
||||
|
||||
- 무엇을: `P1-R2` 문서 정정 뒤 기존 기능과 정적 품질이 유지되는지 확인했다.
|
||||
- 왜: 최종 완료 상태가 코드·test·문서에서 동일한지 다시 판정하기 위해서다.
|
||||
- 어떻게:
|
||||
- `npm run test:run -- src/features/comments` — 성공, `15/15`
|
||||
- `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium` — 성공, `3/3`
|
||||
- `npm run typecheck`, `npm run lint`, `npm run build:dev` — 모두 exit 0; 기존 500kB chunk warning만 발생
|
||||
- `git diff --check` — 성공, 오류 0건
|
||||
- 남은 항목: 없음.
|
||||
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();
|
||||
|
||||
@@ -74,7 +74,7 @@ test("navigates to /ai-characters after a successful login", async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters"));
|
||||
expect(screen.getByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("routes /ai-characters/new to the character create form", async () => {
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getAudioContentCreateCharacterIdFromPath, getAudioContentDetailRouteFromPath, getAudioContentEditRouteFromPath, getAudioContentListCharacterIdFromPath, getCharacterEditIdFromPath, getCharacterIdFromPath, getCommunityPostCreateCharacterIdFromPath, getCommunityPostListCharacterIdFromPath, getFanTalkListCharacterIdFromPath, getSeriesCreateCharacterIdFromPath, getSeriesDetailRouteFromPath, getSeriesEditRouteFromPath, getSeriesListCharacterIdFromPath, getSeriesOrderCharacterIdFromPath, navigateTo, useBrowserLocation } from "@/app/browser-location";
|
||||
import { routePaths } from "@/app/route-paths";
|
||||
import { AudioContentDetailPage } from "@/features/audio-contents/pages/AudioContentDetailPage";
|
||||
import { AudioContentFormPage } from "@/features/audio-contents/pages/AudioContentFormPage";
|
||||
import { AudioContentListPage } from "@/features/audio-contents/pages/AudioContentListPage";
|
||||
import { useAuthSession } from "@/features/auth/model/auth-session-context";
|
||||
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
|
||||
import { CharacterCreatePage } from "@/features/characters/pages/CharacterCreatePage";
|
||||
import { CharacterDetailPage } from "@/features/characters/pages/CharacterDetailPage";
|
||||
import { CharacterEditPage } from "@/features/characters/pages/CharacterEditPage";
|
||||
import { CharacterListPage } from "@/features/characters/pages/CharacterListPage";
|
||||
import { CommunityPostListPage } from "@/features/community-posts/pages/CommunityPostListPage";
|
||||
import { CommunityPostFormPage } from "@/features/community-posts/pages/CommunityPostFormPage";
|
||||
import { FanTalkListPage } from "@/features/fan-talks/pages/FanTalkListPage";
|
||||
import { SeriesDetailPage } from "@/features/series/pages/SeriesDetailPage";
|
||||
import { SeriesFormPage } from "@/features/series/pages/SeriesFormPage";
|
||||
import { SeriesListPage } from "@/features/series/pages/SeriesListPage";
|
||||
import { SeriesOrderPage } from "@/features/series/pages/SeriesOrderPage";
|
||||
import type { ApiClient } from "@/shared/api/client";
|
||||
import type { ApiMode } from "@/shared/config/env";
|
||||
import { MockModeBanner } from "@/shared/ui/mock-mode-banner";
|
||||
import { PageState } from "@/shared/ui/page-state";
|
||||
|
||||
const AudioContentDetailPage = lazy(() => import("@/features/audio-contents/pages/AudioContentDetailPage").then(({ AudioContentDetailPage }) => ({ default: AudioContentDetailPage })));
|
||||
const AudioContentFormPage = lazy(() => import("@/features/audio-contents/pages/AudioContentFormPage").then(({ AudioContentFormPage }) => ({ default: AudioContentFormPage })));
|
||||
const AudioContentListPage = lazy(() => import("@/features/audio-contents/pages/AudioContentListPage").then(({ AudioContentListPage }) => ({ default: AudioContentListPage })));
|
||||
const CharacterCreatePage = lazy(() => import("@/features/characters/pages/CharacterCreatePage").then(({ CharacterCreatePage }) => ({ default: CharacterCreatePage })));
|
||||
const CharacterDetailPage = lazy(() => import("@/features/characters/pages/CharacterDetailPage").then(({ CharacterDetailPage }) => ({ default: CharacterDetailPage })));
|
||||
const CharacterEditPage = lazy(() => import("@/features/characters/pages/CharacterEditPage").then(({ CharacterEditPage }) => ({ default: CharacterEditPage })));
|
||||
const CharacterListPage = lazy(() => import("@/features/characters/pages/CharacterListPage").then(({ CharacterListPage }) => ({ default: CharacterListPage })));
|
||||
const CommunityPostFormPage = lazy(() => import("@/features/community-posts/pages/CommunityPostFormPage").then(({ CommunityPostFormPage }) => ({ default: CommunityPostFormPage })));
|
||||
const CommunityPostListPage = lazy(() => import("@/features/community-posts/pages/CommunityPostListPage").then(({ CommunityPostListPage }) => ({ default: CommunityPostListPage })));
|
||||
const FanTalkListPage = lazy(() => import("@/features/fan-talks/pages/FanTalkListPage").then(({ FanTalkListPage }) => ({ default: FanTalkListPage })));
|
||||
const SeriesDetailPage = lazy(() => import("@/features/series/pages/SeriesDetailPage").then(({ SeriesDetailPage }) => ({ default: SeriesDetailPage })));
|
||||
const SeriesFormPage = lazy(() => import("@/features/series/pages/SeriesFormPage").then(({ SeriesFormPage }) => ({ default: SeriesFormPage })));
|
||||
const SeriesListPage = lazy(() => import("@/features/series/pages/SeriesListPage").then(({ SeriesListPage }) => ({ default: SeriesListPage })));
|
||||
const SeriesOrderPage = lazy(() => import("@/features/series/pages/SeriesOrderPage").then(({ SeriesOrderPage }) => ({ default: SeriesOrderPage })));
|
||||
|
||||
const focusableSelector = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
|
||||
const sessionExpiredNotice = "세션이 만료되었습니다. 다시 로그인하세요.";
|
||||
@@ -167,7 +169,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}
|
||||
@@ -196,22 +198,24 @@ export function ProtectedAdminShell({ apiClient, apiMode, routeError }: { readon
|
||||
{location.successNotification}
|
||||
</p>
|
||||
)}
|
||||
{location.path === routePaths.aiCharacterCreate ? <CharacterCreatePage apiClient={apiClient} /> : null}
|
||||
{characterEditId !== null ? <CharacterEditPage apiClient={apiClient} characterId={characterEditId} /> : null}
|
||||
{location.path !== routePaths.aiCharacterCreate && characterEditId === null && characterId === null && audioContentListCharacterId === null && audioContentCreateCharacterId === null && audioContentEditRoute === null && audioContentDetailRoute === null && communityPostCreateCharacterId === null && communityPostListCharacterId === null && fanTalkListCharacterId === null && seriesCreateCharacterId === null && seriesEditRoute === null && seriesListCharacterId === null && seriesOrderCharacterId === null && seriesDetailRoute === null ? <CharacterListPage apiClient={apiClient} routeError={routeError} /> : null}
|
||||
{location.path !== routePaths.aiCharacterCreate && characterEditId === null && characterId !== null ? <CharacterDetailPage apiClient={apiClient} characterId={characterId} /> : null}
|
||||
{audioContentListCharacterId !== null ? <AudioContentListPage apiClient={apiClient} characterId={audioContentListCharacterId} /> : null}
|
||||
{audioContentCreateCharacterId !== null ? <AudioContentFormPage apiClient={apiClient} characterId={audioContentCreateCharacterId} uploadAuth={uploadAuth} /> : null}
|
||||
{audioContentEditRoute !== null ? <AudioContentFormPage apiClient={apiClient} characterId={audioContentEditRoute.characterId} contentId={audioContentEditRoute.contentId} uploadAuth={uploadAuth} /> : null}
|
||||
{audioContentDetailRoute !== null ? <AudioContentDetailPage apiClient={apiClient} characterId={audioContentDetailRoute.characterId} contentId={audioContentDetailRoute.contentId} /> : null}
|
||||
{communityPostListCharacterId !== null ? <CommunityPostListPage apiClient={apiClient} characterId={communityPostListCharacterId} /> : null}
|
||||
{communityPostCreateCharacterId !== null ? <CommunityPostFormPage apiClient={apiClient} characterId={communityPostCreateCharacterId} /> : null}
|
||||
{fanTalkListCharacterId !== null ? <FanTalkListPage apiClient={apiClient} characterId={fanTalkListCharacterId} /> : null}
|
||||
{seriesListCharacterId !== null ? <SeriesListPage apiClient={apiClient} characterId={seriesListCharacterId} /> : null}
|
||||
{seriesCreateCharacterId !== null ? <SeriesFormPage apiClient={apiClient} characterId={seriesCreateCharacterId} /> : null}
|
||||
{seriesEditRoute !== null ? <SeriesFormPage apiClient={apiClient} characterId={seriesEditRoute.characterId} seriesId={seriesEditRoute.seriesId} /> : null}
|
||||
{seriesOrderCharacterId !== null ? <SeriesOrderPage apiClient={apiClient} characterId={seriesOrderCharacterId} /> : null}
|
||||
{seriesDetailRoute !== null ? <SeriesDetailPage apiClient={apiClient} characterId={seriesDetailRoute.characterId} seriesId={seriesDetailRoute.seriesId} /> : null}
|
||||
<Suspense fallback={<PageState state="loading" title="화면을 불러오는 중" />} key={location.path}>
|
||||
{location.path === routePaths.aiCharacterCreate ? <CharacterCreatePage apiClient={apiClient} /> : null}
|
||||
{characterEditId !== null ? <CharacterEditPage apiClient={apiClient} characterId={characterEditId} /> : null}
|
||||
{location.path !== routePaths.aiCharacterCreate && characterEditId === null && characterId === null && audioContentListCharacterId === null && audioContentCreateCharacterId === null && audioContentEditRoute === null && audioContentDetailRoute === null && communityPostCreateCharacterId === null && communityPostListCharacterId === null && fanTalkListCharacterId === null && seriesCreateCharacterId === null && seriesEditRoute === null && seriesListCharacterId === null && seriesOrderCharacterId === null && seriesDetailRoute === null ? <CharacterListPage apiClient={apiClient} routeError={routeError} /> : null}
|
||||
{location.path !== routePaths.aiCharacterCreate && characterEditId === null && characterId !== null ? <CharacterDetailPage apiClient={apiClient} characterId={characterId} /> : null}
|
||||
{audioContentListCharacterId !== null ? <AudioContentListPage apiClient={apiClient} characterId={audioContentListCharacterId} /> : null}
|
||||
{audioContentCreateCharacterId !== null ? <AudioContentFormPage apiClient={apiClient} characterId={audioContentCreateCharacterId} uploadAuth={uploadAuth} /> : null}
|
||||
{audioContentEditRoute !== null ? <AudioContentFormPage apiClient={apiClient} characterId={audioContentEditRoute.characterId} contentId={audioContentEditRoute.contentId} uploadAuth={uploadAuth} /> : null}
|
||||
{audioContentDetailRoute !== null ? <AudioContentDetailPage apiClient={apiClient} characterId={audioContentDetailRoute.characterId} contentId={audioContentDetailRoute.contentId} /> : null}
|
||||
{communityPostListCharacterId !== null ? <CommunityPostListPage apiClient={apiClient} characterId={communityPostListCharacterId} /> : null}
|
||||
{communityPostCreateCharacterId !== null ? <CommunityPostFormPage apiClient={apiClient} characterId={communityPostCreateCharacterId} /> : null}
|
||||
{fanTalkListCharacterId !== null ? <FanTalkListPage apiClient={apiClient} characterId={fanTalkListCharacterId} /> : null}
|
||||
{seriesListCharacterId !== null ? <SeriesListPage apiClient={apiClient} characterId={seriesListCharacterId} /> : null}
|
||||
{seriesCreateCharacterId !== null ? <SeriesFormPage apiClient={apiClient} characterId={seriesCreateCharacterId} /> : null}
|
||||
{seriesEditRoute !== null ? <SeriesFormPage apiClient={apiClient} characterId={seriesEditRoute.characterId} seriesId={seriesEditRoute.seriesId} /> : null}
|
||||
{seriesOrderCharacterId !== null ? <SeriesOrderPage apiClient={apiClient} characterId={seriesOrderCharacterId} /> : null}
|
||||
{seriesDetailRoute !== null ? <SeriesDetailPage apiClient={apiClient} characterId={seriesDetailRoute.characterId} seriesId={seriesDetailRoute.seriesId} /> : null}
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,14 +5,15 @@ 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";
|
||||
import type { AudioContentDetail } from "@/features/audio-contents/model/types";
|
||||
import { audioContentCreateResponseSchema } from "@/features/audio-contents/schemas/audio-content-schema";
|
||||
import type { AudioContentTheme } from "@/features/audio-contents/schemas/audio-content-schema";
|
||||
import type { AudioContentTheme, AudioContentUpdateRequest } from "@/features/audio-contents/schemas/audio-content-schema";
|
||||
import { AUDIO_COVER_POLICY } from "@/features/audio-contents/validation/audio-cover-policy";
|
||||
import type { ApiClient } from "@/shared/api/client";
|
||||
import { ApiError } from "@/shared/api/api-error";
|
||||
@@ -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,12 @@ const errorIds = {
|
||||
title: "audio-content-title-error",
|
||||
} as const;
|
||||
|
||||
const previewTimePattern = /^\d{2}:\d{2}:\d{2}$/;
|
||||
|
||||
function hasUpdateFields(request: AudioContentUpdateRequest): boolean {
|
||||
return Object.keys(request).length > 0;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -71,14 +80,20 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
|
||||
const isDeactivatingRef = useRef(false);
|
||||
const isSubmittingRef = useRef(false);
|
||||
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 parsedEditPrice = parsePrice(price);
|
||||
const editRequest = mode === "edit" && audio !== undefined && parsedEditPrice !== null ? toUpdateRequest({ audio, detail: detail.trim(), price: parsedEditPrice, tags: tags.trim(), title: title.trim() }) : {};
|
||||
const editHasChanges = mode === "edit" && (coverImage !== null || hasUpdateFields(editRequest) || (audio !== undefined && parsedEditPrice === null && price !== createInitialPrice(audio.price)));
|
||||
const createHasChanges = mode === "create" && (title !== "" || detail !== "" || tags !== "" || price !== createInitialPrice(undefined) || audioFile !== null || coverImage !== null || themeId !== null || releaseSchedule.publishMode !== "immediate" || releaseSchedule.releaseDateTime !== "" || createSettings !== defaultAudioContentCreateSettings);
|
||||
const isDirty = editHasChanges || createHasChanges;
|
||||
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 +136,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,
|
||||
@@ -137,6 +154,9 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
|
||||
if (isSubmittingRef.current) {
|
||||
return;
|
||||
}
|
||||
if (mode === "edit" && !editHasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextErrors = validateForm();
|
||||
setErrors(nextErrors);
|
||||
@@ -163,7 +183,7 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
|
||||
return;
|
||||
}
|
||||
if (mode === "edit" && audio !== undefined && contentId !== null) {
|
||||
await updateAudioContent(apiClient, characterId, contentId, { coverImage: coverImage ?? undefined, request: toUpdateRequest({ audio, detail: detail.trim(), price: parsedPrice, tags: tags.trim(), title: title.trim() }) });
|
||||
await updateAudioContent(apiClient, characterId, contentId, { coverImage: coverImage ?? undefined, request: editRequest });
|
||||
setUploadState({ progress: 100, status: "success" });
|
||||
navigateTo(routePaths.aiCharacterAudioContentDetail(characterId, contentId), { successNotification: "오디오 콘텐츠를 저장했습니다." });
|
||||
}
|
||||
@@ -222,42 +242,25 @@ 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} />}
|
||||
<div className="flex justify-end gap-2">
|
||||
{mode === "edit" ? <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={() => setIsDeactivateDialogOpen(true)} type="button">비활성화</button> : null}
|
||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(mode === "create" ? routePaths.aiCharacterAudioContents(characterId) : routePaths.aiCharacterAudioContentDetail(characterId, contentId ?? "")))} 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:cursor-not-allowed disabled:opacity-60" disabled={uploadState.status === "uploading" || isCoverSubmitBlocked} type="submit">{mode === "create" ? "생성" : "저장"}</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:cursor-not-allowed disabled:opacity-60" disabled={uploadState.status === "uploading" || isCoverSubmitBlocked || (mode === "edit" && !editHasChanges)} type="submit">{mode === "create" ? "생성" : "저장"}</button>
|
||||
</div>
|
||||
</form>
|
||||
{cropSource === null ? null : <ImageCropDialog image={cropSource} onApply={(file) => { setCoverImage(file); setCropSource(null); setErrors((current) => ({ ...current, cover: undefined })); }} onCancel={() => { setCoverImage(null); setCropSource(null); setErrors((current) => ({ ...current, cover: undefined })); }} open policy={AUDIO_COVER_POLICY} renderCrop={renderCrop} />}
|
||||
|
||||
@@ -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}$/;
|
||||
|
||||
@@ -114,13 +112,12 @@ export function toCreateRequest(params: { readonly detail: string; readonly pric
|
||||
}
|
||||
|
||||
export function toUpdateRequest(params: { readonly detail: string; readonly price: number; readonly tags: string; readonly title: string; readonly audio: AudioContentDetail }): AudioContentUpdateRequest {
|
||||
return {
|
||||
title: params.title,
|
||||
detail: params.detail,
|
||||
tags: params.tags,
|
||||
price: params.price,
|
||||
isAdult: params.audio.isAdult,
|
||||
isPointAvailable: params.audio.isAvailableUsePoint,
|
||||
isCommentAvailable: params.audio.isCommentAvailable,
|
||||
const request: AudioContentUpdateRequest = {
|
||||
...(params.title === params.audio.title.trim() ? {} : { title: params.title }),
|
||||
...(params.detail === params.audio.detail.trim() ? {} : { detail: params.detail }),
|
||||
...(params.tags === params.audio.tag.trim() ? {} : { tags: params.tags }),
|
||||
...(params.price === params.audio.price ? {} : { price: params.price }),
|
||||
};
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
@@ -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,7 +26,70 @@ function createInactiveFormClient(requests: CapturedRequest[]): ApiClient {
|
||||
};
|
||||
}
|
||||
|
||||
test("AudioContentFormPage update omits unsupported controls and soft delete navigates to the audio list", async () => {
|
||||
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 disables edit save when no field or cover changed", 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
|
||||
const saveButton = await screen.findByRole("button", { name: "저장" });
|
||||
|
||||
// Then
|
||||
expect(saveButton).toBeDisabled();
|
||||
fireEvent.click(saveButton);
|
||||
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("AudioContentFormPage disables edit save when price formatting normalizes to the original value", 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
|
||||
const priceInput = await screen.findByLabelText("가격");
|
||||
fireEvent.change(priceInput, { target: { value: "01000" } });
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
||||
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("AudioContentFormPage update sends only changed fields and soft delete navigates to the audio list", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
||||
@@ -42,7 +105,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,11 +119,7 @@ test("AudioContentFormPage update omits unsupported controls and soft delete nav
|
||||
expect(await readJsonPart(updateBody.get("request"))).toEqual({
|
||||
title: "수정 오디오",
|
||||
detail: "수정 설명",
|
||||
tags: "수정",
|
||||
price: 0,
|
||||
isAdult: false,
|
||||
isPointAvailable: true,
|
||||
isCommentAvailable: true,
|
||||
});
|
||||
|
||||
// When
|
||||
@@ -163,12 +221,10 @@ test("AudioContentFormPage keeps existing edit cover when replacement crop is ca
|
||||
fireEvent.change(await screen.findByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "replacement.png", { type: "image/png" })] } });
|
||||
const cropDialog = await screen.findByRole("dialog", { name: "이미지 crop" });
|
||||
fireEvent.click(within(cropDialog).getByRole("button", { name: "취소" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9001"));
|
||||
const updateRequest = requests.find((request) => request.method === "PUT");
|
||||
expect(requireFormData(updateRequest?.body).has("coverImage")).toBe(false);
|
||||
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
|
||||
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("AudioContentFormPage rejects a replacement cover MIME mismatch before crop preparation", async () => {
|
||||
@@ -216,11 +272,13 @@ test("AudioContentFormPage ignores stale edit cover sources and saves the latest
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9001"));
|
||||
const coverPart = requireFormData(requests.at(-1)?.body).get("coverImage");
|
||||
const updateBody = requireFormData(requests.at(-1)?.body);
|
||||
const coverPart = updateBody.get("coverImage");
|
||||
if (!(coverPart instanceof File)) {
|
||||
throw new TypeError("Expected cover image file");
|
||||
}
|
||||
expect(coverPart.name).toBe("fresh.png");
|
||||
expect(await readJsonPart(updateBody.get("request"))).toEqual({});
|
||||
});
|
||||
|
||||
test("AudioContentFormPage shows an edit cover preparation error when preview creation rejects", async () => {
|
||||
@@ -234,5 +292,5 @@ test("AudioContentFormPage shows an edit cover preparation error when preview cr
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("이미지 미리보기 준비에 실패했습니다.");
|
||||
expect(screen.getByRole("button", { name: "저장" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -25,10 +25,10 @@ export function CharacterListItem({ character }: { readonly character: Character
|
||||
<img alt="" className="size-12 shrink-0 rounded-md object-cover" height="48" loading="lazy" src={character.imageUrl} width="48" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-semibold">{character.name}</span>
|
||||
<span className="mt-1 line-clamp-2 block text-sm text-muted-foreground">{description}</span>
|
||||
<span className="mt-2 block text-xs font-semibold text-info">ID {character.id} · {character.region}</span>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">{character.tags.join(", ")}</span>
|
||||
<span className="block break-keep break-words font-semibold">{character.name}</span>
|
||||
<span className="mt-1 line-clamp-2 block break-keep break-words text-sm text-muted-foreground">{description}</span>
|
||||
<span className="mt-2 block break-keep break-words text-xs font-semibold text-info">ID {character.id} · {character.region}</span>
|
||||
<span className="mt-1 block break-keep break-words text-xs text-muted-foreground">{character.tags.join(", ")}</span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -8,6 +8,9 @@ type BackgroundRequest = NonNullable<CreateCharacterParams["request"]["backgroun
|
||||
type MemoryRequest = NonNullable<CreateCharacterParams["request"]["memories"]>[number];
|
||||
type CreateOptionalRequest = Partial<CreateCharacterParams["request"]>;
|
||||
type MutableCreateOptionalRequest = { -readonly [Key in keyof CreateOptionalRequest]: CreateOptionalRequest[Key] };
|
||||
type UpdateOptionalRequest = UpdateCharacterParams["request"];
|
||||
type MutableUpdateOptionalRequest = { -readonly [Key in keyof UpdateOptionalRequest]: UpdateOptionalRequest[Key] };
|
||||
const updateOptionalKeys = ["age", "gender", "mbti", "speechPattern", "speechStyle", "appearance", "originalTitle", "originalLink", "characterType", "tags", "hobbies", "values", "goals", "relationships", "personalities", "backgrounds", "memories"] as const satisfies readonly (keyof UpdateOptionalRequest)[];
|
||||
|
||||
function emptyRelationship(): RelationshipDraft {
|
||||
return { personName: "", relationshipName: "", description: "", importance: "", relationshipType: "", currentStatus: "" };
|
||||
@@ -123,7 +126,7 @@ export function toCreateCharacterOptionalRequest(value: CharacterOptionalFieldsV
|
||||
return request;
|
||||
}
|
||||
|
||||
export function toUpdateCharacterOptionalRequest(value: CharacterOptionalFieldsValue): UpdateCharacterParams["request"] {
|
||||
function toSerializedUpdateCharacterOptionalRequest(value: CharacterOptionalFieldsValue): UpdateOptionalRequest {
|
||||
const tags = splitList(value.tags);
|
||||
const hobbies = splitList(value.hobbies);
|
||||
const values = splitList(value.values);
|
||||
@@ -139,3 +142,25 @@ export function toUpdateCharacterOptionalRequest(value: CharacterOptionalFieldsV
|
||||
relationships: relationshipRows.length === 0 ? null : relationshipRows, personalities: personalityRows.length === 0 ? null : personalityRows, backgrounds: backgroundRows.length === 0 ? null : backgroundRows, memories: memoryRows.length === 0 ? null : memoryRows,
|
||||
};
|
||||
}
|
||||
|
||||
function isSameSerializedValue(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
export function toUpdateCharacterOptionalRequest(value: CharacterOptionalFieldsValue, initialValue: CharacterOptionalFieldsValue): UpdateOptionalRequest {
|
||||
const current = toSerializedUpdateCharacterOptionalRequest(value);
|
||||
const initial = toSerializedUpdateCharacterOptionalRequest(initialValue);
|
||||
const request: MutableUpdateOptionalRequest = {};
|
||||
|
||||
function addChanged<Key extends keyof UpdateOptionalRequest>(key: Key): void {
|
||||
if (!isSameSerializedValue(current[key], initial[key])) {
|
||||
request[key] = current[key];
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of updateOptionalKeys) {
|
||||
addChanged(key);
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { navigateTo } from "@/app/browser-location";
|
||||
import { routePaths } from "@/app/route-paths";
|
||||
import { getCharacter, updateCharacter } from "@/features/characters/api/character-api";
|
||||
import type { UpdateCharacterParams } from "@/features/characters/api/character-api";
|
||||
import { CharacterOptionalFields } from "@/features/characters/components/CharacterOptionalFields";
|
||||
import { characterOptionalFieldsFromDetail, toUpdateCharacterOptionalRequest } from "@/features/characters/components/character-optional-field-serialization";
|
||||
import { OriginalWorkSearchField } from "@/features/characters/components/OriginalWorkSearchField";
|
||||
@@ -64,6 +65,10 @@ function validateImage(image: File | null): string | undefined {
|
||||
return "JPEG 또는 PNG 파일만 업로드하세요.";
|
||||
}
|
||||
|
||||
function hasRequestFields(request: UpdateCharacterParams["request"]): boolean {
|
||||
return Object.keys(request).length > 0;
|
||||
}
|
||||
|
||||
function CharacterEditForm({ apiClient, character, createCropSource, renderCrop }: { readonly apiClient: ApiClient; readonly character: CharacterDetail; readonly createCropSource: (file: File) => Promise<CropSourceImage>; readonly renderCrop?: (request: CropRenderRequest) => Promise<File> }) {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [description, setDescription] = useState(character.description);
|
||||
@@ -78,7 +83,19 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const imageSelectionId = useRef(0);
|
||||
const detailPath = routePaths.aiCharacterDetail(String(character.id));
|
||||
const dirty = image !== null || isImagePreparing || cropSource !== null || description !== character.description || name !== character.name || originalWork?.id !== character.originalWork?.id || systemPrompt !== character.systemPrompt || JSON.stringify(optionalFields) !== JSON.stringify(characterOptionalFieldsFromDetail(character));
|
||||
const initialOptionalFields = characterOptionalFieldsFromDetail(character);
|
||||
const initialDescription = character.description.trim();
|
||||
const initialName = character.name.trim();
|
||||
const initialSystemPrompt = character.systemPrompt.trim();
|
||||
const request: UpdateCharacterParams["request"] = {
|
||||
...toUpdateCharacterOptionalRequest(optionalFields, initialOptionalFields),
|
||||
...(description.trim() === initialDescription ? {} : { description: description.trim() }),
|
||||
...(name.trim() === initialName ? {} : { name: name.trim() }),
|
||||
...(originalWork !== null && originalWork.id !== character.originalWork?.id ? { originalWorkId: originalWork.id } : {}),
|
||||
...(systemPrompt.trim() === initialSystemPrompt ? {} : { systemPrompt: systemPrompt.trim() }),
|
||||
};
|
||||
const hasChanges = image !== null || hasRequestFields(request);
|
||||
const dirty = hasChanges || isImagePreparing || cropSource !== null;
|
||||
const imageSubmitBlocked = isImagePreparing || cropSource !== null;
|
||||
|
||||
useEffect(() => () => cropSource?.release?.(), [cropSource]);
|
||||
@@ -143,6 +160,9 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
if (!hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextErrors = validate();
|
||||
setErrors(nextErrors);
|
||||
@@ -155,13 +175,7 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
|
||||
try {
|
||||
await updateCharacter(apiClient, String(character.id), {
|
||||
image: image ?? undefined,
|
||||
request: {
|
||||
...toUpdateCharacterOptionalRequest(optionalFields),
|
||||
description: description.trim(),
|
||||
name: name.trim(),
|
||||
originalWorkId: originalWork?.id,
|
||||
systemPrompt: systemPrompt.trim(),
|
||||
},
|
||||
request,
|
||||
});
|
||||
navigateTo(routePaths.aiCharacterDetail(String(character.id)), { successNotification: "AI 캐릭터를 저장했습니다." });
|
||||
} catch (error: unknown) {
|
||||
@@ -208,7 +222,7 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
|
||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(detailPath))} 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={isSubmitting || imageSubmitBlocked} type="submit">
|
||||
<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={isSubmitting || imageSubmitBlocked || !hasChanges} type="submit">
|
||||
{isSubmitting ? "저장 중" : "저장"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -67,23 +67,12 @@ const inactiveCharacter: CharacterDetail = {
|
||||
isActive: false,
|
||||
};
|
||||
|
||||
const activeCharacterOptionalPayload = {
|
||||
age: null,
|
||||
gender: null,
|
||||
mbti: null,
|
||||
speechPattern: null,
|
||||
speechStyle: null,
|
||||
appearance: null,
|
||||
characterType: "Character",
|
||||
tags: null,
|
||||
hobbies: null,
|
||||
values: null,
|
||||
goals: null,
|
||||
relationships: null,
|
||||
personalities: null,
|
||||
backgrounds: null,
|
||||
memories: null,
|
||||
} as const;
|
||||
const characterWithWhitespaceDirectFields: CharacterDetail = {
|
||||
...activeCharacter,
|
||||
name: " 루나 ",
|
||||
description: " 차분한 상담형 캐릭터 ",
|
||||
systemPrompt: " 친절하게 답한다. ",
|
||||
};
|
||||
|
||||
function createEditClient(requests: CapturedRequest[], character: CharacterDetail = activeCharacter): ApiClient {
|
||||
return {
|
||||
@@ -185,7 +174,7 @@ function renderOriginalCrop(request: CropRenderRequest): Promise<File> {
|
||||
return Promise.resolve(request.file);
|
||||
}
|
||||
|
||||
test("CharacterEditPage submits changed fields without region or active state", async () => {
|
||||
test("CharacterEditPage submits only the changed direct field", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101/edit");
|
||||
@@ -193,8 +182,6 @@ test("CharacterEditPage submits changed fields without region or active state",
|
||||
|
||||
// When
|
||||
fireEvent.change(await screen.findByLabelText("이름"), { target: { value: "루나 수정" } });
|
||||
fireEvent.change(screen.getByLabelText("시스템 프롬프트"), { target: { value: "짧고 안전하게 답한다." } });
|
||||
fireEvent.change(screen.getByLabelText("설명"), { target: { value: "업데이트된 상담형 캐릭터" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
||||
|
||||
// Then
|
||||
@@ -203,12 +190,44 @@ test("CharacterEditPage submits changed fields without region or active state",
|
||||
{ path: "/api/v2/admin/ai-characters/101", method: undefined },
|
||||
{ path: "/api/v2/admin/ai-characters/101", method: "PUT" },
|
||||
]);
|
||||
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({
|
||||
...activeCharacterOptionalPayload,
|
||||
name: "루나 수정",
|
||||
systemPrompt: "짧고 안전하게 답한다.",
|
||||
description: "업데이트된 상담형 캐릭터",
|
||||
});
|
||||
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ name: "루나 수정" });
|
||||
});
|
||||
|
||||
test("CharacterEditPage disables save when no field or image changed", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101/edit");
|
||||
render(<CharacterEditPage apiClient={createEditClient(requests)} characterId="101" />);
|
||||
|
||||
// When
|
||||
const saveButton = await screen.findByRole("button", { name: "저장" });
|
||||
|
||||
// Then
|
||||
expect(saveButton).toBeDisabled();
|
||||
fireEvent.click(saveButton);
|
||||
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101", method: undefined }]);
|
||||
});
|
||||
|
||||
test("CharacterEditPage compares direct fields after applying the same trim rule", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101/edit");
|
||||
render(<CharacterEditPage apiClient={createEditClient(requests, characterWithWhitespaceDirectFields)} characterId="101" />);
|
||||
|
||||
// When
|
||||
const saveButton = await screen.findByRole("button", { name: "저장" });
|
||||
|
||||
// Then
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("이름"), { target: { value: "루나 수정" } });
|
||||
expect(saveButton).toBeEnabled();
|
||||
fireEvent.change(screen.getByLabelText("이름"), { target: { value: "루나" } });
|
||||
|
||||
// Then
|
||||
expect(saveButton).toBeDisabled();
|
||||
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101", method: undefined }]);
|
||||
});
|
||||
|
||||
test("CharacterEditPage blocks the direct edit route for inactive characters", async () => {
|
||||
@@ -282,7 +301,7 @@ test("CharacterEditPage keeps entered values and retries after an ApiError rejec
|
||||
expect(requests).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("CharacterEditPage submits optional scalar and array fields without region", async () => {
|
||||
test("CharacterEditPage submits only changed optional scalar fields", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101/edit");
|
||||
@@ -296,26 +315,8 @@ test("CharacterEditPage submits optional scalar and array fields without region"
|
||||
// Then
|
||||
await waitFor(() => expect(requests).toHaveLength(2));
|
||||
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({
|
||||
name: "루나",
|
||||
systemPrompt: "친절하게 답한다.",
|
||||
description: "차분한 상담형 캐릭터",
|
||||
age: "24",
|
||||
gender: "여성",
|
||||
mbti: "INFJ",
|
||||
speechPattern: "존댓말",
|
||||
speechStyle: "부드러움",
|
||||
appearance: "긴 머리",
|
||||
originalTitle: "달빛 상담소",
|
||||
originalLink: "https://example.com/original",
|
||||
characterType: "Character",
|
||||
tags: ["상담", "힐링"],
|
||||
hobbies: ["독서", "산책"],
|
||||
values: ["안전"],
|
||||
goals: ["도움"],
|
||||
relationships: [{ personName: "테오", relationshipName: "친구", description: "오랜 친구", importance: 3, relationshipType: "friend", currentStatus: "active" }],
|
||||
personalities: [{ trait: "차분함", description: "침착하게 응대" }],
|
||||
backgrounds: [{ topic: "출신", description: "달빛 상담소" }],
|
||||
memories: [{ title: "첫 상담", content: "따뜻한 기억", emotion: "calm" }],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -350,9 +351,6 @@ test("CharacterEditPage serializes cleared optional scalar and array fields as n
|
||||
// Then
|
||||
await waitFor(() => expect(requests).toHaveLength(2));
|
||||
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({
|
||||
name: "루나",
|
||||
systemPrompt: "친절하게 답한다.",
|
||||
description: "차분한 상담형 캐릭터",
|
||||
age: null,
|
||||
gender: null,
|
||||
mbti: null,
|
||||
@@ -422,6 +420,7 @@ test("CharacterEditPage crops a replacement image before update", async () => {
|
||||
const submittedImage = requireFile(requireFormData(requests[1]?.body).get("image"));
|
||||
expect(submittedImage.name).toBe("luna-cropped.png");
|
||||
expect(await submittedImage.text()).toBe("cropped");
|
||||
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({});
|
||||
});
|
||||
|
||||
test("CharacterEditPage blocks submit while replacement crop source preparation is pending", async () => {
|
||||
@@ -537,11 +536,10 @@ test("CharacterEditPage keeps the existing image when replacement crop is cancel
|
||||
// When
|
||||
fireEvent.change(await screen.findByLabelText("프로필 이미지"), { target: { files: [originalImage] } });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "취소" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(requests).toHaveLength(2));
|
||||
expect(requireFormData(requests[1]?.body).get("image")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(screen.queryByRole("button", { name: /기존 이미지 삭제/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -563,15 +561,9 @@ test("P10-T1 CharacterEditPage searches v2 original works and omits originalWork
|
||||
fireEvent.click(await screen.findByRole("button", { name: "별빛 기록실 선택" }));
|
||||
expect(screen.getByText("선택된 원작: 별빛 기록실")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "원작 선택 해제" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(requests).toHaveLength(3));
|
||||
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests[1]?.path).toBe("/api/v2/admin/ai-characters/original-works/search?searchTerm=%EB%B3%84%EB%B9%9B");
|
||||
expect(await readJsonPart(requireFormData(requests[2]?.body).get("request"))).toEqual({
|
||||
...activeCharacterOptionalPayload,
|
||||
name: "루나",
|
||||
systemPrompt: "친절하게 답한다.",
|
||||
description: "차분한 상담형 캐릭터",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from "react";
|
||||
import type { CommentRecord } from "@/features/comments/model/types";
|
||||
import { formatSeoulDateTime } from "@/shared/lib/formatters";
|
||||
|
||||
export function CommentItem({ canDelete = true, canEdit, comment, isSaving, onDelete, onEdit, onShowReplies, showRepliesButton }: { readonly canDelete?: boolean; readonly canEdit: boolean; readonly comment: CommentRecord; readonly isSaving: boolean; readonly onDelete: () => void; readonly onEdit: (comment: string) => void; readonly onShowReplies?: () => void; readonly showRepliesButton?: boolean }) {
|
||||
export function CommentItem({ canDelete = true, canEdit, comment, isSaving, onDelete, onEdit, onShowReplies, replyActionLabel }: { readonly canDelete?: boolean; readonly canEdit: boolean; readonly comment: CommentRecord; readonly isSaving: boolean; readonly onDelete: () => void; readonly onEdit: (comment: string) => void; readonly onShowReplies?: () => void; readonly replyActionLabel?: "답글 보기" | "답글 작성" }) {
|
||||
const [draft, setDraft] = useState(comment.comment);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const label = `${comment.comment}`;
|
||||
@@ -25,9 +25,9 @@ export function CommentItem({ canDelete = true, canEdit, comment, isSaving, onDe
|
||||
<p className="text-xs text-muted-foreground">{formatSeoulDateTime(comment.date)}{comment.isSecret ? " · 비밀" : ""}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{showRepliesButton === true && onShowReplies !== undefined ? <button className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={onShowReplies} type="button">{label} 답글 보기</button> : null}
|
||||
{canEdit ? <button className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={() => setIsEditing(true)} type="button">{label} 수정</button> : null}
|
||||
{canDelete ? <button className="rounded-md border border-destructive bg-card px-3 py-2 text-sm font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={onDelete} type="button">{label} 삭제</button> : null}
|
||||
{replyActionLabel !== undefined && onShowReplies !== undefined ? <button aria-label={`${label} ${replyActionLabel}`} className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={onShowReplies} type="button">{replyActionLabel}</button> : null}
|
||||
{canEdit ? <button aria-label={`${label} 수정`} className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={() => setIsEditing(true)} type="button">수정</button> : null}
|
||||
{canDelete ? <button aria-label={`${label} 삭제`} className="rounded-md border border-destructive bg-card px-3 py-2 text-sm font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={onDelete} type="button">삭제</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
{isEditing ? (
|
||||
|
||||
@@ -167,7 +167,7 @@ export function CommentThread({ apiClient, canMutate = true, target }: { readonl
|
||||
<div className="flex flex-col gap-3">
|
||||
{roots.data.items.map((comment) => (
|
||||
<div className="flex flex-col gap-3" key={comment.id}>
|
||||
<CommentItem canDelete={canMutate} canEdit={canMutate && canEdit(comment, requestTarget)} comment={comment} isSaving={isSaving} onDelete={() => void runMutation(() => deleteComment(apiClient, requestTarget, { commentId: comment.id }))} onEdit={(nextComment) => void runMutation(() => updateComment(apiClient, requestTarget, { commentId: comment.id, request: { comment: nextComment } }))} onShowReplies={() => toggleReplies(comment.id)} showRepliesButton={comment.replyCount > 0 || expandedRootIds.includes(comment.id)} />
|
||||
<CommentItem canDelete={canMutate} canEdit={canMutate && canEdit(comment, requestTarget)} comment={comment} isSaving={isSaving} onDelete={() => void runMutation(() => deleteComment(apiClient, requestTarget, { commentId: comment.id }))} onEdit={(nextComment) => void runMutation(() => updateComment(apiClient, requestTarget, { commentId: comment.id, request: { comment: nextComment } }))} onShowReplies={() => toggleReplies(comment.id)} replyActionLabel={comment.replyCount > 0 || expandedRootIds.includes(comment.id) ? "답글 보기" : canMutate ? "답글 작성" : undefined} />
|
||||
{renderReplies(comment)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -12,6 +12,7 @@ type CapturedRequest = {
|
||||
};
|
||||
|
||||
const target = { kind: "audio", characterId: "101", contentId: "9001", creatorId: 101 } satisfies CommentTarget;
|
||||
const communityTarget = { kind: "community", characterId: "101", postId: "7001", creatorId: 101 } satisfies CommentTarget;
|
||||
|
||||
const fanRoot = {
|
||||
id: 1101,
|
||||
@@ -59,6 +60,9 @@ function createThreadClient(requests: CapturedRequest[]): ApiClient {
|
||||
if (options.path.includes("/1101/replies")) {
|
||||
return options.responseSchema.parse({ totalCount: 2, items: [fanReply, aiReply] });
|
||||
}
|
||||
if (options.path.includes("/1102/replies")) {
|
||||
return options.responseSchema.parse({ totalCount: 0, items: [] });
|
||||
}
|
||||
|
||||
return options.responseSchema.parse({ totalCount: 2, items: [fanRoot, aiRoot] });
|
||||
},
|
||||
@@ -131,6 +135,80 @@ function getFormForControl(control: HTMLElement): HTMLFormElement {
|
||||
throw new Error("expected parent form");
|
||||
}
|
||||
|
||||
test("CommentThread opens the existing reply form for a first Audio reply", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
render(<CommentThread apiClient={createThreadClient(requests)} target={target} />);
|
||||
expect(await screen.findByText("AI 루트 댓글")).toBeInTheDocument();
|
||||
|
||||
// When
|
||||
fireEvent.click(screen.getByRole("button", { name: "AI 루트 댓글 답글 작성" }));
|
||||
const repliesRegion = await screen.findByRole("region", { name: "AI 루트 댓글 답글" });
|
||||
const replyInput = within(repliesRegion).getByLabelText("AI 루트 댓글에 답글");
|
||||
fireEvent.change(replyInput, { target: { value: "첫 답글" } });
|
||||
fireEvent.click(within(repliesRegion).getByRole("button", { name: "답글 등록" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(requests.filter((request) => request.method === "POST" && request.body === JSON.stringify({ comment: "첫 답글", parentId: 1102, isSecret: false, languageCode: null }))).toHaveLength(1));
|
||||
expect(requests.some((request) => request.method === undefined && request.path.includes("/1102/replies?page=0&size=20"))).toBe(true);
|
||||
expect(within(repliesRegion).queryByRole("button", { name: /답글 작성/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("CommentThread keeps contextual action names while showing concise button labels", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
render(<CommentThread apiClient={createThreadClient(requests)} target={target} />);
|
||||
const showRepliesButton = await screen.findByRole("button", { name: "팬 루트 댓글 답글 보기" });
|
||||
const writeReplyButton = screen.getByRole("button", { name: "AI 루트 댓글 답글 작성" });
|
||||
const rootEditButton = screen.getByRole("button", { name: "AI 루트 댓글 수정" });
|
||||
const rootDeleteButton = screen.getByRole("button", { name: "팬 루트 댓글 삭제" });
|
||||
|
||||
// When
|
||||
fireEvent.click(showRepliesButton);
|
||||
const repliesRegion = await screen.findByRole("region", { name: "팬 루트 댓글 답글" });
|
||||
const replyArticle = within(repliesRegion).getByRole("article", { name: "루나 댓글" });
|
||||
const replyEditButton = within(replyArticle).getByRole("button", { name: "AI 답글 수정" });
|
||||
const replyDeleteButton = within(replyArticle).getByRole("button", { name: "AI 답글 삭제" });
|
||||
|
||||
// Then
|
||||
expect(within(replyArticle).queryByRole("button", { name: /답글 (작성|보기)/ })).not.toBeInTheDocument();
|
||||
expect(showRepliesButton.textContent).toBe("답글 보기");
|
||||
expect(writeReplyButton.textContent).toBe("답글 작성");
|
||||
expect(rootEditButton.textContent).toBe("수정");
|
||||
expect(rootDeleteButton.textContent).toBe("삭제");
|
||||
expect(replyEditButton.textContent).toBe("수정");
|
||||
expect(replyDeleteButton.textContent).toBe("삭제");
|
||||
});
|
||||
|
||||
test("CommentThread creates a first Community reply and keeps read-only Community roots closed", async () => {
|
||||
// Given
|
||||
const communityRequests: CapturedRequest[] = [];
|
||||
const readOnlyRequests: CapturedRequest[] = [];
|
||||
|
||||
// When
|
||||
const { unmount } = render(<CommentThread apiClient={createThreadClient(communityRequests)} target={communityTarget} />);
|
||||
expect(await screen.findByText("AI 루트 댓글")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "AI 루트 댓글 답글 작성" }));
|
||||
const repliesRegion = await screen.findByRole("region", { name: "AI 루트 댓글 답글" });
|
||||
await waitFor(() => expect(communityRequests.filter((request) => request.method === undefined && request.path.includes("/1102/replies?page=0&size=20"))).toHaveLength(1));
|
||||
fireEvent.change(within(repliesRegion).getByLabelText("AI 루트 댓글에 답글"), { target: { value: " 커뮤니티 첫 답글 " } });
|
||||
fireEvent.click(within(repliesRegion).getByRole("button", { name: "답글 등록" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(communityRequests.filter((request) => request.method === "POST")).toEqual([
|
||||
{ body: JSON.stringify({ comment: "커뮤니티 첫 답글", parentId: 1102, isSecret: false }), method: "POST", path: "/api/v2/admin/ai-characters/101/community-posts/7001/comments" },
|
||||
]));
|
||||
expect(within(repliesRegion).queryByRole("button", { name: /답글 작성/ })).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
// When
|
||||
render(<CommentThread apiClient={createThreadClient(readOnlyRequests)} canMutate={false} target={communityTarget} />);
|
||||
|
||||
// Then
|
||||
expect(await screen.findByText("AI 루트 댓글")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "AI 루트 댓글 답글 작성" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("CommentThread links and focuses empty root and reply comment errors without sending mutations", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { createCommunityPost } from "@/features/community-posts/api/community-post-api";
|
||||
import { communityPostAudioErrorMessage, formatCommunityPostPrice, hasCommunityPostFormErrors, parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
|
||||
import { communityPostAudioErrorMessage, hasCommunityPostFormErrors, parseCommunityPostPrice } from "@/features/community-posts/components/community-post-form-helpers";
|
||||
import type { CommunityPostFormErrors } from "@/features/community-posts/components/community-post-form-helpers";
|
||||
import { COMMUNITY_POST_IMAGE_POLICY, prepareCommunityPostImage } from "@/features/community-posts/validation/community-post-media-policy";
|
||||
import { ApiError } from "@/shared/api/api-error";
|
||||
import type { ApiClient } from "@/shared/api/client";
|
||||
import type { CropRenderRequest } from "@/shared/lib/crop-image";
|
||||
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
|
||||
import { CanPriceField } from "@/shared/ui/can-price-field";
|
||||
import { FileField } from "@/shared/ui/file-field";
|
||||
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
|
||||
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";
|
||||
@@ -27,12 +28,6 @@ const errorIds = {
|
||||
price: "community-post-price-error",
|
||||
} as const;
|
||||
|
||||
function buildCreateRequest(content: string, isAdult: boolean, isCommentAvailable: boolean, price: number | null) {
|
||||
const base = { content, isAdult, isCommentAvailable };
|
||||
|
||||
return price === null ? base : { ...base, price };
|
||||
}
|
||||
|
||||
export function CommunityPostForm({ apiClient, characterId, createCropSource, onCreated, renderCrop }: CommunityPostFormProps) {
|
||||
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||
const [content, setContent] = useState("");
|
||||
@@ -43,7 +38,7 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
|
||||
const [isImagePreparing, setIsImagePreparing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [postImage, setPostImage] = useState<File | null>(null);
|
||||
const [price, setPrice] = useState("");
|
||||
const [price, setPrice] = useState("0");
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const imageSelectionId = useRef(0);
|
||||
const isSavingRef = useRef(false);
|
||||
@@ -53,9 +48,11 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
|
||||
async function selectPostImage(file: File | null) {
|
||||
imageSelectionId.current += 1;
|
||||
const currentSelectionId = imageSelectionId.current;
|
||||
setAudioFile(null);
|
||||
setErrors((current) => ({ ...current, audio: undefined }));
|
||||
setPostImage(null);
|
||||
setCropSource(null);
|
||||
if (file === null) {
|
||||
setPostImage(null);
|
||||
setCropSource(null);
|
||||
setIsImagePreparing(false);
|
||||
setErrors((current) => ({ ...current, image: undefined }));
|
||||
return;
|
||||
@@ -110,13 +107,12 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
|
||||
|
||||
function validateForm(): CommunityPostFormErrors {
|
||||
const parsedPrice = parseCommunityPostPrice(price);
|
||||
const hasPriceInput = price.trim().length > 0;
|
||||
|
||||
return {
|
||||
audio: communityPostAudioErrorMessage(audioFile),
|
||||
content: content.trim().length === 0 ? "내용을 입력하세요." : undefined,
|
||||
image: isImagePreparing || cropSource !== null ? "이미지 처리가 끝난 뒤 저장하세요." : errors.image,
|
||||
price: (hasPriceInput && parsedPrice === null) || (parsedPrice !== null && (!Number.isInteger(parsedPrice) || parsedPrice < 0 || parsedPrice > CAN_PRICE_MAX)) ? "가격은 0 이상 99,999 이하 정수 캔으로 입력하세요." : undefined,
|
||||
price: parsedPrice === null || !Number.isInteger(parsedPrice) || parsedPrice < 0 || parsedPrice > CAN_PRICE_MAX ? "가격은 0 이상 99,999 이하 정수 캔으로 입력하세요." : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,6 +127,10 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
|
||||
queueMicrotask(() => focusFirstInvalidControl(formRef.current));
|
||||
return;
|
||||
}
|
||||
const parsedPrice = parseCommunityPostPrice(price);
|
||||
if (parsedPrice === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSavingRef.current = true;
|
||||
setIsSaving(true);
|
||||
@@ -138,7 +138,7 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
|
||||
await createCommunityPost(apiClient, characterId, {
|
||||
audioFile: audioFile ?? undefined,
|
||||
postImage: postImage ?? undefined,
|
||||
request: buildCreateRequest(content.trim(), isAdult, isCommentAvailable, parseCommunityPostPrice(price)),
|
||||
request: { content: content.trim(), isAdult, isCommentAvailable, price: parsedPrice },
|
||||
});
|
||||
onCreated();
|
||||
} catch (error: unknown) {
|
||||
@@ -154,17 +154,16 @@ export function CommunityPostForm({ apiClient, characterId, createCropSource, on
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-semibold text-info">COMMUNITY</p>
|
||||
<h2 className="text-2xl font-bold leading-tight" id="community-post-form-title">커뮤니티 게시글 생성</h2>
|
||||
<p className="text-sm text-muted-foreground">내용과 첨부 파일은 저장 전 운영 기준에 맞게 검토하세요. GIF는 crop 없이 원본을 보존합니다.</p>
|
||||
<p className="break-keep text-sm text-muted-foreground">내용과 첨부 파일은 저장 전 운영 기준에 맞게 검토하세요. GIF는 crop 없이 원본을 보존합니다.</p>
|
||||
</div>
|
||||
<form aria-label="커뮤니티 게시글 생성 입력 화면" className="flex flex-col gap-4 rounded-lg border border-border bg-card p-4" onSubmit={(event) => void submit(event)} ref={formRef}>
|
||||
<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">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { softDeleteCommunityPost, updateCommunityPost } from "@/features/community-posts/api/community-post-api";
|
||||
import { COMMUNITY_POST_IMAGE_POLICY, prepareCommunityPostImage } from "@/features/community-posts/validation/community-post-media-policy";
|
||||
import type { CommunityPostListItem } from "@/features/community-posts/model/types";
|
||||
import type { CommunityPostListItem, CommunityPostUpdateRequest } from "@/features/community-posts/model/types";
|
||||
import { CommentThread } from "@/features/comments/components/CommentThread";
|
||||
import { ApiError } from "@/shared/api/api-error";
|
||||
import type { ApiClient } from "@/shared/api/client";
|
||||
@@ -29,7 +29,19 @@ export function CommunityPostSheet({ apiClient, canMutate, canMutateComments = c
|
||||
const isSavingRef = useRef(false);
|
||||
const imageSelectionId = useRef(0);
|
||||
const { dialogRef, trapFocus } = useModalFocus<HTMLDivElement>(true);
|
||||
const updateRequest: CommunityPostUpdateRequest = {};
|
||||
if (content !== post.content) {
|
||||
updateRequest.content = content;
|
||||
}
|
||||
if (isAdult !== post.isAdult) {
|
||||
updateRequest.isAdult = isAdult;
|
||||
}
|
||||
if (isCommentAvailable !== post.isCommentAvailable) {
|
||||
updateRequest.isCommentAvailable = isCommentAvailable;
|
||||
}
|
||||
const hasPostChanges = Object.keys(updateRequest).length > 0 || postImage !== null;
|
||||
const isMutationDisabled = isSaving || isImagePreparing || cropSource !== null;
|
||||
const isSaveDisabled = isMutationDisabled || imageErrorMessage !== undefined || !hasPostChanges;
|
||||
|
||||
useEffect(() => () => cropSource?.release?.(), [cropSource]);
|
||||
|
||||
@@ -38,14 +50,14 @@ export function CommunityPostSheet({ apiClient, canMutate, canMutateComments = c
|
||||
}
|
||||
|
||||
async function savePost() {
|
||||
if (isSavingRef.current || imageErrorMessage !== undefined || isImagePreparing || cropSource !== null) {
|
||||
if (isSavingRef.current || isSaveDisabled) {
|
||||
return;
|
||||
}
|
||||
isSavingRef.current = true;
|
||||
setIsSaving(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await updateCommunityPost(apiClient, characterId, String(post.postId), { postImage: postImage ?? undefined, request: { content, isAdult, isCommentAvailable, isFixed: post.isFixed } });
|
||||
await updateCommunityPost(apiClient, characterId, String(post.postId), { postImage: postImage ?? undefined, request: updateRequest });
|
||||
onMutated();
|
||||
} catch (error: unknown) {
|
||||
setErrorMessage(getMutationErrorMessage(error));
|
||||
@@ -191,7 +203,7 @@ export function CommunityPostSheet({ apiClient, canMutate, canMutateComments = c
|
||||
{post.firstComment === null ? null : <p className="rounded-lg border border-border bg-card p-3 text-sm">첫 댓글: {post.firstComment.comment}</p>}
|
||||
{post.isCommentAvailable ? <CommentThread apiClient={apiClient} canMutate={canMutateComments} target={{ kind: "community", characterId, postId: String(post.postId), creatorId: post.creatorId }} /> : null}
|
||||
{canMutate ? <div className="flex flex-wrap 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)] disabled:opacity-60" disabled={isMutationDisabled} onClick={() => void savePost()} 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={isSaveDisabled} onClick={() => void savePost()} 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={isMutationDisabled} onClick={() => void toggleFixed()} type="button">{post.isFixed ? "고정 해제" : "고정하기"}</button>
|
||||
<button className="rounded-md border border-destructive bg-card px-4 py-2 font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isMutationDisabled} onClick={() => setIsDeleteDialogOpen(true)} type="button">비활성화</button>
|
||||
</div> : null}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -111,7 +111,7 @@ test("Community Sheet sends actual multipart update requests without audio or pr
|
||||
const updateBody = requireFormData(requests[0]?.body);
|
||||
expect(updateBody.has("audioFile")).toBe(false);
|
||||
expect(updateBody.has("price")).toBe(false);
|
||||
expect(await readJsonPart(updateBody.get("request"))).toEqual({ content: "수정된 커뮤니티 게시글", isAdult: true, isCommentAvailable: false, isFixed: false });
|
||||
expect(await readJsonPart(updateBody.get("request"))).toEqual({ content: "수정된 커뮤니티 게시글", isAdult: true, isCommentAvailable: false });
|
||||
|
||||
// When
|
||||
fireEvent.click(screen.getByRole("button", { name: "비활성화" }));
|
||||
@@ -129,6 +129,41 @@ test("Community Sheet sends actual multipart update requests without audio or pr
|
||||
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ isActive: false, isFixed: false });
|
||||
});
|
||||
|
||||
test("Community Sheet disables save when no field or image changed", () => {
|
||||
// Given
|
||||
const requests: CapturedApiRequest[] = [];
|
||||
render(<CommunityPostSheet apiClient={createSheetClient(requests)} canMutate characterId="101" createCropSource={createCropSource} onClose={() => undefined} onDeleted={() => undefined} onMutated={() => undefined} post={basePost} />);
|
||||
|
||||
// When
|
||||
const saveButton = screen.getByRole("button", { name: "수정 저장" });
|
||||
|
||||
// Then
|
||||
expect(saveButton).toBeDisabled();
|
||||
fireEvent.click(saveButton);
|
||||
expect(requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("Community Sheet preserves whitespace-only content changes", async () => {
|
||||
// Given
|
||||
const requests: CapturedApiRequest[] = [];
|
||||
const changedContent = ` ${basePost.content} `;
|
||||
render(<CommunityPostSheet apiClient={createSheetClient(requests)} canMutate characterId="101" createCropSource={createCropSource} onClose={() => undefined} onDeleted={() => undefined} onMutated={() => undefined} post={basePost} />);
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("내용"), { target: { value: changedContent } });
|
||||
const saveButton = screen.getByRole("button", { name: "수정 저장" });
|
||||
|
||||
// Then
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
|
||||
// When
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(requests).toHaveLength(1));
|
||||
expect(await readJsonPart(requireFormData(requests[0]?.body).get("request"))).toEqual({ content: changedContent });
|
||||
});
|
||||
|
||||
test("Community Sheet keeps save impossible while image preparation is pending", async () => {
|
||||
// Given
|
||||
let resolveCropSource: (source: CropSourceImage) => void = () => undefined;
|
||||
@@ -175,6 +210,7 @@ test("Community Sheet ignores stale image preparation results", async () => {
|
||||
|
||||
await waitFor(() => expect(requests).toHaveLength(1));
|
||||
expect(requireFormData(requests[0]?.body).get("postImage")).toBe(freshGif);
|
||||
expect(await readJsonPart(requireFormData(requests[0]?.body).get("request"))).toEqual({});
|
||||
});
|
||||
|
||||
test("Community Sheet resets saving state and shows an alert when mutation fails", async () => {
|
||||
@@ -191,6 +227,7 @@ test("Community Sheet resets saving state and shows an alert when mutation fails
|
||||
|
||||
render(<App />);
|
||||
fireEvent.click((await screen.findAllByRole("button", { name: "오늘의 상담 기록입니다. 게시글 열기" }))[0]);
|
||||
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "저장 실패 테스트" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "수정 저장" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("저장 실패");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useRef, useState } from "react";
|
||||
|
||||
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
|
||||
|
||||
export function FanTalkReplyForm({ content, errorMessage, isSaving, onChange, onSubmit, submitLabel = "답변 등록" }: { readonly content: string; readonly errorMessage: string | null; readonly isSaving: boolean; readonly onChange: (content: string) => void; readonly onSubmit: () => void; readonly submitLabel?: string }) {
|
||||
export function FanTalkReplyForm({ content, errorMessage, isSaving, isSubmitDisabled = false, onChange, onSubmit, submitLabel = "답변 등록" }: { readonly content: string; readonly errorMessage: string | null; readonly isSaving: boolean; readonly isSubmitDisabled?: boolean; readonly onChange: (content: string) => void; readonly onSubmit: () => void; readonly submitLabel?: string }) {
|
||||
const errorId = "fan-talk-reply-error";
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [contentErrorMessage, setContentErrorMessage] = useState<string | null>(null);
|
||||
@@ -39,7 +39,7 @@ export function FanTalkReplyForm({ content, errorMessage, isSaving, onChange, on
|
||||
}} value={content} />
|
||||
</label>
|
||||
{visibleErrorMessage === null ? null : <p aria-label="답변 내용 오류" className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" id={errorId} role="alert">{visibleErrorMessage}</p>}
|
||||
<button className="w-fit 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={isSaving} type="submit">
|
||||
<button className="w-fit 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={isSaving || isSubmitDisabled} type="submit">
|
||||
{submitLabel}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -36,9 +36,10 @@ export function FanTalkReplySheet({ apiClient, characterId, fanTalk, onClose, on
|
||||
const { dialogRef, trapFocus } = useModalFocus<HTMLDivElement>(true);
|
||||
const visibleReply = savedReply ?? existingReply;
|
||||
const isEditing = !startedWithoutReply && existingReply !== null;
|
||||
const isReplyChanged = existingReply === null || content !== existingReply.content;
|
||||
|
||||
async function submitReply() {
|
||||
if (isSavingRef.current) {
|
||||
if (isSavingRef.current || (isEditing && !isReplyChanged)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -113,7 +114,7 @@ export function FanTalkReplySheet({ apiClient, characterId, fanTalk, onClose, on
|
||||
<p className="mt-2 text-xs text-muted-foreground">{formatSeoulDateTime(fanTalk.createdAtUtc)}</p>
|
||||
</section>
|
||||
{successMessage === null ? null : <p aria-label="답변 저장 성공" className="rounded-md border border-border bg-success-surface p-3 text-sm font-semibold text-success" role="status">{successMessage}</p>}
|
||||
{visibleReply === null || isEditing ? <FanTalkReplyForm content={content} errorMessage={errorMessage} isSaving={isSaving} onChange={setContent} onSubmit={() => void submitReply()} submitLabel={isEditing ? "답변 수정" : "답변 등록"} /> : <ReadonlyReply reply={visibleReply} />}
|
||||
{visibleReply === null || isEditing ? <FanTalkReplyForm content={content} errorMessage={errorMessage} isSaving={isSaving} isSubmitDisabled={isEditing && !isReplyChanged} onChange={setContent} onSubmit={() => void submitReply()} submitLabel={isEditing ? "답변 수정" : "답변 등록"} /> : <ReadonlyReply reply={visibleReply} />}
|
||||
{visibleReply === null || errorMessage === null ? null : <p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">{errorMessage}</p>}
|
||||
<button className="w-fit rounded-md border border-destructive bg-card px-4 py-2 font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={() => setIsDeleteDialogOpen(true)} type="button">FanTalk 원글 삭제</button>
|
||||
</div>
|
||||
|
||||
@@ -216,6 +216,47 @@ test("FanTalk reply form creates unanswered items with POST and edits existing r
|
||||
expect(screen.getAllByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" })[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("FanTalk reply edit only submits when content changed", async () => {
|
||||
// Given
|
||||
saveAdminSession();
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
useAiCharactersResponse();
|
||||
useAiCharacterDetailResponse("101");
|
||||
const requests: Request[] = [];
|
||||
const state = { items: [pendingFanTalk, answeredFanTalk] };
|
||||
await renderFanTalkPage(state, requests);
|
||||
clickFirstButton("두 번째로 온 응원입니다. 답변 보기");
|
||||
const dialog = screen.getByRole("dialog", { name: "FanTalk 답변" });
|
||||
const contentInput = within(dialog).getByLabelText("답변 내용");
|
||||
const submitButton = within(dialog).getByRole("button", { name: "답변 수정" });
|
||||
|
||||
// When / Then
|
||||
expect(submitButton).toBeDisabled();
|
||||
fireEvent.click(submitButton);
|
||||
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
|
||||
|
||||
// When
|
||||
fireEvent.change(contentInput, { target: { value: "수정한 답변입니다." } });
|
||||
|
||||
// Then
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
// When
|
||||
fireEvent.change(contentInput, { target: { value: "이미 답변했습니다." } });
|
||||
|
||||
// Then
|
||||
expect(submitButton).toBeDisabled();
|
||||
fireEvent.click(submitButton);
|
||||
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
|
||||
|
||||
// When
|
||||
fireEvent.change(contentInput, { target: { value: "수정한 답변입니다." } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(requests.filter((request) => request.method === "PUT")).toHaveLength(1));
|
||||
});
|
||||
|
||||
test("FanTalk reply fast double submit sends one POST, reflects returned reply, and refreshes current page", async () => {
|
||||
// Given
|
||||
saveAdminSession();
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createSeries, deactivateSeries, updateSeries } from "@/features/series/
|
||||
import { GenreCombobox } from "@/features/series/components/GenreCombobox";
|
||||
import { PublishedDaysField } from "@/features/series/components/PublishedDaysField";
|
||||
import type { SeriesGenreItem, SeriesListItem } from "@/features/series/model/types";
|
||||
import type { SeriesPublishedDay, SeriesState } from "@/features/series/schemas/series-schema";
|
||||
import type { SeriesPublishedDay, SeriesState, SeriesUpdateRequest } from "@/features/series/schemas/series-schema";
|
||||
import { SERIES_IMAGE_POLICY, validateSeriesImageFile } from "@/features/series/validation/series-image-policy";
|
||||
import { ApiError } from "@/shared/api/api-error";
|
||||
import type { ApiClient } from "@/shared/api/client";
|
||||
@@ -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 = {
|
||||
@@ -68,10 +69,6 @@ function dayError(days: readonly SeriesPublishedDay[]): string | undefined {
|
||||
return days.includes("RANDOM") && days.length > 1 ? "랜덤은 단독으로만 선택하세요." : undefined;
|
||||
}
|
||||
|
||||
function editedState(state: SeriesState, series: SeriesListItem | undefined): SeriesState | undefined {
|
||||
return state === (series?.state ?? "PROCEEDING") ? undefined : state;
|
||||
}
|
||||
|
||||
function hasErrors(errors: FieldErrors): boolean {
|
||||
return Object.values(errors).some((error) => error !== undefined);
|
||||
}
|
||||
@@ -99,8 +96,40 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
|
||||
const isDeactivatingRef = useRef(false);
|
||||
const isSavingRef = useRef(false);
|
||||
const seriesId = series === undefined ? null : String(series.seriesId);
|
||||
const dirty = image !== null || title !== (series?.title ?? "") || introduction !== (series?.introduction ?? "") || keyword !== "" || genreId !== (series?.genreId ?? null) || isAdult !== (series?.isAdult ?? false) || publishedDays.join(",") !== (series?.publishedDaysOfWeek ?? []).join(",") || state !== (series?.state ?? "PROCEEDING") || studio !== (series?.studio ?? "") || writer !== (series?.writer ?? "");
|
||||
const isImageSubmitBlocked = isImagePreparing || cropSource !== null;
|
||||
const editRequest: SeriesUpdateRequest = {};
|
||||
if (mode === "edit" && series !== undefined) {
|
||||
const nextTitle = title.trim();
|
||||
const nextIntroduction = introduction.trim();
|
||||
const nextWriter = textOrNull(writer);
|
||||
const nextStudio = textOrNull(studio);
|
||||
if (nextTitle !== series.title.trim()) {
|
||||
editRequest.title = nextTitle;
|
||||
}
|
||||
if (nextIntroduction !== series.introduction.trim()) {
|
||||
editRequest.introduction = nextIntroduction;
|
||||
}
|
||||
if (publishedDays.join(",") !== series.publishedDaysOfWeek.join(",")) {
|
||||
editRequest.publishedDaysOfWeek = [...publishedDays];
|
||||
}
|
||||
if (genreId !== series.genreId) {
|
||||
editRequest.genreId = genreId;
|
||||
}
|
||||
if (isAdult !== series.isAdult) {
|
||||
editRequest.isAdult = isAdult;
|
||||
}
|
||||
if (state !== series.state) {
|
||||
editRequest.state = state;
|
||||
}
|
||||
if (nextWriter !== textOrNull(series.writer ?? "")) {
|
||||
editRequest.writer = nextWriter;
|
||||
}
|
||||
if (nextStudio !== textOrNull(series.studio ?? "")) {
|
||||
editRequest.studio = nextStudio;
|
||||
}
|
||||
}
|
||||
const hasEditChanges = image !== null || Object.keys(editRequest).length > 0;
|
||||
const dirty = mode === "create" ? image !== null || title !== "" || introduction !== "" || keyword !== "" || genreId !== null || isAdult || publishedDays.length > 0 || studio !== "" || writer !== "" : hasEditChanges;
|
||||
|
||||
useEffect(() => () => cropSource?.release?.(), [cropSource]);
|
||||
|
||||
@@ -156,7 +185,7 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
|
||||
|
||||
async function submit(event: { readonly preventDefault: () => void }): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (isSavingRef.current) {
|
||||
if (isSavingRef.current || (mode === "edit" && !hasEditChanges)) {
|
||||
return;
|
||||
}
|
||||
const nextErrors = validate();
|
||||
@@ -174,7 +203,7 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
|
||||
return;
|
||||
}
|
||||
if (mode === "edit" && seriesId !== null) {
|
||||
await updateSeries(apiClient, { characterId, seriesId, image: image ?? undefined, request: { title: title.trim(), introduction: introduction.trim(), publishedDaysOfWeek: [...publishedDays], genreId, isAdult, state: editedState(state, series), writer: textOrNull(writer), studio: textOrNull(studio) } });
|
||||
await updateSeries(apiClient, { characterId, seriesId, image: image ?? undefined, request: editRequest });
|
||||
navigateTo(routePaths.aiCharacterSeriesDetail(characterId, seriesId), { successNotification: "시리즈를 저장했습니다." });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
@@ -216,25 +245,24 @@ 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">
|
||||
{mode === "edit" ? <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={() => setIsDeactivateDialogOpen(true)} type="button">비활성화</button> : null}
|
||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(mode === "create" ? routePaths.aiCharacterSeries(characterId) : routePaths.aiCharacterSeriesDetail(characterId, seriesId ?? "")))} 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={isSaving || isImageSubmitBlocked} type="submit">{mode === "create" ? "생성" : "저장"}</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={isSaving || isImageSubmitBlocked || (mode === "edit" && !hasEditChanges)} type="submit">{mode === "create" ? "생성" : "저장"}</button>
|
||||
</div>
|
||||
</form>
|
||||
{cropSource === null ? null : <ImageCropDialog image={cropSource} onApply={(file) => { setImage(file); setCropSource(null); setErrors((current) => ({ ...current, image: undefined })); }} onCancel={() => { setImage(null); setCropSource(null); setErrors((current) => ({ ...current, image: undefined })); }} open policy={SERIES_IMAGE_POLICY} renderCrop={renderCrop} />}
|
||||
|
||||
@@ -88,9 +88,8 @@ test("SeriesForm keeps existing edit image when replacement crop is canceled", a
|
||||
fireEvent.click(within(cropDialog).getByRole("button", { name: "취소" }));
|
||||
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/series/5001"));
|
||||
const updateBody = requests.find((request) => request.method === "PUT")?.body as FormData;
|
||||
expect(updateBody.get("image")).toBeNull();
|
||||
expect(window.location.pathname).toBe("/ai-characters/101/series/5001/edit");
|
||||
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test.each(["create", "edit"] as const)("SeriesForm shows an image preparation error when %s preview creation rejects", async (mode) => {
|
||||
|
||||
@@ -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("시리즈를 생성했습니다.");
|
||||
});
|
||||
@@ -177,7 +194,7 @@ test("SeriesForm edits enum fields without keyword or image requirement and deac
|
||||
await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true));
|
||||
const updateBody = requests.find((request) => request.method === "PUT")?.body as FormData;
|
||||
expect(updateBody.get("image")).toBeNull();
|
||||
await expect((updateBody.get("request") as Blob).text()).resolves.toBe(JSON.stringify({ title: "달빛 상담 시리즈", introduction: "밤마다 이어지는 상담 에피소드", publishedDaysOfWeek: ["RANDOM"], genreId: 77, isAdult: false, state: "COMPLETE", writer: "스튜디오 루나", studio: "소다랩" }));
|
||||
await expect((updateBody.get("request") as Blob).text()).resolves.toBe(JSON.stringify({ publishedDaysOfWeek: ["RANDOM"], state: "COMPLETE" }));
|
||||
expect(window.location.pathname).toBe("/ai-characters/101/series/5001");
|
||||
|
||||
// When
|
||||
@@ -232,15 +249,59 @@ test("SeriesForm omits unchanged state from edit payload", async () => {
|
||||
await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true));
|
||||
await expect(readRequestPart(requests.find((request) => request.method === "PUT")?.body)).resolves.toEqual({
|
||||
title: "달빛 상담 시리즈 수정",
|
||||
introduction: "밤마다 이어지는 상담 에피소드",
|
||||
publishedDaysOfWeek: ["SUN", "WED"],
|
||||
genreId: 77,
|
||||
isAdult: false,
|
||||
writer: "스튜디오 루나",
|
||||
studio: "소다랩",
|
||||
});
|
||||
});
|
||||
|
||||
test("SeriesForm disables edit save when unchanged", () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101/series/5001/edit");
|
||||
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="edit" renderCrop={renderCrop} series={existingSeries} />);
|
||||
|
||||
// When
|
||||
const saveButton = screen.getByRole("button", { name: "저장" });
|
||||
|
||||
// Then
|
||||
expect(saveButton).toBeDisabled();
|
||||
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
|
||||
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("SeriesForm sends null when optional writer is cleared", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101/series/5001/edit");
|
||||
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="edit" renderCrop={renderCrop} series={existingSeries} />);
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("작가"), { target: { value: "" } });
|
||||
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true));
|
||||
await expect(readRequestPart(requests.find((request) => request.method === "PUT")?.body)).resolves.toEqual({ writer: null });
|
||||
});
|
||||
|
||||
test("SeriesForm sends empty request when only image changes", async () => {
|
||||
// Given
|
||||
const requests: CapturedRequest[] = [];
|
||||
window.history.pushState({}, "", "/ai-characters/101/series/5001/edit");
|
||||
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="edit" renderCrop={renderCrop} series={existingSeries} />);
|
||||
|
||||
// When
|
||||
fireEvent.change(screen.getByLabelText("시리즈 이미지"), { target: { files: [createImage("changed.png")] } });
|
||||
await screen.findByRole("dialog", { name: "이미지 crop" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||
await screen.findByText("cropped-series.png");
|
||||
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true));
|
||||
const requestBody = requests.find((request) => request.method === "PUT")?.body as FormData;
|
||||
expect(requestBody.get("image")).toBeInstanceOf(File);
|
||||
await expect(readRequestPart(requestBody)).resolves.toEqual({});
|
||||
});
|
||||
|
||||
test("SeriesForm rejects mismatched image extension and MIME before crop", async () => {
|
||||
const requests: CapturedRequest[] = [];
|
||||
let cropSourceCalls = 0;
|
||||
@@ -261,6 +322,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 +339,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);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { build } from "vite";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
describe("production mock graph", () => {
|
||||
test("excludes the browser mock module from the production bundle", async () => {
|
||||
test("splits production chunks and excludes the browser mock module from the production bundle", async () => {
|
||||
// Given
|
||||
const outDir = mkdtempSync(join(tmpdir(), "ai-character-admin-prod-"));
|
||||
const previousNodeEnv = process.env.NODE_ENV;
|
||||
@@ -21,12 +21,16 @@ describe("production mock graph", () => {
|
||||
mode: "production",
|
||||
});
|
||||
const outputFiles = collectFiles(outDir);
|
||||
const output = outputFiles
|
||||
.filter((filePath) => filePath.endsWith(".js"))
|
||||
const jsFiles = outputFiles.filter((filePath) => filePath.endsWith(".js"));
|
||||
const output = jsFiles
|
||||
.map((filePath) => readFileSync(filePath, "utf8"))
|
||||
.join("\n");
|
||||
|
||||
// Then
|
||||
expect(jsFiles.length).toBeGreaterThanOrEqual(2);
|
||||
for (const jsFile of jsFiles) {
|
||||
expect(statSync(jsFile).size).toBeLessThanOrEqual(500_000);
|
||||
}
|
||||
expect(outputFiles.some((filePath) => filePath.endsWith("mockServiceWorker.js"))).toBe(false);
|
||||
expect(output).not.toContain("mockServiceWorker.js");
|
||||
expect(output).not.toContain("startMockWorker");
|
||||
|
||||
@@ -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: () => ({}) };
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cropperFake.checkOrientation = undefined;
|
||||
cropperFake.getCoordinates.mockReturnValue({ height: 600, left: 300, top: 0, width: 600 });
|
||||
cropperFake.src = "";
|
||||
cropperFake.stencilAspectRatio = 0;
|
||||
});
|
||||
|
||||
async function withElementRects(testBody: () => Promise<void>): Promise<void> {
|
||||
const originalElementRect = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "getBoundingClientRect");
|
||||
const originalImageRect = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, "getBoundingClientRect");
|
||||
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 });
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value(this: HTMLElement) {
|
||||
if (this.getAttribute("aria-label") === "이미지 crop viewport") {
|
||||
return rect(181, 256);
|
||||
}
|
||||
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();
|
||||
});
|
||||
|
||||
return rect(0, 0);
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLImageElement.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => rect(384, 384),
|
||||
});
|
||||
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 });
|
||||
|
||||
return testBody().finally(() => {
|
||||
if (originalElementRect === undefined) {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, "getBoundingClientRect");
|
||||
} else {
|
||||
Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", originalElementRect);
|
||||
}
|
||||
expect(cropperFake.checkOrientation).toBe(false);
|
||||
});
|
||||
|
||||
if (originalImageRect === undefined) {
|
||||
Reflect.deleteProperty(HTMLImageElement.prototype, "getBoundingClientRect");
|
||||
} else {
|
||||
Object.defineProperty(HTMLImageElement.prototype, "getBoundingClientRect", originalImageRect);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("ImageCropDialog provides move, zoom, reset, preview, cancel, and apply controls", async () => {
|
||||
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="자료 없음" />);
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ test("ResourcePagination connects each page size label to a unique select", () =
|
||||
expect(labels.map((label) => label.control)).toEqual(selects);
|
||||
});
|
||||
|
||||
test("ResourcePagination separates page size from an equal-width mobile movement row", () => {
|
||||
test("ResourcePagination separates page size from stacked mobile movement controls", () => {
|
||||
render(<ResourcePagination data={pageData} onPageChange={vi.fn()} onSizeChange={vi.fn()} />);
|
||||
|
||||
const sizeControls = screen.getByRole("group", { name: "페이지 크기 설정" });
|
||||
@@ -67,7 +67,7 @@ test("ResourcePagination separates page size from an equal-width mobile movement
|
||||
|
||||
expect(within(sizeControls).getByLabelText("페이지 크기")).toBeInTheDocument();
|
||||
expect(sizeControls).not.toContainElement(previous);
|
||||
expect(movementControls).toHaveClass("grid-cols-2");
|
||||
expect(previous).toHaveClass("min-h-11", "w-full");
|
||||
expect(next).toHaveClass("min-h-11", "w-full");
|
||||
expect(movementControls).toHaveClass("grid-cols-1");
|
||||
expect(previous).toHaveClass("min-h-11", "w-full", "whitespace-nowrap");
|
||||
expect(next).toHaveClass("min-h-11", "w-full", "whitespace-nowrap");
|
||||
});
|
||||
|
||||
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>
|
||||
<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})` }} />
|
||||
</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">
|
||||
다시 시도
|
||||
|
||||
@@ -13,10 +13,10 @@ export function ResourcePagination({ data, onPageChange, onSizeChange, sizeOptio
|
||||
|
||||
return (
|
||||
<nav aria-label="페이지" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm font-semibold text-muted-foreground">총 {data.totalCount.toLocaleString("ko-KR")}개 · {data.page + 1}페이지</p>
|
||||
<p className="break-keep text-sm font-semibold text-muted-foreground">총 {data.totalCount.toLocaleString("ko-KR")}개 · {data.page + 1}페이지</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div aria-label="페이지 크기 설정" className="flex items-center gap-2" role="group">
|
||||
<label className="text-sm font-semibold" htmlFor={pageSizeId}>
|
||||
<label className="break-keep text-sm font-semibold" htmlFor={pageSizeId}>
|
||||
페이지 크기
|
||||
</label>
|
||||
<select className="min-h-11 rounded-md border border-input bg-card px-3 py-2 text-base" id={pageSizeId} onChange={(event) => onSizeChange(Number(event.currentTarget.value))} value={data.size}>
|
||||
@@ -25,11 +25,11 @@ export function ResourcePagination({ data, onPageChange, onSizeChange, sizeOptio
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div aria-label="페이지 이동" className="grid grid-cols-2 gap-2 sm:flex" role="group">
|
||||
<button className="min-h-11 w-full rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60 sm:w-auto" disabled={data.page <= 0} onClick={() => onPageChange(data.page - 1)} type="button">
|
||||
<div aria-label="페이지 이동" className="grid grid-cols-1 gap-2 sm:flex" role="group">
|
||||
<button className="min-h-11 w-full whitespace-nowrap rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60 sm:w-auto" disabled={data.page <= 0} onClick={() => onPageChange(data.page - 1)} type="button">
|
||||
이전 페이지
|
||||
</button>
|
||||
<button className="min-h-11 w-full rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60 sm:w-auto" disabled={!data.hasNext} onClick={() => onPageChange(data.page + 1)} type="button">
|
||||
<button className="min-h-11 w-full whitespace-nowrap rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60 sm:w-auto" disabled={!data.hasNext} onClick={() => onPageChange(data.page + 1)} type="button">
|
||||
다음 페이지
|
||||
</button>
|
||||
</div>
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
|
||||
// allow: SIZE_OK - Character workspace browser scenarios share one authenticated E2E surface.
|
||||
const interactiveActionRoles = ["button", "link", "menuitem"] as const;
|
||||
|
||||
async function loginThroughMockMode(page: Page): Promise<void> {
|
||||
@@ -42,6 +43,27 @@ async function zoomTo200Percent(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function expectNoKoreanSyllableColumns(root: Locator): Promise<void> {
|
||||
const syllableColumns = 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;
|
||||
})
|
||||
.flatMap((candidate) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(candidate);
|
||||
const lineWidths = Array.from(range.getClientRects(), (rect) => rect.width).filter((width) => width > 0);
|
||||
const fontSize = Number.parseFloat(getComputedStyle(candidate).fontSize);
|
||||
const koreanSyllableCount = candidate.textContent?.match(/[가-힣]/g)?.length ?? 0;
|
||||
const isSyllableColumn = koreanSyllableCount >= 3 && lineWidths.length >= 3 && Math.max(...lineWidths) <= fontSize * 2.25;
|
||||
|
||||
return isSyllableColumn ? [{ lineWidths, text: candidate.textContent?.trim() }] : [];
|
||||
}));
|
||||
|
||||
expect(syllableColumns).toEqual([]);
|
||||
}
|
||||
|
||||
async function pressTabUntilFocused(page: Page, target: Locator): Promise<void> {
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
if (await target.evaluate((element) => element === document.activeElement || element.contains(document.activeElement))) {
|
||||
@@ -91,7 +113,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();
|
||||
@@ -193,6 +215,19 @@ test("mobile cards preserve the selection name and table-visible core metadata",
|
||||
await expect(lunaCard).toContainText("상담, 힐링");
|
||||
});
|
||||
|
||||
test("mobile zoom keeps Korean list and pagination text out of syllable columns", async ({ page }) => {
|
||||
// Given
|
||||
await page.setViewportSize({ width: 320, height: 640 });
|
||||
await loginThroughMockMode(page);
|
||||
|
||||
// When
|
||||
await zoomTo200Percent(page);
|
||||
|
||||
// Then
|
||||
await expectNoKoreanSyllableColumns(page.getByRole("region", { name: "AI 캐릭터 목록" }));
|
||||
await expectNoKoreanSyllableColumns(page.getByRole("navigation", { name: "페이지" }));
|
||||
});
|
||||
|
||||
test("keyboard-only path reaches the edit form and dirty-leave dialog", async ({ browserName, isMobile, page }) => {
|
||||
test.skip(isMobile, "Mobile view intentionally hides Character mutation actions.");
|
||||
test.skip(browserName === "webkit", "WebKit does not consistently tab-focus links in this keyboard path.");
|
||||
@@ -204,8 +239,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: "프로필" }));
|
||||
|
||||
@@ -52,6 +52,13 @@ test("Audio comments create reply edit AI rows and delete fan or AI rows through
|
||||
// When
|
||||
await page.getByLabel("새 댓글").fill("오디오 루트 생성");
|
||||
await page.getByRole("button", { name: "댓글 등록" }).click();
|
||||
await page.getByRole("button", { name: "오디오 AI 루트 댓글 답글 작성" }).click();
|
||||
const emptyReplies = page.getByRole("region", { name: "오디오 AI 루트 댓글 답글" });
|
||||
await expect(emptyReplies.getByLabel("오디오 AI 루트 댓글에 답글")).toBeVisible();
|
||||
await expect(emptyReplies.getByText("오디오 팬 루트 댓글", { exact: true })).toHaveCount(0);
|
||||
await emptyReplies.getByLabel("오디오 AI 루트 댓글에 답글").fill("오디오 첫 답글 생성");
|
||||
await emptyReplies.getByRole("button", { name: "답글 등록" }).click();
|
||||
await expect(emptyReplies.getByText("오디오 첫 답글 생성", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "오디오 팬 루트 댓글 답글 보기" }).click();
|
||||
const replies = page.getByRole("region", { name: "오디오 팬 루트 댓글 답글" });
|
||||
await replies.getByLabel("오디오 팬 루트 댓글에 답글").fill("오디오 답글 생성");
|
||||
@@ -70,6 +77,7 @@ test("Audio comments create reply edit AI rows and delete fan or AI rows through
|
||||
await expect(page.getByText("오디오 AI 루트 댓글", { exact: true })).toBeHidden();
|
||||
await expect(replies.getByText("오디오 답글 생성", { exact: true })).toBeVisible();
|
||||
expect(commentRequests).toContainEqual({ body: JSON.stringify({ comment: "오디오 루트 생성", parentId: null, isSecret: false, languageCode: null }), method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments", search: "" });
|
||||
expect(commentRequests).toContainEqual({ body: JSON.stringify({ comment: "오디오 첫 답글 생성", parentId: 1102, isSecret: false, languageCode: null }), method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments", search: "" });
|
||||
expect(commentRequests).toContainEqual({ body: JSON.stringify({ comment: "오디오 답글 생성", parentId: 1101, isSecret: false, languageCode: null }), method: "POST", path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments", search: "" });
|
||||
expect(commentRequests).toContainEqual({ body: JSON.stringify({ comment: "오디오 AI 답글 수정" }), method: "PUT", path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments/1202", search: "" });
|
||||
expect(commentRequests).toContainEqual({ body: null, method: "DELETE", path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments/1201", search: "" });
|
||||
@@ -79,6 +87,13 @@ test("Audio comments create reply edit AI rows and delete fan or AI rows through
|
||||
|
||||
test("Community sheet comments keep two-level controls usable at 320px", async ({ page }) => {
|
||||
// Given
|
||||
const commentRequests: { readonly body: string | null; readonly method: string; readonly path: string; readonly search: string }[] = [];
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname.includes("/community-posts/7001/comments")) {
|
||||
commentRequests.push({ body: request.postData(), method: request.method(), path: url.pathname, search: url.search });
|
||||
}
|
||||
});
|
||||
await page.setViewportSize({ width: 320, height: 640 });
|
||||
await loginThroughMockMode(page);
|
||||
|
||||
@@ -91,24 +106,62 @@ test("Community sheet comments keep two-level controls usable at 320px", async (
|
||||
await expect(dialog.getByRole("heading", { name: "댓글 관리" })).toBeVisible();
|
||||
await expect(dialog.getByLabel("새 댓글")).toBeInViewport();
|
||||
await expect(dialog.getByText("커뮤니티 팬 루트 댓글", { exact: true })).toBeVisible();
|
||||
const writeReply = dialog.getByRole("button", { name: "커뮤니티 AI 루트 댓글 답글 작성" });
|
||||
await expect(writeReply).toBeVisible();
|
||||
await expect(writeReply).toHaveText("답글 작성");
|
||||
await expectNoHorizontalOverflow(page);
|
||||
|
||||
// When
|
||||
await dialog.getByLabel("새 댓글").fill("커뮤니티 루트 생성");
|
||||
await dialog.getByRole("button", { name: "댓글 등록" }).click();
|
||||
await expect(dialog.getByText("커뮤니티 루트 생성", { exact: true })).toBeVisible();
|
||||
await writeReply.click();
|
||||
const emptyReplies = dialog.getByRole("region", { name: "커뮤니티 AI 루트 댓글 답글" });
|
||||
const emptyReplyInput = emptyReplies.getByLabel("커뮤니티 AI 루트 댓글에 답글");
|
||||
await expect(emptyReplyInput).toBeVisible();
|
||||
await expect(emptyReplies.getByText("커뮤니티 팬 루트 댓글", { exact: true })).toHaveCount(0);
|
||||
await expect.poll(() => commentRequests.filter((request) => request.method === "GET" && request.path.endsWith("/2102/replies") && request.search === "?page=0&size=20")).toHaveLength(1);
|
||||
await emptyReplyInput.fill(" 커뮤니티 첫 답글 생성 ");
|
||||
await emptyReplies.getByRole("button", { name: "답글 등록" }).click();
|
||||
await expect(emptyReplyInput).toHaveValue("");
|
||||
await emptyReplyInput.fill("커뮤니티 두 번째 답글 생성");
|
||||
await emptyReplies.getByRole("button", { name: "답글 등록" }).click();
|
||||
await expect(emptyReplyInput).toHaveValue("");
|
||||
await expect.poll(() => commentRequests.filter((request) => request.method === "POST")).toEqual([
|
||||
{ body: JSON.stringify({ comment: "커뮤니티 루트 생성", parentId: null, isSecret: false }), method: "POST", path: "/api/v2/admin/ai-characters/101/community-posts/7001/comments", search: "" },
|
||||
{ body: JSON.stringify({ comment: "커뮤니티 첫 답글 생성", parentId: 2102, isSecret: false }), method: "POST", path: "/api/v2/admin/ai-characters/101/community-posts/7001/comments", search: "" },
|
||||
{ body: JSON.stringify({ comment: "커뮤니티 두 번째 답글 생성", parentId: 2102, isSecret: false }), method: "POST", path: "/api/v2/admin/ai-characters/101/community-posts/7001/comments", search: "" },
|
||||
]);
|
||||
await dialog.getByRole("button", { name: "닫기" }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await page.getByRole("button", { name: "오늘의 상담 기록입니다. 게시글 열기" }).click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("button", { name: /^커뮤니티 AI 루트 댓글 답글 (작성|보기)$/ }).click();
|
||||
await expect(emptyReplies).toBeVisible();
|
||||
await expect(emptyReplies.getByText("커뮤니티 첫 답글 생성", { exact: true })).toBeVisible();
|
||||
await expect(emptyReplies.getByText("커뮤니티 두 번째 답글 생성", { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText("커뮤니티 첫 답글 생성", { exact: true })).toHaveCount(1);
|
||||
await expect(dialog.getByText("커뮤니티 두 번째 답글 생성", { exact: true })).toHaveCount(1);
|
||||
await expect(emptyReplies.getByRole("button", { name: /^(커뮤니티 첫 답글 생성|커뮤니티 두 번째 답글 생성) 답글 (작성|보기)$/ })).toHaveCount(0);
|
||||
const showReplies = dialog.getByRole("button", { name: "커뮤니티 팬 루트 댓글 답글 보기" });
|
||||
await expect(showReplies).toBeEnabled();
|
||||
await expect(showReplies).toHaveText("답글 보기");
|
||||
await showReplies.click();
|
||||
const replies = dialog.getByRole("region", { name: "커뮤니티 팬 루트 댓글 답글" });
|
||||
await expect(replies).toBeVisible();
|
||||
await expect(replies.getByText("커뮤니티 AI 답글", { exact: true })).toBeVisible();
|
||||
await expect(replies.getByRole("button", { name: /^커뮤니티 AI 답글 답글 (작성|보기)$/ })).toHaveCount(0);
|
||||
await replies.getByLabel("커뮤니티 팬 루트 댓글에 답글").fill("커뮤니티 답글 생성");
|
||||
await replies.getByRole("button", { name: "답글 등록" }).click();
|
||||
await replies.getByRole("button", { name: "커뮤니티 AI 답글 수정" }).click();
|
||||
const editReply = replies.getByRole("button", { name: "커뮤니티 AI 답글 수정" });
|
||||
await expect(editReply).toHaveText("수정");
|
||||
await editReply.click();
|
||||
await replies.getByLabel("댓글 수정 내용").fill("커뮤니티 AI 답글 수정");
|
||||
await replies.getByRole("button", { name: "수정 저장" }).click();
|
||||
await expect.poll(() => replies.getByRole("button", { name: "커뮤니티 팬 답글 삭제" }).isEnabled()).toBe(true);
|
||||
await replies.getByRole("button", { name: "커뮤니티 팬 답글 삭제" }).click();
|
||||
const deleteReply = replies.getByRole("button", { name: "커뮤니티 팬 답글 삭제" });
|
||||
await expect(deleteReply).toHaveText("삭제");
|
||||
await expect.poll(() => deleteReply.isEnabled()).toBe(true);
|
||||
await deleteReply.click();
|
||||
|
||||
// Then
|
||||
await expect(replies.getByText("커뮤니티 팬 답글", { exact: true })).toBeHidden();
|
||||
@@ -123,6 +176,7 @@ test("keyboard-only Audio comment flow reaches form and reply controls", async (
|
||||
await loginThroughMockMode(page);
|
||||
await page.goto("/ai-characters/101/audio-contents/9001");
|
||||
const rootInput = page.getByLabel("새 댓글");
|
||||
const firstReplyAction = page.getByRole("button", { name: "오디오 AI 루트 댓글 답글 작성" });
|
||||
|
||||
// When / Then
|
||||
await pressTabUntilFocused(page, rootInput);
|
||||
@@ -130,4 +184,7 @@ test("keyboard-only Audio comment flow reaches form and reply controls", async (
|
||||
await pressTabUntilFocused(page, page.getByRole("button", { name: "댓글 등록" }));
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByText("키보드 루트 댓글", { exact: true })).toBeVisible();
|
||||
await pressTabUntilFocused(page, firstReplyAction);
|
||||
await page.keyboard.press("Enter");
|
||||
await pressTabUntilFocused(page, page.getByRole("region", { name: "오디오 AI 루트 댓글 답글" }).getByLabel("오디오 AI 루트 댓글에 답글"));
|
||||
});
|
||||
|
||||
@@ -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