feat(frontend): add code editor
This commit is contained in:
@@ -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