Штуки
Some checks failed
Build and Push Docker Images / build (src/LiquidCode.Tester.Gateway/Dockerfile, git.nullptr.top/liquidcode/liquidcode-tester-gateway-roman, gateway) (push) Successful in 1m12s
Build and Push Docker Images / build (src/LiquidCode.Tester.Worker/Dockerfile, git.nullptr.top/liquidcode/liquidcode-tester-worker-roman, worker) (push) Has been cancelled

This commit is contained in:
2025-11-02 19:31:34 +03:00
parent 50a94ae2be
commit e154890897
103 changed files with 11185 additions and 155 deletions

View File

@@ -3,6 +3,7 @@ namespace LiquidCode.Tester.Common.Models;
public class ProblemPackage
{
public string WorkingDirectory { get; set; } = string.Empty;
public string? ExtractionRoot { get; set; }
public List<TestCase> TestCases { get; set; } = new();
public string? CheckerPath { get; set; }
public int DefaultTimeLimit { get; set; } = 2000; // milliseconds

View File

@@ -136,23 +136,37 @@ public class AnswerGenerationService
return (null, "");
}
if (solutionType.StartsWith("python."))
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)
var versionPart = solutionType.Replace("python", string.Empty, StringComparison.OrdinalIgnoreCase)
.Trim('.', ' ');
if (string.IsNullOrWhiteSpace(versionPart))
{
return ("python", "3");
}
// Normalize python version; Polygon often uses python.3 or python3.10
versionPart = versionPart.TrimStart('.');
if (!versionPart.Contains('.'))
{
// Assume major version provided, default to CPython minor 10
versionPart = versionPart switch
{
"2" => "2.7",
"3" => "3.10",
_ => $"3.{versionPart}"
};
}
return ("python", versionPart);
}
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
var standard = ExtractCppStandard(solutionType);
return ("cpp", standard);
}
if (solutionType.StartsWith("java"))
@@ -178,4 +192,22 @@ public class AnswerGenerationService
_logger.LogWarning("Unknown solution type: {Type}", solutionType);
return (null, "");
}
private static string ExtractCppStandard(string solutionType)
{
var knownStandards = new[] { "26", "23", "20", "17", "14", "11", "03", "98" };
foreach (var standard in knownStandards)
{
if (solutionType.Contains($"++{standard}", StringComparison.OrdinalIgnoreCase) ||
solutionType.Contains($"c++{standard}", StringComparison.OrdinalIgnoreCase))
{
// Normalize 03 to 03, 98 stays 98
return standard.TrimStart('0');
}
}
// Default to modern standard if not specified
return "17";
}
}

View File

