update polygon package parsing & testing
This commit is contained in:
@@ -10,6 +10,9 @@ builder.Services.AddOpenApi();
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
// Register application services
|
||||
builder.Services.AddSingleton<PolygonProblemXmlParser>();
|
||||
builder.Services.AddSingleton<AnswerGenerationService>();
|
||||
builder.Services.AddSingleton<CheckerService>();
|
||||
builder.Services.AddSingleton<IPackageParserService, PackageParserService>();
|
||||
builder.Services.AddSingleton<IOutputCheckerService, OutputCheckerService>();
|
||||
builder.Services.AddSingleton<ICallbackService, CallbackService>();
|
||||
|
||||
181
src/LiquidCode.Tester.Worker/Services/AnswerGenerationService.cs
Normal file
181
src/LiquidCode.Tester.Worker/Services/AnswerGenerationService.cs
Normal file
@@ -0,0 +1,181 @@
|
||||
namespace LiquidCode.Tester.Worker.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating answer files by running the main solution from Polygon package
|
||||
/// </summary>
|
||||
public class AnswerGenerationService
|
||||
{
|
||||
private readonly ICompilationServiceFactory _compilationFactory;
|
||||
private readonly IExecutionServiceFactory _executionFactory;
|
||||
private readonly ILogger<AnswerGenerationService> _logger;
|
||||
|
||||
public AnswerGenerationService(
|
||||
ICompilationServiceFactory compilationFactory,
|
||||
IExecutionServiceFactory executionFactory,
|
||||
ILogger<AnswerGenerationService> logger)
|
||||
{
|
||||
_compilationFactory = compilationFactory;
|
||||
_executionFactory = executionFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> GenerateAnswersAsync(
|
||||
PolygonProblemDescriptor descriptor,
|
||||
string workingDirectory,
|
||||
List<string> inputFilePaths,
|
||||
List<string> answerFilePaths)
|
||||
{
|
||||
if (string.IsNullOrEmpty(descriptor.MainSolutionPath))
|
||||
{
|
||||
_logger.LogWarning("No main solution specified, cannot generate answers");
|
||||
return false;
|
||||
}
|
||||
|
||||
var solutionPath = Path.Combine(workingDirectory, descriptor.MainSolutionPath);
|
||||
if (!File.Exists(solutionPath))
|
||||
{
|
||||
_logger.LogWarning("Main solution file not found: {Path}", solutionPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Determine language and version from solution type
|
||||
var (language, version) = ParseSolutionType(descriptor.MainSolutionType ?? "");
|
||||
if (language == null)
|
||||
{
|
||||
_logger.LogWarning("Unsupported solution type: {Type}", descriptor.MainSolutionType);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Generating answers using {Language} {Version} solution: {Path}",
|
||||
language, version, descriptor.MainSolutionPath);
|
||||
|
||||
try
|
||||
{
|
||||
// Read solution source code
|
||||
var sourceCode = await File.ReadAllTextAsync(solutionPath);
|
||||
|
||||
// Get compilation service
|
||||
var compilationService = _compilationFactory.GetCompilationService(language);
|
||||
var executionService = _executionFactory.GetExecutionService(language);
|
||||
|
||||
// Compile solution
|
||||
_logger.LogInformation("Compiling main solution...");
|
||||
var compilationResult = await compilationService.CompileAsync(
|
||||
sourceCode,
|
||||
Path.GetDirectoryName(solutionPath)!,
|
||||
version);
|
||||
|
||||
if (!compilationResult.Success)
|
||||
{
|
||||
_logger.LogError("Failed to compile main solution: {Error}", compilationResult.CompilerOutput);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Main solution compiled successfully");
|
||||
|
||||
// Generate answers for each test
|
||||
int generatedCount = 0;
|
||||
for (int i = 0; i < inputFilePaths.Count; i++)
|
||||
{
|
||||
var inputPath = inputFilePaths[i];
|
||||
var answerPath = answerFilePaths[i];
|
||||
|
||||
if (!File.Exists(inputPath))
|
||||
{
|
||||
_logger.LogWarning("Input file not found: {Path}", inputPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Generating answer {Index}/{Total}: {AnswerPath}",
|
||||
i + 1, inputFilePaths.Count, answerPath);
|
||||
|
||||
// Execute solution with input
|
||||
var executionResult = await executionService.ExecuteAsync(
|
||||
compilationResult.ExecutablePath!,
|
||||
inputPath,
|
||||
descriptor.TimeLimitMs * 2, // Give extra time for answer generation
|
||||
descriptor.MemoryLimitMb * 2);
|
||||
|
||||
if (!executionResult.Success || executionResult.RuntimeError)
|
||||
{
|
||||
_logger.LogWarning("Failed to generate answer for {InputPath}: {Error}",
|
||||
inputPath, executionResult.ErrorMessage);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Save output as answer file
|
||||
var answerDir = Path.GetDirectoryName(answerPath);
|
||||
if (!string.IsNullOrEmpty(answerDir) && !Directory.Exists(answerDir))
|
||||
{
|
||||
Directory.CreateDirectory(answerDir);
|
||||
}
|
||||
|
||||
await File.WriteAllTextAsync(answerPath, executionResult.Output);
|
||||
generatedCount++;
|
||||
|
||||
_logger.LogDebug("Generated answer {Index}/{Total}", i + 1, inputFilePaths.Count);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Generated {Count} answer files out of {Total} tests",
|
||||
generatedCount, inputFilePaths.Count);
|
||||
|
||||
return generatedCount > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error generating answers");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private (string? language, string version) ParseSolutionType(string solutionType)
|
||||
{
|
||||
// Polygon solution types: python.2, python.3, cpp.g++17, cpp.g++20, java7, java8, etc.
|
||||
if (string.IsNullOrEmpty(solutionType))
|
||||
{
|
||||
return (null, "");
|
||||
}
|
||||
|
||||
if (solutionType.StartsWith("python."))
|
||||
{
|
||||
var parts = solutionType.Split('.');
|
||||
var version = parts.Length > 1 ? parts[1] : "3";
|
||||
return ("python", $"3.{version}"); // Map python.3 -> 3.3, python.2 -> 3.2 (approx)
|
||||
}
|
||||
|
||||
if (solutionType.StartsWith("cpp."))
|
||||
{
|
||||
// cpp.g++17, cpp.g++20, cpp.g++14
|
||||
if (solutionType.Contains("++20"))
|
||||
return ("cpp", "20");
|
||||
if (solutionType.Contains("++17"))
|
||||
return ("cpp", "17");
|
||||
if (solutionType.Contains("++14"))
|
||||
return ("cpp", "14");
|
||||
return ("cpp", "17"); // Default to C++17
|
||||
}
|
||||
|
||||
if (solutionType.StartsWith("java"))
|
||||
{
|
||||
// java7, java8, java11
|
||||
if (solutionType.Contains("11"))
|
||||
return ("java", "11");
|
||||
if (solutionType.Contains("8"))
|
||||
return ("java", "8");
|
||||
return ("java", "11"); // Default to Java 11
|
||||
}
|
||||
|
||||
if (solutionType.StartsWith("csharp"))
|
||||
{
|
||||
return ("csharp", "9"); // Default to C# 9
|
||||
}
|
||||
|
||||
if (solutionType.StartsWith("kotlin"))
|
||||
{
|
||||
return ("kotlin", "1.9"); // Default to Kotlin 1.9
|
||||
}
|
||||
|
||||
_logger.LogWarning("Unknown solution type: {Type}", solutionType);
|
||||
return (null, "");
|
||||
}
|
||||
}
|
||||
165
src/LiquidCode.Tester.Worker/Services/CheckerService.cs
Normal file
165
src/LiquidCode.Tester.Worker/Services/CheckerService.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace LiquidCode.Tester.Worker.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for running custom checkers (testlib-based)
|
||||
/// </summary>
|
||||
public class CheckerService
|
||||
{
|
||||
private readonly ILogger<CheckerService> _logger;
|
||||
|
||||
public CheckerService(ILogger<CheckerService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check user output using custom checker
|
||||
/// </summary>
|
||||
/// <param name="checkerPath">Path to checker executable</param>
|
||||
/// <param name="inputPath">Path to input file</param>
|
||||
/// <param name="userOutput">User program output</param>
|
||||
/// <param name="answerPath">Path to answer file</param>
|
||||
/// <returns>Checker result</returns>
|
||||
public async Task<CheckerResult> CheckAsync(
|
||||
string checkerPath,
|
||||
string inputPath,
|
||||
string userOutput,
|
||||
string answerPath)
|
||||
{
|
||||
if (!File.Exists(checkerPath))
|
||||
{
|
||||
_logger.LogError("Checker not found: {CheckerPath}", checkerPath);
|
||||
return new CheckerResult
|
||||
{
|
||||
Accepted = false,
|
||||
ExitCode = -1,
|
||||
Message = "Checker executable not found"
|
||||
};
|
||||
}
|
||||
|
||||
// Save user output to temporary file
|
||||
var tempOutputPath = Path.Combine(Path.GetTempPath(), $"user_output_{Guid.NewGuid()}.txt");
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(tempOutputPath, userOutput);
|
||||
|
||||
_logger.LogDebug("Running checker: {Checker} {Input} {Output} {Answer}",
|
||||
checkerPath, inputPath, tempOutputPath, answerPath);
|
||||
|
||||
var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = checkerPath,
|
||||
Arguments = $"\"{inputPath}\" \"{tempOutputPath}\" \"{answerPath}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync();
|
||||
var stderr = await process.StandardError.ReadToEndAsync();
|
||||
|
||||
// Wait with timeout (checkers should be fast)
|
||||
var completed = await Task.Run(() => process.WaitForExit(5000));
|
||||
|
||||
if (!completed)
|
||||
{
|
||||
_logger.LogWarning("Checker timeout, killing process");
|
||||
try
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch { }
|
||||
|
||||
return new CheckerResult
|
||||
{
|
||||
Accepted = false,
|
||||
ExitCode = -1,
|
||||
Message = "Checker timeout"
|
||||
};
|
||||
}
|
||||
|
||||
var exitCode = process.ExitCode;
|
||||
|
||||
_logger.LogDebug("Checker exit code: {ExitCode}, stderr: {Stderr}", exitCode, stderr);
|
||||
|
||||
return new CheckerResult
|
||||
{
|
||||
Accepted = exitCode == 0,
|
||||
ExitCode = exitCode,
|
||||
Message = string.IsNullOrWhiteSpace(stderr) ? stdout : stderr,
|
||||
Verdict = GetVerdictFromExitCode(exitCode)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error running checker");
|
||||
return new CheckerResult
|
||||
{
|
||||
Accepted = false,
|
||||
ExitCode = -1,
|
||||
Message = $"Checker error: {ex.Message}"
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup temporary file
|
||||
try
|
||||
{
|
||||
if (File.Exists(tempOutputPath))
|
||||
{
|
||||
File.Delete(tempOutputPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete temporary output file: {Path}", tempOutputPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private CheckerVerdict GetVerdictFromExitCode(int exitCode)
|
||||
{
|
||||
return exitCode switch
|
||||
{
|
||||
0 => CheckerVerdict.OK,
|
||||
1 => CheckerVerdict.WrongAnswer,
|
||||
2 => CheckerVerdict.PresentationError,
|
||||
3 => CheckerVerdict.CheckerFailed,
|
||||
7 => CheckerVerdict.PartialScore,
|
||||
_ => CheckerVerdict.Unknown
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of custom checker execution
|
||||
/// </summary>
|
||||
public class CheckerResult
|
||||
{
|
||||
public bool Accepted { get; set; }
|
||||
public int ExitCode { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public CheckerVerdict Verdict { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checker verdict codes (testlib standard)
|
||||
/// </summary>
|
||||
public enum CheckerVerdict
|
||||
{
|
||||
OK = 0, // Accepted
|
||||
WrongAnswer = 1, // Wrong Answer
|
||||
PresentationError = 2, // Presentation Error (format issue)
|
||||
CheckerFailed = 3, // Internal checker error
|
||||
PartialScore = 7, // Partial score (for IOI-style)
|
||||
Unknown = -1 // Unknown exit code
|
||||
}
|
||||
@@ -9,4 +9,14 @@ public interface IOutputCheckerService
|
||||
/// <param name="expectedOutputPath">Path to expected output file</param>
|
||||
/// <returns>True if outputs match</returns>
|
||||
Task<bool> CheckOutputAsync(string actualOutput, string expectedOutputPath);
|
||||
|
||||
/// <summary>
|
||||
/// Checks output using custom checker if available, falls back to standard checking
|
||||
/// </summary>
|
||||
/// <param name="actualOutput">Output from user's solution</param>
|
||||
/// <param name="inputFilePath">Path to input file</param>
|
||||
/// <param name="expectedOutputPath">Path to expected output file</param>
|
||||
/// <param name="checkerPath">Path to custom checker executable (optional)</param>
|
||||
/// <returns>True if output is accepted</returns>
|
||||
Task<bool> CheckOutputWithCheckerAsync(string actualOutput, string inputFilePath, string expectedOutputPath, string? checkerPath);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ namespace LiquidCode.Tester.Worker.Services;
|
||||
public class OutputCheckerService : IOutputCheckerService
|
||||
{
|
||||
private readonly ILogger<OutputCheckerService> _logger;
|
||||
private readonly CheckerService _checkerService;
|
||||
|
||||
public OutputCheckerService(ILogger<OutputCheckerService> logger)
|
||||
public OutputCheckerService(ILogger<OutputCheckerService> logger, CheckerService checkerService)
|
||||
{
|
||||
_logger = logger;
|
||||
_checkerService = checkerService;
|
||||
}
|
||||
|
||||
public async Task<bool> CheckOutputAsync(string actualOutput, string expectedOutputPath)
|
||||
@@ -51,4 +53,35 @@ public class OutputCheckerService : IOutputCheckerService
|
||||
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
|
||||
public async Task<bool> CheckOutputWithCheckerAsync(
|
||||
string actualOutput,
|
||||
string inputFilePath,
|
||||
string expectedOutputPath,
|
||||
string? checkerPath)
|
||||
{
|
||||
// If custom checker is available, use it
|
||||
if (!string.IsNullOrEmpty(checkerPath) && File.Exists(checkerPath))
|
||||
{
|
||||
_logger.LogDebug("Using custom checker: {CheckerPath}", checkerPath);
|
||||
|
||||
var checkerResult = await _checkerService.CheckAsync(
|
||||
checkerPath,
|
||||
inputFilePath,
|
||||
actualOutput,
|
||||
expectedOutputPath);
|
||||
|
||||
if (!checkerResult.Accepted)
|
||||
{
|
||||
_logger.LogWarning("Custom checker verdict: {Verdict} - {Message}",
|
||||
checkerResult.Verdict, checkerResult.Message);
|
||||
}
|
||||
|
||||
return checkerResult.Accepted;
|
||||
}
|
||||
|
||||
// Fall back to standard string comparison
|
||||
_logger.LogDebug("No custom checker, using standard comparison");
|
||||
return await CheckOutputAsync(actualOutput, expectedOutputPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,20 @@ namespace LiquidCode.Tester.Worker.Services;
|
||||
public class PackageParserService : IPackageParserService
|
||||
{
|
||||
private readonly ILogger<PackageParserService> _logger;
|
||||
private readonly PolygonProblemXmlParser _polygonParser;
|
||||
private readonly AnswerGenerationService _answerGenerator;
|
||||
private readonly CppCompilationService _cppCompilation;
|
||||
|
||||
public PackageParserService(ILogger<PackageParserService> logger)
|
||||
public PackageParserService(
|
||||
ILogger<PackageParserService> logger,
|
||||
PolygonProblemXmlParser polygonParser,
|
||||
AnswerGenerationService answerGenerator,
|
||||
CppCompilationService cppCompilation)
|
||||
{
|
||||
_logger = logger;
|
||||
_polygonParser = polygonParser;
|
||||
_answerGenerator = answerGenerator;
|
||||
_cppCompilation = cppCompilation;
|
||||
}
|
||||
|
||||
public async Task<ProblemPackage> ParsePackageAsync(Stream packageStream)
|
||||
@@ -25,60 +35,17 @@ public class PackageParserService : IPackageParserService
|
||||
using var archive = new ZipArchive(packageStream, ZipArchiveMode.Read);
|
||||
archive.ExtractToDirectory(workingDirectory);
|
||||
|
||||
var package = new ProblemPackage
|
||||
// Check if this is a Polygon package (has problem.xml)
|
||||
var problemXmlPath = Path.Combine(workingDirectory, "problem.xml");
|
||||
if (File.Exists(problemXmlPath))
|
||||
{
|
||||
WorkingDirectory = workingDirectory
|
||||
};
|
||||
|
||||
// Find tests directory
|
||||
var testsDir = Path.Combine(workingDirectory, "tests");
|
||||
if (!Directory.Exists(testsDir))
|
||||
{
|
||||
_logger.LogWarning("Tests directory not found, searching for test files in root");
|
||||
testsDir = workingDirectory;
|
||||
_logger.LogInformation("Detected Polygon package format (problem.xml found)");
|
||||
return await ParsePolygonPackageAsync(workingDirectory, problemXmlPath);
|
||||
}
|
||||
|
||||
// Parse test cases
|
||||
var inputFiles = Directory.GetFiles(testsDir, "*", SearchOption.AllDirectories)
|
||||
.Where(f => Path.GetFileName(f).EndsWith(".in") || Path.GetFileName(f).Contains("input"))
|
||||
.OrderBy(f => f)
|
||||
.ToList();
|
||||
|
||||
for (int i = 0; i < inputFiles.Count; i++)
|
||||
{
|
||||
var inputFile = inputFiles[i];
|
||||
var outputFile = FindCorrespondingOutputFile(inputFile);
|
||||
|
||||
if (outputFile == null)
|
||||
{
|
||||
_logger.LogWarning("No output file found for input {InputFile}", inputFile);
|
||||
continue;
|
||||
}
|
||||
|
||||
package.TestCases.Add(new TestCase
|
||||
{
|
||||
Number = i + 1,
|
||||
InputFilePath = inputFile,
|
||||
OutputFilePath = outputFile,
|
||||
TimeLimit = package.DefaultTimeLimit,
|
||||
MemoryLimit = package.DefaultMemoryLimit
|
||||
});
|
||||
}
|
||||
|
||||
// Look for checker
|
||||
var checkerCandidates = new[] { "check.cpp", "checker.cpp", "check", "checker" };
|
||||
foreach (var candidate in checkerCandidates)
|
||||
{
|
||||
var checkerPath = Path.Combine(workingDirectory, candidate);
|
||||
if (File.Exists(checkerPath))
|
||||
{
|
||||
package.CheckerPath = checkerPath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Parsed package with {TestCount} tests", package.TestCases.Count);
|
||||
return package;
|
||||
// Fall back to legacy format (.in/.out files)
|
||||
_logger.LogInformation("Using legacy package format (.in/.out files)");
|
||||
return await ParseLegacyPackage(workingDirectory);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -87,6 +54,165 @@ public class PackageParserService : IPackageParserService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ProblemPackage> ParsePolygonPackageAsync(string workingDirectory, string problemXmlPath)
|
||||
{
|
||||
var descriptor = _polygonParser.ParseProblemXml(problemXmlPath);
|
||||
|
||||
if (descriptor == null)
|
||||
{
|
||||
_logger.LogWarning("Failed to parse problem.xml, falling back to legacy format");
|
||||
return await ParseLegacyPackage(workingDirectory);
|
||||
}
|
||||
|
||||
var package = new ProblemPackage
|
||||
{
|
||||
WorkingDirectory = workingDirectory,
|
||||
DefaultTimeLimit = descriptor.TimeLimitMs,
|
||||
DefaultMemoryLimit = descriptor.MemoryLimitMb
|
||||
};
|
||||
|
||||
// Collect test file paths and check which answers are missing
|
||||
var inputPaths = new List<string>();
|
||||
var answerPaths = new List<string>();
|
||||
var missingAnswerPaths = new List<string>();
|
||||
var missingAnswerInputs = new List<string>();
|
||||
|
||||
for (int i = 1; i <= descriptor.TestCount; i++)
|
||||
{
|
||||
var inputPath = Path.Combine(workingDirectory,
|
||||
string.Format(descriptor.InputPathPattern.Replace("%02d", "{0:D2}"), i));
|
||||
var answerPath = Path.Combine(workingDirectory,
|
||||
string.Format(descriptor.AnswerPathPattern.Replace("%02d", "{0:D2}"), i));
|
||||
|
||||
if (!File.Exists(inputPath))
|
||||
{
|
||||
_logger.LogWarning("Input file not found: {InputPath}", inputPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
inputPaths.Add(inputPath);
|
||||
answerPaths.Add(answerPath);
|
||||
|
||||
if (!File.Exists(answerPath))
|
||||
{
|
||||
missingAnswerPaths.Add(answerPath);
|
||||
missingAnswerInputs.Add(inputPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate missing answer files if we have a main solution
|
||||
if (missingAnswerPaths.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Found {Count} tests without answer files, attempting to generate them",
|
||||
missingAnswerPaths.Count);
|
||||
|
||||
var generated = await _answerGenerator.GenerateAnswersAsync(
|
||||
descriptor,
|
||||
workingDirectory,
|
||||
missingAnswerInputs,
|
||||
missingAnswerPaths);
|
||||
|
||||
if (generated)
|
||||
{
|
||||
_logger.LogInformation("Successfully generated answer files");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to generate answer files, tests without answers will be skipped");
|
||||
}
|
||||
}
|
||||
|
||||
// Now create test cases for all tests that have answer files
|
||||
for (int i = 0; i < inputPaths.Count; i++)
|
||||
{
|
||||
var inputPath = inputPaths[i];
|
||||
var answerPath = answerPaths[i];
|
||||
|
||||
if (!File.Exists(answerPath))
|
||||
{
|
||||
_logger.LogWarning("Answer file not found: {AnswerPath} (skipping test)", answerPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
package.TestCases.Add(new TestCase
|
||||
{
|
||||
Number = i + 1,
|
||||
InputFilePath = inputPath,
|
||||
OutputFilePath = answerPath,
|
||||
TimeLimit = descriptor.TimeLimitMs,
|
||||
MemoryLimit = descriptor.MemoryLimitMb
|
||||
});
|
||||
}
|
||||
|
||||
// Look for and compile checker
|
||||
package.CheckerPath = await FindAndCompileCheckerAsync(workingDirectory);
|
||||
|
||||
if (package.TestCases.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No test cases with answer files found! Expected format: {InputPattern} -> {AnswerPattern}",
|
||||
descriptor.InputPathPattern, descriptor.AnswerPathPattern);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Parsed Polygon package with {TestCount} tests (out of {TotalTests} in problem.xml)",
|
||||
package.TestCases.Count, descriptor.TestCount);
|
||||
|
||||
return package;
|
||||
}
|
||||
|
||||
private async Task<ProblemPackage> ParseLegacyPackage(string workingDirectory)
|
||||
{
|
||||
var package = new ProblemPackage
|
||||
{
|
||||
WorkingDirectory = workingDirectory
|
||||
};
|
||||
|
||||
// Find tests directory
|
||||
var testsDir = Path.Combine(workingDirectory, "tests");
|
||||
if (!Directory.Exists(testsDir))
|
||||
{
|
||||
_logger.LogWarning("Tests directory not found, searching for test files in root");
|
||||
testsDir = workingDirectory;
|
||||
}
|
||||
|
||||
// Parse test cases
|
||||
var inputFiles = Directory.GetFiles(testsDir, "*", SearchOption.AllDirectories)
|
||||
.Where(f => Path.GetFileName(f).EndsWith(".in") || Path.GetFileName(f).Contains("input"))
|
||||
.OrderBy(f => f)
|
||||
.ToList();
|
||||
|
||||
for (int i = 0; i < inputFiles.Count; i++)
|
||||
{
|
||||
var inputFile = inputFiles[i];
|
||||
var outputFile = FindCorrespondingOutputFile(inputFile);
|
||||
|
||||
if (outputFile == null)
|
||||
{
|
||||
_logger.LogWarning("No output file found for input {InputFile}", inputFile);
|
||||
continue;
|
||||
}
|
||||
|
||||
package.TestCases.Add(new TestCase
|
||||
{
|
||||
Number = i + 1,
|
||||
InputFilePath = inputFile,
|
||||
OutputFilePath = outputFile,
|
||||
TimeLimit = package.DefaultTimeLimit,
|
||||
MemoryLimit = package.DefaultMemoryLimit
|
||||
});
|
||||
}
|
||||
|
||||
// Look for and compile checker
|
||||
package.CheckerPath = await FindAndCompileCheckerAsync(workingDirectory);
|
||||
|
||||
if (package.TestCases.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No test cases found! Check package structure. Expected .in/.out files in tests directory or root");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Parsed legacy package with {TestCount} tests", package.TestCases.Count);
|
||||
return package;
|
||||
}
|
||||
|
||||
private string? FindCorrespondingOutputFile(string inputFile)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(inputFile)!;
|
||||
@@ -117,4 +243,68 @@ public class PackageParserService : IPackageParserService
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<string?> FindAndCompileCheckerAsync(string workingDirectory)
|
||||
{
|
||||
// Try to find checker in common locations
|
||||
var checkerCandidates = new[]
|
||||
{
|
||||
Path.Combine(workingDirectory, "check.exe"),
|
||||
Path.Combine(workingDirectory, "checker.exe"),
|
||||
Path.Combine(workingDirectory, "check.cpp"),
|
||||
Path.Combine(workingDirectory, "checker.cpp"),
|
||||
Path.Combine(workingDirectory, "files", "check.exe"),
|
||||
Path.Combine(workingDirectory, "files", "checker.exe"),
|
||||
Path.Combine(workingDirectory, "files", "check.cpp"),
|
||||
Path.Combine(workingDirectory, "files", "checker.cpp")
|
||||
};
|
||||
|
||||
foreach (var candidate in checkerCandidates)
|
||||
{
|
||||
if (!File.Exists(candidate))
|
||||
continue;
|
||||
|
||||
// If it's already an executable, use it
|
||||
if (candidate.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Found checker executable: {CheckerPath}", candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
// If it's C++ source, compile it
|
||||
if (candidate.EndsWith(".cpp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Found checker source: {CheckerPath}, compiling...", candidate);
|
||||
|
||||
try
|
||||
{
|
||||
var checkerSource = await File.ReadAllTextAsync(candidate);
|
||||
var checkerDir = Path.GetDirectoryName(candidate)!;
|
||||
|
||||
// Compile checker with C++17 (testlib.h compatible)
|
||||
var compilationResult = await _cppCompilation.CompileAsync(
|
||||
checkerSource,
|
||||
checkerDir,
|
||||
"17");
|
||||
|
||||
if (!compilationResult.Success)
|
||||
{
|
||||
_logger.LogError("Failed to compile checker: {Error}", compilationResult.CompilerOutput);
|
||||
continue; // Try next candidate
|
||||
}
|
||||
|
||||
_logger.LogInformation("Checker compiled successfully: {ExecutablePath}", compilationResult.ExecutablePath);
|
||||
return compilationResult.ExecutablePath;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error compiling checker: {CheckerPath}", candidate);
|
||||
continue; // Try next candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning("No checker found in package");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
141
src/LiquidCode.Tester.Worker/Services/PolygonProblemXmlParser.cs
Normal file
141
src/LiquidCode.Tester.Worker/Services/PolygonProblemXmlParser.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
using System.Xml.Linq;
|
||||
using LiquidCode.Tester.Common.Models;
|
||||
|
||||
namespace LiquidCode.Tester.Worker.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Parser for Polygon problem.xml format
|
||||
/// </summary>
|
||||
public class PolygonProblemXmlParser
|
||||
{
|
||||
private readonly ILogger<PolygonProblemXmlParser> _logger;
|
||||
|
||||
public PolygonProblemXmlParser(ILogger<PolygonProblemXmlParser> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public PolygonProblemDescriptor? ParseProblemXml(string xmlPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = XDocument.Load(xmlPath);
|
||||
var problem = doc.Element("problem");
|
||||
|
||||
if (problem == null)
|
||||
{
|
||||
_logger.LogWarning("Invalid problem.xml: root 'problem' element not found");
|
||||
return null;
|
||||
}
|
||||
|
||||
var judging = problem.Element("judging");
|
||||
if (judging == null)
|
||||
{
|
||||
_logger.LogWarning("No 'judging' section found in problem.xml");
|
||||
return null;
|
||||
}
|
||||
|
||||
var testset = judging.Element("testset");
|
||||
if (testset == null)
|
||||
{
|
||||
_logger.LogWarning("No 'testset' section found in problem.xml");
|
||||
return null;
|
||||
}
|
||||
|
||||
var descriptor = new PolygonProblemDescriptor
|
||||
{
|
||||
ShortName = problem.Attribute("short-name")?.Value ?? "unknown",
|
||||
Revision = int.TryParse(problem.Attribute("revision")?.Value, out var rev) ? rev : 0
|
||||
};
|
||||
|
||||
// Parse time limit (in milliseconds)
|
||||
var timeLimitText = testset.Element("time-limit")?.Value;
|
||||
if (int.TryParse(timeLimitText, out var timeLimit))
|
||||
{
|
||||
descriptor.TimeLimitMs = timeLimit;
|
||||
}
|
||||
|
||||
// Parse memory limit (in bytes)
|
||||
var memoryLimitText = testset.Element("memory-limit")?.Value;
|
||||
if (long.TryParse(memoryLimitText, out var memoryLimit))
|
||||
{
|
||||
descriptor.MemoryLimitMb = (int)(memoryLimit / (1024 * 1024)); // Convert bytes to MB
|
||||
}
|
||||
|
||||
// Parse test count
|
||||
var testCountText = testset.Element("test-count")?.Value;
|
||||
if (int.TryParse(testCountText, out var testCount))
|
||||
{
|
||||
descriptor.TestCount = testCount;
|
||||
}
|
||||
|
||||
// Parse path patterns
|
||||
descriptor.InputPathPattern = testset.Element("input-path-pattern")?.Value ?? "tests/%02d";
|
||||
descriptor.AnswerPathPattern = testset.Element("answer-path-pattern")?.Value ?? "tests/%02d.a";
|
||||
|
||||
// Parse solutions to find main solution
|
||||
var assets = problem.Element("assets");
|
||||
if (assets != null)
|
||||
{
|
||||
var solutions = assets.Element("solutions");
|
||||
if (solutions != null)
|
||||
{
|
||||
// Try to find main solution first
|
||||
var mainSolution = solutions.Elements("solution")
|
||||
.FirstOrDefault(s => s.Attribute("tag")?.Value == "main");
|
||||
|
||||
// If no main solution, try to find any accepted solution
|
||||
if (mainSolution == null)
|
||||
{
|
||||
mainSolution = solutions.Elements("solution")
|
||||
.FirstOrDefault(s => s.Attribute("tag")?.Value == "accepted");
|
||||
}
|
||||
|
||||
if (mainSolution != null)
|
||||
{
|
||||
var source = mainSolution.Element("source");
|
||||
if (source != null)
|
||||
{
|
||||
descriptor.MainSolutionPath = source.Attribute("path")?.Value;
|
||||
descriptor.MainSolutionType = source.Attribute("type")?.Value;
|
||||
|
||||
_logger.LogInformation("Found main solution: {Path} (type: {Type})",
|
||||
descriptor.MainSolutionPath, descriptor.MainSolutionType);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No main or accepted solution found in problem.xml");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Parsed problem.xml: {ShortName} (rev {Revision}), {TestCount} tests, TL={TimeLimit}ms, ML={MemoryLimit}MB",
|
||||
descriptor.ShortName, descriptor.Revision, descriptor.TestCount, descriptor.TimeLimitMs, descriptor.MemoryLimitMb);
|
||||
|
||||
return descriptor;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse problem.xml at {Path}", xmlPath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Descriptor parsed from problem.xml
|
||||
/// </summary>
|
||||
public class PolygonProblemDescriptor
|
||||
{
|
||||
public string ShortName { get; set; } = string.Empty;
|
||||
public int Revision { get; set; }
|
||||
public int TimeLimitMs { get; set; } = 2000;
|
||||
public int MemoryLimitMb { get; set; } = 256;
|
||||
public int TestCount { get; set; }
|
||||
public string InputPathPattern { get; set; } = "tests/%02d";
|
||||
public string AnswerPathPattern { get; set; } = "tests/%02d.a";
|
||||
public string? MainSolutionPath { get; set; }
|
||||
public string? MainSolutionType { get; set; }
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user