feat: Add bulk test case import functionality with dedicated API endpoint and frontend interface.

This commit is contained in:
n.tolstov 2025-12-01 00:45:54 +03:00
parent 1906e220d0
commit c1ac522574
4 changed files with 340 additions and 7 deletions

View File

@ -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]
)

View File

@ -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

View File

@ -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">

View File

@ -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);