Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 35 additions & 18 deletions .specify/scripts/bash/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
fi
fi
fi
done
done

if [[ -n "$latest_feature" ]]; then
Expand Down Expand Up @@ -107,29 +112,41 @@ 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
Comment on lines +130 to +133
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The type_prefix defaults to "feat" for legacy branch names (those not following the type/#number pattern). This means if a legacy branch (e.g., 441-mute-timer-layout-fix) refers to a specification that has been moved to specs/fix/, this script will fail to locate it. To improve robustness, consider iterating through all subdirectories of specs/ to find a matching issue number if the branch name doesn't explicitly include a type prefix, similar to the logic implemented in get_current_branch.


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

# 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
Expand Down
1 change: 1 addition & 0 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions public/locales/ko/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@
"발언당 {{minutes}}분 {{seconds}}초": "발언당 {{minutes}}분 {{seconds}}초",
"위/아래로 드래그": "위/아래로 드래그",
" | {{speaker}} 토론자": " | {{speaker}} 토론자",
"{{speaker}} 토론자": "{{speaker}} 토론자",
"토론 정보를 {{val0}}해주세요": "토론 정보를 {{val0}}해주세요",
"수정": "수정",
"설정": "설정",
Expand Down
97 changes: 97 additions & 0 deletions src/page/TimerPage/TimerPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<QueryClientProvider client={queryClient}>
<GlobalPortal.Provider>
<MemoryRouter initialEntries={['/timer/5']}>
<Routes>
<Route path="/timer/:id" element={<TimerPage />} />
</Routes>
</MemoryRouter>
</GlobalPortal.Provider>
</QueryClientProvider>,
);
}

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();
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
19 changes: 17 additions & 2 deletions src/page/TimerPage/TimerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -35,6 +39,7 @@ export default function TimerPage() {
const state = useTimerPageState(tableId);

useTimerHotkey(state);
const isMuted = state.volume === 0;
const {
data,
bg,
Expand Down Expand Up @@ -120,7 +125,17 @@ export default function TimerPage() {
title={t('볼륨 조절')}
onClick={toggleVolumeBar}
>
<DTVolume className="h-full w-full" />
{isMuted ? (
<RiVolumeMuteFill
className="h-full w-full"
data-testid="volume-icon-muted"
/>
) : (
<DTVolume
className="h-full w-full"
data-testid="volume-icon-normal"
/>
)}
</button>

{isVolumeBarOpen && (
Expand Down
103 changes: 103 additions & 0 deletions src/page/TimerPage/components/NormalTimer.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<NormalTimer
normalTimerInstance={mockNormalTimerInstance}
isAdditionalTimerAvailable={false}
item={item}
teamName={teamName}
/>,
);
}

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();
Comment on lines +84 to +87
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

긴 토론자 이름 케이스가 실제로는 검증되지 않습니다.

테스트명은 긴 이름을 검증한다고 되어 있지만 입력값이 발언자 1이라서 max-character 증가/긴 문자열 레이아웃 회귀를 잡지 못합니다.

🧪 제안 수정
   it('토론자 이름이 긴 경우에도 전체 이름이 화면에 표시된다', () => {
-    renderNormalTimer(null, '발언자 1');
+    const longSpeakerName = '아주 긴 이름의 토론자 1';
 
-    expect(screen.getByText('발언자 1 토론자')).toBeInTheDocument();
+    renderNormalTimer(null, longSpeakerName);
+
+    expect(screen.getByText(`${longSpeakerName} 토론자`)).toBeInTheDocument();
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('토론자 이름이 긴 경우에도 전체 이름이 화면에 표시된다', () => {
renderNormalTimer(null, '발언자 1');
expect(screen.getByText('발언자 1 토론자')).toBeInTheDocument();
it('토론자 이름이 긴 경우에도 전체 이름이 화면에 표시된다', () => {
const longSpeakerName = '아주 긴 이름의 토론자 1';
renderNormalTimer(null, longSpeakerName);
expect(screen.getByText(`${longSpeakerName} 토론자`)).toBeInTheDocument();
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/page/TimerPage/components/NormalTimer.test.tsx` around lines 84 - 87, The
test "토론자 이름이 긴 경우에도 전체 이름이 화면에 표시된다" is passing a short name ('발언자 1') so it
doesn't exercise long-name layout; update the test to pass a truly long string
(e.g., a > max characters username) into renderNormalTimer instead of '발언자 1'
and assert the full long name is rendered with screen.getByText; locate the test
case using the test description and the render helper renderNormalTimer to
change the input value and keep the existing expectation but matching the full
long name.

});
});

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');

Check warning on line 101 in src/page/TimerPage/components/NormalTimer.test.tsx

View workflow job for this annotation

GitHub Actions / test

Replace `'Opening·Statement'` with `⏎······'Opening·Statement',⏎····`
Comment on lines +91 to +101
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

포맷팅 경고를 반영해주세요.

정적 분석 경고대로 긴 matcher 호출을 여러 줄로 나누면 현재 포맷 규칙과 맞습니다.

🧹 제안 수정
-    expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Opening Statement');
+    expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(
+      'Opening Statement',
+    );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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');
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',
);
🧰 Tools
🪛 GitHub Check: test

[warning] 101-101:
Replace 'Opening·Statement' with ⏎······'Opening·Statement',⏎····

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/page/TimerPage/components/NormalTimer.test.tsx` around lines 91 - 101,
The test assertions in NormalTimer.test.tsx use long matcher calls on a single
line; update the expect statements for both tests (the one calling
renderNormalTimer(..., '입론') and the one with 'Opening Statement') to break the
matcher chain across multiple lines so they conform to the formatter: call
expect(...) on one line, call .getByRole/arguments on the next line if needed,
and place .toHaveTextContent(...) on its own line; adjust the two occurrences
that use screen.getByRole and toHaveTextContent accordingly while keeping the
test names and renderNormalTimer usage unchanged.

});
});
26 changes: 17 additions & 9 deletions src/page/TimerPage/components/NormalTimer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -76,17 +75,26 @@ export default function NormalTimer({
<span className="flex w-[360px] flex-shrink-0 flex-col items-center justify-center xl:w-[450px]">
<span className="flex w-full flex-col items-center justify-center space-y-[20px] px-[45px] xl:space-y-[36px]">
{/* 제목 */}
<h1 className="text-[52px] font-bold xl:text-[68px]">{titleText}</h1>
<h1 className="text-center text-[52px] font-bold xl:text-[68px]">
{titleText}
</h1>

{/* 발언자 및 팀 정보 */}
{(teamName || item.speaker) && (
<span className="flex w-full max-w-[600px] flex-row items-center justify-center space-x-[16px]">
<DTDebate className="w-[20px] flex-shrink-0 xl:w-[28px]" />
<p className="min-w-0 flex-1 truncate text-[20px] xl:text-[28px]">
{teamName && t('{{team}} 팀', { team: t(teamName) })}
{item.speaker &&
t(' | {{speaker}} 토론자', { speaker: t(item.speaker) })}
</p>
<span className="flex w-full max-w-[600px] flex-col items-center justify-center gap-y-[8px] xl:gap-y-[12px]">
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The DTDebate icon (previously located at line 84 in the old version) has been removed in this refactor. While the transition to a two-line layout improves readability for long team names, the removal of this visual element isn't explicitly mentioned in the PR description or the research document. If the icon was removed intentionally to simplify the UI, please update the documentation to confirm this. Otherwise, consider restoring it (e.g., centered above the team name) to maintain visual consistency.

{teamName && (
<p className="w-full min-w-0 truncate text-center text-[20px] xl:text-[28px]">
{t('{{team}} 팀', { team: t(teamName) })}
</p>
)}
{teamName && item.speaker && (
<hr className="w-1/3 border-[1.2px] border-current opacity-30" />
)}
{item.speaker && (
<p className="w-full min-w-0 text-center text-[20px] xl:text-[28px]">
{t('{{speaker}} 토론자', { speaker: t(item.speaker) })}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 부분이 번역되지 않는 문제가 있습니다!

Image

원인은 번역키가 ' ㅣ {{speaker}} 토론자'로 되어 있는데 구조를 바꾸면서 |를 없애고 '{{speaker}} 토론자'로 수정됐는데 키에는 반영하지 않았기 때문입니다.

키가 알맞게 대응되도록 수정해 주시면 번역이 될 것 같아요~!

</p>
)}
</span>
)}

Expand Down
Loading