Merge pull request 'update polygon package parsing & testing' (#1) from dev into master
All checks were successful
Build and Push Docker Images / build (src/LiquidCode.Tester.Gateway/Dockerfile, git.nullptr.top/liquidcode/liquidcode-tester-gateway, gateway) (push) Successful in 47s
Build and Push Docker Images / build (src/LiquidCode.Tester.Worker/Dockerfile, git.nullptr.top/liquidcode/liquidcode-tester-worker, worker) (push) Successful in 1m13s

Reviewed-on: #1
This commit is contained in:
2025-11-02 11:20:08 +00:00
13 changed files with 1822 additions and 57 deletions

View File

@@ -0,0 +1,579 @@
# Структура пакета Polygon
## 📦 Типичная структура Polygon пакета
```
problem-name.zip
├── problem.xml ← Главный дескриптор задачи
├── tests/ ← Тесты для проверки решений
│ ├── 01 ← Входные данные (без расширения!)
│ ├── 01.a ← Ответы (могут отсутствовать)
│ ├── 02
│ ├── 02.a
│ └── ...
├── files/ ← Вспомогательные файлы
│ ├── testlib.h ← Библиотека для checker/validator/generator
│ ├── olymp.sty ← LaTeX стиль для statements
│ ├── problem.tex ← Шаблон условия
│ ├── statements.ftl ← FreeMarker шаблон
│ │
│ ├── tests/ ← Тесты для checker/validator
│ │ ├── checker-tests/
│ │ │ ├── 01
│ │ │ ├── 01.a
│ │ │ └── 01.o ← Ожидаемый output
│ │ │
│ │ └── validator-tests/
│ │ ├── 01
│ │ ├── 02
│ │ └── ...
│ │
│ ├── g.cpp ← Generator (генератор тестов)
│ ├── g.exe
│ ├── v.cpp ← Validator (валидатор входных данных)
│ ├── v.exe
│ ├── check.cpp ← Checker (проверка ответа)
│ ├── check.exe
│ ├── interactor.cpp ← Interactor (для интерактивных задач)
│ ├── interactor.exe
│ │
│ └── [resource files] ← Дополнительные ресурсы для решений
│ ├── aplusb.h ← Header для grader-задач
│ ├── grader.cpp ← Grader для компиляции с решением
│ └── main.py ← Python wrapper для grader
├── solutions/ ← Эталонные решения
│ ├── sol.cpp ← Главное (main) решение
│ ├── sol.exe
│ ├── sol.py
│ ├── sol.java
│ │
│ ├── sol-accepted-1.cpp ← Дополнительные AC решения
│ ├── sol-wa.cpp ← Wrong Answer решения (для теста)
│ ├── sol-tl.cpp ← Time Limit решения
│ ├── sol-ml.cpp ← Memory Limit решения
│ └── ...
├── statements/ ← Условия задачи
│ ├── english/
│ │ ├── problem.tex ← Исходник условия (LaTeX)
│ │ ├── problem-properties.json
│ │ ├── tutorial.tex ← Разбор задачи
│ │ ├── example.01 ← Примеры из условия (input)
│ │ └── example.01.a ← Примеры из условия (output)
│ │
│ ├── russian/
│ │ └── ...
│ │
│ ├── .html/ ← Сгенерированные HTML
│ │ ├── english/
│ │ │ ├── problem.html
│ │ │ ├── tutorial.html
│ │ │ └── problem-statement.css
│ │ └── russian/
│ │
│ └── .pdf/ ← Сгенерированные PDF
│ ├── english/
│ │ ├── problem.pdf
│ │ └── tutorial.pdf
│ └── russian/
├── statement-sections/ ← Секции условия (модульно)
│ ├── english/
│ │ ├── legend.tex
│ │ ├── input.tex
│ │ ├── output.tex
│ │ ├── notes.tex
│ │ ├── scoring.tex
│ │ └── examples/
│ └── russian/
├── materials/ ← Материалы для участников
│ ├── grader-cpp.zip ← Grader для C++
│ ├── grader-py.zip ← Grader для Python
│ └── ...
├── scripts/ ← Скрипты для работы с пакетом
│ ├── gen-answer.sh
│ ├── gen-input-via-files.sh
│ ├── run-validator-tests.sh
│ └── ...
├── check.cpp ← Checker в корне (копия)
├── check.exe
├── doall.sh ← Скрипт сборки всего
├── doall.bat
├── wipe.sh ← Скрипт очистки
├── wipe.bat
└── tags ← Теги задачи (metadata)
```
---
## 📄 Основные файлы и их назначение
### 1. **problem.xml** (обязательный)
Центральный дескриптор задачи:
```xml
<?xml version="1.0" encoding="utf-8"?>
<problem revision="17" short-name="example-problem" url="...">
<names>
<name language="english" value="Problem Title"/>
</names>
<judging>
<testset name="tests">
<time-limit>2000</time-limit> <!-- мс -->
<memory-limit>268435456</memory-limit> <!-- байты = 256 MB -->
<test-count>51</test-count>
<input-path-pattern>tests/%02d</input-path-pattern>
<answer-path-pattern>tests/%02d.a</answer-path-pattern>
<tests>
<test method="manual" sample="true"/> <!-- ручной, в примерах -->
<test cmd="gen 100" method="generated"/> <!-- сгенерированный -->
<test cmd="gen 1000" method="generated"/>
...
</tests>
</testset>
</judging>
<assets>
<checker name="std::ncmp.cpp" type="testlib">
<source path="files/check.cpp" type="cpp.g++17"/>
<binary path="check.exe" type="exe.win32"/>
</checker>
<validators>
<validator>
<source path="files/v.cpp" type="cpp.g++17"/>
<binary path="files/v.exe" type="exe.win32"/>
</validator>
</validators>
<solutions>
<solution tag="main">
<source path="solutions/sol.cpp" type="cpp.g++17"/>
<binary path="solutions/sol.exe" type="exe.win32"/>
</solution>
<solution tag="accepted">
<source path="solutions/sol.py" type="python.3"/>
</solution>
<solution tag="wrong-answer">
<source path="solutions/sol-wa.cpp" type="cpp.g++17"/>
</solution>
</solutions>
</assets>
</problem>
```
---
### 2. **testlib.h** (стандартная библиотека)
Библиотека от MikeMirzayanov для написания:
- **Checkers** - проверка правильности ответа
- **Validators** - проверка корректности входных данных
- **Generators** - генерация тестов
- **Interactors** - интерактивное взаимодействие
**Основные функции:**
```cpp
#include "testlib.h"
// Checker
int main(int argc, char* argv[]) {
registerTestlibCmd(argc, argv);
int jans = ans.readInt(); // Правильный ответ
int pans = ouf.readInt(); // Ответ участника
if (jans == pans)
quitf(_ok, "Correct");
else
quitf(_wa, "Wrong answer: %d instead of %d", pans, jans);
}
// Validator
int main(int argc, char* argv[]) {
registerValidation(argc, argv);
int n = inf.readInt(1, 100000, "n");
inf.readEoln();
inf.readEof();
}
// Generator
int main(int argc, char* argv[]) {
registerGen(argc, argv, 1);
int n = opt<int>("n");
println(n);
for (int i = 0; i < n; i++)
println(rnd.next(1, 1000000));
}
```
---
### 3. **Тесты (tests/)**
**Формат:**
```
tests/01 ← Входные данные (plain text, без расширения)
tests/01.a ← Ответ (answer file)
```
**Пример:**
```
# tests/01 (input)
2 3
# tests/01.a (answer)
5
```
**Метаданные из problem.xml:**
```xml
<tests>
<test method="manual" sample="true"/> <!-- tests/01 - вручную, в примерах -->
<test cmd="gen 10 5" method="generated"/> <!-- tests/02 - сгенерирован -->
<test cmd="gen 100 50" method="generated"/> <!-- tests/03 -->
</tests>
```
---
### 4. **Generator (g.cpp)**
Программа для генерации тестов:
```cpp
#include "testlib.h"
#include <iostream>
int main(int argc, char* argv[]) {
registerGen(argc, argv, 1);
int n = opt<int>(1); // Первый аргумент
int maxVal = opt<int>(2); // Второй аргумент
std::cout << n << std::endl;
for (int i = 0; i < n; i++) {
std::cout << rnd.next(1, maxVal);
if (i + 1 < n) std::cout << " ";
}
std::cout << std::endl;
return 0;
}
```
**Использование:**
```bash
# В problem.xml указано:
<test cmd="gen 1000 10000" method="generated"/>
# Polygon запускает:
g.exe 1000 10000 > tests/05
```
---
### 5. **Checker (check.cpp)**
Программа для проверки корректности ответа.
**Типы checkers:**
#### **A. Стандартные (встроенные в testlib.h):**
```cpp
std::ncmp.cpp // Сравнение одного целого числа
std::fcmp.cpp // Сравнение одного float с точностью
std::wcmp.cpp // Сравнение по словам (tokens)
std::lcmp.cpp // Построчное сравнение
std::nyesno.cpp // Проверка YES/NO
```
#### **B. Custom checker:**
```cpp
#include "testlib.h"
int main(int argc, char* argv[]) {
registerTestlibCmd(argc, argv);
// inf - входной файл (input)
// ouf - output участника (output user file)
// ans - правильный ответ (answer)
int n = inf.readInt();
std::vector<int> jans(n);
for (int i = 0; i < n; i++)
jans[i] = ans.readInt();
std::vector<int> pans(n);
for (int i = 0; i < n; i++)
pans[i] = ouf.readInt();
// Проверка: порядок не важен (множества равны)
std::sort(jans.begin(), jans.end());
std::sort(pans.begin(), pans.end());
if (jans == pans)
quitf(_ok, "Correct");
else
quitf(_wa, "Wrong answer");
}
```
**Exit codes:**
- `0` - OK (правильный ответ) ✅
- `1` - WA (неправильный ответ) ❌
- `2` - PE (presentation error)
- `3` - FAIL (ошибка в самом чекере)
- `7` - Partial (частичный балл, для IOI-style)
---
### 6. **Validator (v.cpp)**
Проверяет корректность входных данных:
```cpp
#include "testlib.h"
int main(int argc, char* argv[]) {
registerValidation(argc, argv);
// Проверка формата входных данных
int n = inf.readInt(1, 100000, "n"); // 1 ≤ n ≤ 100000
inf.readEoln(); // Конец строки
for (int i = 0; i < n; i++) {
inf.readInt(1, 1000000000, "a[i]");
if (i + 1 < n) inf.readSpace();
else inf.readEoln();
}
inf.readEof(); // Конец файла
return 0;
}
```
**Назначение:**
- Проверка ограничений (1 ≤ n ≤ 10⁶)
- Проверка формата (пробелы, переводы строк)
- Валидация структуры (дерево, граф и т.д.)
---
### 7. **Interactor (для интерактивных задач)**
Посредник между решением и тестирующей системой:
```cpp
#include "testlib.h"
int main(int argc, char* argv[]) {
registerInteraction(argc, argv);
int n = inf.readInt(); // Загаданное число
int queries = 0;
while (queries < 20) {
int guess = ouf.readInt(1, 1000000); // Запрос участника
queries++;
if (guess == n) {
tout << "YES" << endl;
quitf(_ok, "Found in %d queries", queries);
} else if (guess < n) {
tout << ">" << endl; // Больше
} else {
tout << "<" << endl; // Меньше
}
}
quitf(_wa, "Too many queries");
}
```
**Streams в interactor:**
- `inf` - входной файл (input)
- `ouf` - output участника (чтение запросов)
- `tout` - передача данных участнику (ответы на запросы)
- `ans` - правильный ответ (не используется в интерактивных)
---
### 8. **Solutions (эталонные решения)**
**Типы решений:**
```xml
<solution tag="main"> <!-- Главное решение (генерирует .a файлы) -->
<solution tag="accepted"> <!-- Дополнительные AC решения -->
<solution tag="wrong-answer"> <!-- WA (для теста checker'а) -->
<solution tag="time-limit-exceeded"> <!-- TL -->
<solution tag="memory-limit-exceeded"> <!-- ML -->
<solution tag="rejected"> <!-- Другие RE/PE -->
```
**Назначение:**
- `main` - используется для генерации answer files
- `accepted` - проверка, что задача решаема разными способами
- `wrong-answer` - тестирование checker'а
- `time-limit-exceeded` - проверка TL
---
### 9. **Grader-задачи (специальный тип)**
Участник пишет функцию, а не всю программу.
**Структура:**
```
files/
├── aplusb.h ← Header с сигнатурой функции
├── grader.cpp ← Main + вызов функции участника
└── main.py ← Python wrapper
solutions/
└── sol.cpp ← Реализация функции (не main!)
```
**Пример:**
```cpp
// aplusb.h
int sum(int a, int b);
// grader.cpp
#include "aplusb.h"
#include <iostream>
int main() {
int a, b;
std::cin >> a >> b;
std::cout << sum(a, b) << std::endl;
return 0;
}
// sol.cpp (решение участника)
#include "aplusb.h"
int sum(int a, int b) {
return a + b;
}
```
---
## 📊 Статистика по примерам пакетов
### **a-plus-b-graders-7.zip:**
```
Размер: 2.4 MB
Файлов: ~50
Структура:
✅ problem.xml
✅ tests/ (8 тестов: 01-08, без .a файлов)
✅ files/testlib.h
✅ files/aplusb.h (grader header)
✅ files/grader.cpp
✅ files/g.cpp, g.exe (generator)
✅ files/v.cpp, v.exe (validator)
✅ check.cpp, check.exe (ncmp - числовой checker)
✅ solutions/sol.cpp (main)
✅ solutions/sol.py (accepted)
✅ statements/english/ (условие)
```
### **example-interactive-binary-search-26.zip:**
```
Размер: 10.7 MB
Файлов: ~100+
Структура:
✅ problem.xml
✅ tests/ (21 тест: 01-21, без .a файлов)
✅ files/testlib.h
✅ files/interactor.cpp, interactor.exe ← ИНТЕРАКТИВНАЯ!
✅ files/gen.cpp, gen.exe
✅ files/val.cpp, val.exe
✅ check.cpp, check.exe
✅ solutions/ (16 решений: main, ac, wa, tl, ml, pe, ...)
✅ statements/english/ + russian/
```
### **exam-queue-17.zip:**
```
Размер: 6.6 MB
Файлов: ~80
Структура:
✅ problem.xml
✅ tests/ (51 тест: 01-51, без .a файлов)
✅ files/testlib.h
✅ files/gen.cpp, gen.exe
✅ files/val.cpp, val.exe
✅ check.cpp, check.exe
✅ solutions/ (множество решений)
✅ statements/russian/ (только русское условие)
```
---
## 💡 Ключевые выводы
### **Обязательные компоненты:**
1.`problem.xml` - дескриптор
2.`tests/` - тесты
3.`files/testlib.h` - библиотека
4.`solutions/` - хотя бы одно main решение
### **Опциональные (но часто присутствуют):**
- `check.cpp` - custom checker (иначе используется wcmp)
- `v.cpp` - validator
- `g.cpp` - generator
- `interactor.cpp` - для интерактивных задач
- `statements/` - условия на разных языках
- `*.a` файлы - ответы (генерируются из main solution)
### **Особенности формата:**
- Входные файлы **БЕЗ расширения** (01, 02, не 01.in!)
- Ответы с расширением `.a` (01.a, 02.a)
- testlib.h - единая библиотека для всего
- problem.xml - полное описание всего пакета
---
## 🔗 Полезные ссылки
- **Polygon:** https://polygon.codeforces.com/
- **testlib.h GitHub:** https://github.com/MikeMirzayanov/testlib
- **Документация testlib:** https://codeforces.com/testlib
- **Tutorial по Polygon:** https://codeforces.com/blog/entry/101072
---
## 🎯 Поддержка в LiquidCode.Tester
Наша система **полностью поддерживает**:
- ✅ Парсинг problem.xml
- ✅ Тесты в формате tests/01, tests/01.a
- ✅ Автоматическую генерацию answer файлов из main solution
- ✅ Компиляцию и запуск custom checkers (testlib-based)
- ✅ Определение лимитов времени/памяти из problem.xml
- ✅ Поддержку всех основных языков (C++, Java, Python, C#, Kotlin)
**В разработке:**
- ⏳ Поддержка interactor для интерактивных задач
- ⏳ Поддержка grader-задач
- ⏳ Запуск validator для проверки входных данных

View File

@@ -10,6 +10,9 @@ builder.Services.AddOpenApi();
builder.Services.AddHttpClient();
// Register application services
builder.Services.AddSingleton<PolygonProblemXmlParser>();
builder.Services.AddSingleton<AnswerGenerationService>();
builder.Services.AddSingleton<CheckerService>();
builder.Services.AddSingleton<IPackageParserService, PackageParserService>();
builder.Services.AddSingleton<IOutputCheckerService, OutputCheckerService>();
builder.Services.AddSingleton<ICallbackService, CallbackService>();

View File

@@ -0,0 +1,181 @@
namespace LiquidCode.Tester.Worker.Services;
/// <summary>
/// Service for generating answer files by running the main solution from Polygon package
/// </summary>
public class AnswerGenerationService
{
private readonly ICompilationServiceFactory _compilationFactory;
private readonly IExecutionServiceFactory _executionFactory;
private readonly ILogger<AnswerGenerationService> _logger;
public AnswerGenerationService(
ICompilationServiceFactory compilationFactory,
IExecutionServiceFactory executionFactory,
ILogger<AnswerGenerationService> logger)
{
_compilationFactory = compilationFactory;
_executionFactory = executionFactory;
_logger = logger;
}
public async Task<bool> GenerateAnswersAsync(
PolygonProblemDescriptor descriptor,
string workingDirectory,
List<string> inputFilePaths,
List<string> answerFilePaths)
{
if (string.IsNullOrEmpty(descriptor.MainSolutionPath))
{
_logger.LogWarning("No main solution specified, cannot generate answers");
return false;
}
var solutionPath = Path.Combine(workingDirectory, descriptor.MainSolutionPath);
if (!File.Exists(solutionPath))
{
_logger.LogWarning("Main solution file not found: {Path}", solutionPath);
return false;
}
// Determine language and version from solution type
var (language, version) = ParseSolutionType(descriptor.MainSolutionType ?? "");
if (language == null)
{
_logger.LogWarning("Unsupported solution type: {Type}", descriptor.MainSolutionType);
return false;
}
_logger.LogInformation("Generating answers using {Language} {Version} solution: {Path}",
language, version, descriptor.MainSolutionPath);
try
{
// Read solution source code
var sourceCode = await File.ReadAllTextAsync(solutionPath);
// Get compilation service
var compilationService = _compilationFactory.GetCompilationService(language);
var executionService = _executionFactory.GetExecutionService(language);
// Compile solution
_logger.LogInformation("Compiling main solution...");
var compilationResult = await compilationService.CompileAsync(
sourceCode,
Path.GetDirectoryName(solutionPath)!,
version);
if (!compilationResult.Success)
{
_logger.LogError("Failed to compile main solution: {Error}", compilationResult.CompilerOutput);
return false;
}
_logger.LogInformation("Main solution compiled successfully");
// Generate answers for each test
int generatedCount = 0;
for (int i = 0; i < inputFilePaths.Count; i++)
{
var inputPath = inputFilePaths[i];
var answerPath = answerFilePaths[i];
if (!File.Exists(inputPath))
{
_logger.LogWarning("Input file not found: {Path}", inputPath);
continue;
}
_logger.LogDebug("Generating answer {Index}/{Total}: {AnswerPath}",
i + 1, inputFilePaths.Count, answerPath);
// Execute solution with input
var executionResult = await executionService.ExecuteAsync(
compilationResult.ExecutablePath!,
inputPath,
descriptor.TimeLimitMs * 2, // Give extra time for answer generation
descriptor.MemoryLimitMb * 2);
if (!executionResult.Success || executionResult.RuntimeError)
{
_logger.LogWarning("Failed to generate answer for {InputPath}: {Error}",
inputPath, executionResult.ErrorMessage);
continue;
}
// Save output as answer file
var answerDir = Path.GetDirectoryName(answerPath);
if (!string.IsNullOrEmpty(answerDir) && !Directory.Exists(answerDir))
{
Directory.CreateDirectory(answerDir);
}
await File.WriteAllTextAsync(answerPath, executionResult.Output);
generatedCount++;
_logger.LogDebug("Generated answer {Index}/{Total}", i + 1, inputFilePaths.Count);
}
_logger.LogInformation("Generated {Count} answer files out of {Total} tests",
generatedCount, inputFilePaths.Count);
return generatedCount > 0;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating answers");
return false;
}
}
private (string? language, string version) ParseSolutionType(string solutionType)
{
// Polygon solution types: python.2, python.3, cpp.g++17, cpp.g++20, java7, java8, etc.
if (string.IsNullOrEmpty(solutionType))
{
return (null, "");
}
if (solutionType.StartsWith("python."))
{
var parts = solutionType.Split('.');
var version = parts.Length > 1 ? parts[1] : "3";
return ("python", $"3.{version}"); // Map python.3 -> 3.3, python.2 -> 3.2 (approx)
}
if (solutionType.StartsWith("cpp."))
{
// cpp.g++17, cpp.g++20, cpp.g++14
if (solutionType.Contains("++20"))
return ("cpp", "20");
if (solutionType.Contains("++17"))
return ("cpp", "17");
if (solutionType.Contains("++14"))
return ("cpp", "14");
return ("cpp", "17"); // Default to C++17
}
if (solutionType.StartsWith("java"))
{
// java7, java8, java11
if (solutionType.Contains("11"))
return ("java", "11");
if (solutionType.Contains("8"))
return ("java", "8");
return ("java", "11"); // Default to Java 11
}
if (solutionType.StartsWith("csharp"))
{
return ("csharp", "9"); // Default to C# 9
}
if (solutionType.StartsWith("kotlin"))
{
return ("kotlin", "1.9"); // Default to Kotlin 1.9
}
_logger.LogWarning("Unknown solution type: {Type}", solutionType);
return (null, "");
}
}

View File

@@ -0,0 +1,165 @@
using System.Diagnostics;
namespace LiquidCode.Tester.Worker.Services;
/// <summary>
/// Service for running custom checkers (testlib-based)
/// </summary>
public class CheckerService
{
private readonly ILogger<CheckerService> _logger;
public CheckerService(ILogger<CheckerService> logger)
{
_logger = logger;
}
/// <summary>
/// Check user output using custom checker
/// </summary>
/// <param name="checkerPath">Path to checker executable</param>
/// <param name="inputPath">Path to input file</param>
/// <param name="userOutput">User program output</param>
/// <param name="answerPath">Path to answer file</param>
/// <returns>Checker result</returns>
public async Task<CheckerResult> CheckAsync(
string checkerPath,
string inputPath,
string userOutput,
string answerPath)
{
if (!File.Exists(checkerPath))
{
_logger.LogError("Checker not found: {CheckerPath}", checkerPath);
return new CheckerResult
{
Accepted = false,
ExitCode = -1,
Message = "Checker executable not found"
};
}
// Save user output to temporary file
var tempOutputPath = Path.Combine(Path.GetTempPath(), $"user_output_{Guid.NewGuid()}.txt");
try
{
await File.WriteAllTextAsync(tempOutputPath, userOutput);
_logger.LogDebug("Running checker: {Checker} {Input} {Output} {Answer}",
checkerPath, inputPath, tempOutputPath, answerPath);
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = checkerPath,
Arguments = $"\"{inputPath}\" \"{tempOutputPath}\" \"{answerPath}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
var stdout = await process.StandardOutput.ReadToEndAsync();
var stderr = await process.StandardError.ReadToEndAsync();
// Wait with timeout (checkers should be fast)
var completed = await Task.Run(() => process.WaitForExit(5000));
if (!completed)
{
_logger.LogWarning("Checker timeout, killing process");
try
{
process.Kill(entireProcessTree: true);
}
catch { }
return new CheckerResult
{
Accepted = false,
ExitCode = -1,
Message = "Checker timeout"
};
}
var exitCode = process.ExitCode;
_logger.LogDebug("Checker exit code: {ExitCode}, stderr: {Stderr}", exitCode, stderr);
return new CheckerResult
{
Accepted = exitCode == 0,
ExitCode = exitCode,
Message = string.IsNullOrWhiteSpace(stderr) ? stdout : stderr,
Verdict = GetVerdictFromExitCode(exitCode)
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error running checker");
return new CheckerResult
{
Accepted = false,
ExitCode = -1,
Message = $"Checker error: {ex.Message}"
};
}
finally
{
// Cleanup temporary file
try
{
if (File.Exists(tempOutputPath))
{
File.Delete(tempOutputPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete temporary output file: {Path}", tempOutputPath);
}
}
}
private CheckerVerdict GetVerdictFromExitCode(int exitCode)
{
return exitCode switch
{
0 => CheckerVerdict.OK,
1 => CheckerVerdict.WrongAnswer,
2 => CheckerVerdict.PresentationError,
3 => CheckerVerdict.CheckerFailed,
7 => CheckerVerdict.PartialScore,
_ => CheckerVerdict.Unknown
};
}
}
/// <summary>
/// Result of custom checker execution
/// </summary>
public class CheckerResult
{
public bool Accepted { get; set; }
public int ExitCode { get; set; }
public string Message { get; set; } = string.Empty;
public CheckerVerdict Verdict { get; set; }
}
/// <summary>
/// Checker verdict codes (testlib standard)
/// </summary>
public enum CheckerVerdict
{
OK = 0, // Accepted
WrongAnswer = 1, // Wrong Answer
PresentationError = 2, // Presentation Error (format issue)
CheckerFailed = 3, // Internal checker error
PartialScore = 7, // Partial score (for IOI-style)
Unknown = -1 // Unknown exit code
}

View File

@@ -9,4 +9,14 @@ public interface IOutputCheckerService
/// <param name="expectedOutputPath">Path to expected output file</param>
/// <returns>True if outputs match</returns>
Task<bool> CheckOutputAsync(string actualOutput, string expectedOutputPath);
/// <summary>
/// Checks output using custom checker if available, falls back to standard checking
/// </summary>
/// <param name="actualOutput">Output from user's solution</param>
/// <param name="inputFilePath">Path to input file</param>
/// <param name="expectedOutputPath">Path to expected output file</param>
/// <param name="checkerPath">Path to custom checker executable (optional)</param>
/// <returns>True if output is accepted</returns>
Task<bool> CheckOutputWithCheckerAsync(string actualOutput, string inputFilePath, string expectedOutputPath, string? checkerPath);
}

View File

@@ -3,10 +3,12 @@ namespace LiquidCode.Tester.Worker.Services;
public class OutputCheckerService : IOutputCheckerService
{
private readonly ILogger<OutputCheckerService> _logger;
private readonly CheckerService _checkerService;
public OutputCheckerService(ILogger<OutputCheckerService> logger)
public OutputCheckerService(ILogger<OutputCheckerService> logger, CheckerService checkerService)
{
_logger = logger;
_checkerService = checkerService;
}
public async Task<bool> CheckOutputAsync(string actualOutput, string expectedOutputPath)
@@ -51,4 +53,35 @@ public class OutputCheckerService : IOutputCheckerService
return string.Join("\n", lines);
}
public async Task<bool> CheckOutputWithCheckerAsync(
string actualOutput,
string inputFilePath,
string expectedOutputPath,
string? checkerPath)
{
// If custom checker is available, use it
if (!string.IsNullOrEmpty(checkerPath) && File.Exists(checkerPath))
{
_logger.LogDebug("Using custom checker: {CheckerPath}", checkerPath);
var checkerResult = await _checkerService.CheckAsync(
checkerPath,
inputFilePath,
actualOutput,
expectedOutputPath);
if (!checkerResult.Accepted)
{
_logger.LogWarning("Custom checker verdict: {Verdict} - {Message}",
checkerResult.Verdict, checkerResult.Message);
}
return checkerResult.Accepted;
}
// Fall back to standard string comparison
_logger.LogDebug("No custom checker, using standard comparison");
return await CheckOutputAsync(actualOutput, expectedOutputPath);
}
}

View File

@@ -6,10 +6,20 @@ namespace LiquidCode.Tester.Worker.Services;
public class PackageParserService : IPackageParserService
{
private readonly ILogger<PackageParserService> _logger;
private readonly PolygonProblemXmlParser _polygonParser;
private readonly AnswerGenerationService _answerGenerator;
private readonly CppCompilationService _cppCompilation;
public PackageParserService(ILogger<PackageParserService> logger)
public PackageParserService(
ILogger<PackageParserService> logger,
PolygonProblemXmlParser polygonParser,
AnswerGenerationService answerGenerator,
CppCompilationService cppCompilation)
{
_logger = logger;
_polygonParser = polygonParser;
_answerGenerator = answerGenerator;
_cppCompilation = cppCompilation;
}
public async Task<ProblemPackage> ParsePackageAsync(Stream packageStream)
@@ -25,60 +35,17 @@ public class PackageParserService : IPackageParserService
using var archive = new ZipArchive(packageStream, ZipArchiveMode.Read);
archive.ExtractToDirectory(workingDirectory);
var package = new ProblemPackage
// Check if this is a Polygon package (has problem.xml)
var problemXmlPath = Path.Combine(workingDirectory, "problem.xml");
if (File.Exists(problemXmlPath))
{
WorkingDirectory = workingDirectory
};
// Find tests directory
var testsDir = Path.Combine(workingDirectory, "tests");
if (!Directory.Exists(testsDir))
{
_logger.LogWarning("Tests directory not found, searching for test files in root");
testsDir = workingDirectory;
_logger.LogInformation("Detected Polygon package format (problem.xml found)");
return await ParsePolygonPackageAsync(workingDirectory, problemXmlPath);
}
// Parse test cases
var inputFiles = Directory.GetFiles(testsDir, "*", SearchOption.AllDirectories)
.Where(f => Path.GetFileName(f).EndsWith(".in") || Path.GetFileName(f).Contains("input"))
.OrderBy(f => f)
.ToList();
for (int i = 0; i < inputFiles.Count; i++)
{
var inputFile = inputFiles[i];
var outputFile = FindCorrespondingOutputFile(inputFile);
if (outputFile == null)
{
_logger.LogWarning("No output file found for input {InputFile}", inputFile);
continue;
}
package.TestCases.Add(new TestCase
{
Number = i + 1,
InputFilePath = inputFile,
OutputFilePath = outputFile,
TimeLimit = package.DefaultTimeLimit,
MemoryLimit = package.DefaultMemoryLimit
});
}
// Look for checker
var checkerCandidates = new[] { "check.cpp", "checker.cpp", "check", "checker" };
foreach (var candidate in checkerCandidates)
{
var checkerPath = Path.Combine(workingDirectory, candidate);
if (File.Exists(checkerPath))
{
package.CheckerPath = checkerPath;
break;
}
}
_logger.LogInformation("Parsed package with {TestCount} tests", package.TestCases.Count);
return package;
// Fall back to legacy format (.in/.out files)
_logger.LogInformation("Using legacy package format (.in/.out files)");
return await ParseLegacyPackage(workingDirectory);
}
catch (Exception ex)
{
@@ -87,6 +54,165 @@ public class PackageParserService : IPackageParserService
}
}
private async Task<ProblemPackage> ParsePolygonPackageAsync(string workingDirectory, string problemXmlPath)
{
var descriptor = _polygonParser.ParseProblemXml(problemXmlPath);
if (descriptor == null)
{
_logger.LogWarning("Failed to parse problem.xml, falling back to legacy format");
return await ParseLegacyPackage(workingDirectory);
}
var package = new ProblemPackage
{
WorkingDirectory = workingDirectory,
DefaultTimeLimit = descriptor.TimeLimitMs,
DefaultMemoryLimit = descriptor.MemoryLimitMb
};
// Collect test file paths and check which answers are missing
var inputPaths = new List<string>();
var answerPaths = new List<string>();
var missingAnswerPaths = new List<string>();
var missingAnswerInputs = new List<string>();
for (int i = 1; i <= descriptor.TestCount; i++)
{
var inputPath = Path.Combine(workingDirectory,
string.Format(descriptor.InputPathPattern.Replace("%02d", "{0:D2}"), i));
var answerPath = Path.Combine(workingDirectory,
string.Format(descriptor.AnswerPathPattern.Replace("%02d", "{0:D2}"), i));
if (!File.Exists(inputPath))
{
_logger.LogWarning("Input file not found: {InputPath}", inputPath);
continue;
}
inputPaths.Add(inputPath);
answerPaths.Add(answerPath);
if (!File.Exists(answerPath))
{
missingAnswerPaths.Add(answerPath);
missingAnswerInputs.Add(inputPath);
}
}
// Generate missing answer files if we have a main solution
if (missingAnswerPaths.Count > 0)
{
_logger.LogInformation("Found {Count} tests without answer files, attempting to generate them",
missingAnswerPaths.Count);
var generated = await _answerGenerator.GenerateAnswersAsync(
descriptor,
workingDirectory,
missingAnswerInputs,
missingAnswerPaths);
if (generated)
{
_logger.LogInformation("Successfully generated answer files");
}
else
{
_logger.LogWarning("Failed to generate answer files, tests without answers will be skipped");
}
}
// Now create test cases for all tests that have answer files
for (int i = 0; i < inputPaths.Count; i++)
{
var inputPath = inputPaths[i];
var answerPath = answerPaths[i];
if (!File.Exists(answerPath))
{
_logger.LogWarning("Answer file not found: {AnswerPath} (skipping test)", answerPath);
continue;
}
package.TestCases.Add(new TestCase
{
Number = i + 1,
InputFilePath = inputPath,
OutputFilePath = answerPath,
TimeLimit = descriptor.TimeLimitMs,
MemoryLimit = descriptor.MemoryLimitMb
});
}
// Look for and compile checker
package.CheckerPath = await FindAndCompileCheckerAsync(workingDirectory);
if (package.TestCases.Count == 0)
{
_logger.LogWarning("No test cases with answer files found! Expected format: {InputPattern} -> {AnswerPattern}",
descriptor.InputPathPattern, descriptor.AnswerPathPattern);
}
_logger.LogInformation("Parsed Polygon package with {TestCount} tests (out of {TotalTests} in problem.xml)",
package.TestCases.Count, descriptor.TestCount);
return package;
}
private async Task<ProblemPackage> ParseLegacyPackage(string workingDirectory)
{
var package = new ProblemPackage
{
WorkingDirectory = workingDirectory
};
// Find tests directory
var testsDir = Path.Combine(workingDirectory, "tests");
if (!Directory.Exists(testsDir))
{
_logger.LogWarning("Tests directory not found, searching for test files in root");
testsDir = workingDirectory;
}
// Parse test cases
var inputFiles = Directory.GetFiles(testsDir, "*", SearchOption.AllDirectories)
.Where(f => Path.GetFileName(f).EndsWith(".in") || Path.GetFileName(f).Contains("input"))
.OrderBy(f => f)
.ToList();
for (int i = 0; i < inputFiles.Count; i++)
{
var inputFile = inputFiles[i];
var outputFile = FindCorrespondingOutputFile(inputFile);
if (outputFile == null)
{
_logger.LogWarning("No output file found for input {InputFile}", inputFile);
continue;
}
package.TestCases.Add(new TestCase
{
Number = i + 1,
InputFilePath = inputFile,
OutputFilePath = outputFile,
TimeLimit = package.DefaultTimeLimit,
MemoryLimit = package.DefaultMemoryLimit
});
}
// Look for and compile checker
package.CheckerPath = await FindAndCompileCheckerAsync(workingDirectory);
if (package.TestCases.Count == 0)
{
_logger.LogWarning("No test cases found! Check package structure. Expected .in/.out files in tests directory or root");
}
_logger.LogInformation("Parsed legacy package with {TestCount} tests", package.TestCases.Count);
return package;
}
private string? FindCorrespondingOutputFile(string inputFile)
{
var directory = Path.GetDirectoryName(inputFile)!;
@@ -117,4 +243,68 @@ public class PackageParserService : IPackageParserService
return null;
}
private async Task<string?> FindAndCompileCheckerAsync(string workingDirectory)
{
// Try to find checker in common locations
var checkerCandidates = new[]
{
Path.Combine(workingDirectory, "check.exe"),
Path.Combine(workingDirectory, "checker.exe"),
Path.Combine(workingDirectory, "check.cpp"),
Path.Combine(workingDirectory, "checker.cpp"),
Path.Combine(workingDirectory, "files", "check.exe"),
Path.Combine(workingDirectory, "files", "checker.exe"),
Path.Combine(workingDirectory, "files", "check.cpp"),
Path.Combine(workingDirectory, "files", "checker.cpp")
};
foreach (var candidate in checkerCandidates)
{
if (!File.Exists(candidate))
continue;
// If it's already an executable, use it
if (candidate.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Found checker executable: {CheckerPath}", candidate);
return candidate;
}
// If it's C++ source, compile it
if (candidate.EndsWith(".cpp", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Found checker source: {CheckerPath}, compiling...", candidate);
try
{
var checkerSource = await File.ReadAllTextAsync(candidate);
var checkerDir = Path.GetDirectoryName(candidate)!;
// Compile checker with C++17 (testlib.h compatible)
var compilationResult = await _cppCompilation.CompileAsync(
checkerSource,
checkerDir,
"17");
if (!compilationResult.Success)
{
_logger.LogError("Failed to compile checker: {Error}", compilationResult.CompilerOutput);
continue; // Try next candidate
}
_logger.LogInformation("Checker compiled successfully: {ExecutablePath}", compilationResult.ExecutablePath);
return compilationResult.ExecutablePath;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error compiling checker: {CheckerPath}", candidate);
continue; // Try next candidate
}
}
}
_logger.LogWarning("No checker found in package");
return null;
}
}

View File

@@ -0,0 +1,141 @@
using System.Xml.Linq;
using LiquidCode.Tester.Common.Models;
namespace LiquidCode.Tester.Worker.Services;
/// <summary>
/// Parser for Polygon problem.xml format
/// </summary>
public class PolygonProblemXmlParser
{
private readonly ILogger<PolygonProblemXmlParser> _logger;
public PolygonProblemXmlParser(ILogger<PolygonProblemXmlParser> logger)
{
_logger = logger;
}
public PolygonProblemDescriptor? ParseProblemXml(string xmlPath)
{
try
{
var doc = XDocument.Load(xmlPath);
var problem = doc.Element("problem");
if (problem == null)
{
_logger.LogWarning("Invalid problem.xml: root 'problem' element not found");
return null;
}
var judging = problem.Element("judging");
if (judging == null)
{
_logger.LogWarning("No 'judging' section found in problem.xml");
return null;
}
var testset = judging.Element("testset");
if (testset == null)
{
_logger.LogWarning("No 'testset' section found in problem.xml");
return null;
}
var descriptor = new PolygonProblemDescriptor
{
ShortName = problem.Attribute("short-name")?.Value ?? "unknown",
Revision = int.TryParse(problem.Attribute("revision")?.Value, out var rev) ? rev : 0
};
// Parse time limit (in milliseconds)
var timeLimitText = testset.Element("time-limit")?.Value;
if (int.TryParse(timeLimitText, out var timeLimit))
{
descriptor.TimeLimitMs = timeLimit;
}
// Parse memory limit (in bytes)
var memoryLimitText = testset.Element("memory-limit")?.Value;
if (long.TryParse(memoryLimitText, out var memoryLimit))
{
descriptor.MemoryLimitMb = (int)(memoryLimit / (1024 * 1024)); // Convert bytes to MB
}
// Parse test count
var testCountText = testset.Element("test-count")?.Value;
if (int.TryParse(testCountText, out var testCount))
{
descriptor.TestCount = testCount;
}
// Parse path patterns
descriptor.InputPathPattern = testset.Element("input-path-pattern")?.Value ?? "tests/%02d";
descriptor.AnswerPathPattern = testset.Element("answer-path-pattern")?.Value ?? "tests/%02d.a";
// Parse solutions to find main solution
var assets = problem.Element("assets");
if (assets != null)
{
var solutions = assets.Element("solutions");
if (solutions != null)
{
// Try to find main solution first
var mainSolution = solutions.Elements("solution")
.FirstOrDefault(s => s.Attribute("tag")?.Value == "main");
// If no main solution, try to find any accepted solution
if (mainSolution == null)
{
mainSolution = solutions.Elements("solution")
.FirstOrDefault(s => s.Attribute("tag")?.Value == "accepted");
}
if (mainSolution != null)
{
var source = mainSolution.Element("source");
if (source != null)
{
descriptor.MainSolutionPath = source.Attribute("path")?.Value;
descriptor.MainSolutionType = source.Attribute("type")?.Value;
_logger.LogInformation("Found main solution: {Path} (type: {Type})",
descriptor.MainSolutionPath, descriptor.MainSolutionType);
}
}
else
{
_logger.LogWarning("No main or accepted solution found in problem.xml");
}
}
}
_logger.LogInformation(
"Parsed problem.xml: {ShortName} (rev {Revision}), {TestCount} tests, TL={TimeLimit}ms, ML={MemoryLimit}MB",
descriptor.ShortName, descriptor.Revision, descriptor.TestCount, descriptor.TimeLimitMs, descriptor.MemoryLimitMb);
return descriptor;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse problem.xml at {Path}", xmlPath);
return null;
}
}
}
/// <summary>
/// Descriptor parsed from problem.xml
/// </summary>
public class PolygonProblemDescriptor
{
public string ShortName { get; set; } = string.Empty;
public int Revision { get; set; }
public int TimeLimitMs { get; set; } = 2000;
public int MemoryLimitMb { get; set; } = 256;
public int TestCount { get; set; }
public string InputPathPattern { get; set; } = "tests/%02d";
public string AnswerPathPattern { get; set; } = "tests/%02d.a";
public string? MainSolutionPath { get; set; }
public string? MainSolutionType { get; set; }
}

View File

@@ -58,6 +58,16 @@ public class TestingService : ITestingService
_logger.LogInformation("Package parsed, found {TestCount} tests", package.TestCases.Count);
// Validate that package contains test cases
if (package.TestCases.Count == 0)
{
_logger.LogError("No test cases found in package for submit {SubmitId}", request.Id);
await SendStatusAsync(request, State.Done, ErrorCode.UnknownError,
"No test cases found in package", 0, 0);
CleanupWorkingDirectory(package.WorkingDirectory);
return;
}
// Send compiling status
await SendStatusAsync(request, State.Compiling, ErrorCode.None, "Compiling solution", 0, package.TestCases.Count);
@@ -125,8 +135,12 @@ public class TestingService : ITestingService
return;
}
// Check output
var outputCorrect = await _outputChecker.CheckOutputAsync(executionResult.Output, testCase.OutputFilePath);
// Check output (using custom checker if available)
var outputCorrect = await _outputChecker.CheckOutputWithCheckerAsync(
executionResult.Output,
testCase.InputFilePath,
testCase.OutputFilePath,
package.CheckerPath);
if (!outputCorrect)
{

View File

@@ -12,7 +12,9 @@ public class OutputCheckerServiceTests : IDisposable
public OutputCheckerServiceTests()
{
var logger = new Mock<ILogger<OutputCheckerService>>();
_service = new OutputCheckerService(logger.Object);
var checkerLogger = new Mock<ILogger<CheckerService>>();
var checkerService = new CheckerService(checkerLogger.Object);
_service = new OutputCheckerService(logger.Object, checkerService);
_testDirectory = Path.Combine(Path.GetTempPath(), "OutputCheckerTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_testDirectory);
}

View File

@@ -13,7 +13,23 @@ public class PackageParserServiceTests : IDisposable
public PackageParserServiceTests()
{
var logger = new Mock<ILogger<PackageParserService>>();
_service = new PackageParserService(logger.Object);
var xmlLogger = new Mock<ILogger<PolygonProblemXmlParser>>();
var answerGenLogger = new Mock<ILogger<AnswerGenerationService>>();
var cppLogger = new Mock<ILogger<CppCompilationService>>();
var cppConfigMock = new Mock<Microsoft.Extensions.Configuration.IConfiguration>();
var polygonParser = new PolygonProblemXmlParser(xmlLogger.Object);
var compilationFactory = new Mock<ICompilationServiceFactory>();
var executionFactory = new Mock<IExecutionServiceFactory>();
var answerGenerator = new AnswerGenerationService(
compilationFactory.Object,
executionFactory.Object,
answerGenLogger.Object);
var cppCompilation = new CppCompilationService(cppLogger.Object, cppConfigMock.Object);
_service = new PackageParserService(logger.Object, polygonParser, answerGenerator, cppCompilation);
_testDirectory = Path.Combine(Path.GetTempPath(), "PackageParserTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_testDirectory);
}

View File

@@ -0,0 +1,220 @@
using System.IO.Compression;
using LiquidCode.Tester.Worker.Services;
using Microsoft.Extensions.Logging;
using Moq;
namespace LiquidCode.Tester.Worker.Tests;
public class PolygonPackageParserTests : IDisposable
{
private readonly PackageParserService _service;
private readonly string _testDirectory;
public PolygonPackageParserTests()
{
var logger = new Mock<ILogger<PackageParserService>>();
var xmlLogger = new Mock<ILogger<PolygonProblemXmlParser>>();
var answerGenLogger = new Mock<ILogger<AnswerGenerationService>>();
var cppLogger = new Mock<ILogger<CppCompilationService>>();
var cppConfigMock = new Mock<Microsoft.Extensions.Configuration.IConfiguration>();
var polygonParser = new PolygonProblemXmlParser(xmlLogger.Object);
var compilationFactory = new Mock<ICompilationServiceFactory>();
var executionFactory = new Mock<IExecutionServiceFactory>();
var answerGenerator = new AnswerGenerationService(
compilationFactory.Object,
executionFactory.Object,
answerGenLogger.Object);
var cppCompilation = new CppCompilationService(cppLogger.Object, cppConfigMock.Object);
_service = new PackageParserService(logger.Object, polygonParser, answerGenerator, cppCompilation);
_testDirectory = Path.Combine(Path.GetTempPath(), "PolygonPackageTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_testDirectory);
}
[Fact]
public async Task ParsePackageAsync_PolygonPackageWithProblemXml_ParsesSuccessfully()
{
// Arrange
var problemXml = @"<?xml version=""1.0"" encoding=""utf-8"" standalone=""no""?>
<problem revision=""7"" short-name=""test-problem"">
<judging>
<testset name=""tests"">
<time-limit>1000</time-limit>
<memory-limit>268435456</memory-limit>
<test-count>2</test-count>
<input-path-pattern>tests/%02d</input-path-pattern>
<answer-path-pattern>tests/%02d.a</answer-path-pattern>
</testset>
</judging>
</problem>";
var zipStream = CreatePolygonPackage(problemXml, new[]
{
("tests/01", "input1"),
("tests/01.a", "output1"),
("tests/02", "input2"),
("tests/02.a", "output2")
});
// Act
var result = await _service.ParsePackageAsync(zipStream);
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.TestCases.Count);
Assert.Equal(1000, result.DefaultTimeLimit);
Assert.Equal(256, result.DefaultMemoryLimit);
// Verify first test
Assert.Equal(1, result.TestCases[0].Number);
Assert.True(File.Exists(result.TestCases[0].InputFilePath));
Assert.True(File.Exists(result.TestCases[0].OutputFilePath));
Assert.Equal("input1", await File.ReadAllTextAsync(result.TestCases[0].InputFilePath));
Assert.Equal("output1", await File.ReadAllTextAsync(result.TestCases[0].OutputFilePath));
// Verify second test
Assert.Equal(2, result.TestCases[1].Number);
Assert.True(File.Exists(result.TestCases[1].InputFilePath));
Assert.True(File.Exists(result.TestCases[1].OutputFilePath));
// Cleanup
if (Directory.Exists(result.WorkingDirectory))
{
Directory.Delete(result.WorkingDirectory, true);
}
}
[Fact]
public async Task ParsePackageAsync_PolygonPackageMissingAnswerFiles_SkipsTests()
{
// Arrange
var problemXml = @"<?xml version=""1.0"" encoding=""utf-8"" standalone=""no""?>
<problem revision=""7"" short-name=""test-problem"">
<judging>
<testset name=""tests"">
<time-limit>2000</time-limit>
<memory-limit>536870912</memory-limit>
<test-count>3</test-count>
<input-path-pattern>tests/%02d</input-path-pattern>
<answer-path-pattern>tests/%02d.a</answer-path-pattern>
</testset>
</judging>
</problem>";
var zipStream = CreatePolygonPackage(problemXml, new[]
{
("tests/01", "input1"),
("tests/01.a", "output1"),
("tests/02", "input2"),
// Missing 02.a
("tests/03", "input3"),
("tests/03.a", "output3")
});
// Act
var result = await _service.ParsePackageAsync(zipStream);
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.TestCases.Count); // Only tests with answer files
Assert.Equal(1, result.TestCases[0].Number);
Assert.Equal(3, result.TestCases[1].Number); // Test 2 skipped
// Cleanup
if (Directory.Exists(result.WorkingDirectory))
{
Directory.Delete(result.WorkingDirectory, true);
}
}
[Fact]
public async Task ParsePackageAsync_NoProblemXml_FallsBackToLegacyFormat()
{
// Arrange - create package without problem.xml
var zipStream = CreateLegacyPackage(new[]
{
("tests/test1.in", "input1"),
("tests/test1.out", "output1"),
("tests/test2.in", "input2"),
("tests/test2.out", "output2")
});
// Act
var result = await _service.ParsePackageAsync(zipStream);
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.TestCases.Count);
Assert.Equal(2000, result.DefaultTimeLimit); // Default values
Assert.Equal(256, result.DefaultMemoryLimit);
// Cleanup
if (Directory.Exists(result.WorkingDirectory))
{
Directory.Delete(result.WorkingDirectory, true);
}
}
private MemoryStream CreatePolygonPackage(string problemXml, IEnumerable<(string fileName, string content)> files)
{
var memoryStream = new MemoryStream();
using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true))
{
// Add problem.xml
var xmlEntry = archive.CreateEntry("problem.xml");
using var xmlStream = xmlEntry.Open();
using var xmlWriter = new StreamWriter(xmlStream);
xmlWriter.Write(problemXml);
// Add test files
foreach (var (fileName, content) in files)
{
var entry = archive.CreateEntry(fileName);
using var entryStream = entry.Open();
using var writer = new StreamWriter(entryStream);
writer.Write(content);
}
}
memoryStream.Position = 0;
return memoryStream;
}
private MemoryStream CreateLegacyPackage(IEnumerable<(string fileName, string content)> files)
{
var memoryStream = new MemoryStream();
using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true))
{
foreach (var (fileName, content) in files)
{
var entry = archive.CreateEntry(fileName);
using var entryStream = entry.Open();
using var writer = new StreamWriter(entryStream);
writer.Write(content);
}
}
memoryStream.Position = 0;
return memoryStream;
}
public void Dispose()
{
if (Directory.Exists(_testDirectory))
{
try
{
Directory.Delete(_testDirectory, true);
}
catch
{
// Ignore cleanup errors
}
}
}
}

