Skip to content
Open
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
13 changes: 12 additions & 1 deletion src/apis/problems/problems.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import api from '@/apis/api';
import type { GetProblemResponse, GetProblemSearchResponse, ProblemSearch } from './problems.type';
import type {
GetProblemResponse,
GetProblemSearchResponse,
ProblemDetail,
ProblemSearch,
} from './problems.type';

// 문제 목록 조회
export async function getProblem(
Expand Down Expand Up @@ -29,3 +34,9 @@ export async function getProblemSearch(keyword: string, limit = 10): Promise<Pro

return res.data;
}

// 특정 문제 상세 조회
export async function getProblemDetail(problemId: number): Promise<ProblemDetail> {
const res = await api.get<ProblemDetail>(`/problems/${problemId}`);
return res.data;
}
9 changes: 9 additions & 0 deletions src/apis/problems/problems.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,12 @@ export interface ProblemSearch {
}

export type GetProblemSearchResponse = ProblemSearch[];

export interface ProblemDetail {
problemId: number;
problemNo: number;
title: string;
platform: string;
totalSubmissions: number;
foundSubmissions: number;
}
56 changes: 56 additions & 0 deletions src/components/problem/ProblemInfoBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { ProblemDetail } from '@/apis/problems/problems.type';

interface ProblemInfoBarProps {
data: ProblemDetail;
}

export default function ProblemInfoBar({ data }: ProblemInfoBarProps) {
return (
<div className="mb-6 flex flex-wrap items-center gap-x-6 gap-y-4 rounded-xl border border-slate-200 bg-white px-6 py-3 shadow-sm dark:border-slate-700 dark:bg-slate-800">
{/* 문제번호 & 플랫폼 */}
<div className="flex items-center space-x-6">
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase leading-tight tracking-widest text-slate-400 dark:text-slate-500">
문제번호
</span>
<span className="font-mono text-sm font-medium text-slate-700 dark:text-slate-200">
{data.problemNo}
</span>
</div>

<div className="h-8 w-px bg-slate-200 dark:bg-slate-700" />

<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase leading-tight tracking-widest text-slate-400 dark:text-slate-500">
플랫폼
</span>
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">
{data.platform}
</span>
</div>
</div>

<div className="h-8 w-px bg-slate-200 dark:bg-slate-700" />

{/* 총 제출 수 */}
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase leading-tight tracking-widest text-slate-400 dark:text-slate-500">
이 문제에 대한 총 제출
</span>
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">
{data.totalSubmissions ?? 0}
</span>
</div>

<div className="h-8 w-px bg-slate-200 dark:bg-slate-700" />

{/* 찾은 반례 수 */}
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase leading-tight tracking-widest text-slate-400 dark:text-slate-500">
이 문제에 대한 찾은 총 반례
</span>
<span className="text-sm font-semibold text-red-500">{data.foundSubmissions ?? 0}</span>
</div>
</div>
);
}
165 changes: 96 additions & 69 deletions src/pages/CounterExamplePage.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
/**반례 찾기 페이지*/
import { useCallback, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useCallback, useEffect, useState, useReducer } from 'react';
import { useSearchParams } from 'react-router-dom';
import SolutionEditorPanel from '@/components/counterexample/SolutionEditorPanel';
import CounterExampleStatusPanel from '@/components/counterexample/CounterExampleStatusPanel';

import { DEFAULT_CODE_BY_LANG, LANG_OPTIONS } from '@/constants/counterexample';
import ProblemInfoBar from '@/components/problem/ProblemInfoBar';
import { LANG_OPTIONS } from '@/constants/counterexample';
import { postCounterexampleApi } from '@/apis/submissions/postCounterExampleApi';
import type { FailedCase, Language } from '@/types/counterexample';
import { getProblemDetail } from '@/apis/problems/problems';
import type { ProblemDetail } from '@/apis/problems/problems.type';
import type { FailedCase } from '@/types/counterexample';
import { counterExampleReducer, initialState } from '@/types/counterExampleReducer';

