diff --git a/src/commands/test_command.py b/src/commands/test_command.py index 5fe39cd..a37ef42 100644 --- a/src/commands/test_command.py +++ b/src/commands/test_command.py @@ -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() @@ -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 diff --git a/tests/test_test_command.py b/tests/test_test_command.py index db0827b..09af231 100644 --- a/tests/test_test_command.py +++ b/tests/test_test_command.py @@ -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 + +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 + +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."""