From f939fe6dea25fed202c818ecfa3b4d0eceea5259 Mon Sep 17 00:00:00 2001 From: Roman Pytkov Date: Mon, 27 Oct 2025 23:29:57 +0300 Subject: [PATCH 1/2] =?UTF-8?q?workflow=20=D0=B4=D0=BB=D1=8F=20=D1=81?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/build-and-push.yaml | 45 ++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .gitea/workflows/build-and-push.yaml diff --git a/.gitea/workflows/build-and-push.yaml b/.gitea/workflows/build-and-push.yaml new file mode 100644 index 0000000..87e7c83 --- /dev/null +++ b/.gitea/workflows/build-and-push.yaml @@ -0,0 +1,45 @@ +name: Build and Push Docker Images + +on: + push: + branches: [ master ] + +env: + REGISTRY: git.nullptr.top + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + service: [ gateway, worker ] + include: + - service: gateway + dockerfile: src/LiquidCode.Tester.Gateway/Dockerfile + image: git.nullptr.top/liquidcode/liquidcode-tester-gateway + - service: worker + dockerfile: src/LiquidCode.Tester.Worker/Dockerfile + image: git.nullptr.top/liquidcode/liquidcode-tester-worker + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: liquidcode-ci-service + password: ${{ secrets.SERVICE_ACCOUNT_TOKEN }} + + - name: Build and Push ${{ matrix.service }} image + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ matrix.dockerfile }} + push: true + tags: ${{ matrix.image }}:latest,${{ matrix.image }}:${{ gitea.sha }} + cache-from: type=registry,ref=${{ matrix.image }}:buildcache + cache-to: type=registry,ref=${{ matrix.image }}:buildcache,mode=max From 06c6d06186917dca0c7a2abf847f2d7d47e38254 Mon Sep 17 00:00:00 2001 From: prixod Date: Tue, 28 Oct 2025 21:10:39 +0400 Subject: [PATCH 2/2] update polygon package parsing & testing --- POLYGON_PACKAGE_STRUCTURE.md | 579 ++++++++++++++++++ src/LiquidCode.Tester.Worker/Program.cs | 3 + .../Services/AnswerGenerationService.cs | 181 ++++++ .../Services/CheckerService.cs | 165 +++++ .../Services/IOutputCheckerService.cs | 10 + .../Services/OutputCheckerService.cs | 35 +- .../Services/PackageParserService.cs | 294 +++++++-- .../Services/PolygonProblemXmlParser.cs | 141 +++++ .../Services/TestingService.cs | 18 +- .../OutputCheckerServiceTests.cs | 4 +- .../PackageParserServiceTests.cs | 18 +- .../PolygonPackageParserTests.cs | 220 +++++++ .../TestingServiceTests.cs | 211 +++++++ 13 files changed, 1822 insertions(+), 57 deletions(-) create mode 100644 POLYGON_PACKAGE_STRUCTURE.md create mode 100644 src/LiquidCode.Tester.Worker/Services/AnswerGenerationService.cs create mode 100644 src/LiquidCode.Tester.Worker/Services/CheckerService.cs create mode 100644 src/LiquidCode.Tester.Worker/Services/PolygonProblemXmlParser.cs create mode 100644 tests/LiquidCode.Tester.Worker.Tests/PolygonPackageParserTests.cs create mode 100644 tests/LiquidCode.Tester.Worker.Tests/TestingServiceTests.cs diff --git a/POLYGON_PACKAGE_STRUCTURE.md b/POLYGON_PACKAGE_STRUCTURE.md new file mode 100644 index 0000000..52ce76d --- /dev/null +++ b/POLYGON_PACKAGE_STRUCTURE.md @@ -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 + + + + + + + + + 2000 + 268435456 + 51 + tests/%02d + tests/%02d.a + + + + + + ... + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +--- + +### 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("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 + + + + + +``` + +--- + +### 4. **Generator (g.cpp)** + +Программа для генерации тестов: + +```cpp +#include "testlib.h" +#include + +int main(int argc, char* argv[]) { + registerGen(argc, argv, 1); + + int n = opt(1); // Первый аргумент + int maxVal = opt(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 указано: + + +# 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 jans(n); + for (int i = 0; i < n; i++) + jans[i] = ans.readInt(); + + std::vector 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 + + + + + + +``` + +**Назначение:** +- `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 + +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 для проверки входных данных diff --git a/src/LiquidCode.Tester.Worker/Program.cs b/src/LiquidCode.Tester.Worker/Program.cs index d46cee5..0a8e4bf 100644 --- a/src/LiquidCode.Tester.Worker/Program.cs +++ b/src/LiquidCode.Tester.Worker/Program.cs @@ -10,6 +10,9 @@ builder.Services.AddOpenApi(); builder.Services.AddHttpClient(); // Register application services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/LiquidCode.Tester.Worker/Services/AnswerGenerationService.cs b/src/LiquidCode.Tester.Worker/Services/AnswerGenerationService.cs new file mode 100644 index 0000000..e1440cc --- /dev/null +++ b/src/LiquidCode.Tester.Worker/Services/AnswerGenerationService.cs @@ -0,0 +1,181 @@ +namespace LiquidCode.Tester.Worker.Services; + +/// +/// Service for generating answer files by running the main solution from Polygon package +/// +public class AnswerGenerationService +{ + private readonly ICompilationServiceFactory _compilationFactory; + private readonly IExecutionServiceFactory _executionFactory; + private readonly ILogger _logger; + + public AnswerGenerationService( + ICompilationServiceFactory compilationFactory, + IExecutionServiceFactory executionFactory, + ILogger logger) + { + _compilationFactory = compilationFactory; + _executionFactory = executionFactory; + _logger = logger; + } + + public async Task GenerateAnswersAsync( + PolygonProblemDescriptor descriptor, + string workingDirectory, + List inputFilePaths, + List 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, ""); + } +} diff --git a/src/LiquidCode.Tester.Worker/Services/CheckerService.cs b/src/LiquidCode.Tester.Worker/Services/CheckerService.cs new file mode 100644 index 0000000..0758260 --- /dev/null +++ b/src/LiquidCode.Tester.Worker/Services/CheckerService.cs @@ -0,0 +1,165 @@ +using System.Diagnostics; + +namespace LiquidCode.Tester.Worker.Services; + +/// +/// Service for running custom checkers (testlib-based) +/// +public class CheckerService +{ + private readonly ILogger _logger; + + public CheckerService(ILogger logger) + { + _logger = logger; + } + + /// + /// Check user output using custom checker + /// + /// Path to checker executable + /// Path to input file + /// User program output + /// Path to answer file + /// Checker result + public async Task 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 + }; + } +} + +/// +/// Result of custom checker execution +/// +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; } +} + +/// +/// Checker verdict codes (testlib standard) +/// +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 +} diff --git a/src/LiquidCode.Tester.Worker/Services/IOutputCheckerService.cs b/src/LiquidCode.Tester.Worker/Services/IOutputCheckerService.cs index c189f37..97a3bb8 100644 --- a/src/LiquidCode.Tester.Worker/Services/IOutputCheckerService.cs +++ b/src/LiquidCode.Tester.Worker/Services/IOutputCheckerService.cs @@ -9,4 +9,14 @@ public interface IOutputCheckerService /// Path to expected output file /// True if outputs match Task CheckOutputAsync(string actualOutput, string expectedOutputPath); + + /// + /// Checks output using custom checker if available, falls back to standard checking + /// + /// Output from user's solution + /// Path to input file + /// Path to expected output file + /// Path to custom checker executable (optional) + /// True if output is accepted + Task CheckOutputWithCheckerAsync(string actualOutput, string inputFilePath, string expectedOutputPath, string? checkerPath); } diff --git a/src/LiquidCode.Tester.Worker/Services/OutputCheckerService.cs b/src/LiquidCode.Tester.Worker/Services/OutputCheckerService.cs index 69456e4..f209899 100644 --- a/src/LiquidCode.Tester.Worker/Services/OutputCheckerService.cs +++ b/src/LiquidCode.Tester.Worker/Services/OutputCheckerService.cs @@ -3,10 +3,12 @@ namespace LiquidCode.Tester.Worker.Services; public class OutputCheckerService : IOutputCheckerService { private readonly ILogger _logger; + private readonly CheckerService _checkerService; - public OutputCheckerService(ILogger logger) + public OutputCheckerService(ILogger logger, CheckerService checkerService) { _logger = logger; + _checkerService = checkerService; } public async Task CheckOutputAsync(string actualOutput, string expectedOutputPath) @@ -51,4 +53,35 @@ public class OutputCheckerService : IOutputCheckerService return string.Join("\n", lines); } + + public async Task 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); + } } diff --git a/src/LiquidCode.Tester.Worker/Services/PackageParserService.cs b/src/LiquidCode.Tester.Worker/Services/PackageParserService.cs index 769dc4c..35d330d 100644 --- a/src/LiquidCode.Tester.Worker/Services/PackageParserService.cs +++ b/src/LiquidCode.Tester.Worker/Services/PackageParserService.cs @@ -6,10 +6,20 @@ namespace LiquidCode.Tester.Worker.Services; public class PackageParserService : IPackageParserService { private readonly ILogger _logger; + private readonly PolygonProblemXmlParser _polygonParser; + private readonly AnswerGenerationService _answerGenerator; + private readonly CppCompilationService _cppCompilation; - public PackageParserService(ILogger logger) + public PackageParserService( + ILogger logger, + PolygonProblemXmlParser polygonParser, + AnswerGenerationService answerGenerator, + CppCompilationService cppCompilation) { _logger = logger; + _polygonParser = polygonParser; + _answerGenerator = answerGenerator; + _cppCompilation = cppCompilation; } public async Task 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 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(); + var answerPaths = new List(); + var missingAnswerPaths = new List(); + var missingAnswerInputs = new List(); + + 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 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 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; + } } diff --git a/src/LiquidCode.Tester.Worker/Services/PolygonProblemXmlParser.cs b/src/LiquidCode.Tester.Worker/Services/PolygonProblemXmlParser.cs new file mode 100644 index 0000000..cef0102 --- /dev/null +++ b/src/LiquidCode.Tester.Worker/Services/PolygonProblemXmlParser.cs @@ -0,0 +1,141 @@ +using System.Xml.Linq; +using LiquidCode.Tester.Common.Models; + +namespace LiquidCode.Tester.Worker.Services; + +/// +/// Parser for Polygon problem.xml format +/// +public class PolygonProblemXmlParser +{ + private readonly ILogger _logger; + + public PolygonProblemXmlParser(ILogger 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; + } + } +} + +/// +/// Descriptor parsed from problem.xml +/// +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; } +} diff --git a/src/LiquidCode.Tester.Worker/Services/TestingService.cs b/src/LiquidCode.Tester.Worker/Services/TestingService.cs index 061a17a..9c05d43 100644 --- a/src/LiquidCode.Tester.Worker/Services/TestingService.cs +++ b/src/LiquidCode.Tester.Worker/Services/TestingService.cs @@ -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) { diff --git a/tests/LiquidCode.Tester.Worker.Tests/OutputCheckerServiceTests.cs b/tests/LiquidCode.Tester.Worker.Tests/OutputCheckerServiceTests.cs index b38c2a7..d64c75f 100644 --- a/tests/LiquidCode.Tester.Worker.Tests/OutputCheckerServiceTests.cs +++ b/tests/LiquidCode.Tester.Worker.Tests/OutputCheckerServiceTests.cs @@ -12,7 +12,9 @@ public class OutputCheckerServiceTests : IDisposable public OutputCheckerServiceTests() { var logger = new Mock>(); - _service = new OutputCheckerService(logger.Object); + var checkerLogger = new Mock>(); + var checkerService = new CheckerService(checkerLogger.Object); + _service = new OutputCheckerService(logger.Object, checkerService); _testDirectory = Path.Combine(Path.GetTempPath(), "OutputCheckerTests", Guid.NewGuid().ToString()); Directory.CreateDirectory(_testDirectory); } diff --git a/tests/LiquidCode.Tester.Worker.Tests/PackageParserServiceTests.cs b/tests/LiquidCode.Tester.Worker.Tests/PackageParserServiceTests.cs index 2f1e0f6..903ccad 100644 --- a/tests/LiquidCode.Tester.Worker.Tests/PackageParserServiceTests.cs +++ b/tests/LiquidCode.Tester.Worker.Tests/PackageParserServiceTests.cs @@ -13,7 +13,23 @@ public class PackageParserServiceTests : IDisposable public PackageParserServiceTests() { var logger = new Mock>(); - _service = new PackageParserService(logger.Object); + var xmlLogger = new Mock>(); + var answerGenLogger = new Mock>(); + var cppLogger = new Mock>(); + var cppConfigMock = new Mock(); + + var polygonParser = new PolygonProblemXmlParser(xmlLogger.Object); + + var compilationFactory = new Mock(); + var executionFactory = new Mock(); + 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); } diff --git a/tests/LiquidCode.Tester.Worker.Tests/PolygonPackageParserTests.cs b/tests/LiquidCode.Tester.Worker.Tests/PolygonPackageParserTests.cs new file mode 100644 index 0000000..f29574c --- /dev/null +++ b/tests/LiquidCode.Tester.Worker.Tests/PolygonPackageParserTests.cs @@ -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>(); + var xmlLogger = new Mock>(); + var answerGenLogger = new Mock>(); + var cppLogger = new Mock>(); + var cppConfigMock = new Mock(); + + var polygonParser = new PolygonProblemXmlParser(xmlLogger.Object); + + var compilationFactory = new Mock(); + var executionFactory = new Mock(); + 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 = @" + + + + 1000 + 268435456 + 2 + tests/%02d + tests/%02d.a + + +"; + + 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 = @" + + + + 2000 + 536870912 + 3 + tests/%02d + tests/%02d.a + + +"; + + 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 + } + } + } +} diff --git a/tests/LiquidCode.Tester.Worker.Tests/TestingServiceTests.cs b/tests/LiquidCode.Tester.Worker.Tests/TestingServiceTests.cs new file mode 100644 index 0000000..c425e62 --- /dev/null +++ b/tests/LiquidCode.Tester.Worker.Tests/TestingServiceTests.cs @@ -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 _packageParserMock; + private readonly Mock _compilationFactoryMock; + private readonly Mock _executionFactoryMock; + private readonly Mock _outputCheckerMock; + private readonly Mock _callbackServiceMock; + private readonly Mock> _loggerMock; + private readonly TestingService _service; + private readonly string _testDirectory; + + public TestingServiceTests() + { + _packageParserMock = new Mock(); + _compilationFactoryMock = new Mock(); + _executionFactoryMock = new Mock(); + _outputCheckerMock = new Mock(); + _callbackServiceMock = new Mock(); + _loggerMock = new Mock>(); + + _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() // Empty list! + }; + + _packageParserMock + .Setup(x => x.ParsePackageAsync(It.IsAny())) + .ReturnsAsync(emptyPackage); + + // Act + await _service.ProcessSubmitAsync(request); + + // Assert - verify callback was called with error + _callbackServiceMock.Verify( + x => x.SendStatusAsync( + request.CallbackUrl, + It.Is(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()), + 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 + { + new TestCase + { + Number = 1, + InputFilePath = inputFile, + OutputFilePath = outputFile, + TimeLimit = 2000, + MemoryLimit = 256 + } + } + }; + + var compilationService = new Mock(); + var executionService = new Mock(); + + _packageParserMock + .Setup(x => x.ParsePackageAsync(It.IsAny())) + .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(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new CompilationResult + { + Success = true, + ExecutablePath = executablePath + }); + + executionService + .Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ExecutionResult + { + Success = true, + Output = "expected output", + ExitCode = 0, + RuntimeError = false, + TimeLimitExceeded = false, + MemoryLimitExceeded = false + }); + + _outputCheckerMock + .Setup(x => x.CheckOutputAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + // Act + await _service.ProcessSubmitAsync(request); + + // Assert - verify callback was called with success + _callbackServiceMock.Verify( + x => x.SendStatusAsync( + request.CallbackUrl, + It.Is(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 + } + } + } +}