@@ -1,4 +1,6 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using LiquidCode.Tester.Worker.Models;
namespace LiquidCode.Tester.Worker.Services;
@@ -23,57 +25,8 @@ public class CppCompilationService : ICompilationService
try
{
// Write source code to file
await File.WriteAllTextAsync(sourceFilePath, sourceCode);
// Resolve version-specific configuration
var (compiler, compilerFlags) = ResolveVersion(version);
_logger.LogDebug("Using compiler: {Compiler} with flags: {Flags}", compiler, compilerFlags);
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = compiler,
Arguments = $"{compilerFlags} {sourceFilePath} -o {executablePath}",
WorkingDirectory = workingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
var output = await process.StandardOutput.ReadToEndAsync();
var error = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
var compilerOutput = $"{output}\n{error}".Trim();
if (process.ExitCode == 0 && File.Exists(executablePath))
{
_logger.LogInformation("Compilation successful");
return new CompilationResult
{
Success = true,
ExecutablePath = executablePath,
CompilerOutput = compilerOutput
};
}
else
{
_logger.LogWarning("Compilation failed with exit code {ExitCode}", process.ExitCode);
return new CompilationResult
{
Success = false,
ErrorMessage = "Compilation failed",
CompilerOutput = compilerOutput
};
}
return await CompileFileAsync(sourceFilePath, executablePath, version);
}
catch (Exception ex)
{
@@ -86,31 +39,168 @@ public class CppCompilationService : ICompilationService
}
}
private (string compiler, string compilerFlags) ResolveVersion(string? version)
public async Task<CompilationResult> CompileFileAsync(
string sourceFilePath,
string outputExecutablePath,
string? version = null,
IEnumerable<string>? includeDirectories = null,
IEnumerable<string>? additionalFlags = null)
{
// If version is null or "latest", use default configuration
_logger.LogInformation("Compiling C++ source {Source} -> {Output} with version {Version}", sourceFilePath, outputExecutablePath, version ?? "latest");
try
{
Directory.CreateDirectory(Path.GetDirectoryName(outputExecutablePath)!);
var (compiler, compilerFlags) = ResolveVersion(version);
_logger.LogDebug("Using compiler: {Compiler} with flags: {Flags}", compiler, string.Join(' ', compilerFlags));
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = compiler,
WorkingDirectory = Path.GetDirectoryName(sourceFilePath) ?? Directory.GetCurrentDirectory(),
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
foreach (var flag in compilerFlags)
{
process.StartInfo.ArgumentList.Add(flag);
}
if (includeDirectories != null)
{
foreach (var includeDir in includeDirectories.Where(d => !string.IsNullOrWhiteSpace(d)))
{
process.StartInfo.ArgumentList.Add($"-I{includeDir}");
}
}
if (additionalFlags != null)
{
foreach (var flag in additionalFlags.Where(f => !string.IsNullOrWhiteSpace(f)))
{
process.StartInfo.ArgumentList.Add(flag);
}
}
process.StartInfo.ArgumentList.Add(sourceFilePath);
process.StartInfo.ArgumentList.Add("-o");
process.StartInfo.ArgumentList.Add(outputExecutablePath);
process.Start();
var stdOutTask = process.StandardOutput.ReadToEndAsync();
var stdErrTask = process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
var compilerOutput = $"{await stdOutTask}\n{await stdErrTask}".Trim();
if (process.ExitCode == 0 && File.Exists(outputExecutablePath))
{
_logger.LogInformation("Compilation successful");
return new CompilationResult
{
Success = true,
ExecutablePath = outputExecutablePath,
CompilerOutput = compilerOutput
};
}
_logger.LogWarning("Compilation failed with exit code {ExitCode}", process.ExitCode);
return new CompilationResult
{
Success = false,
ErrorMessage = "Compilation failed",
CompilerOutput = compilerOutput
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during compilation");
return new CompilationResult
{
Success = false,
ErrorMessage = $"Compilation error: {ex.Message}"
};
}
}
private (string compiler, List<string> compilerFlags) ResolveVersion(string? version)
{
var defaultCompiler = _configuration["Cpp:Compiler"] ?? "g++";
var defaultFlags = SplitFlags(_configuration["Cpp:CompilerFlags"] ?? "-O2 -std=c++17 -Wall");
if (string.IsNullOrEmpty(version) || version.Equals("latest", StringComparison.OrdinalIgnoreCase))
{
var compiler = _configuration["Cpp:Compiler"] ?? "g++";
var compilerFlags = _configuration["Cpp:CompilerFlags"] ?? "-O2 -std=c++17 -Wall";
return (compiler, compilerFlags);
return (defaultCompiler, defaultFlags);
}
// Try to find version-specific configuration
var versionKey = $"Cpp:Versions:{version}";
var versionCompiler = _configuration[$"{versionKey}:Compiler"];
var versionFlags = _configuration[$"{versionKey}:CompilerFlags"];
var versionFlagsValue = _configuration[$"{versionKey}:CompilerFlags"];
if (!string.IsNullOrEmpty(versionCompiler))
if (!string.IsNullOrEmpty(versionCompiler) || !string.IsNullOrEmpty(versionFlagsValue))
{
var resolvedFlags = !string.IsNullOrEmpty(versionFlagsValue)
? SplitFlags(versionFlagsValue)
: defaultFlags;
_logger.LogInformation("Using C++ version {Version} configuration", version);
return (versionCompiler, versionFlags ?? "-O2 -Wall");
return (versionCompiler ?? defaultCompiler, resolvedFlags);
}
var normalized = NormalizeCppVersion(version);
if (normalized != null)
{
var flagsWithoutStd = defaultFlags
.Where(flag => !flag.StartsWith("-std=", StringComparison.OrdinalIgnoreCase))
.ToList();
flagsWithoutStd.Add($"-std=c++{normalized}");
_logger.LogInformation("Using inferred C++ standard c++{Standard}", normalized);
return (defaultCompiler, flagsWithoutStd);
}
// Version not found, use default and log warning
_logger.LogWarning("C++ version {Version} not found in configuration, using default", version);
var defaultCompiler = _configuration["Cpp:Compiler"] ?? "g++";
var defaultFlags = _configuration["Cpp:CompilerFlags"] ?? "-O2 -std=c++17 -Wall";
return (defaultCompiler, defaultFlags);
}
private static List<string> SplitFlags(string flags) =>
flags.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.ToList();
private static string? NormalizeCppVersion(string version)
{
var cleaned = version.Trim().ToLowerInvariant();
cleaned = cleaned.Replace("c++", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("gnu++", string.Empty, StringComparison.OrdinalIgnoreCase)
.Trim('+', ' ');
cleaned = cleaned switch
{
"2b" => "23",
"2a" => "20",
"1z" => "17",
"0x" => "11",
_ => cleaned
};
return cleaned switch
{
"26" => "26",
"23" => "23",
"20" => "20",
"17" => "17",
"14" => "14",
"11" => "11",
_ => null
};
}
}

View File

@@ -51,6 +51,12 @@ public class OutputCheckerService : IOutputCheckerService
lines.RemoveAt(lines.Count - 1);
}
// Remove leading empty lines
while (lines.Count > 0 && string.IsNullOrWhiteSpace(lines[0]))
{
lines.RemoveAt(0);
}
return string.Join("\n", lines);
}

