Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 192 additions & 2 deletions src/commands/test_command.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,35 @@
"""Test command for competitive programming."""
import click
import concurrent.futures
import tempfile
import sys
import os
import subprocess
import time

from config import load_config
from plugins.cpp_plugin import CppPreprocessor
from plugins.rust_plugin import RustPreprocessor


@click.command(name="test")
@click.command(name="test", context_settings={"ignore_unknown_options": True, "allow_extra_args": True})
@click.argument('args', nargs=-1, type=click.UNPROCESSED)
def test(args):
"""Compile/run one case, or use `test suite` for multi-case runs."""
if args and args[0] == "suite":
suite_cmd.main(args=list(args[1:]), prog_name="test suite", standalone_mode=False)
else:
single_cmd.main(args=list(args), prog_name="test", standalone_mode=False)


@click.command(name="run")
@click.argument('file', type=click.Path(exists=True, resolve_path=True))
@click.option('-i', '--input', 'input_file', type=click.Path(exists=True, resolve_path=True), help='Input file to feed to the program')
@click.option('-e', '--expected', 'expected_file', type=click.Path(exists=True, resolve_path=True), help='Expected output file for comparison')
@click.option('--preprocess', is_flag=True, help='Preprocess the file before compiling')
@click.option('-I', '--include-path', 'include_paths', multiple=True, type=click.Path(exists=True, file_okay=False, resolve_path=True), help='Include paths for preprocessing')
@click.option('--rust', is_flag=True, help='Force Rust mode (auto-detected from .rs extension)')
def test(file, input_file, expected_file, preprocess, include_paths, rust):
def single_cmd(file, input_file, expected_file, preprocess, include_paths, rust):
"""Compile and run C++ or Rust code with optional test input/output comparison."""
# Auto-detect language from file extension
file_ext = os.path.splitext(file)[1].lower()
Expand All @@ -35,6 +47,184 @@ def test(file, input_file, expected_file, preprocess, include_paths, rust):
_test_cpp(file, input_file, expected_file, preprocess, include_paths)


@click.command(name="suite")
@click.argument('file', type=click.Path(exists=True, resolve_path=True))
@click.argument('cases_dir', type=click.Path(exists=True, file_okay=False, resolve_path=True))
@click.option('--pattern', default='*.in', show_default=True, help='Input filename glob inside cases_dir')
@click.option('-j', '--workers', default=1, show_default=True, type=click.IntRange(min=1), help='Parallel case worker count')
@click.option('--timeout', type=float, help='Per-case timeout in seconds (defaults to prepkit_config.yaml test.timeout or 5)')
@click.option('--preprocess', is_flag=True, help='Preprocess the file before compiling')
@click.option('-I', '--include-path', 'include_paths', multiple=True, type=click.Path(exists=True, file_okay=False, resolve_path=True), help='Include paths for preprocessing')
@click.option('--rust', is_flag=True, help='Force Rust mode (auto-detected from .rs extension)')
def suite_cmd(file, cases_dir, pattern, workers, timeout, preprocess, include_paths, rust):
"""Compile once and run an exact-match test suite over *.in/*.out cases."""
config = load_config()
timeout = timeout if timeout is not None else config.get("test", {}).get("timeout", 5)

file_ext = os.path.splitext(file)[1].lower()
is_rust = rust or file_ext == '.rs'
is_cpp = file_ext in ['.cpp', '.cc', '.cxx', '.c++']

if not is_rust and not is_cpp:
click.echo(f"❌ Unsupported file extension: {file_ext}", err=True)
click.echo(" Supported: .cpp, .cc, .cxx, .c++, .rs", err=True)
sys.exit(1)

cases = _discover_suite_cases(cases_dir, pattern)
if not cases:
click.echo(f"❌ No cases found in {cases_dir} matching {pattern} with .out files", err=True)
sys.exit(1)

executable = None
source_file = None
try:
source_file = _prepare_source_for_compile(file, preprocess, include_paths, is_rust, config)
executable = _compile_for_suite(file, source_file, is_rust, config)

click.echo(f"Running {len(cases)} case(s) with {workers} worker(s)...")
results = []
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
futures = [
executor.submit(_run_suite_case, executable, input_file, expected_file, timeout)
for input_file, expected_file in cases
]
for future in concurrent.futures.as_completed(futures):
results.append(future.result())

results.sort(key=lambda result: result["case"])
passed = sum(1 for result in results if result["passed"])

click.echo("\n--- Suite Results ---")
for result in results:
status = "PASS" if result["passed"] else "FAIL"
click.echo(f"{status} {result['case']} ({result['runtime']:.3f}s)")
if not result["passed"] and result["error"]:
click.echo(f" {result['error']}")

click.echo(f"\nSummary: {passed}/{len(results)} passed")
if passed != len(results):
sys.exit(1)
finally:
if preprocess and source_file and os.path.exists(source_file):
os.remove(source_file)
if executable and os.path.exists(executable):
os.remove(executable)


def _discover_suite_cases(cases_dir, pattern):
input_files = sorted(
os.path.join(cases_dir, filename)
for filename in os.listdir(cases_dir)
if _matches_case_pattern(filename, pattern)
)
cases = []
for input_file in input_files:
expected_file = os.path.splitext(input_file)[0] + ".out"
if os.path.exists(expected_file):
cases.append((input_file, expected_file))
return cases


def _matches_case_pattern(filename, pattern):
if pattern == "*.in":
return filename.endswith(".in")
import fnmatch
return fnmatch.fnmatch(filename, pattern)


