diff --git a/.specify/scripts/bash/common.sh b/.specify/scripts/bash/common.sh index 612ebd86..3d67f4e8 100755 --- a/.specify/scripts/bash/common.sh +++ b/.specify/scripts/bash/common.sh @@ -28,24 +28,29 @@ get_current_branch() { # For non-git repos, try to find the latest feature directory local repo_root=$(get_repo_root) - local specs_dir="$repo_root/specs/feat" + local specs_base="$repo_root/specs" - if [[ -d "$specs_dir" ]]; then + if [[ -d "$specs_base" ]]; then local latest_feature="" local highest=0 - for dir in "$specs_dir"/*; do - if [[ -d "$dir" ]]; then - local dirname=$(basename "$dir") - if [[ "$dirname" =~ ^([0-9]+)- ]]; then - local number=${BASH_REMATCH[1]} - number=$((10#$number)) - if [[ "$number" -gt "$highest" ]]; then - highest=$number - latest_feature=$dirname + for type_dir in "$specs_base"/*/; do + [[ -d "$type_dir" ]] || continue + local type_name + type_name="$(basename "$type_dir")" + for dir in "$type_dir"*/; do + if [[ -d "$dir" ]]; then + local dirname=$(basename "$dir") + if [[ "$dirname" =~ ^([0-9]+)- ]]; then + local number=${BASH_REMATCH[1]} + number=$((10#$number)) + if [[ "$number" -gt "$highest" ]]; then + highest=$number + latest_feature="${type_name}/#${dirname}" + fi fi fi - fi + done done if [[ -n "$latest_feature" ]]; then @@ -107,21 +112,33 @@ check_feature_branch() { return 1 } -get_feature_dir() { echo "$1/specs/feat/$2"; } +get_feature_dir() { + local repo_root="$1" + local branch_name="$2" + find_feature_dir_by_prefix "$repo_root" "$branch_name" +} # Find feature directory by issue number or numeric prefix -# Supports: feat/#96-social-login → specs/feat/096-* -# 096-social-login → specs/feat/096-* +# Supports: fix/#441-slug → specs/fix/441-* +# feat/#96-social-login → specs/feat/096-* +# 096-social-login → specs/feat/096-* (legacy) find_feature_dir_by_prefix() { local repo_root="$1" local branch_name="$2" - local specs_dir="$repo_root/specs/feat" + + # Extract type prefix from branch name (e.g., fix/#441-slug → fix, feat/#96-slug → feat) + local type_prefix="feat" # default for legacy branches + if [[ "$branch_name" =~ ^([a-z]+)/#[0-9]+ ]]; then + type_prefix="${BASH_REMATCH[1]}" + fi + + local specs_dir="$repo_root/specs/$type_prefix" # Extract issue number from branch name local issue_num=$(extract_issue_number "$branch_name") if [[ -z "$issue_num" ]]; then - # If no issue number found, fall back to exact match under specs/feat/ + # If no issue number found, fall back to exact match under specs/{type}/ echo "$specs_dir/$branch_name" return fi @@ -129,7 +146,7 @@ find_feature_dir_by_prefix() { # Zero-pad to 3 digits for matching local padded=$(printf "%03d" "$((10#$issue_num))") - # Search for directories in specs/feat/ that start with this prefix + # Search for directories in specs/{type}/ that start with this prefix local matches=() if [[ -d "$specs_dir" ]]; then for dir in "$specs_dir"/"$padded"-*; do diff --git a/specs/fix/441-mute-timer-layout-fix/checklists/requirements.md b/specs/fix/441-mute-timer-layout-fix/checklists/requirements.md new file mode 100644 index 00000000..6933193e --- /dev/null +++ b/specs/fix/441-mute-timer-layout-fix/checklists/requirements.md @@ -0,0 +1,37 @@ +# Specification Quality Checklist: 음소거 아이콘 헤더 반영 및 타이머 화면 레이아웃 개선 + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-04 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- 음소거 아이콘(FR-001~002)과 레이아웃(FR-003~009)은 독립적으로 구현/테스트 가능 +- 볼륨 0 = 음소거 동작 정책은 명확화를 통해 확정됨 (세션 2026-04-04) +- 두 줄 레이아웃 정렬 방식(중앙)은 명확화를 통해 확정됨 (세션 2026-04-04) +- 타이머 설정 화면은 이번 수정 범위에서 제외됨 diff --git a/specs/fix/441-mute-timer-layout-fix/data-model.md b/specs/fix/441-mute-timer-layout-fix/data-model.md new file mode 100644 index 00000000..321c62d5 --- /dev/null +++ b/specs/fix/441-mute-timer-layout-fix/data-model.md @@ -0,0 +1,49 @@ +# Data Model: 음소거 아이콘 헤더 반영 및 타이머 화면 레이아웃 개선 + +> 이 픽스는 순수 UI 레이어 수정이므로 새로운 타입/엔티티 추가 없음. +> 기존 타입을 그대로 활용. + +## 관련 기존 타입 + +### `TimerPageLogics` (src/page/TimerPage/hooks/useTimerPageState.ts) + +```ts +export interface TimerPageLogics { + // ... 기존 필드 ... + volume: number; // 0~10 정수. 0이면 음소거 상태 + setVolume: (value: number) => void; + isVolumeBarOpen: boolean; + toggleVolumeBar: () => void; + volumeRef: React.RefObject; +} +``` + +**`isMuted` 파생 로직**: `const isMuted = volume === 0;` +- `TimerPage.tsx` 내에서 파생하여 사용 +- `TimerPageLogics` 인터페이스 변경 없음 + +### `NormalTimerProps` (src/page/TimerPage/components/NormalTimer.tsx) + +```ts +interface NormalTimerProps { + normalTimerInstance: NormalTimerInstance; + isAdditionalTimerAvailable: boolean; + item: TimeBoxInfo; // item.speaker: string | null + teamName: string | null; +} +``` + +**레이아웃 로직 변경**: +- `teamName` → 첫 번째 줄 (truncate 적용) +- `item.speaker` → 두 번째 줄 (완전 표시) +- 두 줄 모두 중앙 정렬 + +## 변경 영향 범위 + +| 파일 | 변경 종류 | 설명 | +|---|---|---| +| `src/page/TimerPage/TimerPage.tsx` | 수정 | volume === 0 분기로 음소거 아이콘 조건부 렌더링 | +| `src/page/TimerPage/components/NormalTimer.tsx` | 수정 | 두 줄 레이아웃 + h1 text-center 추가 | +| `src/components/icons/` | 변경 없음 | DTVolumeMuted 미생성, react-icons 사용 | +| `src/page/TimerPage/hooks/useTimerPageState.ts` | 변경 없음 | 인터페이스 그대로 유지 | +| `src/apis/` | 변경 없음 | API 호출 없음 | diff --git a/specs/fix/441-mute-timer-layout-fix/plan.md b/specs/fix/441-mute-timer-layout-fix/plan.md new file mode 100644 index 00000000..d7519b63 --- /dev/null +++ b/specs/fix/441-mute-timer-layout-fix/plan.md @@ -0,0 +1,119 @@ +# Implementation Plan: 음소거 아이콘 헤더 반영 및 타이머 화면 레이아웃 개선 + +**Branch**: `fix/#441-mute-timer-layout-fix` | **Date**: 2026-04-04 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/fix/441-mute-timer-layout-fix/spec.md` + +## Summary + +헤더의 볼륨 버튼 아이콘이 음소거 상태를 반영하지 않는 버그와 +`NormalTimer`에서 팀명과 토론자 정보가 한 줄로 압축되는 레이아웃 버그를 수정한다. +순수 UI 레이어 변경(2개 파일 수정)으로, API/상태관리 변경 없음. + +## Technical Context + +**Language/Version**: TypeScript 5.7 (strict mode) +**Primary Dependencies**: React 18, Tailwind CSS 3, react-icons 5, react-i18next +**Storage**: N/A (localStorage 볼륨 값 - 기존 로직 유지) +**Testing**: Vitest + @testing-library/react + @testing-library/user-event + MSW +**Target Platform**: Web (Chrome/Safari/Firefox) +**Project Type**: Web SPA (Vite + React Router v7) +**Performance Goals**: 즉각적인 아이콘 상태 변경 (React 상태 업데이트 기반, 별도 성능 목표 없음) +**Constraints**: 기존 TimerPageLogics 인터페이스 변경 없음, API 호출 없음 +**Scale/Scope**: TimerPage 1개 + NormalTimer 1개 컴포넌트 수정 + +## Constitution Check + +✅ **레이어드 폴더 구조**: 수정 대상 파일이 기존 구조(`page/TimerPage/`, `page/TimerPage/components/`) 내 위치 +✅ **코드 스타일**: function declaration 유지, camelCase/PascalCase 준수 +✅ **TDD**: 테스트 파일 먼저 작성 후 구현 예정 +✅ **i18n**: 기존 `t()` 사용 패턴 유지, 하드코딩 텍스트 없음 +✅ **API 레이어 패턴**: API 변경 없음 +✅ **순환 의존성**: 없음 + +**Constitution 위반 사항**: 없음 + +## Project Structure + +### Documentation (this feature) + +```text +specs/fix/441-mute-timer-layout-fix/ +├── plan.md # 이 파일 +├── research.md # Phase 0 output ✅ +├── data-model.md # Phase 1 output ✅ +├── test-contracts/ +│ ├── NormalTimer.md # Phase 1 output ✅ +│ └── TimerPage-mute-icon.md # Phase 1 output ✅ +└── tasks.md # Phase 2 output (/speckits:tasks 로 생성) +``` + +### Source Code (수정 대상) + +```text +src/ +├── page/TimerPage/ +│ ├── TimerPage.tsx # 수정: 음소거 시 다른 아이콘 표시 +│ ├── TimerPage.test.tsx # 신규: 헤더 음소거 아이콘 TDD 테스트 +│ └── components/ +│ ├── NormalTimer.tsx # 수정: 두 줄 레이아웃 + text-center +│ └── NormalTimer.test.tsx # 신규: 레이아웃 TDD 테스트 +``` + +## Architecture Decision Table + +| 결정 | 고려한 옵션 | 선택 | 근거 | 프로젝트 구조 영향 | 테스트 용이성 | +|---|---|---|---|---|---| +| 음소거 아이콘 소스 | ① react-icons `RiVolumeMuteFill` ② 새 DTVolumeMuted 아이콘 | react-icons | 전체화면 토글 패턴과 일관성, 새 파일 불필요 | 변경 없음 | 높음 (클래스명/aria로 검증) | +| isMuted 파생 위치 | ① `TimerPage` 내 `volume === 0` ② `useTimerPageState` 인터페이스에 추가 | TimerPage 내 파생 | 파생값 추가 노출 불필요, 인터페이스 변경 최소화 | 변경 없음 | 높음 | +| 두 줄 레이아웃 방식 | ① flex-col + 개별 `

` ② `
` 구분 | flex-col + 개별 `

` | 시맨틱, 각 줄 독립 스타일 적용 용이 | 없음 | 높음 (별도 요소로 쿼리) | +| 팀명 말줄임 | ① truncate를 팀명 줄에만 | truncate 팀명 줄만 | 토론자 줄은 항상 완전 표시 (FR-004) | 없음 | 높음 | +| 순서명 정렬 | ① text-center 추가 ② 현행 유지 | text-center 추가 | 한/영 모두 일관 중앙 정렬 보장 | 없음 | 높음 | + +## TDD Implementation Order + +### RED 단계 (테스트 먼저 작성) + +**우선순위 1: NormalTimer 컴포넌트 (component 레이어)** + +``` +NormalTimer.test.tsx 작성 (모두 RED): +1. 팀명과 토론자가 각각 독립된 DOM 요소로 분리되는지 +2. 팀명 줄에 truncate 클래스가 있는지 +3. 토론자 줄에 truncate 클래스가 없는지 +4. 팀명만 있을 때 토론자 요소가 미렌더링되는지 +5. 팀명, 토론자 모두 없을 때 아이콘 영역 미렌더링되는지 +6. h1(순서명)에 text-center 클래스가 있는지 +``` + +**우선순위 2: TimerPage 페이지 (page 레이어)** + +``` +TimerPage.test.tsx 작성 (모두 RED): +1. volume > 0일 때 음소거 아이콘이 없는지 +2. volume === 0일 때 음소거 아이콘이 표시되는지 +3. 볼륨을 0으로 변경하면 헤더 아이콘이 즉시 음소거로 변경되는지 +``` + +### GREEN 단계 (최소 구현) + +``` +NormalTimer.tsx 수정: +- flex-row → flex-col 변경 +- 팀명 p 태그 분리 + truncate 유지 +- 토론자 p 태그 분리 +- h1에 text-center 추가 + +TimerPage.tsx 수정: +- volume === 0 조건으로 RiVolumeMuteFill / DTVolume 분기 +``` + +### REFACTOR 단계 + +``` +- 불필요한 className 정리 +- 테스트 가독성 개선 +``` + +## Complexity Tracking + +> Constitution 위반 없음 — 이 섹션은 해당 없음 diff --git a/specs/fix/441-mute-timer-layout-fix/research.md b/specs/fix/441-mute-timer-layout-fix/research.md new file mode 100644 index 00000000..82df628b --- /dev/null +++ b/specs/fix/441-mute-timer-layout-fix/research.md @@ -0,0 +1,115 @@ +# Phase 0 Research: 음소거 아이콘 헤더 반영 및 타이머 화면 레이아웃 개선 + +## 1. 음소거 상태 공유 구조 분석 + +### 현재 상태 흐름 + +``` +useBellSound (볼륨 원시값: 0.0~1.0) + └── useTimerPageState (VOLUME_SCALE=10 적용, volume: 0~10) + ├── TimerPage.tsx (헤더 버튼 - DTVolume 항상 표시 ← 버그) + └── VolumeBar.tsx (volume prop 수신, 내부적으로 isMuted 계산) +``` + +### 버그 원인 + +- `TimerPage.tsx`에서 헤더 볼륨 버튼은 항상 `DTVolume` 렌더링 +- `volume === 0` 조건 체크 없음 +- 반면 `VolumeBar.tsx`는 `isNotMute = volume > 0` 로직으로 아이콘 스타일만 변경(CSS dimming)하고 다른 아이콘을 표시하지 않음 + +### Decision: 헤더 아이콘 조건부 렌더링 + +- **Decision**: `volume === 0` 조건으로 헤더에 음소거/일반 아이콘 분기 렌더링 +- **Rationale**: 전역 volume 상태가 이미 `TimerPageLogics` 인터페이스에 노출되어 있어 추가적인 상태 관리 없이 구현 가능 +- **Alternatives considered**: + 1. `isMuted` boolean을 `useTimerPageState` 인터페이스에 추가 → 오버엔지니어링, `volume === 0` 파생값이라 별도 노출 불필요 + 2. Context로 음소거 상태 별도 관리 → 현재 상태 흐름이 이미 충분히 공유됨 + +--- + +## 2. 음소거 아이콘 소스 결정 + +### 현재 아이콘 체계 + +- `src/components/icons/` — DT-prefix 커스텀 SVG 아이콘 (Volume.tsx, Help.tsx, etc.) +- `react-icons` 라이브러리 — 전체화면 토글에 `RiFullscreenFill` / `RiFullscreenExitFill` 사용 중 + +### Decision: react-icons 사용 + +- **Decision**: 음소거 아이콘으로 `react-icons` (`RiVolumeMuteFill` from `react-icons/ri`)를 사용 +- **Rationale**: + - 전체화면 토글 패턴(`RiFullscreenFill` / `RiFullscreenExitFill`)과 동일한 방식 + - 새 SVG 파일 생성 없이 구현 가능 (최소 변경 원칙) + - `react-icons/ri` (Remix Icons)가 이미 import 중 +- **Alternatives considered**: + - `DTVolumeMuted` 커스텀 아이콘 생성 → SVG 디자인 리소스 없이는 기존 DTVolume과 시각적 일관성 유지 어려움 + - DTVolume에 `isMuted` prop 추가 → 컴포넌트 인터페이스 변경, 오버엔지니어링 + +--- + +## 3. NormalTimer 두 줄 레이아웃 분석 + +### 현재 코드 + +```tsx +{(teamName || item.speaker) && ( + + +

+ {teamName && t('{{team}} 팀', { team: t(teamName) })} + {item.speaker && + t(' | {{speaker}} 토론자', { speaker: t(item.speaker) })} +

+ +)} +``` + +### 버그 원인 + +- 팀명과 토론자 정보가 단일 `

` 태그 안에서 한 줄로 표시됨 +- `truncate`(text-overflow: ellipsis)가 전체 줄에 적용되어 팀명이 길면 토론자 정보가 잘림 + +### Decision: flex-col + 개별 줄 분리 + +- **Decision**: `flex-row` → `flex-col`로 변경, 팀명과 토론자 정보를 각각 독립된 줄에 표시 +- **Rationale**: + - 팀명 줄은 `truncate` 유지로 긴 팀명 말줄임 처리 (FR-005) + - 토론자 줄은 항상 완전하게 표시 가능 (FR-004) + - 스펙 SR: 두 줄 모두 중앙 정렬 (FR-009) +- **Alternatives considered**: + - `
` 태그로 줄 구분 → 시맨틱하지 않음 + - Grid 레이아웃 → 두 줄에 오버킬 + +--- + +## 4. 순서명 정렬 이슈 분석 + +### 현재 코드 + +```tsx +

{titleText}

+``` + +### 이슈 + +- 부모 ``에 `items-center justify-center`가 있으나 h1 자체에 `text-center`가 없음 +- 한글 폰트의 경우 기본 text-left일 때 다른 너비를 가질 수 있음 +- `flex-col` 컨테이너에서 자식이 block이면 가로 전체 차지 → 텍스트가 중앙 정렬처럼 보이나 실제로는 left-aligned + +### Decision: text-center 명시 추가 + +- **Decision**: h1 태그에 `text-center` 클래스 추가로 텍스트 중앙 정렬 명시 +- **Rationale**: 한글/영어 모두 동일 중앙 정렬 보장, 팀 정보 영역과 시각적 일치 +- **Alternatives considered**: + - 현재 스타일 유지 → 한글에서 정렬 불일치 유지됨 + +--- + +## Summary + +| 결정 사항 | 선택한 방식 | 근거 | +|---|---|---| +| 음소거 아이콘 소스 | react-icons (`RiVolumeMuteFill`) | 전체화면 토글 패턴과 일관성 | +| isMuted 파생 위치 | `TimerPage.tsx` 내 `volume === 0` | 별도 상태 불필요 | +| 두 줄 레이아웃 | flex-col, 개별 `

` 태그 | 시맨틱, 명확한 줄 구분 | +| 순서명 정렬 | `text-center` 추가 | 한글/영어 일관 중앙 정렬 | diff --git a/specs/fix/441-mute-timer-layout-fix/spec.md b/specs/fix/441-mute-timer-layout-fix/spec.md new file mode 100644 index 00000000..632e8491 --- /dev/null +++ b/specs/fix/441-mute-timer-layout-fix/spec.md @@ -0,0 +1,101 @@ +# Feature Specification: 음소거 아이콘 헤더 반영 및 타이머 화면 레이아웃 개선 + +**Feature Branch**: `fix/#441-mute-timer-layout-fix` +**Created**: 2026-04-04 +**Status**: Draft +**GitHub Issue**: [#441](https://github.com/debate-timer/debate-timer-fe/issues/441) + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - 음소거 아이콘 헤더 실시간 반영 (Priority: P1) + +진행자가 타이머 화면에서 음소거 상태를 변경하면, 헤더의 스피커 아이콘이 즉시 음소거/음소거 해제 상태에 맞게 변경된다. + +**Why this priority**: 현재 헤더 아이콘이 실제 상태를 반영하지 않아 진행자가 현재 음소거 상태를 헤더만 보고는 알 수 없다. 가장 명백한 버그이므로 최우선 수정 대상이다. + +**Independent Test**: 음소거 버튼을 클릭하고 헤더 아이콘이 변경되는지 확인하는 것만으로 독립 테스트 가능. + +**Acceptance Scenarios**: + +1. **Given** 음소거가 해제된 상태에서 타이머 화면을 열었을 때, **When** 헤더의 스피커 아이콘을 확인하면, **Then** 소리 켜짐 아이콘(🔊)이 표시된다. +2. **Given** 음소거 해제 상태에서, **When** 볼륨 패널에서 음소거를 활성화하면, **Then** 헤더의 스피커 아이콘이 즉시 음소거 아이콘(🔇)으로 변경된다. +3. **Given** 음소거 상태에서, **When** 다시 음소거를 해제하면, **Then** 헤더의 스피커 아이콘이 즉시 소리 켜짐 아이콘으로 복원된다. +4. **Given** 음소거 상태에서, **When** 페이지를 새로고침하면, **Then** 헤더 아이콘이 현재 음소거 상태를 올바르게 표시한다. + +--- + +### User Story 2 - 팀 정보와 토론자 정보를 두 줄 레이아웃으로 분리 (Priority: P2) + +타이머 화면의 토론 순서 아래에 표시되는 팀명과 토론자 정보가 한 줄에서 두 줄로 분리되어, 팀명이 얼마나 길어도 토론자 정보가 일관되게 표시된다. + +**Why this priority**: 현재 팀명 길이에 따라 토론자 번호가 잘리는 방식이 달라져 비일관적인 UX를 제공한다. 두 줄 분리로 일관성 문제와 가독성 문제를 동시에 해결한다. + +**Independent Test**: 팀명 길이가 다른 두 팀으로 토론을 설정하고, 두 경우 모두 토론자 정보가 동일한 방식으로 표시되는지 확인. + +**Acceptance Scenarios**: + +1. **Given** 팀명이 짧은 경우(예: "찬성 팀"), **When** 타이머 화면을 열면, **Then** 첫 번째 줄에 팀명, 두 번째 줄에 토론자 정보가 표시된다. +2. **Given** 팀명이 긴 경우(예: "Negative team"), **When** 타이머 화면을 열면, **Then** 팀명과 토론자 정보가 각각 독립된 줄에 표시되어 어느 줄도 잘리지 않는다. +3. **Given** 한국어/영어 팀명 모두에서, **When** 타이머 화면을 확인하면, **Then** 팀명 줄과 토론자 줄이 항상 동일한 정렬로 표시된다. + +--- + +### User Story 3 - 토론 순서 제목과 팀 정보 영역의 정렬 개선 (Priority: P3) + +한글 토론 순서명(예: "입론", "교차질문")이 영어 이름과 동일한 중앙 정렬로 자연스럽게 표시되며, 최대 15자 순서명 확대 이후에도 레이아웃이 깨지지 않는다. + +**Why this priority**: 영어에서는 정상이지만 한글에서 정렬이 부자연스러운 회귀 버그이다. P1/P2 해결 후 추가 개선으로 처리 가능하다. + +**Independent Test**: 한글 순서명과 영어 순서명 각각 설정 후 타이머 화면에서 정렬 상태 확인. + +**Acceptance Scenarios**: + +1. **Given** 한글 토론 순서명(예: "교차질문및반박", 15자)이 설정된 경우, **When** 타이머 화면을 열면, **Then** 순서명이 중앙 정렬로 자연스럽게 표시된다. +2. **Given** 영어 토론 순서명(예: "Cross-Examination", 17자 이내)이 설정된 경우, **When** 타이머 화면을 열면, **Then** 순서명이 동일한 중앙 정렬로 표시된다. +3. **Given** 순서명 길이가 짧은 경우(예: "입론", 3자), **When** 타이머 화면을 열면, **Then** 아래 팀 정보 영역과 정렬이 일치한다. + +--- + +### Edge Cases + +- 팀명이 매우 긴 경우(예: 20자 이상) 두 번째 줄에도 팀명이 잘리는 상황 → 팀명 줄은 말줄임(ellipsis)으로 처리 +- 토론자 이름이 설정된 경우와 없는 경우 모두 두 줄 레이아웃이 유지되어야 함 +- 음소거 상태가 유지되는 중 화면 전환(다음 순서 이동) 시 헤더 아이콘이 여전히 음소거 상태를 표시해야 함 +- 볼륨이 0으로 설정된 경우(슬라이더를 최소로 내린 경우)와 명시적 음소거 버튼 클릭의 아이콘 표시 방식 + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: 헤더의 스피커 아이콘은 현재 음소거 상태와 항상 동기화되어야 한다. +- **FR-002**: 음소거 상태가 변경될 때(활성화/해제) 헤더 아이콘이 즉시 업데이트되어야 한다. +- **FR-003**: 타이머 화면의 팀 정보 영역에서 팀명과 토론자 정보(번호/이름)는 각각 독립된 줄에 표시되어야 한다. +- **FR-004**: 팀명의 길이와 무관하게 토론자 번호/이름은 항상 완전하게 표시되어야 한다. +- **FR-005**: 팀명이 해당 줄의 표시 가능 너비를 초과할 경우 말줄임(...)으로 처리되어야 한다. +- **FR-006**: 토론 순서명(한글/영어)이 타이머 화면 중앙에 일관된 정렬로 표시되어야 한다. +- **FR-007**: 최대 15자의 토론 순서명이 레이아웃을 벗어나지 않고 올바르게 표시되어야 한다. +- **FR-008**: 볼륨이 0으로 설정된 경우와 명시적 음소거 버튼 클릭 모두 동일하게 음소거 아이콘으로 표시되어야 한다. +- **FR-009**: 두 줄로 분리된 팀명과 토론자 정보는 모두 중앙 정렬로 표시되어야 한다. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: 음소거 버튼 클릭 후 헤더 아이콘이 즉시(사용자가 인지할 수 있는 지연 없이) 변경된다. +- **SC-002**: 팀명 길이(짧음/보통/긺)에 관계없이 토론자 정보가 항상 동일한 형식으로 표시된다. +- **SC-003**: 한글과 영어 토론 순서명 모두 동일한 중앙 정렬 기준을 따른다. +- **SC-004**: 팀명이 표시 영역을 초과할 때 레이아웃이 깨지지 않고 말줄임으로 처리된다. + +## Clarifications + +### Session 2026-04-04 + +- Q: 볼륨 슬라이더를 0으로 내렸을 때와 명시적으로 음소거 버튼을 클릭했을 때 헤더 아이콘 표시가 달라야 하나요? → A: 동일하게 음소거 아이콘으로 표시한다. +- Q: 두 줄로 분리된 팀명 + 토론자 정보의 텍스트 정렬 방식은? → A: 중앙 정렬 (Image #6 목업과 일치) + +## Assumptions + +- 음소거 상태는 전역 상태(또는 Context)로 관리되며 헤더와 볼륨 패널이 동일한 상태를 공유할 수 있다고 가정한다. +- 두 줄 레이아웃 전환 시 기존 한 줄 레이아웃보다 세로 높이가 늘어나는 것은 허용된다. +- 토론자 정보는 "N번 토론자" 또는 "No.N Debater" 형식으로 고정된다. +- 이번 수정 범위는 타이머 진행 화면(타이머 뷰)에 한정되며, 설정 화면은 포함하지 않는다. diff --git a/specs/fix/441-mute-timer-layout-fix/tasks.md b/specs/fix/441-mute-timer-layout-fix/tasks.md new file mode 100644 index 00000000..3be98ebb --- /dev/null +++ b/specs/fix/441-mute-timer-layout-fix/tasks.md @@ -0,0 +1,193 @@ +# Tasks: 음소거 아이콘 헤더 반영 및 타이머 화면 레이아웃 개선 + +**Input**: Design documents from `/specs/fix/441-mute-timer-layout-fix/` +**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, test-contracts/ ✅ + +**Tests**: TDD 방식 적용 — spec.md와 plan.md에 명시된 대로 테스트 먼저 작성(RED) 후 구현(GREEN) + +**Organization**: 유저 스토리별로 태스크를 그룹화하여 독립적인 구현 및 테스트 가능 + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: 병렬 실행 가능 (다른 파일, 의존성 없음) +- **[Story]**: 해당 유저 스토리 (US1, US2, US3) +- 각 태스크에 정확한 파일 경로 포함 + +## Path Conventions + +``` +src/page/TimerPage/TimerPage.tsx → 음소거 아이콘 조건부 렌더링 (US1) +src/page/TimerPage/TimerPage.test.tsx → TimerPage 테스트 (US1) +src/page/TimerPage/components/NormalTimer.tsx → 두 줄 레이아웃 + text-center (US2, US3) +src/page/TimerPage/components/NormalTimer.test.tsx → NormalTimer 테스트 (US2, US3) +src/mocks/handlers/ → MSW 핸들러 (기존, 필요 시 추가) +``` + +--- + +## Phase 1: Setup + +**Purpose**: 테스트 환경 확인 및 MSW 핸들러 준비 + +- [x] T001 src/mocks/handlers/ 에서 debateTable 응답 MSW 핸들러 존재 여부 확인 — 없으면 TimerPage 테스트용 핸들러 추가 + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: 공통 인프라 확인 + +> 이 픽스는 순수 UI 레이어 수정이므로 추가 Foundational 작업 없음. +> 새로운 타입/엔티티 없음, API 변경 없음, `useTimerPageState` 인터페이스 변경 없음. + +**⚠️ Foundational 단계 해당 없음**: Phase 1 완료 후 바로 User Story 구현 진행 가능 + +--- + +## Phase 3: User Story 1 - 음소거 아이콘 헤더 실시간 반영 (Priority: P1) 🎯 MVP + +**Goal**: 헤더 볼륨 버튼이 `volume === 0` 조건에 따라 음소거/일반 아이콘을 즉시 전환 + +**Independent Test**: localStorage `timer-volume`을 0으로 설정 후 TimerPage를 렌더링하면 헤더에 음소거 아이콘이 표시되는지 확인 + +### TDD - RED 단계 (US1) + +> **NOTE: 테스트를 먼저 작성하고 FAIL 확인 후 구현 진행** + +- [x] T002 [US1] src/page/TimerPage/TimerPage.test.tsx 신규 작성 — test-contracts/TimerPage-mute-icon.md 기준: ① 볼륨 > 0 시 일반 볼륨 아이콘 표시 ② 볼륨 = 0 시 음소거 아이콘 표시 ③ VolumeBar에서 볼륨 변경 시 헤더 아이콘 즉시 업데이트 (MSW + MemoryRouter + localStorage 세팅 포함) + +### TDD - GREEN 단계 (US1) + +- [x] T003 [US1] src/page/TimerPage/TimerPage.tsx 수정 — `const isMuted = volume === 0;` 파생 추가, 헤더 볼륨 버튼 JSX에서 isMuted 조건으로 `RiVolumeMuteFill` (react-icons/ri) / `DTVolume` 조건부 렌더링 + +**Checkpoint**: T002 테스트 전부 PASS → US1 독립 동작 확인 후 다음 단계 진행 + +--- + +## Phase 4: User Story 2 - 팀 정보와 토론자 정보 두 줄 레이아웃 (Priority: P2) + +**Goal**: NormalTimer에서 팀명과 토론자 정보가 각각 독립된 DOM 요소로 분리되어 별도 줄에 표시 + +**Independent Test**: `teamName = 'Negative team'`, `speaker = '발언자 1'`로 렌더링 시 팀명 요소와 토론자 요소가 각각 별도 DOM 노드로 존재하는지 확인 + +### TDD - RED 단계 (US2) + +> **NOTE: 테스트를 먼저 작성하고 FAIL 확인 후 구현 진행** + +- [x] T004 [US2] src/page/TimerPage/components/NormalTimer.test.tsx 신규 작성 — test-contracts/NormalTimer.md의 두 줄 레이아웃 항목 기준: ① 팀명만 있을 때 팀명 표시·토론자 줄 미렌더링 ② 토론자만 있을 때 토론자 표시·팀명 줄 미렌더링 ③ 둘 다 있을 때 각각 독립 DOM 요소 ④ 둘 다 없을 때 DTDebate 영역 전체 미렌더링 ⑤ 팀명 요소에 truncate 클래스 존재 ⑥ 토론자 요소에 truncate 클래스 미존재 + +### TDD - GREEN 단계 (US2) + +- [x] T005 [US2] src/page/TimerPage/components/NormalTimer.tsx 수정 — 기존 단일 `` 구조를 `flex-col`로 변경, 팀명을 `

` 독립 태그로 분리, 토론자 정보를 `

` 독립 태그로 분리 (truncate 없음) + +**Checkpoint**: T004 테스트 전부 PASS → US2 독립 동작 확인 후 다음 단계 진행 + +--- + +## Phase 5: User Story 3 - 토론 순서 제목 정렬 개선 (Priority: P3) + +**Goal**: `

` 순서명이 한글/영어 모두 동일한 중앙 정렬로 표시 + +**Independent Test**: 한글 순서명("교차질문및반박")과 영어 순서명("Opening Statement") 각각 렌더링 후 h1 요소에 `text-center` 클래스 존재 확인 + +### TDD - RED 단계 (US3) + +> **NOTE: 테스트를 먼저 작성하고 FAIL 확인 후 구현 진행** + +- [x] T006 [US3] src/page/TimerPage/components/NormalTimer.test.tsx에 US3 테스트 추가 — test-contracts/NormalTimer.md의 제목 렌더링 항목: ① 한글 순서명 렌더링 시 h1 요소에 `text-center` 클래스 존재 ② 영어 순서명 렌더링 시 h1 요소에 `text-center` 클래스 존재 + +### TDD - GREEN 단계 (US3) + +- [x] T007 [US3] src/page/TimerPage/components/NormalTimer.tsx 수정 — h1 태그 className에 `text-center` 추가 (기존: `text-[52px] font-bold xl:text-[68px]` → `text-[52px] font-bold xl:text-[68px] text-center`) + +**Checkpoint**: T006 테스트 전부 PASS → US3 독립 동작 확인 후 다음 단계 진행 + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: 리팩토링 및 최종 검증 + +- [x] T008 [P] src/page/TimerPage/TimerPage.tsx 불필요한 className 정리 — REFACTOR 단계 (plan.md 기준) +- [x] T009 [P] src/page/TimerPage/components/NormalTimer.tsx 불필요한 className 정리 — REFACTOR 단계 (space-x-[16px] 제거, flex-col 전환) +- [x] T010 전체 테스트 실행 (`pnpm test` 또는 `vitest run`) — TimerPage.test.tsx 및 NormalTimer.test.tsx 모든 케이스 GREEN 확인 + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: 바로 시작 가능 — 의존성 없음 +- **Foundational (Phase 2)**: 이 픽스에서 해당 없음 +- **US1 (Phase 3)**: Phase 1 완료 후 시작 +- **US2 (Phase 4)**: Phase 1 완료 후 시작 — US1과 **파일이 다르므로 병렬 진행 가능** +- **US3 (Phase 5)**: Phase 4 완료 후 시작 — NormalTimer.tsx / NormalTimer.test.tsx 파일 공유 +- **Polish (Phase 6)**: US1 + US2 + US3 모두 완료 후 + +### User Story Dependencies + +- **US1 (P1)**: T001 → T002(RED) → T003(GREEN) — `TimerPage.tsx` 단독 수정 +- **US2 (P2)**: T001 → T004(RED) → T005(GREEN) — `NormalTimer.tsx` 단독 수정 +- **US3 (P3)**: T004 완료 후 → T006(RED) → T007(GREEN) — `NormalTimer.tsx` 파일 공유 (US2 이후 순차 진행) + +### 병렬 실행 가능 범위 + +- **US1과 US2**: 서로 다른 파일 수정 → 병렬 진행 가능 +- **US2와 US3**: 동일 파일(NormalTimer.tsx, NormalTimer.test.tsx) 수정 → 순차 진행 필요 +- **T008, T009 (Polish)**: 서로 다른 파일 → 병렬 가능 + +--- + +## Parallel Example: US1 + US2 병렬 진행 + +```bash +# Agent 1: US1 진행 (TimerPage 파일) +Task: "T002 [US1] src/page/TimerPage/TimerPage.test.tsx 신규 작성 (RED)" +Task: "T003 [US1] src/page/TimerPage/TimerPage.tsx 수정 (GREEN)" + +# Agent 2: US2 진행 (NormalTimer 파일) — Agent 1과 동시 진행 가능 +Task: "T004 [US2] src/page/TimerPage/components/NormalTimer.test.tsx 신규 작성 (RED)" +Task: "T005 [US2] src/page/TimerPage/components/NormalTimer.tsx 수정 (GREEN)" + +# Agent 2가 US2 완료 후 US3 진행 (US3은 NormalTimer 파일 공유로 US2 이후 순차): +Task: "T006 [US3] NormalTimer.test.tsx에 US3 테스트 추가 (RED)" +Task: "T007 [US3] NormalTimer.tsx에 h1 text-center 추가 (GREEN)" +``` + +--- + +## Implementation Strategy + +### MVP First (US1 Only) + +1. Phase 1 완료 (MSW 핸들러 확인) +2. Phase 3 완료 (US1: 음소거 아이콘) — 가장 명백한 버그 수정 +3. **STOP and VALIDATE**: `TimerPage.test.tsx` 전부 PASS 확인 +4. 필요 시 배포/데모 + +### Incremental Delivery + +1. Phase 1 완료 → 테스트 환경 준비 +2. Phase 3 완료 (US1) → 음소거 아이콘 버그 수정 ✅ **(MVP!)** +3. Phase 4 완료 (US2) → 두 줄 레이아웃 적용 ✅ +4. Phase 5 완료 (US3) → 순서명 정렬 개선 ✅ +5. Phase 6 완료 (Polish) → 리팩토링 마무리 ✅ + +### Parallel Team Strategy (2인 팀) + +1. T001 — 함께 Setup 확인 +2. Agent A: US1 (TimerPage) / Agent B: US2 (NormalTimer) — 동시 진행 +3. Agent B가 US3 완료 후 Agent A와 합류 → Polish 병렬 진행 + +--- + +## Notes + +- **TDD 원칙**: 각 구현(GREEN) 전 반드시 테스트(RED) 먼저 작성 및 FAIL 확인 +- `[P]` 태스크 = 다른 파일, 의존성 없음 → 병렬 실행 가능 +- US1과 US2는 다른 파일 수정으로 병렬 진행 가능 (효율 극대화) +- US2와 US3은 동일 파일(NormalTimer.tsx) 수정 → 순차 진행 필수 +- 음소거 아이콘: `react-icons/ri`에서 `RiVolumeMuteFill` 사용 (기존 전체화면 토글 패턴 동일 방식) +- `isMuted` 파생: `TimerPage.tsx` 내 `const isMuted = volume === 0;` — `useTimerPageState` 인터페이스 변경 없음 +- 각 Checkpoint에서 독립 테스트 실행 후 다음 단계 진행 diff --git a/specs/fix/441-mute-timer-layout-fix/test-contracts/NormalTimer.md b/specs/fix/441-mute-timer-layout-fix/test-contracts/NormalTimer.md new file mode 100644 index 00000000..6766a755 --- /dev/null +++ b/specs/fix/441-mute-timer-layout-fix/test-contracts/NormalTimer.md @@ -0,0 +1,63 @@ +# Test Contract: NormalTimer 레이아웃 + +**파일**: `src/page/TimerPage/components/NormalTimer.test.tsx` +**우선순위**: component 레이어 + +--- + +## 모듈 개요 + +`NormalTimer`는 일반 타이머 화면의 좌측 정보 영역을 렌더링한다. +팀명(`teamName`)과 토론자(`item.speaker`)를 각각 독립된 줄에 표시해야 한다. + +--- + +## 테스트 대상 행위 + +### 1. 제목(순서명) 렌더링 + +| 테스트 | 입력 | 예상 출력 | +|---|---|---| +| 한글 순서명이 중앙 정렬로 렌더링된다 | `item.speechType = '입론'` | h1 요소에 `text-center` 클래스 존재 | +| 영어 순서명이 중앙 정렬로 렌더링된다 | `item.speechType = 'Opening Statement'` | h1 요소에 `text-center` 클래스 존재 | + +### 2. 두 줄 레이아웃 + +| 테스트 | 입력 | 예상 출력 | +|---|---|---| +| 팀명만 있을 때 첫 번째 줄에 팀명이 표시된다 | `teamName = '찬성'`, `speaker = null` | 팀명 텍스트 화면에 표시, 토론자 줄 미표시 | +| 토론자만 있을 때 두 번째 줄에 토론자 정보가 표시된다 | `teamName = null`, `speaker = '발언자 1'` | 토론자 텍스트 화면에 표시, 팀명 줄 미표시 | +| 팀명과 토론자 모두 있을 때 각각 독립된 줄로 표시된다 | `teamName = '찬성'`, `speaker = '발언자 1'` | 팀명과 토론자가 각각 별도 DOM 요소로 렌더링 | +| 팀명과 토론자 모두 없으면 해당 영역 전체가 미표시된다 | `teamName = null`, `speaker = null` | DTDebate 아이콘 및 팀 정보 영역 미렌더링 | + +### 3. 팀명 말줄임 처리 + +| 테스트 | 입력 | 예상 출력 | +|---|---|---| +| 팀명 줄에 truncate 스타일이 적용된다 | `teamName = 'A very long team name'` | 팀명 요소에 `truncate` 클래스 존재 | +| 토론자 줄에는 truncate 스타일이 적용되지 않는다 | `speaker = '발언자 1'` | 토론자 요소에 `truncate` 클래스 미존재 | + +--- + +## 경계 조건 + +- 팀명이 빈 문자열인 경우 → `null`과 동일하게 처리(팀명 렌더링 안 함) +- `item.speaker`가 빈 문자열인 경우 → `null`과 동일하게 처리 + +--- + +## 테스트 환경 + +- Vitest + @testing-library/react +- i18n: `ko` 언어 설정 (setup.ts) +- ResizeObserver 모킹 (setup.ts) +- 테스트 설명: 한국어 + +--- + +## Mock 전략 + +- 외부 API 없음 → MSW 핸들러 불필요 +- `useCircularTimerAnimation`, `useBreakpoint` → 실제 훅 사용 (가능한 경우) + - `useBreakpoint`가 ResizeObserver 의존 → setup.ts의 모킹으로 처리됨 +- `normalTimerInstance` 관련 props → stub 객체로 전달 diff --git a/specs/fix/441-mute-timer-layout-fix/test-contracts/TimerPage-mute-icon.md b/specs/fix/441-mute-timer-layout-fix/test-contracts/TimerPage-mute-icon.md new file mode 100644 index 00000000..c203e83c --- /dev/null +++ b/specs/fix/441-mute-timer-layout-fix/test-contracts/TimerPage-mute-icon.md @@ -0,0 +1,65 @@ +# Test Contract: TimerPage 헤더 음소거 아이콘 + +**파일**: `src/page/TimerPage/TimerPage.test.tsx` +**우선순위**: page 레이어 (component 레이어 테스트 이후) + +--- + +## 모듈 개요 + +`TimerPage`의 헤더 우측 볼륨 버튼은 현재 음소거 상태(volume === 0)에 따라 +음소거 아이콘 또는 일반 볼륨 아이콘을 표시해야 한다. + +--- + +## 테스트 대상 행위 + +### 1. 초기 렌더링 시 아이콘 상태 + +| 테스트 | 조건 | 예상 출력 | +|---|---|---| +| 볼륨이 0보다 클 때 일반 볼륨 아이콘이 표시된다 | localStorage `timer-volume`이 `0.5` (volume=5) | 음소거 아이콘 미표시, 볼륨 버튼에 aria-label='볼륨 조절' | +| 볼륨이 0일 때 음소거 아이콘이 표시된다 | localStorage `timer-volume`이 `0` (volume=0) | 음소거 상태 아이콘 표시 | + +### 2. 볼륨 변경 시 아이콘 즉시 업데이트 + +| 테스트 | 액션 | 예상 출력 | +|---|---|---| +| VolumeBar에서 슬라이더를 0으로 내리면 헤더 아이콘이 즉시 음소거로 변경된다 | VolumeBar의 range input value를 0으로 변경 | 헤더 볼륨 버튼 내 음소거 아이콘 표시 | +| VolumeBar에서 음소거 버튼을 클릭하면 헤더 아이콘이 즉시 음소거로 변경된다 | VolumeBar 음소거 버튼 클릭 | 헤더 볼륨 버튼 내 음소거 아이콘 표시 | +| 음소거 해제 후 헤더 아이콘이 일반 볼륨으로 복원된다 | 음소거 상태에서 음소거 버튼 재클릭 | 헤더 볼륨 버튼 내 일반 볼륨 아이콘 복원 | + +--- + +## 경계 조건 + +- 볼륨 슬라이더를 0으로 내린 경우 + 명시적 음소거 버튼 클릭 → 두 경우 모두 동일하게 음소거 아이콘 (FR-008) +- 화면 전환(라운드 이동) 후에도 음소거 상태가 유지되면 헤더 아이콘이 음소거로 표시 + +--- + +## 테스트 환경 + +- Vitest + @testing-library/react +- MSW: `useGetDebateTableData` 호출을 mock handler로 처리 + - `src/mocks/handlers/` 기존 핸들러 활용 또는 새 핸들러 추가 +- localStorage: `timer-volume` 값 설정으로 초기 볼륨 상태 제어 +- 테스트 설명: 한국어 + +--- + +## Mock 전략 + +- `useGetDebateTableData` → MSW handler (`src/mocks/handlers/`)로 debateTable 응답 mock +- `useParams` → MemoryRouter로 라우트 파라미터 주입 +- 전체 TimerPage 렌더링이 무거울 경우: `TimerPage` 대신 헤더 볼륨 버튼 부분만 테스트하는 서브컴포넌트로 분리 고려 + +--- + +## 참고 + +현재 `VolumeBar.tsx`가 별도의 팝오버 내에 있으므로, 통합 테스트 관점에서: +1. VolumeBar를 열고(toggleVolumeBar 클릭) +2. 슬라이더/음소거 버튼으로 볼륨 변경 +3. 헤더 아이콘 상태 확인 +순서로 테스트 diff --git a/src/page/TimerPage/TimerPage.test.tsx b/src/page/TimerPage/TimerPage.test.tsx new file mode 100644 index 00000000..ca55a188 --- /dev/null +++ b/src/page/TimerPage/TimerPage.test.tsx @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import userEvent from '@testing-library/user-event'; +import TimerPage from './TimerPage'; +import { GlobalPortal } from '../../util/GlobalPortal'; + +function renderTimerPage() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + } /> + + + + , + ); +} + +describe('TimerPage - 헤더 음소거 아이콘', () => { + beforeEach(() => { + localStorage.clear(); + // 첫 방문 모달이 테스트를 방해하지 않도록 방문 기록 설정 + localStorage.setItem('isVisited', 'true'); + }); + + it('볼륨이 0보다 클 때 일반 볼륨 아이콘이 표시된다', () => { + localStorage.setItem('timer-volume', '0.5'); + renderTimerPage(); + + expect(screen.queryByTestId('volume-icon-muted')).not.toBeInTheDocument(); + expect(screen.getByTestId('volume-icon-normal')).toBeInTheDocument(); + }); + + it('볼륨이 0일 때 음소거 아이콘이 표시된다', () => { + localStorage.setItem('timer-volume', '0'); + renderTimerPage(); + + expect(screen.getByTestId('volume-icon-muted')).toBeInTheDocument(); + }); + + it('VolumeBar 음소거 버튼 클릭 시 헤더 아이콘이 즉시 음소거로 변경된다', async () => { + localStorage.setItem('timer-volume', '0.5'); + renderTimerPage(); + + // VolumeBar 열기 + const volumeButton = screen.getByRole('button', { name: '볼륨 조절' }); + await userEvent.click(volumeButton); + + // VolumeBar 음소거 버튼 클릭 (볼륨 > 0이면 title='음소거') + const muteButton = await screen.findByTitle('음소거'); + await userEvent.click(muteButton); + + await waitFor(() => { + expect(screen.getByTestId('volume-icon-muted')).toBeInTheDocument(); + }); + }); + + it('VolumeBar 슬라이더를 0으로 내리면 헤더 아이콘이 즉시 음소거로 변경된다', async () => { + localStorage.setItem('timer-volume', '0.5'); + renderTimerPage(); + + const volumeButton = screen.getByRole('button', { name: '볼륨 조절' }); + await userEvent.click(volumeButton); + + const slider = await screen.findByRole('slider'); + fireEvent.change(slider, { target: { value: '0' } }); + + await waitFor(() => { + expect(screen.getByTestId('volume-icon-muted')).toBeInTheDocument(); + }); + }); + + it('음소거 상태에서 음소거 해제 시 헤더 아이콘이 일반 볼륨으로 복원된다', async () => { + localStorage.setItem('timer-volume', '0'); + renderTimerPage(); + + // VolumeBar 열기 + const volumeButton = screen.getByRole('button', { name: '볼륨 조절' }); + await userEvent.click(volumeButton); + + // 음소거 해제 버튼 클릭 (볼륨 = 0이면 title='음소거 해제') + const unmuteButton = await screen.findByTitle('음소거 해제'); + await userEvent.click(unmuteButton); + + await waitFor(() => { + expect(screen.queryByTestId('volume-icon-muted')).not.toBeInTheDocument(); + expect(screen.getByTestId('volume-icon-normal')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/page/TimerPage/TimerPage.tsx b/src/page/TimerPage/TimerPage.tsx index 208bf5b8..d5a671cd 100644 --- a/src/page/TimerPage/TimerPage.tsx +++ b/src/page/TimerPage/TimerPage.tsx @@ -15,7 +15,11 @@ import DTHelp from '../../components/icons/Help'; import clsx from 'clsx'; import ErrorIndicator from '../../components/ErrorIndicator/ErrorIndicator'; import LoadingIndicator from '../../components/LoadingIndicator/LoadingIndicator'; -import { RiFullscreenFill, RiFullscreenExitFill } from 'react-icons/ri'; +import { + RiFullscreenFill, + RiFullscreenExitFill, + RiVolumeMuteFill, +} from 'react-icons/ri'; import DTVolume from '../../components/icons/Volume'; import VolumeBar from '../../components/VolumeBar/VolumeBar'; @@ -35,6 +39,7 @@ export default function TimerPage() { const state = useTimerPageState(tableId); useTimerHotkey(state); + const isMuted = state.volume === 0; const { data, bg, @@ -120,7 +125,17 @@ export default function TimerPage() { title={t('볼륨 조절')} onClick={toggleVolumeBar} > - + {isMuted ? ( + + ) : ( + + )} {isVolumeBarOpen && ( diff --git a/src/page/TimerPage/components/NormalTimer.test.tsx b/src/page/TimerPage/components/NormalTimer.test.tsx new file mode 100644 index 00000000..d42566af --- /dev/null +++ b/src/page/TimerPage/components/NormalTimer.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import NormalTimer from './NormalTimer'; +import { TimeBoxInfo } from '../../../type/type'; + +const mockNormalTimerInstance = { + timer: 120, + isAdditionalTimerOn: false, + isRunning: false, + handleChangeAdditionalTimer: vi.fn(), + handleCloseAdditionalTimer: vi.fn(), + startTimer: vi.fn(), + pauseTimer: vi.fn(), + resetTimer: vi.fn(), + setTimer: vi.fn(), +}; + +const baseItem: TimeBoxInfo = { + stance: 'PROS', + speechType: '입론', + bell: null, + boxType: 'NORMAL', + time: 120, + timePerTeam: null, + timePerSpeaking: null, + speaker: null, +}; + +function renderNormalTimer( + teamName: string | null, + speaker: string | null, + speechType = '입론', +) { + const item: TimeBoxInfo = { ...baseItem, speaker, speechType }; + return render( + , + ); +} + +describe('NormalTimer - 두 줄 레이아웃 (US2)', () => { + it('팀명만 있을 때 팀명이 표시되고 토론자 줄은 렌더링되지 않는다', () => { + renderNormalTimer('찬성', null); + + expect(screen.getByText('찬성 팀')).toBeInTheDocument(); + expect(screen.queryByText(/토론자/)).not.toBeInTheDocument(); + }); + + it('토론자만 있을 때 토론자 정보가 표시되고 팀명 줄은 렌더링되지 않는다', () => { + renderNormalTimer(null, '발언자 1'); + + expect(screen.getByText('발언자 1 토론자')).toBeInTheDocument(); + expect(screen.queryByText(/찬성/)).not.toBeInTheDocument(); + }); + + it('팀명과 토론자 모두 있을 때 각각 독립된 DOM 요소로 렌더링된다', () => { + renderNormalTimer('찬성', '발언자 1'); + + const teamEl = screen.getByText('찬성 팀'); + const speakerEl = screen.getByText('발언자 1 토론자'); + + expect(teamEl).toBeInTheDocument(); + expect(speakerEl).toBeInTheDocument(); + expect(teamEl).not.toBe(speakerEl); + }); + + it('팀명과 토론자 모두 없으면 팀 정보 영역이 렌더링되지 않는다', () => { + renderNormalTimer(null, null); + + expect(screen.queryByText(/팀$/)).not.toBeInTheDocument(); + expect(screen.queryByText(/토론자/)).not.toBeInTheDocument(); + }); + + it('팀명 요소에 truncate 클래스가 존재한다', () => { + renderNormalTimer('A very long team name', null); + + const teamEl = screen.getByText('A very long team name 팀'); + expect(teamEl).toHaveClass('truncate'); + }); + + it('토론자 요소에는 truncate 클래스가 존재하지 않는다', () => { + renderNormalTimer(null, '발언자 1'); + + const speakerEl = screen.getByText('발언자 1 토론자'); + expect(speakerEl).not.toHaveClass('truncate'); + }); +}); + +describe('NormalTimer - 순서명 정렬 (US3)', () => { + it('한글 순서명 렌더링 시 h1 요소에 text-center 클래스가 존재한다', () => { + renderNormalTimer(null, null, '입론'); + + const h1 = screen.getByRole('heading', { level: 1 }); + expect(h1).toHaveClass('text-center'); + }); + + it('영어 순서명 렌더링 시 h1 요소에 text-center 클래스가 존재한다', () => { + renderNormalTimer(null, null, 'Opening Statement'); + + const h1 = screen.getByRole('heading', { level: 1 }); + expect(h1).toHaveClass('text-center'); + }); +}); diff --git a/src/page/TimerPage/components/NormalTimer.tsx b/src/page/TimerPage/components/NormalTimer.tsx index f33fceff..967204dc 100644 --- a/src/page/TimerPage/components/NormalTimer.tsx +++ b/src/page/TimerPage/components/NormalTimer.tsx @@ -4,7 +4,6 @@ import TimerController from './TimerController'; import { Formatting } from '../../../util/formatting'; import CircularTimer from './CircularTimer'; import clsx from 'clsx'; -import DTDebate from '../../../components/icons/Debate'; import CompactTimeoutTimer from './CompactTimeoutTimer'; import useCircularTimerAnimation from '../hooks/useCircularTimerAnimation'; import useBreakpoint from '../../../hooks/useBreakpoint'; @@ -76,17 +75,26 @@ export default function NormalTimer({ {/* 제목 */} -

{titleText}

+

+ {titleText} +

{/* 발언자 및 팀 정보 */} {(teamName || item.speaker) && ( - - -

- {teamName && t('{{team}} 팀', { team: t(teamName) })} - {item.speaker && - t(' | {{speaker}} 토론자', { speaker: t(item.speaker) })} -

+ + {teamName && ( +

+ {t('{{team}} 팀', { team: t(teamName) })} +

+ )} + {teamName && item.speaker && ( +
+ )} + {item.speaker && ( +

+ {t('{{speaker}} 토론자', { speaker: t(item.speaker) })} +

+ )}
)}