const LANGUAGE_TO_API = {
cpp: 'CPP',
Expand All @@ -15,99 +18,123 @@ const LANGUAGE_TO_API = {
} as const;

export default function CounterExamplePage() {
const { problemId } = useParams();
const [searchParams] = useSearchParams();
const currentProblemId = searchParams.get('id');
Comment thread
soooheeee marked this conversation as resolved.

const [language, setLanguage] = useState<Language>('cpp');
const [code, setCode] = useState(DEFAULT_CODE_BY_LANG.cpp);
const [isPublic, setIsPublic] = useState(false);
const [state, dispatch] = useReducer(counterExampleReducer, initialState);

const [failedCases, setFailedCases] = useState<FailedCase[]>([]);
const [isFindingCounterExample, setIsFindingCounterExample] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [detail, setDetail] = useState<ProblemDetail | null>(null);
const [isLoadingDetail, setIsLoadingDetail] = useState(true);

const failedCount = failedCases.length;
// 문제 상세 데이터 요청
useEffect(() => {
const problemId = Number(currentProblemId);

const handleChangeLanguage = (next: Language) => {
setLanguage(next);
setCode(DEFAULT_CODE_BY_LANG[next]);
};
if (!currentProblemId || !Number.isFinite(problemId)) return;

let isCancelled = false;

const fetchDetail = async () => {
setIsLoadingDetail(true);

try {
const data = await getProblemDetail(problemId);

if (!isCancelled) setDetail(data);
} catch (error) {
if (!isCancelled) console.error('문제 정보를 불러오는 데 실패했습니다.', error);
} finally {
if (!isCancelled) setIsLoadingDetail(false);
}
};

fetchDetail();

return () => {
isCancelled = true;
};
}, [currentProblemId]);

// 복사 핸들러
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
} catch {}
};

const handleSubmit = () => {
handleFindCounterExample();
await navigator.clipboard.writeText(state.code);
alert('코드가 복사되었습니다.');
} catch {
alert('복사에 실패했습니다.');
}
};

