feat: Add bulk test case import functionality with dedicated API endpoint and frontend interface.
This commit is contained in:
parent
1906e220d0
commit
c1ac522574
@ -15,6 +15,8 @@ from app.schemas.problem import (
|
||||
SampleTestResponse,
|
||||
TestCaseCreate,
|
||||
TestCaseResponse,
|
||||
BulkTestCaseImport,
|
||||
BulkImportResult,
|
||||
)
|
||||
from app.dependencies import get_current_user, get_current_admin
|
||||
|
||||
@ -286,3 +288,107 @@ async def delete_test_case(
|
||||
|
||||
await db.delete(test_case)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/{problem_id}/test-cases/bulk", response_model=BulkImportResult)
|
||||
async def bulk_import_test_cases(
|
||||
problem_id: int,
|
||||
import_data: BulkTestCaseImport,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin),
|
||||
):
|
||||
"""
|
||||
Bulk import test cases from text format.
|
||||
|
||||
Format example:
|
||||
```
|
||||
1 2
|
||||
---
|
||||
3
|
||||
===
|
||||
5 10
|
||||
---
|
||||
15
|
||||
```
|
||||
|
||||
Supports both single-line and multi-line input/output.
|
||||
"""
|
||||
# Check if problem exists
|
||||
result = await db.execute(select(Problem).where(Problem.id == problem_id))
|
||||
problem = result.scalar_one_or_none()
|
||||
if not problem:
|
||||
raise HTTPException(status_code=404, detail="Problem not found")
|
||||
|
||||
# Get current max order_index
|
||||
result = await db.execute(
|
||||
select(TestCase.order_index)
|
||||
.where(TestCase.problem_id == problem_id)
|
||||
.order_by(TestCase.order_index.desc())
|
||||
.limit(1)
|
||||
)
|
||||
max_order = result.scalar() or -1
|
||||
|
||||
# Parse the text
|
||||
text = import_data.text.strip()
|
||||
if not text:
|
||||
raise HTTPException(status_code=400, detail="Empty text provided")
|
||||
|
||||
# Split by test delimiter
|
||||
test_blocks = text.split(import_data.test_delimiter)
|
||||
created_tests = []
|
||||
|
||||
for i, block in enumerate(test_blocks):
|
||||
block = block.strip()
|
||||
if not block:
|
||||
continue
|
||||
|
||||
# Split by IO delimiter
|
||||
parts = block.split(import_data.io_delimiter)
|
||||
if len(parts) != 2:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Test #{i + 1}: Expected exactly one '{import_data.io_delimiter}' delimiter separating input and output, found {len(parts) - 1}"
|
||||
)
|
||||
|
||||
input_data = parts[0].strip()
|
||||
output_data = parts[1].strip()
|
||||
|
||||
if not input_data:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Test #{i + 1}: Empty input"
|
||||
)
|
||||
if not output_data:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Test #{i + 1}: Empty output"
|
||||
)
|
||||
|
||||
# Determine if this should be a sample
|
||||
is_sample = import_data.mark_first_as_sample and i < import_data.sample_count
|
||||
|
||||
max_order += 1
|
||||
test_case = TestCase(
|
||||
problem_id=problem_id,
|
||||
input=input_data,
|
||||
expected_output=output_data,
|
||||
is_sample=is_sample,
|
||||
points=0,
|
||||
order_index=max_order,
|
||||
)
|
||||
db.add(test_case)
|
||||
created_tests.append(test_case)
|
||||
|
||||
if not created_tests:
|
||||
raise HTTPException(status_code=400, detail="No valid test cases found in text")
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Refresh all to get IDs
|
||||
for test in created_tests:
|
||||
await db.refresh(test)
|
||||
|
||||
return BulkImportResult(
|
||||
created_count=len(created_tests),
|
||||
test_cases=[TestCaseResponse.model_validate(t) for t in created_tests]
|
||||
)
|
||||
|
||||
@ -10,6 +10,35 @@ class TestCaseCreate(BaseModel):
|
||||
order_index: int = 0
|
||||
|
||||
|
||||
class BulkTestCaseImport(BaseModel):
|
||||
"""
|
||||
Bulk import test cases from text.
|
||||
|
||||
Format:
|
||||
- Tests separated by test_delimiter (default: ===)
|
||||
- Input and output separated by io_delimiter (default: ---)
|
||||
|
||||
Example:
|
||||
1 2
|
||||
---
|
||||
3
|
||||
===
|
||||
5 10
|
||||
---
|
||||
15
|
||||
"""
|
||||
text: str
|
||||
test_delimiter: str = "==="
|
||||
io_delimiter: str = "---"
|
||||
mark_first_as_sample: bool = True # Mark first N tests as samples
|
||||
sample_count: int = 1 # How many tests to mark as samples
|
||||
|
||||
|
||||
class BulkImportResult(BaseModel):
|
||||
created_count: int
|
||||
test_cases: list["TestCaseResponse"]
|
||||
|
||||
|
||||
class TestCaseResponse(BaseModel):
|
||||
id: int
|
||||
input: str
|
||||
|
||||
@ -18,14 +18,23 @@ export default function ProblemTestsPage() {
|
||||
const [testCases, setTestCases] = useState<TestCase[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [successMessage, setSuccessMessage] = useState("");
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [showBulkImport, setShowBulkImport] = useState(false);
|
||||
const [editingTest, setEditingTest] = useState<TestCase | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
input: "",
|
||||
expected_output: "",
|
||||
is_sample: false,
|
||||
});
|
||||
const [bulkImportData, setBulkImportData] = useState({
|
||||
text: "",
|
||||
test_delimiter: "===",
|
||||
io_delimiter: "---",
|
||||
mark_first_as_sample: true,
|
||||
sample_count: 1,
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@ -49,6 +58,7 @@ export default function ProblemTestsPage() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSuccessMessage("");
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
@ -92,10 +102,40 @@ export default function ProblemTestsPage() {
|
||||
|
||||
const resetForm = () => {
|
||||
setShowForm(false);
|
||||
setShowBulkImport(false);
|
||||
setEditingTest(null);
|
||||
setFormData({ input: "", expected_output: "", is_sample: false });
|
||||
};
|
||||
|
||||
const resetBulkImport = () => {
|
||||
setShowBulkImport(false);
|
||||
setBulkImportData({
|
||||
text: "",
|
||||
test_delimiter: "===",
|
||||
io_delimiter: "---",
|
||||
mark_first_as_sample: true,
|
||||
sample_count: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const handleBulkImport = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSuccessMessage("");
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const result = await api.bulkImportTestCases(problemId, bulkImportData);
|
||||
setTestCases((prev) => [...prev, ...result.test_cases]);
|
||||
setSuccessMessage(`Успешно добавлено тестов: ${result.created_count}`);
|
||||
resetBulkImport();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка импорта");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
@ -131,13 +171,21 @@ export default function ProblemTestsPage() {
|
||||
<p className="text-muted-foreground mt-1">{problem.title}</p>
|
||||
)}
|
||||
</div>
|
||||
{!showForm && (
|
||||
{!showForm && !showBulkImport && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowBulkImport(true)}
|
||||
className="px-4 py-2 border border-primary text-primary rounded-lg font-medium hover:bg-primary/10 transition"
|
||||
>
|
||||
Массовый импорт
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition"
|
||||
>
|
||||
Добавить тест
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -147,6 +195,137 @@ export default function ProblemTestsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<div className="p-4 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-lg mb-4">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBulkImport && (
|
||||
<div className="mb-8 p-6 border border-border rounded-lg bg-muted/30">
|
||||
<h2 className="text-xl font-semibold mb-4">Массовый импорт тестов</h2>
|
||||
|
||||
<div className="mb-4 p-4 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg text-sm">
|
||||
<h3 className="font-semibold mb-2">Формат:</h3>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground mb-3">
|
||||
<li>Тесты разделяются символом <code className="px-1 py-0.5 bg-muted rounded text-xs">===</code></li>
|
||||
<li>Вход и выход разделяются символом <code className="px-1 py-0.5 bg-muted rounded text-xs">---</code></li>
|
||||
<li>Поддерживается как однострочный, так и многострочный ввод/вывод</li>
|
||||
</ul>
|
||||
<div className="bg-muted p-3 rounded font-mono text-xs">
|
||||
<div className="text-muted-foreground mb-1"># Пример:</div>
|
||||
<pre>{`1 2
|
||||
---
|
||||
3
|
||||
===
|
||||
5
|
||||
10
|
||||
---
|
||||
15
|
||||
===
|
||||
100 200
|
||||
---
|
||||
300`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleBulkImport} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Текст с тестами
|
||||
</label>
|
||||
<textarea
|
||||
value={bulkImportData.text}
|
||||
onChange={(e) =>
|
||||
setBulkImportData({ ...bulkImportData, text: e.target.value })
|
||||
}
|
||||
required
|
||||
rows={12}
|
||||
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring font-mono text-sm"
|
||||
placeholder={`1 2\n---\n3\n===\n5 10\n---\n15`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Разделитель тестов
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bulkImportData.test_delimiter}
|
||||
onChange={(e) =>
|
||||
setBulkImportData({ ...bulkImportData, test_delimiter: e.target.value })
|
||||
}
|
||||
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Разделитель входа/выхода
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bulkImportData.io_delimiter}
|
||||
onChange={(e) =>
|
||||
setBulkImportData({ ...bulkImportData, io_delimiter: e.target.value })
|
||||
}
|
||||
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="mark_first_as_sample"
|
||||
checked={bulkImportData.mark_first_as_sample}
|
||||
onChange={(e) =>
|
||||
setBulkImportData({ ...bulkImportData, mark_first_as_sample: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="mark_first_as_sample" className="text-sm">
|
||||
Первые тесты как примеры
|
||||
</label>
|
||||
</div>
|
||||
{bulkImportData.mark_first_as_sample && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm">Кол-во:</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={bulkImportData.sample_count}
|
||||
onChange={(e) =>
|
||||
setBulkImportData({ ...bulkImportData, sample_count: parseInt(e.target.value) || 1 })
|
||||
}
|
||||
className="w-16 px-2 py-1 border border-input rounded bg-background focus:outline-none focus:ring-2 focus:ring-ring text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="px-6 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? "Импорт..." : "Импортировать"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetBulkImport}
|
||||
className="px-6 py-2 border border-border rounded-lg font-medium hover:bg-muted transition"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<div className="mb-8 p-6 border border-border rounded-lg bg-muted/30">
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
|
||||
@ -296,6 +296,25 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async bulkImportTestCases(
|
||||
problemId: number,
|
||||
data: {
|
||||
text: string;
|
||||
test_delimiter?: string;
|
||||
io_delimiter?: string;
|
||||
mark_first_as_sample?: boolean;
|
||||
sample_count?: number;
|
||||
}
|
||||
): Promise<{ created_count: number; test_cases: TestCase[] }> {
|
||||
return this.request<{ created_count: number; test_cases: TestCase[] }>(
|
||||
`/api/problems/${problemId}/test-cases/bulk`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Alias methods
|
||||
async getContestProblems(contestId: number): Promise<ProblemListItem[]> {
|
||||
return this.getProblemsByContest(contestId);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user