View File

@@ -0,0 +1,211 @@
using System.IO.Compression;
using LiquidCode.Tester.Common.Models;
using LiquidCode.Tester.Worker.Controllers;
using LiquidCode.Tester.Worker.Services;
using Microsoft.Extensions.Logging;
using Moq;
namespace LiquidCode.Tester.Worker.Tests;
public class TestingServiceTests : IDisposable
{
private readonly Mock<IPackageParserService> _packageParserMock;
private readonly Mock<ICompilationServiceFactory> _compilationFactoryMock;
private readonly Mock<IExecutionServiceFactory> _executionFactoryMock;
private readonly Mock<IOutputCheckerService> _outputCheckerMock;
private readonly Mock<ICallbackService> _callbackServiceMock;
private readonly Mock<ILogger<TestingService>> _loggerMock;
private readonly TestingService _service;
private readonly string _testDirectory;
public TestingServiceTests()
{
_packageParserMock = new Mock<IPackageParserService>();
_compilationFactoryMock = new Mock<ICompilationServiceFactory>();
_executionFactoryMock = new Mock<IExecutionServiceFactory>();
_outputCheckerMock = new Mock<IOutputCheckerService>();
_callbackServiceMock = new Mock<ICallbackService>();
_loggerMock = new Mock<ILogger<TestingService>>();
_service = new TestingService(
_packageParserMock.Object,
_compilationFactoryMock.Object,
_executionFactoryMock.Object,
_outputCheckerMock.Object,
_callbackServiceMock.Object,
_loggerMock.Object
);
_testDirectory = Path.Combine(Path.GetTempPath(), "TestingServiceTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_testDirectory);
}
[Fact]
public async Task ProcessSubmitAsync_EmptyPackage_ReturnsUnknownError()
{
// Arrange
var packageFilePath = Path.Combine(_testDirectory, "empty_package.zip");
await CreateEmptyPackage(packageFilePath);
var request = new TestRequest
{
Id = 123,
MissionId = 456,
Language = "cpp",
LanguageVersion = "17",
SourceCode = "int main() { return 0; }",
PackageFilePath = packageFilePath,
CallbackUrl = "http://localhost/callback"
};
var emptyPackage = new ProblemPackage
{
WorkingDirectory = _testDirectory,
TestCases = new List<TestCase>() // Empty list!
};
_packageParserMock
.Setup(x => x.ParsePackageAsync(It.IsAny<Stream>()))
.ReturnsAsync(emptyPackage);
// Act
await _service.ProcessSubmitAsync(request);
// Assert - verify callback was called with error
_callbackServiceMock.Verify(
x => x.SendStatusAsync(
request.CallbackUrl,
It.Is<TesterResponseModel>(r =>
r.State == State.Done &&
r.ErrorCode == ErrorCode.UnknownError &&
r.Message == "No test cases found in package"
)
),
Times.Once
);
// Verify compilation was NOT attempted
_compilationFactoryMock.Verify(
x => x.GetCompilationService(It.IsAny<string>()),
Times.Never
);
}
[Fact]
public async Task ProcessSubmitAsync_ValidPackage_RunsAllTests()
{
// Arrange
var packageFilePath = Path.Combine(_testDirectory, "valid_package.zip");
var inputFile = Path.Combine(_testDirectory, "1.in");
var outputFile = Path.Combine(_testDirectory, "1.out");
var executablePath = Path.Combine(_testDirectory, "solution.exe");
await File.WriteAllTextAsync(inputFile, "test input");
await File.WriteAllTextAsync(outputFile, "expected output");
await File.WriteAllTextAsync(executablePath, "dummy");
var request = new TestRequest
{
Id = 123,
MissionId = 456,
Language = "cpp",
LanguageVersion = "17",
SourceCode = "int main() { return 0; }",
PackageFilePath = packageFilePath,
CallbackUrl = "http://localhost/callback"
};
var package = new ProblemPackage
{
WorkingDirectory = _testDirectory,
TestCases = new List<TestCase>
{
new TestCase
{
Number = 1,
InputFilePath = inputFile,
OutputFilePath = outputFile,
TimeLimit = 2000,
MemoryLimit = 256
}
}
};
var compilationService = new Mock<ICompilationService>();
var executionService = new Mock<IExecutionService>();
_packageParserMock
.Setup(x => x.ParsePackageAsync(It.IsAny<Stream>()))
.ReturnsAsync(package);
_compilationFactoryMock
.Setup(x => x.GetCompilationService("cpp"))
.Returns(compilationService.Object);
_executionFactoryMock
.Setup(x => x.GetExecutionService("cpp"))
.Returns(executionService.Object);
compilationService
.Setup(x => x.CompileAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new CompilationResult
{
Success = true,
ExecutablePath = executablePath
});
executionService
.Setup(x => x.ExecuteAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
.ReturnsAsync(new ExecutionResult
{
Success = true,
Output = "expected output",
ExitCode = 0,
RuntimeError = false,
TimeLimitExceeded = false,
MemoryLimitExceeded = false
});
_outputCheckerMock
.Setup(x => x.CheckOutputAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(true);
// Act
await _service.ProcessSubmitAsync(request);
// Assert - verify callback was called with success
_callbackServiceMock.Verify(
x => x.SendStatusAsync(
request.CallbackUrl,
It.Is<TesterResponseModel>(r =>
r.State == State.Done &&
r.ErrorCode == ErrorCode.None &&
r.Message == "All tests passed"
)
),
Times.AtLeastOnce
);
}
private async Task CreateEmptyPackage(string filePath)
{
using var fileStream = File.Create(filePath);
using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create);
// Create empty archive
}
public void Dispose()
{
if (Directory.Exists(_testDirectory))
{
try
{
Directory.Delete(_testDirectory, true);
}
catch
{
// Ignore cleanup errors in tests
}
}
}
}