View File

@@ -1,4 +1,10 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using LiquidCode.Tester.Common.Models;
namespace LiquidCode.Tester.Worker.Services;
@@ -35,12 +41,15 @@ public class PackageParserService : IPackageParserService
using var archive = new ZipArchive(packageStream, ZipArchiveMode.Read);
archive.ExtractToDirectory(workingDirectory);
// Check if this is a Polygon package (has problem.xml)
var problemXmlPath = Path.Combine(workingDirectory, "problem.xml");
if (File.Exists(problemXmlPath))
// Check if this is a Polygon package (search for problem.xml)
var problemXmlPath = Directory.EnumerateFiles(workingDirectory, "problem.xml", SearchOption.AllDirectories)
.FirstOrDefault();
if (!string.IsNullOrEmpty(problemXmlPath))
{
_logger.LogInformation("Detected Polygon package format (problem.xml found)");
return await ParsePolygonPackageAsync(workingDirectory, problemXmlPath);
var packageRoot = Path.GetDirectoryName(problemXmlPath)!;
_logger.LogInformation("Detected Polygon package format (problem.xml found at {ProblemXml})", problemXmlPath);
return await ParsePolygonPackageAsync(packageRoot, problemXmlPath, workingDirectory);
}
// Fall back to legacy format (.in/.out files)
@@ -54,89 +63,120 @@ public class PackageParserService : IPackageParserService
}
}
private async Task<ProblemPackage> ParsePolygonPackageAsync(string workingDirectory, string problemXmlPath)
private async Task<ProblemPackage> ParsePolygonPackageAsync(string packageRoot, string problemXmlPath, string extractionRoot)
{
var descriptor = _polygonParser.ParseProblemXml(problemXmlPath);
if (descriptor == null)
{
_logger.LogWarning("Failed to parse problem.xml, falling back to legacy format");
return await ParseLegacyPackage(workingDirectory);
return await ParseLegacyPackage(packageRoot, extractionRoot);
}
var package = new ProblemPackage
{
WorkingDirectory = workingDirectory,
WorkingDirectory = packageRoot,
ExtractionRoot = extractionRoot,
DefaultTimeLimit = descriptor.TimeLimitMs,
DefaultMemoryLimit = descriptor.MemoryLimitMb
};
// Collect test file paths and check which answers are missing
var inputPaths = new List<string>();
var answerPaths = new List<string>();
var missingAnswerPaths = new List<string>();
var missingAnswerInputs = new List<string>();
var buildDirectory = Path.Combine(packageRoot, ".lc-build");
Directory.CreateDirectory(buildDirectory);
for (int i = 1; i <= descriptor.TestCount; i++)
var compiledExecutables = await CompilePolygonExecutablesAsync(descriptor, packageRoot, buildDirectory);
package.CheckerPath = await CompileCheckerForPackageAsync(descriptor, packageRoot, buildDirectory)
?? await FindAndCompileCheckerAsync(packageRoot);
var validatorPath = await CompileValidatorAsync(descriptor, packageRoot, buildDirectory, compiledExecutables);
await GenerateAndValidateTestsAsync(descriptor, packageRoot, compiledExecutables, validatorPath);
var testIndices = descriptor.Tests.Count > 0
? descriptor.Tests.Select(t => t.Index).Distinct().OrderBy(i => i).ToList()
: Enumerable.Range(1, descriptor.TestCount > 0 ? descriptor.TestCount : 0).ToList();
if (testIndices.Count == 0)
{
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));
_logger.LogWarning("No test definitions discovered in problem.xml; falling back to filesystem scan");
return await ParseLegacyPackage(packageRoot, extractionRoot);
}
if (!File.Exists(inputPath))
var inputs = new List<(int index, string inputPath)>();
var answers = new Dictionary<int, string>();
foreach (var testIndex in testIndices)
{
var inputRelative = FormatPolygonPattern(descriptor.InputPathPattern, testIndex);
var inputFullPath = Path.Combine(packageRoot, NormalizeRelativePath(inputRelative));
if (!File.Exists(inputFullPath))
{
_logger.LogWarning("Input file not found: {InputPath}", inputPath);
_logger.LogWarning("Input file not found for test {Index}: {RelativePath}", testIndex, inputRelative);
continue;
}
inputPaths.Add(inputPath);
answerPaths.Add(answerPath);
var answerRelative = FormatPolygonPattern(descriptor.AnswerPathPattern, testIndex);
var answerFullPath = Path.Combine(packageRoot, NormalizeRelativePath(answerRelative));
inputs.Add((testIndex, inputFullPath));
answers[testIndex] = answerFullPath;
}
var missingAnswerInputs = new List<string>();
var missingAnswerPaths = new List<string>();
foreach (var (index, inputPath) in inputs)
{
if (!answers.TryGetValue(index, out var answerPath))
{
continue;
}
if (!File.Exists(answerPath))
{
missingAnswerPaths.Add(answerPath);
missingAnswerInputs.Add(inputPath);
missingAnswerPaths.Add(answerPath);
}
}
// 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);
_logger.LogInformation("Found {Count} tests without answers, attempting to generate them", missingAnswerPaths.Count);
var generated = await _answerGenerator.GenerateAnswersAsync(
descriptor,
workingDirectory,
packageRoot,
missingAnswerInputs,
missingAnswerPaths);
if (generated)
if (!generated)
{
_logger.LogInformation("Successfully generated answer files");
_logger.LogWarning("Failed to generate answer files, affected tests will be skipped");
}
else
{
_logger.LogWarning("Failed to generate answer files, tests without answers will be skipped");
_logger.LogInformation("Answer files generated successfully");
}
}
// Now create test cases for all tests that have answer files
for (int i = 0; i < inputPaths.Count; i++)
foreach (var (index, inputPath) in inputs.OrderBy(item => item.index))
{
var inputPath = inputPaths[i];
var answerPath = answerPaths[i];
if (!answers.TryGetValue(index, out var answerPath))
{
continue;
}
if (!File.Exists(answerPath))
{
_logger.LogWarning("Answer file not found: {AnswerPath} (skipping test)", answerPath);
_logger.LogWarning("Answer file not found for test {Index}: {AnswerPath}", index, answerPath);
continue;
}
package.TestCases.Add(new TestCase
{
Number = i + 1,
Number = index,
InputFilePath = inputPath,
OutputFilePath = answerPath,
TimeLimit = descriptor.TimeLimitMs,
@@ -144,26 +184,23 @@ public class PackageParserService : IPackageParserService
});
}
// 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.LogWarning("No test cases with answer files found for Polygon package");
}
_logger.LogInformation("Parsed Polygon package with {TestCount} tests (out of {TotalTests} in problem.xml)",
_logger.LogInformation("Parsed Polygon package with {TestCount} tests (declared {TotalTests})",
package.TestCases.Count, descriptor.TestCount);
return package;
}
private async Task<ProblemPackage> ParseLegacyPackage(string workingDirectory)
private async Task<ProblemPackage> ParseLegacyPackage(string workingDirectory, string? extractionRoot = null)
{
var package = new ProblemPackage
{
WorkingDirectory = workingDirectory
WorkingDirectory = workingDirectory,
ExtractionRoot = extractionRoot ?? workingDirectory
};
// Find tests directory
@@ -193,7 +230,7 @@ public class PackageParserService : IPackageParserService
package.TestCases.Add(new TestCase
{
Number = i + 1,
Number = package.TestCases.Count + 1,
InputFilePath = inputFile,
OutputFilePath = outputFile,
TimeLimit = package.DefaultTimeLimit,
@@ -213,6 +250,447 @@ public class PackageParserService : IPackageParserService
return package;
}
private async Task<Dictionary<string, string>> CompilePolygonExecutablesAsync(
PolygonProblemDescriptor descriptor,
string packageRoot,
string buildDirectory)
{
var compiled = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var executable in descriptor.Executables)
{
if (string.IsNullOrWhiteSpace(executable.SourcePath))
{
continue;
}
var alias = !string.IsNullOrWhiteSpace(executable.Name)
? executable.Name
: Path.GetFileNameWithoutExtension(executable.SourcePath);
if (string.IsNullOrWhiteSpace(alias))
{
continue;
}
var sourceFullPath = Path.Combine(packageRoot, NormalizeRelativePath(executable.SourcePath));
if (!File.Exists(sourceFullPath))
{
_logger.LogWarning("Executable source not found: {Path}", executable.SourcePath);
continue;
}
if (IsCppAsset(executable.Type, sourceFullPath))
{
var outputPath = Path.Combine(buildDirectory, alias);
var includeDirs = GetIncludeDirectories(packageRoot, sourceFullPath);
var stdVersion = ExtractCppStandard(executable.Type);
var compilationResult = await _cppCompilation.CompileFileAsync(
sourceFullPath,
outputPath,
stdVersion,
includeDirs);
if (compilationResult.Success && !string.IsNullOrEmpty(compilationResult.ExecutablePath))
{
_logger.LogInformation("Compiled executable {Alias} -> {Path}", alias, compilationResult.ExecutablePath);
compiled[alias] = compilationResult.ExecutablePath;
}
else
{
_logger.LogWarning("Failed to compile executable {Alias}: {Error}", alias, compilationResult.CompilerOutput);
}
}
else
{
var binaryPath = !string.IsNullOrWhiteSpace(executable.BinaryPath)
? Path.Combine(packageRoot, NormalizeRelativePath(executable.BinaryPath))
: null;
if (!string.IsNullOrEmpty(binaryPath) && File.Exists(binaryPath))
{
_logger.LogInformation("Using prebuilt executable {Alias} at {Path}", alias, binaryPath);
compiled[alias] = binaryPath;
}
else
{
_logger.LogWarning("Unsupported executable type {Type} for {Alias}", executable.Type, alias);
}
}
}
return compiled;
}
private async Task<string?> CompileCheckerForPackageAsync(
PolygonProblemDescriptor descriptor,
string packageRoot,
string buildDirectory)
{
if (descriptor.Checker == null || string.IsNullOrWhiteSpace(descriptor.Checker.SourcePath))
{
_logger.LogInformation("No checker declared in problem.xml");
return null;
}
var sourcePath = Path.Combine(packageRoot, NormalizeRelativePath(descriptor.Checker.SourcePath));
if (!File.Exists(sourcePath))
{
_logger.LogWarning("Checker source not found: {SourcePath}", descriptor.Checker.SourcePath);
return null;
}
var alias = !string.IsNullOrWhiteSpace(descriptor.Checker.CopyPath)
? Path.GetFileNameWithoutExtension(descriptor.Checker.CopyPath)
: Path.GetFileNameWithoutExtension(descriptor.Checker.BinaryPath ?? descriptor.Checker.SourcePath);
if (string.IsNullOrWhiteSpace(alias))
{
alias = "checker";
}
var outputPath = Path.Combine(buildDirectory, alias);
var includeDirs = GetIncludeDirectories(packageRoot, sourcePath);
var stdVersion = ExtractCppStandard(descriptor.Checker.Type);
var result = await _cppCompilation.CompileFileAsync(
sourcePath,
outputPath,
stdVersion,
includeDirs);
if (result.Success && !string.IsNullOrEmpty(result.ExecutablePath))
{
_logger.LogInformation("Checker compiled to {Path}", result.ExecutablePath);
return result.ExecutablePath;
}
_logger.LogWarning("Failed to compile checker: {Error}", result.CompilerOutput);
return null;
}
private async Task<string?> CompileValidatorAsync(
PolygonProblemDescriptor descriptor,
string packageRoot,
string buildDirectory,
IDictionary<string, string> compiledExecutables)
{
var validator = descriptor.Validators.FirstOrDefault();
if (validator == null || string.IsNullOrWhiteSpace(validator.SourcePath))
{
_logger.LogInformation("No validator declared in problem.xml");
return null;
}
var sourcePath = Path.Combine(packageRoot, NormalizeRelativePath(validator.SourcePath));
if (!File.Exists(sourcePath))
{
_logger.LogWarning("Validator source not found: {SourcePath}", validator.SourcePath);
return null;
}
var alias = Path.GetFileNameWithoutExtension(validator.BinaryPath ?? validator.SourcePath);
if (string.IsNullOrWhiteSpace(alias))
{
alias = "validator";
}
if (compiledExecutables.TryGetValue(alias, out var compiledPath) && File.Exists(compiledPath))
{
_logger.LogInformation("Reusing precompiled validator executable at {Path}", compiledPath);
return compiledPath;
}
var outputPath = Path.Combine(buildDirectory, alias);
var includeDirs = GetIncludeDirectories(packageRoot, sourcePath);
var stdVersion = ExtractCppStandard(validator.Type);
var result = await _cppCompilation.CompileFileAsync(
sourcePath,
outputPath,
stdVersion,
includeDirs);
if (result.Success && !string.IsNullOrEmpty(result.ExecutablePath))
{
_logger.LogInformation("Validator compiled to {Path}", result.ExecutablePath);
compiledExecutables[alias] = result.ExecutablePath;
return result.ExecutablePath;
}
_logger.LogWarning("Failed to compile validator: {Error}", result.CompilerOutput);
return null;
}
private async Task GenerateAndValidateTestsAsync(
PolygonProblemDescriptor descriptor,
string packageRoot,
IDictionary<string, string> compiledExecutables,
string? validatorPath)
{
if (descriptor.Tests.Count == 0)
{
_logger.LogInformation("problem.xml does not enumerate tests; skipping generation step");
return;
}
foreach (var test in descriptor.Tests)
{
var inputRelative = FormatPolygonPattern(descriptor.InputPathPattern, test.Index);
var inputFullPath = Path.Combine(packageRoot, NormalizeRelativePath(inputRelative));
if (test.Method is PolygonTestMethod.Generated or PolygonTestMethod.Script)
{
await GenerateTestInputAsync(test, packageRoot, inputFullPath, compiledExecutables);
}
else if (!File.Exists(inputFullPath))
{
_logger.LogWarning("Manual test {Index} expected at {Path} but not found", test.Index, inputRelative);
continue;
}
if (!File.Exists(inputFullPath))
{
throw new InvalidOperationException($"Failed to produce input file for test {test.Index} ({inputRelative})");
}
if (!string.IsNullOrEmpty(validatorPath))
{
await ValidateInputAsync(validatorPath, inputFullPath, test.Index);
}
}
}
private async Task GenerateTestInputAsync(
PolygonTestDefinition test,
string packageRoot,
string inputFullPath,
IDictionary<string, string> compiledExecutables)
{
if (string.IsNullOrWhiteSpace(test.Command))
{
_logger.LogWarning("Test {Index} is marked as generated but has no command", test.Index);
return;
}
var args = SplitCommandLine(test.Command);
if (args.Count == 0)
{
_logger.LogWarning("Failed to parse generator command for test {Index}", test.Index);
return;
}
var generatorAlias = args[0];
if (!compiledExecutables.TryGetValue(generatorAlias, out var generatorPath))
{
_logger.LogWarning("Generator {Generator} not found for test {Index}", generatorAlias, test.Index);
return;
}
Directory.CreateDirectory(Path.GetDirectoryName(inputFullPath)!);
_logger.LogInformation("Generating test {Index} using {Generator} {Arguments}",
test.Index,
generatorAlias,
string.Join(' ', args.Skip(1)));
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = generatorPath,
WorkingDirectory = packageRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
foreach (var argument in args.Skip(1))
{
process.StartInfo.ArgumentList.Add(argument);
}
process.Start();
var stderrTask = process.StandardError.ReadToEndAsync();
await using (var outputStream = File.Create(inputFullPath))
{
await process.StandardOutput.BaseStream.CopyToAsync(outputStream);
}
await process.WaitForExitAsync();
var stderr = await stderrTask;
if (process.ExitCode != 0)
{
throw new InvalidOperationException($"Generator '{generatorAlias}' failed for test {test.Index}: {stderr}");
}
if (!string.IsNullOrWhiteSpace(stderr))
{
_logger.LogDebug("Generator '{Generator}' stderr for test {Index}: {Message}", generatorAlias, test.Index, stderr.Trim());
}
}
private async Task ValidateInputAsync(string validatorPath, string inputFilePath, int testIndex)
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = validatorPath,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
var inputContent = await File.ReadAllTextAsync(inputFilePath);
var normalizedInput = inputContent.Replace("\r\n", "\n").Replace("\r", "\n");
await process.StandardInput.WriteAsync(normalizedInput);
process.StandardInput.Close();
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
var stdout = await stdoutTask;
var stderr = await stderrTask;
if (process.ExitCode != 0)
{
var message = string.IsNullOrWhiteSpace(stderr) ? stdout : stderr;
throw new InvalidOperationException($"Validator rejected test {testIndex}: {message?.Trim()}");
}
if (!string.IsNullOrWhiteSpace(stdout))
{
_logger.LogDebug("Validator output for test {Index}: {Message}", testIndex, stdout.Trim());
}
}
private static IEnumerable<string> GetIncludeDirectories(string packageRoot, string sourceFullPath)
{
var sourceDirectory = Path.GetDirectoryName(sourceFullPath);
var filesDirectory = Path.Combine(packageRoot, "files");
return new[] { sourceDirectory, filesDirectory, packageRoot }
.Where(dir => !string.IsNullOrWhiteSpace(dir))
.Select(dir => dir!)
.Where(Directory.Exists)
.Distinct();
}
private static string NormalizeRelativePath(string relativePath)
{
return relativePath
.Replace("\\", Path.DirectorySeparatorChar.ToString(CultureInfo.InvariantCulture))
.Replace("/", Path.DirectorySeparatorChar.ToString(CultureInfo.InvariantCulture));
}
private static string FormatPolygonPattern(string pattern, int index)
{
if (string.IsNullOrWhiteSpace(pattern))
{
return index.ToString(CultureInfo.InvariantCulture);
}
return Regex.Replace(pattern, "%0?(\\d*)d", match =>
{
if (int.TryParse(match.Groups[1].Value, out var width) && width > 0)
{
return index.ToString($"D{width}", CultureInfo.InvariantCulture);
}
return index.ToString(CultureInfo.InvariantCulture);
});
}
private static IReadOnlyList<string> SplitCommandLine(string command)
{
var result = new List<string>();
if (string.IsNullOrWhiteSpace(command))
{
return result;
}
var current = new StringBuilder();
var inQuotes = false;
foreach (var ch in command)
{
if (ch == '"')
{
inQuotes = !inQuotes;
continue;
}
if (char.IsWhiteSpace(ch) && !inQuotes)
{
if (current.Length > 0)
{
result.Add(current.ToString());
current.Clear();
}
}
else
{
current.Append(ch);
}
}
if (current.Length > 0)
{
result.Add(current.ToString());
}
return result;
}
private static bool IsCppAsset(string? type, string sourceFullPath)
{
if (!string.IsNullOrWhiteSpace(type) && type.StartsWith("cpp", StringComparison.OrdinalIgnoreCase))
{
return true;
}
var extension = Path.GetExtension(sourceFullPath);
return extension.Equals(".cpp", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".cc", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".cxx", StringComparison.OrdinalIgnoreCase);
}
private static string? ExtractCppStandard(string? type)
{
if (string.IsNullOrWhiteSpace(type))
{
return null;
}
var normalized = type.ToLowerInvariant();
if (normalized.Contains("++26")) return "26";
if (normalized.Contains("++23")) return "23";
if (normalized.Contains("++20")) return "20";
if (normalized.Contains("++17")) return "17";
if (normalized.Contains("++14")) return "14";
if (normalized.Contains("++11")) return "11";
return null;
}
private string? FindCorrespondingOutputFile(string inputFile)
{
var directory = Path.GetDirectoryName(inputFile)!;
@@ -278,14 +756,16 @@ public class PackageParserService : IPackageParserService
try
{
var checkerSource = await File.ReadAllTextAsync(candidate);
var checkerDir = Path.GetDirectoryName(candidate)!;
var outputPath = Path.Combine(checkerDir, Path.GetFileNameWithoutExtension(candidate));
var includeDirs = GetIncludeDirectories(workingDirectory, candidate);
// Compile checker with C++17 (testlib.h compatible)
var compilationResult = await _cppCompilation.CompileAsync(
checkerSource,
checkerDir,
"17");
// Compile checker with inferred standard (default C++17)
var compilationResult = await _cppCompilation.CompileFileAsync(
candidate,
outputPath,
"17",
includeDirs);
if (!compilationResult.Success)
{

View File

@@ -1,3 +1,6 @@
using System.Globalization;
using System.IO;
using System.Linq;
using System.Xml.Linq;
using LiquidCode.Tester.Common.Models;
@@ -73,7 +76,89 @@ public class PolygonProblemXmlParser
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
// Parse detailed test definitions (if present)
var testsElement = testset.Element("tests");
if (testsElement != null)
{
var index = 1;
foreach (var testElement in testsElement.Elements("test"))
{
var methodValue = testElement.Attribute("method")?.Value ?? "manual";
var method = methodValue.ToLowerInvariant() switch
{
"generated" => PolygonTestMethod.Generated,
"manual" => PolygonTestMethod.Manual,
"script" => PolygonTestMethod.Script,
_ => PolygonTestMethod.Unknown
};
double? points = null;
var pointsAttr = testElement.Attribute("points")?.Value;
if (!string.IsNullOrWhiteSpace(pointsAttr) &&
double.TryParse(pointsAttr, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedPoints))
{
points = parsedPoints;
}
var definition = new PolygonTestDefinition
{
Index = index,
Method = method,
Command = testElement.Attribute("cmd")?.Value,
Group = testElement.Attribute("group")?.Value,
IsSample = bool.TryParse(testElement.Attribute("sample")?.Value, out var sample) && sample,
Points = points,
Comment = testElement.Attribute("comment")?.Value
};
descriptor.Tests.Add(definition);
index++;
}
if (descriptor.TestCount == 0)
{
descriptor.TestCount = descriptor.Tests.Count;
}
}
// Parse auxiliary executables defined in <files><executables>
var filesSection = problem.Element("files");
if (filesSection != null)
{
var executablesSection = filesSection.Element("executables");
if (executablesSection != null)
{
foreach (var executableElement in executablesSection.Elements("executable"))
{
var sourceElement = executableElement.Element("source");
if (sourceElement == null)
{
continue;
}
var sourcePath = sourceElement.Attribute("path")?.Value;
if (string.IsNullOrWhiteSpace(sourcePath))
{
continue;
}
var binaryPath = executableElement.Element("binary")?.Attribute("path")?.Value;
var name = !string.IsNullOrWhiteSpace(binaryPath)
? Path.GetFileNameWithoutExtension(binaryPath)
: Path.GetFileNameWithoutExtension(sourcePath);
descriptor.Executables.Add(new PolygonExecutableDescriptor
{
Name = name,
SourcePath = sourcePath,
BinaryPath = binaryPath,
Type = sourceElement.Attribute("type")?.Value
});
}
}
}
// Parse assets: solutions, checker, validators
var assets = problem.Element("assets");
if (assets != null)
{
@@ -108,6 +193,44 @@ public class PolygonProblemXmlParser
_logger.LogWarning("No main or accepted solution found in problem.xml");
}
}
var checkerElement = assets.Element("checker");
if (checkerElement != null)
{
var checkerSource = checkerElement.Element("source");
if (checkerSource != null)
{
descriptor.Checker = new PolygonCheckerDescriptor
{
SourcePath = checkerSource.Attribute("path")?.Value ?? string.Empty,
Type = checkerSource.Attribute("type")?.Value,
BinaryPath = checkerElement.Element("binary")?.Attribute("path")?.Value,
CopyPath = checkerElement.Element("copy")?.Attribute("path")?.Value
};
}
}
var validatorsSection = assets.Element("validators");
if (validatorsSection != null)
{
foreach (var validatorElement in validatorsSection.Elements("validator"))
{
var validatorSource = validatorElement.Element("source");
if (validatorSource == null)
{
continue;
}
var validator = new PolygonValidatorDescriptor
{
SourcePath = validatorSource.Attribute("path")?.Value ?? string.Empty,
Type = validatorSource.Attribute("type")?.Value,
BinaryPath = validatorElement.Element("binary")?.Attribute("path")?.Value
};
descriptor.Validators.Add(validator);
}
}
}
_logger.LogInformation(
@@ -138,4 +261,62 @@ public class PolygonProblemDescriptor
public string AnswerPathPattern { get; set; } = "tests/%02d.a";
public string? MainSolutionPath { get; set; }
public string? MainSolutionType { get; set; }
public List<PolygonTestDefinition> Tests { get; } = new();
public List<PolygonExecutableDescriptor> Executables { get; } = new();
public PolygonCheckerDescriptor? Checker { get; set; }
public List<PolygonValidatorDescriptor> Validators { get; } = new();
}
/// <summary>
/// Represents a single test entry defined in problem.xml
/// </summary>
public class PolygonTestDefinition
{
public int Index { get; set; }
public PolygonTestMethod Method { get; set; } = PolygonTestMethod.Manual;
public string? Command { get; set; }
public string? Group { get; set; }
public bool IsSample { get; set; }
public double? Points { get; set; }
public string? Comment { get; set; }
}
public enum PolygonTestMethod
{
Manual,
Generated,
Script,
Unknown
}
/// <summary>
/// Represents additional executables (generators, validators, printers) declared in problem.xml
/// </summary>
public class PolygonExecutableDescriptor
{
public string Name { get; set; } = string.Empty;
public string SourcePath { get; set; } = string.Empty;
public string? BinaryPath { get; set; }
public string? Type { get; set; }
}
/// <summary>
/// Represents checker information declared in problem.xml
/// </summary>
public class PolygonCheckerDescriptor
{
public string SourcePath { get; set; } = string.Empty;
public string? BinaryPath { get; set; }
public string? CopyPath { get; set; }
public string? Type { get; set; }
}
/// <summary>
/// Represents validator information declared in problem.xml
/// </summary>
public class PolygonValidatorDescriptor
{
public string SourcePath { get; set; } = string.Empty;
public string? BinaryPath { get; set; }
public string? Type { get; set; }
}

View File

@@ -64,7 +64,7 @@ public class TestingService : ITestingService
_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);
CleanupWorkingDirectory(package.ExtractionRoot ?? package.WorkingDirectory);
return;
}
@@ -113,7 +113,7 @@ public class TestingService : ITestingService
_logger.LogWarning("Time limit exceeded on test {TestNumber}", testCase.Number);
await SendStatusAsync(request, State.Done, ErrorCode.TimeLimitError,
$"Time limit exceeded on test {testCase.Number}", testCase.Number, package.TestCases.Count);
CleanupWorkingDirectory(package.WorkingDirectory);
CleanupWorkingDirectory(package.ExtractionRoot ?? package.WorkingDirectory);
return;
}
@@ -122,7 +122,7 @@ public class TestingService : ITestingService
_logger.LogWarning("Memory limit exceeded on test {TestNumber}", testCase.Number);
await SendStatusAsync(request, State.Done, ErrorCode.MemoryError,
$"Memory limit exceeded on test {testCase.Number}", testCase.Number, package.TestCases.Count);
CleanupWorkingDirectory(package.WorkingDirectory);
CleanupWorkingDirectory(package.ExtractionRoot ?? package.WorkingDirectory);
return;
}
@@ -131,7 +131,7 @@ public class TestingService : ITestingService
_logger.LogWarning("Runtime error on test {TestNumber}: {Error}", testCase.Number, executionResult.ErrorMessage);
await SendStatusAsync(request, State.Done, ErrorCode.RuntimeError,
$"Runtime error on test {testCase.Number}: {executionResult.ErrorMessage}", testCase.Number, package.TestCases.Count);
CleanupWorkingDirectory(package.WorkingDirectory);
CleanupWorkingDirectory(package.ExtractionRoot ?? package.WorkingDirectory);
return;
}
@@ -147,7 +147,7 @@ public class TestingService : ITestingService
_logger.LogWarning("Wrong answer on test {TestNumber}", testCase.Number);
await SendStatusAsync(request, State.Done, ErrorCode.IncorrectAnswer,
$"Wrong answer on test {testCase.Number}", testCase.Number, package.TestCases.Count);
CleanupWorkingDirectory(package.WorkingDirectory);
CleanupWorkingDirectory(package.ExtractionRoot ?? package.WorkingDirectory);
return;
}
@@ -160,7 +160,7 @@ public class TestingService : ITestingService
"All tests passed", package.TestCases.Count, package.TestCases.Count);
// Cleanup
CleanupWorkingDirectory(package.WorkingDirectory);
CleanupWorkingDirectory(package.ExtractionRoot ?? package.WorkingDirectory);
}
catch (Exception ex)
{