// 코드 제출 및 반례 찾기
const handleFindCounterExample = useCallback(async () => {
if (!code.trim()) {
alert('코드를 입력해주세요.');
return;
}
if (!state.code.trim()) return alert('코드를 입력해주세요.');
if (!currentProblemId) return alert('문제 정보를 찾을 수 없습니다.');

if (!problemId) {
alert('문제 정보를 찾을 수 없습니다.');
return;
}

setIsFindingCounterExample(true);
setHasSearched(true);
dispatch({ type: 'START_SEARCH' });

try {
const data = await postCounterexampleApi({
problemId: Number(problemId),
language: LANGUAGE_TO_API[language],
sourceCode: code,
isOpen: isPublic,
problemId: Number(currentProblemId),
language: LANGUAGE_TO_API[state.language],
sourceCode: state.code,
isOpen: state.isPublic,
});

const mappedFailedCases: FailedCase[] = (data.counterExamples ?? []).map((item, index) => ({
const mappedCases: FailedCase[] = (data.counterExamples ?? []).map((item, index) => ({
id: index + 1,
input: item.input,
expected: item.expectedOutput,
output: item.actualOutput,
timeMs: 0,
}));

setFailedCases(mappedFailedCases);
dispatch({ type: 'SEARCH_SUCCESS', payload: mappedCases });
} catch (error) {
console.error(error);
setFailedCases([]);
dispatch({ type: 'SEARCH_FAILURE' });
alert('반례 탐색에 실패했습니다.');
} finally {
setIsFindingCounterExample(false);
}
}, [code, isPublic, language, problemId]);
}, [state.code, state.isPublic, state.language, currentProblemId]);

if (isLoadingDetail)
return <div className="flex h-[400px] items-center justify-center">로딩 중...</div>;
if (!detail)
return (
<div className="flex h-[400px] items-center justify-center">문제를 찾을 수 없습니다.</div>
);

return (
<div className="grid h-[700px] min-h-0 grid-cols-1 gap-6 lg:grid-cols-4">
<div className="h-full min-h-0 lg:col-span-3">
<SolutionEditorPanel
language={language}
languageOptions={LANG_OPTIONS}
onChangeLanguage={handleChangeLanguage}
code={code}
onChangeCode={setCode}
onCopy={handleCopy}
onFindCounterExample={handleFindCounterExample}
onSubmit={handleSubmit}
isPublic={isPublic}
onPublicChange={setIsPublic}
isFindingCounterExample={isFindingCounterExample}
/>
</div>

<div className="h-full min-h-0 lg:col-span-1">
<CounterExampleStatusPanel
failedCount={failedCount}
cases={failedCases}
isLoading={isFindingCounterExample}
hasSearched={hasSearched}
/>
</div>
<div className="flex w-full flex-col space-y-6">
<header className="flex-shrink-0">
<ProblemInfoBar data={detail} />
</header>

<main className="grid h-[750px] min-h-0 w-full grid-cols-1 gap-6 lg:grid-cols-3">
<div className="h-full min-h-0 lg:col-span-2">
<SolutionEditorPanel
language={state.language}
languageOptions={LANG_OPTIONS}
onChangeLanguage={(val) => dispatch({ type: 'SET_LANGUAGE', value: val })}
code={state.code}
onChangeCode={(val) => dispatch({ type: 'SET_CODE', value: val })}
onCopy={handleCopy}
onFindCounterExample={handleFindCounterExample}
onSubmit={handleFindCounterExample}
isPublic={state.isPublic}
onPublicChange={(val) => dispatch({ type: 'SET_PUBLIC', value: val })}
isFindingCounterExample={state.isLoading}
/>
</div>

<div className="h-full min-h-0 lg:col-span-1">
<CounterExampleStatusPanel
failedCount={state.failedCases.length}
cases={state.failedCases}
isLoading={state.isLoading}
hasSearched={state.hasSearched}
/>
</div>
</main>
</div>
);
}
54 changes: 54 additions & 0 deletions src/types/counterExampleReducer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { DEFAULT_CODE_BY_LANG } from '@/constants/counterexample';
import type { FailedCase, Language } from '@/types/counterexample';

export interface CounterExampleState {
language: Language;
code: string;
isPublic: boolean;
isLoading: boolean;
hasSearched: boolean;
failedCases: FailedCase[];
}

export type CounterExampleAction =
| { type: 'SET_LANGUAGE'; value: Language }
| { type: 'SET_CODE'; value: string }
| { type: 'SET_PUBLIC'; value: boolean }
| { type: 'START_SEARCH' }
| { type: 'SEARCH_SUCCESS'; payload: FailedCase[] }
| { type: 'SEARCH_FAILURE' };

export const initialState: CounterExampleState = {
language: 'cpp',
code: DEFAULT_CODE_BY_LANG.cpp,
isPublic: false,
isLoading: false,
hasSearched: false,
failedCases: [],
};

export function counterExampleReducer(
state: CounterExampleState,
action: CounterExampleAction,
): CounterExampleState {
switch (action.type) {
case 'SET_LANGUAGE':
return {
...state,
language: action.value,
code: DEFAULT_CODE_BY_LANG[action.value], // 언어 변경 시 코드 초기화
};
case 'SET_CODE':
return { ...state, code: action.value };
case 'SET_PUBLIC':
return { ...state, isPublic: action.value };
case 'START_SEARCH':
return { ...state, isLoading: true, hasSearched: true };
case 'SEARCH_SUCCESS':
return { ...state, isLoading: false, failedCases: action.payload };
case 'SEARCH_FAILURE':
return { ...state, isLoading: false, failedCases: [] };
default:
return state;
}
}
Loading