feat(frontend): add code editor
This commit is contained in:
48
frontend/src/components/editor/code-editor.tsx
Normal file
48
frontend/src/components/editor/code-editor.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import Editor, { type OnMount } from "@monaco-editor/react";
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
interface CodeEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function CodeEditor({ value, onChange }: CodeEditorProps) {
|
||||
const editorRef = useRef<Parameters<OnMount>[0] | null>(null);
|
||||
|
||||
const handleEditorMount: OnMount = useCallback((editor) => {
|
||||
editorRef.current = editor;
|
||||
}, []);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newValue: string | undefined) => {
|
||||
onChange(newValue ?? "");
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<Editor
|
||||
height="100%"
|
||||
language="python"
|
||||
theme="vs-dark"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onMount={handleEditorMount}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 4,
|
||||
insertSpaces: true,
|
||||
wordWrap: "on",
|
||||
padding: { top: 12 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
frontend/src/components/editor/index.ts
Normal file
4
frontend/src/components/editor/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { CodeEditor } from "./code-editor";
|
||||
export { TestRunner } from "./test-runner";
|
||||
export { TestResults } from "./test-results";
|
||||
export { ProblemWorkspace } from "./problem-workspace";
|
||||
627
frontend/src/components/editor/problem-workspace.tsx
Normal file
627
frontend/src/components/editor/problem-workspace.tsx
Normal file
@@ -0,0 +1,627 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { CodeEditor } from "./code-editor";
|
||||
import { TestResults } from "./test-results";
|
||||
import { usePyodide } from "@/hooks/use-pyodide";
|
||||
import { submitSolution } from "@/lib/api";
|
||||
import type { QuestionDetail, TestResult, HiddenTestOutput } from "@/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { CodeBlock } from "@/components/ui/code-block";
|
||||
import { Callout, ApproachBox } from "@/components/ui/callout";
|
||||
import { Collapsible } from "@/components/ui/collapsible";
|
||||
import { getDifficultyVariant, capitalize } from "@/lib/utils";
|
||||
import {
|
||||
FileText,
|
||||
BookOpen,
|
||||
AlertCircle,
|
||||
Brain,
|
||||
ClipboardList,
|
||||
AlertTriangle,
|
||||
Lightbulb,
|
||||
Code,
|
||||
Play,
|
||||
Send,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface ProblemWorkspaceProps {
|
||||
question: QuestionDetail;
|
||||
}
|
||||
|
||||
interface ExecutionResult {
|
||||
test_id: number;
|
||||
output: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function extractFunctionName(signature: string): string {
|
||||
const match = signature.match(/def\s+(\w+)\s*\(/);
|
||||
return match ? match[1] : "solution";
|
||||
}
|
||||
|
||||
function generateStarterCode(signature: string): string {
|
||||
return `${signature}
|
||||
# Write your solution here
|
||||
pass
|
||||
`;
|
||||
}
|
||||
|
||||
type LeftTab = "problem" | "explanation";
|
||||
type RightTab = "code" | "results";
|
||||
|
||||
export function ProblemWorkspace({ question }: ProblemWorkspaceProps) {
|
||||
const starterCode = useMemo(
|
||||
() => generateStarterCode(question.function_signature!),
|
||||
[question.function_signature]
|
||||
);
|
||||
const [code, setCode] = useState(starterCode);
|
||||
const [leftTab, setLeftTab] = useState<LeftTab>("problem");
|
||||
const [rightTab, setRightTab] = useState<RightTab>("code");
|
||||
const [hasRunTests, setHasRunTests] = useState(false);
|
||||
|
||||
// Test execution state
|
||||
const { pyodide, loading: pyodideLoading, runPython } = usePyodide();
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [visibleResults, setVisibleResults] = useState<TestResult[]>([]);
|
||||
const [hiddenPassedCount, setHiddenPassedCount] = useState(0);
|
||||
const [hiddenTotalCount, setHiddenTotalCount] = useState(0);
|
||||
|
||||
const functionName = useMemo(
|
||||
() => extractFunctionName(question.function_signature!),
|
||||
[question.function_signature]
|
||||
);
|
||||
|
||||
const visibleTestCases = useMemo(
|
||||
() => question.visible_test_cases ?? [],
|
||||
[question.visible_test_cases]
|
||||
);
|
||||
const hiddenTestInputs = useMemo(
|
||||
() => question.hidden_test_inputs ?? [],
|
||||
[question.hidden_test_inputs]
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setCode(starterCode);
|
||||
}, [starterCode]);
|
||||
|
||||
const generateTestHarness = useCallback(
|
||||
(tests: Array<{ id: number; input: Record<string, unknown> }>) => {
|
||||
const testsJson = JSON.stringify(tests);
|
||||
return `
|
||||
import json
|
||||
|
||||
${code}
|
||||
|
||||
__tests__ = json.loads('${testsJson.replace(/'/g, "\\'")}')
|
||||
__results__ = []
|
||||
|
||||
for test in __tests__:
|
||||
try:
|
||||
result = ${functionName}(**test["input"])
|
||||
__results__.append({"test_id": test["id"], "output": result})
|
||||
except Exception as e:
|
||||
__results__.append({"test_id": test["id"], "output": None, "error": str(e)})
|
||||
|
||||
json.dumps(__results__)
|
||||
`;
|
||||
},
|
||||
[code, functionName]
|
||||
);
|
||||
|
||||
const runTests = useCallback(
|
||||
async (tests: Array<{ id: number; input: Record<string, unknown> }>) => {
|
||||
const harness = generateTestHarness(tests);
|
||||
const { output, error } = await runPython(harness);
|
||||
|
||||
if (error) {
|
||||
return tests.map((t) => ({
|
||||
test_id: t.id,
|
||||
output: null,
|
||||
error: error,
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(output as string) as ExecutionResult[];
|
||||
} catch {
|
||||
return tests.map((t) => ({
|
||||
test_id: t.id,
|
||||
output: null,
|
||||
error: "Failed to parse output",
|
||||
}));
|
||||
}
|
||||
},
|
||||
[generateTestHarness, runPython]
|
||||
);
|
||||
|
||||
const handleRunTests = useCallback(async () => {
|
||||
if (!pyodide || isRunning) return;
|
||||
|
||||
setIsRunning(true);
|
||||
setHiddenPassedCount(0);
|
||||
setHiddenTotalCount(0);
|
||||
|
||||
try {
|
||||
const tests = visibleTestCases.map((tc) => ({
|
||||
id: tc.id,
|
||||
input: tc.input,
|
||||
}));
|
||||
const results = await runTests(tests);
|
||||
|
||||
const testResults: TestResult[] = results.map((r, idx) => {
|
||||
const testCase = visibleTestCases[idx];
|
||||
const passed =
|
||||
!r.error &&
|
||||
JSON.stringify(r.output) === JSON.stringify(testCase.expected);
|
||||
|
||||
return {
|
||||
test_id: r.test_id,
|
||||
passed,
|
||||
input: testCase.input,
|
||||
expected: testCase.expected,
|
||||
actual: r.output,
|
||||
error: r.error,
|
||||
};
|
||||
});
|
||||
|
||||
setVisibleResults(testResults);
|
||||
setHasRunTests(true);
|
||||
setRightTab("results");
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
}, [pyodide, isRunning, visibleTestCases, runTests]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!pyodide || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Run visible tests first
|
||||
const visibleTests = visibleTestCases.map((tc) => ({
|
||||
id: tc.id,
|
||||
input: tc.input,
|
||||
}));
|
||||
const visibleExecutionResults = await runTests(visibleTests);
|
||||
|
||||
const visibleTestResults: TestResult[] = visibleExecutionResults.map(
|
||||
(r, idx) => {
|
||||
const testCase = visibleTestCases[idx];
|
||||
const passed =
|
||||
!r.error &&
|
||||
JSON.stringify(r.output) === JSON.stringify(testCase.expected);
|
||||
|
||||
return {
|
||||
test_id: r.test_id,
|
||||
passed,
|
||||
input: testCase.input,
|
||||
expected: testCase.expected,
|
||||
actual: r.output,
|
||||
error: r.error,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
setVisibleResults(visibleTestResults);
|
||||
|
||||
// Run hidden tests
|
||||
const hiddenTests = hiddenTestInputs.map((tc) => ({
|
||||
id: tc.id,
|
||||
input: tc.input,
|
||||
}));
|
||||
const hiddenExecutionResults = await runTests(hiddenTests);
|
||||
|
||||
// Submit hidden outputs to server for validation
|
||||
const hiddenOutputs: HiddenTestOutput[] = hiddenExecutionResults.map(
|
||||
(r) => ({
|
||||
test_id: r.test_id,
|
||||
output: r.output,
|
||||
})
|
||||
);
|
||||
|
||||
const response = await submitSolution(question.slug, {
|
||||
code,
|
||||
hidden_outputs: hiddenOutputs,
|
||||
});
|
||||
|
||||
setHiddenPassedCount(response.total_passed);
|
||||
setHiddenTotalCount(response.total_tests);
|
||||
setHasRunTests(true);
|
||||
setRightTab("results");
|
||||
} catch (error) {
|
||||
console.error("Submission error:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [
|
||||
pyodide,
|
||||
isSubmitting,
|
||||
visibleTestCases,
|
||||
hiddenTestInputs,
|
||||
runTests,
|
||||
question.slug,
|
||||
code,
|
||||
]);
|
||||
|
||||
const isLoading = isRunning || isSubmitting;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 top-[65px] flex bg-[var(--background)]">
|
||||
{/* Left panel - Problem description / Explanation */}
|
||||
<div className="w-1/2 border-r border-[var(--border)] flex flex-col">
|
||||
{/* Header - compact */}
|
||||
<div className="px-4 pt-3 pb-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/questions"
|
||||
className="text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
← Back
|
||||
</Link>
|
||||
<h1 className="text-lg font-bold">{question.title}</h1>
|
||||
<Badge variant={getDifficultyVariant(question.difficulty)} className="text-xs">
|
||||
{capitalize(question.difficulty)}
|
||||
</Badge>
|
||||
</div>
|
||||
{question.leetcode_url && (
|
||||
<a
|
||||
href={question.leetcode_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--primary)] hover:underline text-xs"
|
||||
>
|
||||
LeetCode ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{question.categories.map((cat) => (
|
||||
<Link key={cat.id} href={`/questions?category=${cat.slug}`}>
|
||||
<Badge variant="category" className="text-xs py-0">
|
||||
{cat.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
{question.patterns.map((pat) => (
|
||||
<Link key={pat.id} href={`/patterns/${pat.slug}`}>
|
||||
<Badge variant="pattern" className="text-xs py-0">
|
||||
{pat.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-[var(--border)]">
|
||||
<button
|
||||
onClick={() => setLeftTab("problem")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
leftTab === "problem"
|
||||
? "border-[var(--primary)] text-[var(--foreground)]"
|
||||
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
Problem
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLeftTab("explanation")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
leftTab === "explanation"
|
||||
? "border-[var(--primary)] text-[var(--foreground)]"
|
||||
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
Explanation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab content - scrollable */}
|
||||
<div className="flex-1 overflow-auto px-4 py-3">
|
||||
{leftTab === "problem" ? (
|
||||
<div className="space-y-6">
|
||||
{/* Problem */}
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3">
|
||||
<FileText className="h-5 w-5" />
|
||||
Problem
|
||||
</h2>
|
||||
<div className="prose-content">
|
||||
<Markdown>{question.description}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Constraints */}
|
||||
{question.constraints && (
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
Constraints
|
||||
</h2>
|
||||
<Markdown>{question.constraints}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Examples */}
|
||||
{question.examples && question.examples.length > 0 && (
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
Examples
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{question.examples.map((example, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="p-3 rounded bg-[var(--secondary)] space-y-1"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">Input: </span>
|
||||
<code>{example.input}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Output: </span>
|
||||
<code>{example.output}</code>
|
||||
</div>
|
||||
{example.explanation && (
|
||||
<div className="text-sm text-[var(--muted-foreground)]">
|
||||
{example.explanation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Intuition */}
|
||||
{question.explanation?.intuition && (
|
||||
<Callout variant="insight" icon={Brain} title="Intuition">
|
||||
<Markdown>{question.explanation.intuition}</Markdown>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{/* Approach */}
|
||||
{question.explanation?.approach && (
|
||||
<Callout variant="info" icon={ClipboardList} title="Approach">
|
||||
<Markdown>{question.explanation.approach}</Markdown>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{/* Common Pitfalls */}
|
||||
{question.explanation?.common_pitfalls &&
|
||||
question.explanation.common_pitfalls.length > 0 && (
|
||||
<Callout
|
||||
variant="warning"
|
||||
icon={AlertTriangle}
|
||||
title="Common Pitfalls"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{question.explanation.common_pitfalls.map((pitfall, i) => (
|
||||
<div key={i}>
|
||||
<h4 className="font-medium mb-2">{pitfall.title}</h4>
|
||||
<Markdown>{pitfall.description}</Markdown>
|
||||
{pitfall.wrong_approach && pitfall.correct_approach && (
|
||||
<div className="mt-2 flex gap-2 flex-wrap">
|
||||
<ApproachBox variant="wrong">
|
||||
{pitfall.wrong_approach}
|
||||
</ApproachBox>
|
||||
<ApproachBox variant="correct">
|
||||
{pitfall.correct_approach}
|
||||
</ApproachBox>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{/* Key Takeaways */}
|
||||
{question.explanation?.key_takeaways &&
|
||||
question.explanation.key_takeaways.length > 0 && (
|
||||
<Callout
|
||||
variant="success"
|
||||
icon={Lightbulb}
|
||||
title="Key Takeaways"
|
||||
>
|
||||
<ul className="space-y-2">
|
||||
{question.explanation.key_takeaways.map((takeaway, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="[&>div]:inline [&>div>p]:inline"
|
||||
>
|
||||
<Markdown>{takeaway}</Markdown>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{/* Complexity Analysis */}
|
||||
{(question.explanation?.time_complexity ||
|
||||
question.explanation?.space_complexity) && (
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3">
|
||||
Complexity Analysis
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{question.explanation?.time_complexity && (
|
||||
<p>
|
||||
<strong>Time:</strong>{" "}
|
||||
<Markdown>
|
||||
{question.explanation.time_complexity}
|
||||
</Markdown>
|
||||
</p>
|
||||
)}
|
||||
{question.explanation?.space_complexity && (
|
||||
<p>
|
||||
<strong>Space:</strong>{" "}
|
||||
<Markdown>
|
||||
{question.explanation.space_complexity}
|
||||
</Markdown>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Solutions */}
|
||||
{question.solutions && question.solutions.length > 0 && (
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3">
|
||||
<Code className="h-5 w-5" />
|
||||
Solutions
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{question.solutions.map((solution, i) => {
|
||||
const content = (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">
|
||||
{solution.approach_name}
|
||||
</h3>
|
||||
{solution.is_optimal && (
|
||||
<Badge variant="optimal">Optimal</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CodeBlock language="python" code={solution.code} />
|
||||
{solution.explanation && (
|
||||
<div className="text-sm text-[var(--muted-foreground)]">
|
||||
<Markdown>{solution.explanation}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return solution.is_optimal ? (
|
||||
<div key={i}>{content}</div>
|
||||
) : (
|
||||
<Collapsible
|
||||
key={i}
|
||||
title={`${solution.approach_name} (Non-optimal)`}
|
||||
>
|
||||
{content}
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel - Editor and test results */}
|
||||
<div className="w-1/2 flex flex-col">
|
||||
{/* Tabs header */}
|
||||
<div className="px-4 pt-2 border-b border-[var(--border)] flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setRightTab("code")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
|
||||
rightTab === "code"
|
||||
? "border-[var(--primary)] text-[var(--foreground)]"
|
||||
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
<Code className="h-4 w-4" />
|
||||
Code
|
||||
</button>
|
||||
{hasRunTests && (
|
||||
<button
|
||||
onClick={() => setRightTab("results")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
|
||||
rightTab === "results"
|
||||
? "border-[var(--primary)] text-[var(--foreground)]"
|
||||
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
Results
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] px-2 py-1"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{rightTab === "code" ? (
|
||||
<>
|
||||
{/* Editor - full height */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<CodeEditor value={code} onChange={setCode} />
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="p-3 border-t border-[var(--border)] flex items-center justify-between bg-[var(--secondary)]">
|
||||
<div className="text-sm text-[var(--muted-foreground)]">
|
||||
{pyodideLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading Python...
|
||||
</span>
|
||||
) : (
|
||||
"Python ready"
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleRunTests}
|
||||
disabled={pyodideLoading || isLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded bg-[var(--secondary)] border border-[var(--border)] hover:bg-[var(--accent)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isRunning ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
Run Tests
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={pyodideLoading || isLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Results tab */
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<TestResults
|
||||
visibleResults={visibleResults}
|
||||
hiddenPassedCount={hiddenPassedCount}
|
||||
hiddenTotalCount={hiddenTotalCount}
|
||||
visibleTestCases={visibleTestCases}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
frontend/src/components/editor/test-results.tsx
Normal file
121
frontend/src/components/editor/test-results.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle, XCircle, Loader2 } from "lucide-react";
|
||||
import type { TestResult, VisibleTestCase } from "@/types";
|
||||
|
||||
interface TestResultsProps {
|
||||
visibleResults: TestResult[];
|
||||
hiddenPassedCount: number;
|
||||
hiddenTotalCount: number;
|
||||
isRunning?: boolean;
|
||||
visibleTestCases?: VisibleTestCase[];
|
||||
}
|
||||
|
||||
export function TestResults({
|
||||
visibleResults,
|
||||
hiddenPassedCount,
|
||||
hiddenTotalCount,
|
||||
isRunning,
|
||||
visibleTestCases,
|
||||
}: TestResultsProps) {
|
||||
if (isRunning) {
|
||||
return (
|
||||
<div className="p-4 flex items-center gap-2 text-[var(--muted-foreground)]">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Running tests...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (visibleResults.length === 0 && hiddenTotalCount === 0) {
|
||||
return (
|
||||
<div className="p-4 text-[var(--muted-foreground)]">
|
||||
Click "Run Tests" to test your solution with visible test
|
||||
cases, or "Submit" to run all tests.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return "null";
|
||||
if (typeof value === "string") return `"${value}"`;
|
||||
return JSON.stringify(value);
|
||||
};
|
||||
|
||||
const formatInput = (input: Record<string, unknown>): string => {
|
||||
return Object.entries(input)
|
||||
.map(([key, val]) => `${key} = ${formatValue(val)}`)
|
||||
.join(", ");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-[var(--border)]">
|
||||
{visibleResults.map((result, index) => {
|
||||
const testCase = visibleTestCases?.[index];
|
||||
return (
|
||||
<div key={result.test_id} className="p-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{result.passed ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
<span className="font-medium">
|
||||
Test {result.test_id + 1}: {result.passed ? "Passed" : "Failed"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!result.passed && (
|
||||
<div className="ml-6 space-y-1 text-sm">
|
||||
{result.input && (
|
||||
<div>
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
Input:{" "}
|
||||
</span>
|
||||
<code className="bg-[var(--secondary)] px-1 rounded">
|
||||
{formatInput(result.input)}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{testCase && (
|
||||
<div>
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
Expected:{" "}
|
||||
</span>
|
||||
<code className="bg-[var(--secondary)] px-1 rounded text-green-400">
|
||||
{formatValue(testCase.expected)}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-[var(--muted-foreground)]">Got: </span>
|
||||
<code className="bg-[var(--secondary)] px-1 rounded text-red-400">
|
||||
{result.error ? `Error: ${result.error}` : formatValue(result.actual)}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{hiddenTotalCount > 0 && (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{hiddenPassedCount === hiddenTotalCount ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
<span className="font-medium">
|
||||
Hidden Tests: {hiddenPassedCount}/{hiddenTotalCount} passed
|
||||
</span>
|
||||
</div>
|
||||
<p className="ml-6 text-sm text-[var(--muted-foreground)] mt-1">
|
||||
Hidden test details are not shown to prevent cheating.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
245
frontend/src/components/editor/test-runner.tsx
Normal file
245
frontend/src/components/editor/test-runner.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Play, Send, Loader2 } from "lucide-react";
|
||||
import { usePyodide } from "@/hooks/use-pyodide";
|
||||
import { submitSolution } from "@/lib/api";
|
||||
import { TestResults } from "./test-results";
|
||||
import type {
|
||||
VisibleTestCase,
|
||||
HiddenTestInput,
|
||||
TestResult,
|
||||
HiddenTestOutput,
|
||||
} from "@/types";
|
||||
|
||||
interface TestRunnerProps {
|
||||
code: string;
|
||||
functionName: string;
|
||||
slug: string;
|
||||
visibleTestCases: VisibleTestCase[];
|
||||
hiddenTestInputs: HiddenTestInput[];
|
||||
}
|
||||
|
||||
interface ExecutionResult {
|
||||
test_id: number;
|
||||
output: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function TestRunner({
|
||||
code,
|
||||
functionName,
|
||||
slug,
|
||||
visibleTestCases,
|
||||
hiddenTestInputs,
|
||||
}: TestRunnerProps) {
|
||||
const { pyodide, loading: pyodideLoading, runPython } = usePyodide();
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [visibleResults, setVisibleResults] = useState<TestResult[]>([]);
|
||||
const [hiddenPassedCount, setHiddenPassedCount] = useState(0);
|
||||
const [hiddenTotalCount, setHiddenTotalCount] = useState(0);
|
||||
|
||||
const generateTestHarness = useCallback(
|
||||
(tests: Array<{ id: number; input: Record<string, unknown> }>) => {
|
||||
const testsJson = JSON.stringify(tests);
|
||||
return `
|
||||
import json
|
||||
|
||||
${code}
|
||||
|
||||
__tests__ = json.loads('${testsJson.replace(/'/g, "\\'")}')
|
||||
__results__ = []
|
||||
|
||||
for test in __tests__:
|
||||
try:
|
||||
result = ${functionName}(**test["input"])
|
||||
__results__.append({"test_id": test["id"], "output": result})
|
||||
except Exception as e:
|
||||
__results__.append({"test_id": test["id"], "output": None, "error": str(e)})
|
||||
|
||||
json.dumps(__results__)
|
||||
`;
|
||||
},
|
||||
[code, functionName]
|
||||
);
|
||||
|
||||
const runTests = useCallback(
|
||||
async (tests: Array<{ id: number; input: Record<string, unknown> }>) => {
|
||||
const harness = generateTestHarness(tests);
|
||||
const { output, error } = await runPython(harness);
|
||||
|
||||
if (error) {
|
||||
return tests.map((t) => ({
|
||||
test_id: t.id,
|
||||
output: null,
|
||||
error: error,
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(output as string) as ExecutionResult[];
|
||||
} catch {
|
||||
return tests.map((t) => ({
|
||||
test_id: t.id,
|
||||
output: null,
|
||||
error: "Failed to parse output",
|
||||
}));
|
||||
}
|
||||
},
|
||||
[generateTestHarness, runPython]
|
||||
);
|
||||
|
||||
const handleRunTests = useCallback(async () => {
|
||||
if (!pyodide || isRunning) return;
|
||||
|
||||
setIsRunning(true);
|
||||
setHiddenPassedCount(0);
|
||||
setHiddenTotalCount(0);
|
||||
|
||||
try {
|
||||
const tests = visibleTestCases.map((tc) => ({
|
||||
id: tc.id,
|
||||
input: tc.input,
|
||||
}));
|
||||
const results = await runTests(tests);
|
||||
|
||||
const testResults: TestResult[] = results.map((r, idx) => {
|
||||
const testCase = visibleTestCases[idx];
|
||||
const passed =
|
||||
!r.error &&
|
||||
JSON.stringify(r.output) === JSON.stringify(testCase.expected);
|
||||
|
||||
return {
|
||||
test_id: r.test_id,
|
||||
passed,
|
||||
input: testCase.input,
|
||||
expected: testCase.expected,
|
||||
actual: r.output,
|
||||
error: r.error,
|
||||
};
|
||||
});
|
||||
|
||||
setVisibleResults(testResults);
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
}, [pyodide, isRunning, visibleTestCases, runTests]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!pyodide || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Run visible tests first
|
||||
const visibleTests = visibleTestCases.map((tc) => ({
|
||||
id: tc.id,
|
||||
input: tc.input,
|
||||
}));
|
||||
const visibleExecutionResults = await runTests(visibleTests);
|
||||
|
||||
const visibleTestResults: TestResult[] = visibleExecutionResults.map(
|
||||
(r, idx) => {
|
||||
const testCase = visibleTestCases[idx];
|
||||
const passed =
|
||||
!r.error &&
|
||||
JSON.stringify(r.output) === JSON.stringify(testCase.expected);
|
||||
|
||||
return {
|
||||
test_id: r.test_id,
|
||||
passed,
|
||||
input: testCase.input,
|
||||
expected: testCase.expected,
|
||||
actual: r.output,
|
||||
error: r.error,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
setVisibleResults(visibleTestResults);
|
||||
|
||||
// Run hidden tests
|
||||
const hiddenTests = hiddenTestInputs.map((tc) => ({
|
||||
id: tc.id,
|
||||
input: tc.input,
|
||||
}));
|
||||
const hiddenExecutionResults = await runTests(hiddenTests);
|
||||
|
||||
// Submit hidden outputs to server for validation
|
||||
const hiddenOutputs: HiddenTestOutput[] = hiddenExecutionResults.map(
|
||||
(r) => ({
|
||||
test_id: r.test_id,
|
||||
output: r.output,
|
||||
})
|
||||
);
|
||||
|
||||
const response = await submitSolution(slug, {
|
||||
code,
|
||||
hidden_outputs: hiddenOutputs,
|
||||
});
|
||||
|
||||
setHiddenPassedCount(response.total_passed);
|
||||
setHiddenTotalCount(response.total_tests);
|
||||
} catch (error) {
|
||||
console.error("Submission error:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [
|
||||
pyodide,
|
||||
isSubmitting,
|
||||
visibleTestCases,
|
||||
hiddenTestInputs,
|
||||
runTests,
|
||||
slug,
|
||||
code,
|
||||
]);
|
||||
|
||||
const isLoading = isRunning || isSubmitting;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-[var(--secondary)] border-b border-[var(--border)]">
|
||||
<button
|
||||
onClick={handleRunTests}
|
||||
disabled={pyodideLoading || isLoading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded bg-[var(--muted)] hover:bg-[var(--muted)]/80 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isRunning ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
Run Tests
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={pyodideLoading || isLoading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded bg-green-600 hover:bg-green-700 text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
Submit
|
||||
</button>
|
||||
{pyodideLoading && (
|
||||
<span className="text-sm text-[var(--muted-foreground)] ml-2">
|
||||
Loading Python runtime...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<TestResults
|
||||
visibleResults={visibleResults}
|
||||
hiddenPassedCount={hiddenPassedCount}
|
||||
hiddenTotalCount={hiddenTotalCount}
|
||||
isRunning={isLoading}
|
||||
visibleTestCases={visibleTestCases}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user