From 49beae88c399bf8a3da930cb0c228ed9c6ab4618 Mon Sep 17 00:00:00 2001 From: mikeyoo0405 Date: Wed, 2 Sep 2026 20:28:45 +0900 Subject: [PATCH 1/8] fix(fe): return to course list from management sidebar --- .../admin/_components/ManagementSidebar.tsx | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/apps/frontend/app/admin/_components/ManagementSidebar.tsx b/apps/frontend/app/admin/_components/ManagementSidebar.tsx index 9f7d221462..5774089e1d 100644 --- a/apps/frontend/app/admin/_components/ManagementSidebar.tsx +++ b/apps/frontend/app/admin/_components/ManagementSidebar.tsx @@ -119,7 +119,6 @@ interface ManagementSidebarProps { export function ManagementSidebar({ session }: ManagementSidebarProps) { const [isMainSidebarExpanded, setIsMainSidebarExpanded] = useState(true) const [isAnimationComplete, setIsAnimationComplete] = useState(true) - const [isCourseSidebarOpened, setIsCourseSidebarOpened] = useState(false) const [selectedCourseId, setSelectedCourseId] = useState('') const [userPermissions, setUserPermissions] = useState({ canCreateCourse: false, @@ -200,8 +199,6 @@ export function ManagementSidebar({ session }: ManagementSidebarProps) { const hasCourse = Boolean(id) setSelectedCourseId(id) - setIsCourseSidebarOpened(hasCourse) - if (hasCourse) { setIsMainSidebarExpanded(false) } @@ -262,27 +259,10 @@ export function ManagementSidebar({ session }: ManagementSidebarProps) { : pathname.startsWith(item.path) } isExpanded={isMainSidebarExpanded} - onClick={(e) => { - const isCourseItem = item.path === '/admin/course' - const isOnCourseDetail = - pathname.startsWith('/admin/course/') && - isCourseSidebarOpened - - if (isCourseItem && isOnCourseDetail) { - if (!isMainSidebarExpanded) { - setIsMainSidebarExpanded(true) - } - e.preventDefault() - return - } - + onClick={() => { if (!isMainSidebarExpanded) { setIsMainSidebarExpanded(true) } - - if (!isCourseItem) { - setIsCourseSidebarOpened(false) - } }} /> {item.path === '/admin/course' && @@ -299,7 +279,6 @@ export function ManagementSidebar({ session }: ManagementSidebarProps) { setIsCourseSidebarOpened(true)} className={cn( 'overflow-hidden text-ellipsis text-xs transition-colors', params.courseId === course.id.toString() From 6fb2314bfd5bfc468f07dc3769354837b15f86b0 Mon Sep 17 00:00:00 2001 From: mikeyoo0405 Date: Thu, 3 Sep 2026 17:43:30 +0900 Subject: [PATCH 2/8] feat(fe): add submission progress UI --- .../_components/EditorHeader/EditorHeader.tsx | 70 +++++++--- .../TestcasePanel/TestcasePanel.tsx | 130 ++++++++++++++++-- .../context/TestPollingStoreProvider.tsx | 15 ++ 3 files changed, 191 insertions(+), 24 deletions(-) diff --git a/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.tsx b/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.tsx index d9c10790fc..4b97274cb8 100644 --- a/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.tsx +++ b/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.tsx @@ -35,12 +35,14 @@ import { import { RUN_CODE_TAB, useSidePanelTabStore, - useTestcaseTabStore + useTestcaseTabStore, + TESTCASE_RESULT_TAB } from '@/stores/editorTabs' import type { Language, ProblemDetail, Submission, + SubmissionDetail, Template } from '@/types/type' import { useQueryClient } from '@tanstack/react-query' @@ -86,6 +88,9 @@ export function EditorHeader({ const getCode = useCodeStore((state) => state.getCode) const isTesting = useTestPollingStore((state) => state.isTesting) + const setSubmissionProgress = useTestPollingStore( + (state) => state.setSubmissionProgress + ) const [isLanguageModalOpen, setIsLanguageModalOpen] = useState(false) const [selectedLanguage, setSelectedLanguage] = useState(language) const [isSubmitting, setIsSubmitting] = useState(false) @@ -133,40 +138,65 @@ export function EditorHeader({ async () => { // TODO: Implement assignment submission const res = await fetcherWithAuth(`submission/${submissionId}`, { + cache: 'no-store', + headers: { + 'Cache-Control': 'no-cache' + }, searchParams: { problemId: problem.id, + pollingTime: Date.now(), ...(contestId && { contestId }), ...(assignmentId && { assignmentId }), ...(exerciseId && { assignmentId: exerciseId }) } }) if (res.ok) { - const submission: Submission = await res.json() + const submission: SubmissionDetail = await res.json() + const total = submission.testcaseResult.length + const completed = submission.testcaseResult.filter( + (testcase) => testcase.result !== 'Judging' + ).length + if (submission.result !== 'Judging') { + const failedCaseIndex = submission.testcaseResult.findIndex( + (testcase) => testcase.result !== 'Accepted' + ) + const runtime = Math.max( + 0, + ...submission.testcaseResult.map( + (testcase) => Number(testcase.cpuTime) || 0 + ) + ) + const memoryUsage = Math.max( + 0, + ...submission.testcaseResult.map((testcase) => testcase.memoryUsage) + ) + setSubmissionProgress({ + stage: 'finished', + completed: total, + total, + result: submission.result, + runtime, + memoryUsage, + failedCase: failedCaseIndex === -1 ? undefined : failedCaseIndex + 1 + }) setIsSubmitting(false) - - let href = '' - if (contestId) { - href = `/contest/${contestId}/problem/${problem.id}/submission/${submissionId}?cellProblemId=${problem.id}` - } else if (assignmentId) { - href = `/course/${courseId}/assignment/${assignmentId}/problem/${problem.id}/submission/${submissionId}` - } else if (exerciseId) { - href = `/course/${courseId}/exercise/${exerciseId}/problem/${problem.id}/submission/${submissionId}` - } else { - href = `/problem/${problem.id}/submission/${submissionId}` - } - - !contestId && router.replace(href as Route) - //window.history.pushState(null, '', window.location.href) if (submission.result === 'Accepted') { confetti?.addConfetti() } if (isSidePanelHidden) { toggleSidePanelVisibility() } + } else { + setSubmissionProgress({ + stage: 'grading', + completed, + total + }) } } else { setIsSubmitting(false) + setSubmissionProgress(null) toast.error('Please try again later.') } }, @@ -257,6 +287,8 @@ export function EditorHeader({ setSubmissionId(null) setIsSubmitting(true) + setActiveTestcaseTab(TESTCASE_RESULT_TAB) + setSubmissionProgress({ stage: 'waiting', completed: 0, total: 0 }) const res = await fetcherWithAuth.post('submission', { json: { language, @@ -284,6 +316,11 @@ export function EditorHeader({ const submission: Submission = await res.json() setSubmissionId(submission.id) + setSubmissionProgress({ + stage: 'grading', + completed: 0, + total: problem.problemTestcase.length + }) if (contestId) { queryClient.invalidateQueries({ queryKey: contestProblemQueries.lists(contestId) @@ -322,6 +359,7 @@ export function EditorHeader({ } } else { setIsSubmitting(false) + setSubmissionProgress(null) if (res.status === 401) { showSignIn() toast.error('Log in first to submit your code') diff --git a/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/TestcasePanel.tsx b/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/TestcasePanel.tsx index 61b72d2b5c..1113f09823 100644 --- a/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/TestcasePanel.tsx +++ b/apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/TestcasePanel.tsx @@ -1,6 +1,7 @@ 'use client' import { Badge } from '@/app/(client)/(main)/_components/Badge' +import { Progress } from '@/components/shadcn/progress' import { ScrollArea, ScrollBar } from '@/components/shadcn/scroll-area' import { cn, getResultColor } from '@/libs/utils' import { @@ -12,7 +13,12 @@ import type { TabbedTestResult } from '@/types/type' import { DiffMatchPatch } from 'diff-match-patch-typescript' import { useEffect, useState, type ReactNode, type JSX } from 'react' import { IoMdClose } from 'react-icons/io' +import { PiConfettiFill, PiSmileyNervousFill } from 'react-icons/pi' import { WhitespaceVisualizer } from '../WhitespaceVisualizer' +import { + type SubmissionProgress, + useTestPollingStore +} from '../context/TestPollingStoreProvider' import { AddUserTestcaseDialog } from './AddUserTestcaseDialog' import { RunnerTab } from './RunnerTab' import { TestcaseTable } from './TestcaseTable' @@ -36,6 +42,9 @@ export function TestcasePanel({ isContest }: TestcasePanelProps) { const [testcaseTabList, setTestcaseTabList] = useState([]) const { activeTab, setActiveTab } = useTestcaseTabStore() const [detailTabId, setDetailTabId] = useState(null) + const submissionProgress = useTestPollingStore( + (state) => state.submissionProgress + ) const moveToDetailTab = (result: TabbedTestResult) => { setTestcaseTabList((state) => @@ -66,7 +75,7 @@ export function TestcasePanel({ isContest }: TestcasePanelProps) { setActiveTab(TESTCASE_RESULT_TAB) setDetailTabId(null) } - }, [isContest]) + }, [isContest, setActiveTab]) const MAX_OUTPUT_LENGTH = 100000 const testResults = useTestResults() @@ -87,6 +96,14 @@ export function TestcasePanel({ isContest }: TestcasePanelProps) { const currentVisibleTabIndex = testcaseTabList.findIndex( (tab) => tab.originalId === currentVisibleTab ) + const testcaseResultContent = submissionProgress ? ( + + ) : ( +
+ + +
+ ) return ( <> @@ -206,13 +223,7 @@ export function TestcasePanel({ isContest }: TestcasePanelProps) { /> {currentVisibleTab === TESTCASE_RESULT_TAB ? ( -
- - -
+ testcaseResultContent ) : ( + +

채점 대기 중...

+ + ) + } + + if (progress.stage === 'grading') { + return + } + + const accepted = progress.result === 'Accepted' + return ( +
+ {accepted ? ( + + ) : ( + + )} +

+ {accepted ? '맞았습니다!' : '틀렸습니다!'} +

+
+ +
+ +
+
+ ) +} + +function GradingProgressPanel({ progress }: { progress: SubmissionProgress }) { + const value = progress.total ? (progress.completed / progress.total) * 100 : 0 + + return ( +
+

+ 채점 중 {Math.round(value)}% +

+

+ ({progress.completed} / {progress.total} Test cases) +

+ +
+ ) +} + +function ThreeDots() { + const [activeDot, setActiveDot] = useState(0) + + useEffect(() => { + const intervalId = window.setInterval(() => { + setActiveDot((current) => (current + 1) % 3) + }, 300) + + return () => window.clearInterval(intervalId) + }, []) + + return ( +
+ {[0, 1, 2].map((dot) => ( + + ))} +
+ ) +} + +function ResultMetric({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ) +} + export const TAB_CONTENT = { sample: 'Sample', user: 'User', diff --git a/apps/frontend/app/(client)/(code-editor)/_components/context/TestPollingStoreProvider.tsx b/apps/frontend/app/(client)/(code-editor)/_components/context/TestPollingStoreProvider.tsx index 9b2c3febf1..7350f06dde 100644 --- a/apps/frontend/app/(client)/(code-editor)/_components/context/TestPollingStoreProvider.tsx +++ b/apps/frontend/app/(client)/(code-editor)/_components/context/TestPollingStoreProvider.tsx @@ -8,6 +8,18 @@ interface TestPollingState { userPollingEnabled: boolean startPolling: () => void stopPolling: (type: 'non-user' | 'user') => void + submissionProgress: SubmissionProgress | null + setSubmissionProgress: (progress: SubmissionProgress | null) => void +} + +export interface SubmissionProgress { + stage: 'waiting' | 'grading' | 'finished' + completed: number + total: number + result?: string + runtime?: number + memoryUsage?: number + failedCase?: number } const createTestPollingStore = () => @@ -16,6 +28,9 @@ const createTestPollingStore = () => setIsTesting: (isTesting) => set((state) => ({ ...state, isTesting })), nonUserPollingEnabled: false, userPollingEnabled: false, + submissionProgress: null, + setSubmissionProgress: (submissionProgress) => + set((state) => ({ ...state, submissionProgress })), startPolling: () => { set((state) => ({ ...state, From e8d7b91e4d1fd969dcf3f18726ffde9a5d7bf847 Mon Sep 17 00:00:00 2001 From: mikeyoo0405 Date: Mon, 28 Sep 2026 02:09:55 +0900 Subject: [PATCH 3/8] fix(fe): serialize submission status polling and retry errors --- .../_components/EditorHeader/EditorHeader.tsx | 90 +++++++++++++++---- 1 file changed, 72 insertions(+), 18 deletions(-) diff --git a/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.tsx b/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.tsx index 4b97274cb8..e1cf9c28cc 100644 --- a/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.tsx +++ b/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.tsx @@ -97,6 +97,11 @@ export function EditorHeader({ const loading = isTesting || isSubmitting const [submissionId, setSubmissionId] = useState(null) + const pollingRequestRef = useRef(false) + const pollingGenerationRef = useRef(0) + const finishedSubmissionRef = useRef(null) + const pollingFailuresRef = useRef(0) + const nextPollAtRef = useRef(0) const [templateCode, setTemplateCode] = useState('') const [userName, setUserName] = useState('') const router = useRouter() @@ -134,24 +139,52 @@ export function EditorHeader({ problemId: problem.id, enabled: isSubmitted }) + useEffect(() => { + const generationRef = pollingGenerationRef + return () => { + // Invalidate requests that outlive this editor. + generationRef.current++ + } + }, []) useInterval( async () => { - // TODO: Implement assignment submission - const res = await fetcherWithAuth(`submission/${submissionId}`, { - cache: 'no-store', - headers: { - 'Cache-Control': 'no-cache' - }, - searchParams: { - problemId: problem.id, - pollingTime: Date.now(), - ...(contestId && { contestId }), - ...(assignmentId && { assignmentId }), - ...(exerciseId && { assignmentId: exerciseId }) + if ( + submissionId === null || + pollingRequestRef.current || + finishedSubmissionRef.current === submissionId || + Date.now() < nextPollAtRef.current + ) { + return + } + + const generation = pollingGenerationRef.current + pollingRequestRef.current = true + try { + // TODO: Implement assignment submission + const res = await fetcherWithAuth(`submission/${submissionId}`, { + cache: 'no-store', + headers: { + 'Cache-Control': 'no-cache' + }, + searchParams: { + problemId: problem.id, + pollingTime: Date.now(), + ...(contestId && { contestId }), + ...(assignmentId && { assignmentId }), + ...(exerciseId && { assignmentId: exerciseId }) + } + }) + if (!res.ok) { + throw new Error(`Submission status request failed: ${res.status}`) } - }) - if (res.ok) { + const submission: SubmissionDetail = await res.json() + if (generation !== pollingGenerationRef.current) { + return + } + + pollingFailuresRef.current = 0 + nextPollAtRef.current = 0 const total = submission.testcaseResult.length const completed = submission.testcaseResult.filter( (testcase) => testcase.result !== 'Judging' @@ -171,6 +204,7 @@ export function EditorHeader({ 0, ...submission.testcaseResult.map((testcase) => testcase.memoryUsage) ) + finishedSubmissionRef.current = submissionId setSubmissionProgress({ stage: 'finished', completed: total, @@ -194,10 +228,25 @@ export function EditorHeader({ total }) } - } else { - setIsSubmitting(false) - setSubmissionProgress(null) - toast.error('Please try again later.') + } catch { + if (generation !== pollingGenerationRef.current) { + return + } + + pollingFailuresRef.current++ + nextPollAtRef.current = + Date.now() + + Math.min( + 1000 * 2 ** Math.min(pollingFailuresRef.current - 1, 4), + 10000 + ) + if (pollingFailuresRef.current === 1) { + toast.error('Unable to check submission status. Retrying...') + } + } finally { + if (generation === pollingGenerationRef.current) { + pollingRequestRef.current = false + } } }, isSubmitting && submissionId ? 500 : null @@ -285,6 +334,11 @@ export function EditorHeader({ return } + pollingGenerationRef.current++ + pollingRequestRef.current = false + finishedSubmissionRef.current = null + pollingFailuresRef.current = 0 + nextPollAtRef.current = 0 setSubmissionId(null) setIsSubmitting(true) setActiveTestcaseTab(TESTCASE_RESULT_TAB) From d362c90777c9faf7d056bfda6292425e57035caa Mon Sep 17 00:00:00 2001 From: mikeyoo0405 Date: Sat, 3 Oct 2026 03:13:13 +0900 Subject: [PATCH 4/8] fix(fe): align submission progress with Figma and handle request failures --- .../EditorHeader/EditorHeader.test.tsx | 207 ++++++++++++++++ .../_components/EditorHeader/EditorHeader.tsx | 232 +++++++++--------- .../EditorHeader/RunTestButton.test.tsx | 77 ++++++ .../EditorHeader/RunTestButton.tsx | 6 +- .../_components/EditorResizablePanel.tsx | 1 + .../SubmissionProgressPanel.test.tsx | 118 +++++++++ .../TestcasePanel/SubmissionProgressPanel.tsx | 160 ++++++++++++ .../TestcasePanel/TestcasePanel.tsx | 125 +--------- .../context/TestPollingStoreProvider.tsx | 13 +- .../_libs/submissionProgress.test.ts | 98 ++++++++ .../(code-editor)/_libs/submissionProgress.ts | 91 +++++++ apps/frontend/components/shadcn/progress.tsx | 1 + .../images/submission-progress/accepted.svg | 5 + .../submission-progress/compiling-dot.svg | 3 + .../images/submission-progress/compiling.png | Bin 0 -> 6197 bytes .../images/submission-progress/divider.svg | 3 + .../images/submission-progress/waiting.svg | 9 + .../submission-progress/wrong-answer.svg | 5 + 18 files changed, 920 insertions(+), 234 deletions(-) create mode 100644 apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.test.tsx create mode 100644 apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/RunTestButton.test.tsx create mode 100644 apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/SubmissionProgressPanel.test.tsx create mode 100644 apps/frontend/app/(client)/(code-editor)/_components/TestcasePanel/SubmissionProgressPanel.tsx create mode 100644 apps/frontend/app/(client)/(code-editor)/_libs/submissionProgress.test.ts create mode 100644 apps/frontend/app/(client)/(code-editor)/_libs/submissionProgress.ts create mode 100644 apps/frontend/public/images/submission-progress/accepted.svg create mode 100644 apps/frontend/public/images/submission-progress/compiling-dot.svg create mode 100644 apps/frontend/public/images/submission-progress/compiling.png create mode 100644 apps/frontend/public/images/submission-progress/divider.svg create mode 100644 apps/frontend/public/images/submission-progress/waiting.svg create mode 100644 apps/frontend/public/images/submission-progress/wrong-answer.svg diff --git a/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.test.tsx b/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.test.tsx new file mode 100644 index 0000000000..d88026aaf3 --- /dev/null +++ b/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.test.tsx @@ -0,0 +1,207 @@ +import type * as Utils from '@/libs/utils' +import type { ProblemDetail } from '@/types/type' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SubmissionProgressPanel } from '../TestcasePanel/SubmissionProgressPanel' +import { + TestPollingStoreProvider, + useTestPollingStore +} from '../context/TestPollingStoreProvider' +import { EditorHeader } from './EditorHeader' + +const mocks = vi.hoisted(() => ({ + post: vi.fn(), + get: vi.fn(), + invalidate: vi.fn(), + session: { user: { username: 'student' } }, + router: { push: vi.fn() }, + confetti: vi.fn(), + destroyCanvas: vi.fn(), + onSubmissionStart: vi.fn() +})) + +vi.mock('@/libs/utils', async (importOriginal) => ({ + ...(await importOriginal()), + fetcherWithAuth: Object.assign(mocks.get, { post: mocks.post }) +})) +vi.mock('@/libs/hooks/useSession', () => ({ useSession: () => mocks.session })) +vi.mock('next/navigation', () => ({ + useRouter: () => mocks.router, + usePathname: () => '/problem/1' +})) +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ invalidateQueries: mocks.invalidate }) +})) +vi.mock('js-confetti', () => ({ + default: class { + addConfetti = mocks.confetti + destroyCanvas = mocks.destroyCanvas + } +})) +vi.mock('@/stores/editor', () => ({ + useCodeStore: (selector: (state: unknown) => unknown) => + selector({ getCode: () => 'int main() {}', setCode: vi.fn() }), + useLanguageStore: () => () => ({ language: 'C', setLanguage: vi.fn() }), + getStorageKey: () => undefined, + getCodeFromLocalStorage: () => undefined +})) +vi.mock('../../_libs/hooks/useSubmissionPolling', () => ({ + useSubmissionPolling: vi.fn() +})) +vi.mock('../TestcasePanel/useRunner', () => ({ + useRunner: () => ({ startRunner: vi.fn() }) +})) +vi.mock('@/components/AlertModal', () => ({ AlertModal: () => null })) +vi.mock('./BackCautionDialog', () => ({ BackCautionDialog: () => null })) +vi.mock('./RunTestButton', () => ({ RunTestButton: () => null })) +vi.mock('@/public/icons/submit.svg', () => ({ default: () => })) + +function ProgressView() { + const progress = useTestPollingStore((state) => state.submissionProgress) + return progress ? : null +} + +const problem = { + id: 1, + languages: ['C'], + problemTestcase: [] +} as unknown as ProblemDetail +const response = (result: string) => ({ + ok: true, + json: () => + Promise.resolve({ + result, + testcaseResult: [ + { + id: 1, + problemTestcaseId: 1, + result, + cpuTime: '35', + memoryUsage: 1024 + } + ] + }) +}) + +const mount = () => + render( + + + + + ) + +beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + mocks.post.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ id: 42 }) + }) +}) +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +describe('submission workflow', () => { + it('releases the submit button and shows an error after a rejected POST', async () => { + mocks.post.mockRejectedValueOnce(new Error('offline')) + mount() + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Submit/ })) + await Promise.resolve() + }) + expect(screen.getByRole('alert').textContent).toContain( + '제출 요청에 실패했습니다' + ) + expect( + screen.getByRole('button', { name: /Submit/ }).hasAttribute('disabled') + ).toBe(false) + expect(mocks.get).not.toHaveBeenCalled() + }) + + it('does not overlap slow polls and transitions to the final result once', async () => { + let finishPoll!: (value: ReturnType) => void + mocks.get + .mockReturnValueOnce( + new Promise((resolve) => { + finishPoll = resolve + }) + ) + .mockResolvedValue(response('Accepted')) + mount() + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Submit/ })) + await Promise.resolve() + }) + expect(screen.getByText('채점 대기 중...')).toBeTruthy() + expect(mocks.onSubmissionStart).toHaveBeenCalledTimes(1) + await act(async () => { + await vi.advanceTimersByTimeAsync(2500) + }) + expect(mocks.get).toHaveBeenCalledTimes(1) + await act(async () => { + finishPoll(response('Judging')) + await Promise.resolve() + }) + expect(screen.getByText('채점 대기 중...')).toBeTruthy() + await act(async () => { + await vi.advanceTimersByTimeAsync(500) + }) + expect(screen.getByText('맞았습니다!')).toBeTruthy() + expect(mocks.confetti).toHaveBeenCalledTimes(1) + await act(async () => { + await vi.advanceTimersByTimeAsync(3000) + }) + expect(mocks.get).toHaveBeenCalledTimes(2) + }) + + it('ignores a POST that resolves after the editor unmounts', async () => { + let finishSubmit!: (value: { + ok: boolean + json: () => Promise<{ id: number }> + }) => void + mocks.post.mockReturnValueOnce( + new Promise((resolve) => { + finishSubmit = resolve + }) + ) + const view = mount() + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Submit/ })) + await Promise.resolve() + }) + view.unmount() + await act(async () => { + finishSubmit({ ok: true, json: () => Promise.resolve({ id: 42 }) }) + await vi.advanceTimersByTimeAsync(1000) + }) + expect(mocks.invalidate).not.toHaveBeenCalled() + expect(mocks.get).not.toHaveBeenCalled() + expect(mocks.destroyCanvas).toHaveBeenCalledTimes(1) + }) + + it('stops repeated polling failures and releases the button', async () => { + mocks.get.mockRejectedValue(new Error('offline')) + mount() + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Submit/ })) + await Promise.resolve() + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(20000) + }) + expect(mocks.get).toHaveBeenCalledTimes(5) + expect(screen.getByRole('alert').textContent).toContain( + '제출 내역에서 결과를 확인' + ) + expect( + screen.getByRole('button', { name: /Submit/ }).hasAttribute('disabled') + ).toBe(false) + }) +}) diff --git a/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.tsx b/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.tsx index e1cf9c28cc..f36b05feea 100644 --- a/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.tsx +++ b/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.tsx @@ -55,6 +55,7 @@ import { BsTrash3 } from 'react-icons/bs' import { IoPlayCircleOutline } from 'react-icons/io5' import { useInterval, useKey } from 'react-use' import { toast } from 'sonner' +import { getSubmissionProgress } from '../../_libs/submissionProgress' import { useRunner } from '../TestcasePanel/useRunner' import { useTestPollingStore } from '../context/TestPollingStoreProvider' import { BackCautionDialog } from './BackCautionDialog' @@ -67,6 +68,7 @@ interface ProblemEditorProps { exerciseId?: number courseId?: number templateString: string + onSubmissionStart?: () => void } export function EditorHeader({ @@ -75,7 +77,8 @@ export function EditorHeader({ assignmentId, exerciseId, courseId, - templateString + templateString, + onSubmissionStart }: ProblemEditorProps) { const { language, setLanguage } = useLanguageStore( problem.id, @@ -106,7 +109,7 @@ export function EditorHeader({ const [userName, setUserName] = useState('') const router = useRouter() const pathname = usePathname() - const confetti = typeof window !== 'undefined' ? new JSConfetti() : null + const confettiRef = useRef(null) const storageKey = useRef( getStorageKey( language, @@ -141,7 +144,9 @@ export function EditorHeader({ }) useEffect(() => { const generationRef = pollingGenerationRef + confettiRef.current = new JSConfetti() return () => { + confettiRef.current?.destroyCanvas() // Invalidate requests that outlive this editor. generationRef.current++ } @@ -185,48 +190,18 @@ export function EditorHeader({ pollingFailuresRef.current = 0 nextPollAtRef.current = 0 - const total = submission.testcaseResult.length - const completed = submission.testcaseResult.filter( - (testcase) => testcase.result !== 'Judging' - ).length - - if (submission.result !== 'Judging') { - const failedCaseIndex = submission.testcaseResult.findIndex( - (testcase) => testcase.result !== 'Accepted' - ) - const runtime = Math.max( - 0, - ...submission.testcaseResult.map( - (testcase) => Number(testcase.cpuTime) || 0 - ) - ) - const memoryUsage = Math.max( - 0, - ...submission.testcaseResult.map((testcase) => testcase.memoryUsage) - ) + const progress = getSubmissionProgress(submission) + setSubmissionProgress(progress) + + if (progress.stage === 'finished') { finishedSubmissionRef.current = submissionId - setSubmissionProgress({ - stage: 'finished', - completed: total, - total, - result: submission.result, - runtime, - memoryUsage, - failedCase: failedCaseIndex === -1 ? undefined : failedCaseIndex + 1 - }) setIsSubmitting(false) if (submission.result === 'Accepted') { - confetti?.addConfetti() + confettiRef.current?.addConfetti() } if (isSidePanelHidden) { toggleSidePanelVisibility() } - } else { - setSubmissionProgress({ - stage: 'grading', - completed, - total - }) } } catch { if (generation !== pollingGenerationRef.current) { @@ -240,6 +215,16 @@ export function EditorHeader({ 1000 * 2 ** Math.min(pollingFailuresRef.current - 1, 4), 10000 ) + if (pollingFailuresRef.current >= 5) { + setIsSubmitting(false) + setSubmissionProgress({ + stage: 'error', + completed: 0, + total: 0, + message: + '채점 상태를 확인하지 못했습니다. 제출 내역에서 결과를 확인해 주세요.' + }) + } if (pollingFailuresRef.current === 1) { toast.error('Unable to check submission status. Retrying...') } @@ -321,6 +306,9 @@ export function EditorHeader({ } const submit = async () => { + if (loading) { + return + } const code = getCode() if (session === null) { @@ -334,94 +322,116 @@ export function EditorHeader({ return } - pollingGenerationRef.current++ + const generation = ++pollingGenerationRef.current pollingRequestRef.current = false finishedSubmissionRef.current = null pollingFailuresRef.current = 0 nextPollAtRef.current = 0 setSubmissionId(null) setIsSubmitting(true) + onSubmissionStart?.() setActiveTestcaseTab(TESTCASE_RESULT_TAB) setSubmissionProgress({ stage: 'waiting', completed: 0, total: 0 }) - const res = await fetcherWithAuth.post('submission', { - json: { - language, - code: [ - { - id: 1, - text: code, - locked: false - } - ] - }, - searchParams: { - problemId: problem.id, - ...(contestId && { contestId }), - ...(assignmentId && { assignmentId }), - ...(exerciseId && { assignmentId: exerciseId }) - }, - next: { - revalidate: 0 + try { + const res = await fetcherWithAuth.post('submission', { + json: { + language, + code: [ + { + id: 1, + text: code, + locked: false + } + ] + }, + searchParams: { + problemId: problem.id, + ...(contestId && { contestId }), + ...(assignmentId && { assignmentId }), + ...(exerciseId && { assignmentId: exerciseId }) + }, + next: { + revalidate: 0 + } + }) + if (generation !== pollingGenerationRef.current) { + return } - }) - if (res.ok) { - toast.success('Successfully submitted the code') - storeCodeToLocalStorage(code) - const submission: Submission = await res.json() + if (res.ok) { + toast.success('Successfully submitted the code') + storeCodeToLocalStorage(code) + const submission: Submission = await res.json() - setSubmissionId(submission.id) - setSubmissionProgress({ - stage: 'grading', - completed: 0, - total: problem.problemTestcase.length - }) - if (contestId) { - queryClient.invalidateQueries({ - queryKey: contestProblemQueries.lists(contestId) + if (generation !== pollingGenerationRef.current) { + return + } + setSubmissionId(submission.id) + setSubmissionProgress({ + stage: 'waiting', + completed: 0, + total: 0 }) - queryClient.invalidateQueries({ - queryKey: contestSubmissionQueries.lists({ - contestId, - problemId: problem.id + if (contestId) { + queryClient.invalidateQueries({ + queryKey: contestProblemQueries.lists(contestId) }) - }) - setIsSubmitted(true) - } else if (assignmentId) { - queryClient.invalidateQueries({ - queryKey: assignmentProblemQueries.lists(assignmentId) - }) - queryClient.invalidateQueries({ - queryKey: assignmentSubmissionQueries.lists({ - assignmentId, - problemId: problem.id + queryClient.invalidateQueries({ + queryKey: contestSubmissionQueries.lists({ + contestId, + problemId: problem.id + }) }) - }) - } else if (exerciseId) { - queryClient.invalidateQueries({ - queryKey: assignmentProblemQueries.lists(exerciseId) - }) - queryClient.invalidateQueries({ - queryKey: assignmentSubmissionQueries.lists({ - assignmentId: exerciseId, - problemId: problem.id + setIsSubmitted(true) + } else if (assignmentId) { + queryClient.invalidateQueries({ + queryKey: assignmentProblemQueries.lists(assignmentId) }) - }) + queryClient.invalidateQueries({ + queryKey: assignmentSubmissionQueries.lists({ + assignmentId, + problemId: problem.id + }) + }) + } else if (exerciseId) { + queryClient.invalidateQueries({ + queryKey: assignmentProblemQueries.lists(exerciseId) + }) + queryClient.invalidateQueries({ + queryKey: assignmentSubmissionQueries.lists({ + assignmentId: exerciseId, + problemId: problem.id + }) + }) + } else { + queryClient.invalidateQueries({ + queryKey: problemSubmissionQueries.lists(problem.id) + }) + } } else { - queryClient.invalidateQueries({ - queryKey: problemSubmissionQueries.lists(problem.id) - }) + setIsSubmitting(false) + setSubmissionProgress(null) + if (res.status === 401) { + showSignIn() + toast.error('Log in first to submit your code') + } else if (res.status === 404) { + toast.error('Submission period has ended.') + } else { + toast.error('Please try again later.') + } } - } else { - setIsSubmitting(false) - setSubmissionProgress(null) - if (res.status === 401) { - showSignIn() - toast.error('Log in first to submit your code') - } else if (res.status === 404) { - toast.error('Submission period has ended.') - } else { - toast.error('Please try again later.') + } catch { + if (generation !== pollingGenerationRef.current) { + return } + setIsSubmitting(false) + setSubmissionProgress({ + stage: 'error', + completed: 0, + total: 0, + message: + '제출 요청에 실패했습니다. 제출 내역을 확인한 뒤 다시 시도해 주세요.' + }) + toast.error('Please try again later.') } } @@ -643,7 +653,7 @@ export function EditorHeader({ {contestId === undefined && ( - + +

{progress ? 'Previous submission result' : 'Test results'}

+ + ) +} + +it('clears the previous submission when the user starts a testcase test', async () => { + render( + + + + + + + + + ) + fireEvent.click(screen.getByRole('button', { name: 'Seed submission' })) + expect(screen.getByText('Previous submission result')).toBeTruthy() + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /^Test$/ })) + await Promise.resolve() + }) + expect(screen.queryByText('Previous submission result')).toBeNull() + expect(screen.getByText('Test results')).toBeTruthy() +}) diff --git a/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/RunTestButton.tsx b/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/RunTestButton.tsx index 6dfbbcabf7..ee11867a48 100644 --- a/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/RunTestButton.tsx +++ b/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/RunTestButton.tsx @@ -34,6 +34,9 @@ export function RunTestButton({ const session = useSession() const setIsTesting = useTestPollingStore((state) => state.setIsTesting) const startPolling = useTestPollingStore((state) => state.startPolling) + const setSubmissionProgress = useTestPollingStore( + (state) => state.setSubmissionProgress + ) const showSignIn = useAuthModalStore((state) => state.showSignIn) const getCode = useCodeStore((state) => state.getCode) const getUserTestcases = useTestcaseStore((state) => state.getUserTestcases) @@ -126,12 +129,13 @@ export function RunTestButton({ } setIsTesting(true) + setSubmissionProgress(null) mutate({ code, testcases }) } return ( - +