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
+ }
+ }
+ }
+}