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/public/locales/en/translation.json b/public/locales/en/translation.json index 4e77589f..4c7fd080 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -226,6 +226,7 @@ "발언당 {{minutes}}분 {{seconds}}초": "{{minutes}}m {{seconds}}s per speech", "위/아래로 드래그": "Drag up/down", " | {{speaker}} 토론자": " | {{speaker}} Debater", + "{{speaker}} 토론자": "{{speaker}} Debater", "토론 정보를 {{val0}}해주세요": "Please {{val0}} the debate info", "수정": "edit", "설정": "configure", diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index 21f5e08d..456e9c3d 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -226,6 +226,7 @@ "발언당 {{minutes}}분 {{seconds}}초": "발언당 {{minutes}}분 {{seconds}}초", "위/아래로 드래그": "위/아래로 드래그", " | {{speaker}} 토론자": " | {{speaker}} 토론자", + "{{speaker}} 토론자": "{{speaker}} 토론자", "토론 정보를 {{val0}}해주세요": "토론 정보를 {{val0}}해주세요", "수정": "수정", "설정": "설정", 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..0a49d5d2 --- /dev/null +++ b/src/page/TimerPage/components/NormalTimer.test.tsx @@ -0,0 +1,103 @@ +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('긴 팀명을 가진 팀의 발언 순서일 때 팀명이 화면에 표시된다', () => { + renderNormalTimer('A very long team name', null); + + expect(screen.getByText('A very long team name 팀')).toBeInTheDocument(); + }); + + it('토론자 이름이 긴 경우에도 전체 이름이 화면에 표시된다', () => { + renderNormalTimer(null, '발언자 1'); + + expect(screen.getByText('발언자 1 토론자')).toBeInTheDocument(); + }); +}); + +describe('NormalTimer - 순서명 정렬 (US3)', () => { + it('한글 순서명이 타이머 화면에 표시된다', () => { + renderNormalTimer(null, null, '입론'); + + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('입론'); + }); + + it('영어 순서명이 타이머 화면에 표시된다', () => { + renderNormalTimer(null, null, 'Opening Statement'); + + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Opening Statement'); + }); +}); 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) })} +

+ )}
)}