def _prepare_source_for_compile(file, preprocess, include_paths, is_rust, config):
if not preprocess:
return file

if is_rust:
rust_preprocess_config = config.get("rust_preprocess", {})
config_include_paths = rust_preprocess_config.get("include_paths", [])
preprocessor = RustPreprocessor()
preprocessed_code = preprocessor.preprocess(file, list(config_include_paths) + list(include_paths))
suffix = ".rs"
else:
cpp_preprocess_config = config.get("cpp_preprocess", {})
config_include_paths = cpp_preprocess_config.get("include_paths", [])
preprocessor = CppPreprocessor()
preprocessed_code = preprocessor.preprocess(file, list(config_include_paths) + list(include_paths))
suffix = ".cpp"

with tempfile.NamedTemporaryFile(mode='w', suffix=suffix, delete=False) as tmp:
tmp.write(preprocessed_code)
return tmp.name


def _compile_for_suite(original_file, source_file, is_rust, config):
executable = tempfile.NamedTemporaryFile(delete=False, suffix='.out')
executable.close()

if is_rust:
rust_compile_config = config.get("rust_compile", {})
compiler_edition = rust_compile_config.get("edition", "2021")
compiler_flags = rust_compile_config.get("flags", [])
compile_cmd = ['rustc', source_file, '-o', executable.name, f'--edition={compiler_edition}'] + compiler_flags
else:
cpp_compile_config = config.get("cpp_compile", {})
compiler_std = cpp_compile_config.get("std", "c++17")
compiler_flags = cpp_compile_config.get("flags", [])
compile_cmd = ['g++', source_file, '-o', executable.name, f'-std={compiler_std}'] + compiler_flags

click.echo(f"Compiling {os.path.basename(original_file)}...")
compile_result = subprocess.run(compile_cmd, capture_output=True, text=True)
if compile_result.returncode != 0:
click.echo("❌ Compilation failed", err=True)
click.echo("Compiler output:", err=True)
click.echo(compile_result.stderr, err=True)
if os.path.exists(executable.name):
os.remove(executable.name)
sys.exit(1)

click.echo("✓ Compilation successful")
return executable.name


def _run_suite_case(executable, input_file, expected_file, timeout):
with open(input_file, 'r') as f:
stdin_data = f.read()
with open(expected_file, 'r') as f:
expected_output = f.read()

started = time.perf_counter()
try:
run_result = subprocess.run(
[executable],
input=stdin_data,
capture_output=True,
text=True,
timeout=timeout,
)
runtime = time.perf_counter() - started
except subprocess.TimeoutExpired:
return {
"case": os.path.basename(input_file),
"passed": False,
"runtime": timeout,
"error": f"timed out after {timeout}s",
}

if run_result.returncode != 0:
return {
"case": os.path.basename(input_file),
"passed": False,
"runtime": runtime,
"error": f"runtime error: {run_result.stderr.strip()}",
}

passed = run_result.stdout.strip() == expected_output.strip()
error = "" if passed else "output differs from expected"
return {
"case": os.path.basename(input_file),
"passed": passed,
"runtime": runtime,
"error": error,
}


def _test_cpp(file, input_file, expected_file, preprocess, include_paths):
"""Test C++ code."""
# Load config for defaults
Expand Down
57 changes: 57 additions & 0 deletions tests/test_test_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,63 @@ def test_cpp_still_works(self, tmp_path):
assert "✓ Compilation successful" in result.output
assert "C++ still works!" in result.output

@pytest.mark.skipif(os.system("which g++ > /dev/null 2>&1") != 0, reason="g++ not available")
def test_cpp_suite_all_cases_pass(self, tmp_path):
"""Test parallel exact-match suite execution for C++."""
test_file = tmp_path / "add.cpp"
test_file.write_text("""
#include <iostream>

int main() {
int a, b;
std::cin >> a >> b;
std::cout << a + b << std::endl;
return 0;
}
""")

cases_dir = tmp_path / "cases"
cases_dir.mkdir()
(cases_dir / "001.in").write_text("1 2\n")
(cases_dir / "001.out").write_text("3\n")
(cases_dir / "002.in").write_text("10 20\n")
(cases_dir / "002.out").write_text("30\n")

runner = CliRunner()
result = runner.invoke(cli, ['test', 'suite', str(test_file), str(cases_dir), '--workers', '2'])

assert result.exit_code == 0
assert "Running 2 case(s) with 2 worker(s)..." in result.output
assert "PASS 001.in" in result.output
assert "PASS 002.in" in result.output
assert "Summary: 2/2 passed" in result.output

@pytest.mark.skipif(os.system("which g++ > /dev/null 2>&1") != 0, reason="g++ not available")
def test_cpp_suite_reports_failure(self, tmp_path):
"""Test suite failure reporting for mismatched expected output."""
test_file = tmp_path / "constant.cpp"
test_file.write_text("""
#include <iostream>

int main() {
std::cout << 1 << std::endl;
return 0;
}
""")

cases_dir = tmp_path / "cases"
cases_dir.mkdir()
(cases_dir / "bad.in").write_text("\n")
(cases_dir / "bad.out").write_text("2\n")

runner = CliRunner()
result = runner.invoke(cli, ['test', 'suite', str(test_file), str(cases_dir)])

assert result.exit_code == 1
assert "FAIL bad.in" in result.output
assert "output differs from expected" in result.output
assert "Summary: 0/1 passed" in result.output


class TestTestCommandErrorHandling:
"""Test error handling for the test command."""
Expand Down
Loading