diff --git a/CMakeLists.txt b/CMakeLists.txt index 53b3952..ae3b63a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -92,12 +92,18 @@ if(BUILD_TESTING) find_package(GTest CONFIG REQUIRED) add_executable(cpcli_test core/test/src/test_compiler.cpp + core/test/src/test_parse_config.cpp core/test/src/test_path_manager.cpp core/test/src/test_util.cpp ) target_link_libraries(cpcli_test PRIVATE cpcli_compiler GTest::gtest_main) include(GoogleTest) gtest_discover_tests(cpcli_test) + add_test( + NAME cpcli_subtask_integration + COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/core/test/test_subtasks.sh + $ $ + ) endif() install(TARGETS cpcli_app cpcli_cc RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) diff --git a/README.md b/README.md index 7ff899f..ced1549 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ Please take a look at the [archive](https://github.com/cunbidun/competitive_prog ```bash $ cpcli_app --project-config=project_config.toml task --root-dir="/Users/cunbidun/competitive_programming/task/F - Keep Connect" --build +$ cpcli_app --project-config=project_config.toml task --root-dir="task/F - Keep Connect" --build --subtask full ``` | Num | Attribute | Type | Description | Default value | @@ -126,10 +127,6 @@ cpcli passes `regular_flag` and `debug_flag` directly to the configured compiler New task configs are written as `config.toml`. Existing flat `config.json` and old flat TOML keys are still accepted, but saving through the task editor writes the new sectioned TOML shape. ```toml -tests = [ - { enabled = true, has_expected_output = true, input = "1\n", output = "1\n" }, -] - [problem] name = "E. Trees of Tranquillity" group = "Codeforces - Codeforces Round #722 (Div. 2)" @@ -151,8 +148,37 @@ test_count = 10 seed = "" args = "" expected_output_from_slow = true + +[[subtasks]] +name = "base" +enabled = true +points = 30 +gen = "gen_base" + +[subtasks.generation] +enabled = true +test_count = 20 +seed = "20260726" +expected_output_from_slow = true + +[[subtasks]] +name = "full" +enabled = true +points = 70 +depends_on = ["base"] +checker = "double_6" +time_limit_ms = 5000 + +[[tests]] +subtask = "base" +enabled = true +has_expected_output = true +input = "1\n" +output = "1\n" ``` +When `[[subtasks]]` is absent, cpcli treats the existing run, generation, and tests as one implicit subtask. Subtask generators and tests use `___test_case//`, so subtask names are never interpolated into shell paths. `depends_on` affects scoring only; it does not rerun dependency tests. Focused `--subtask` runs report the selected verdict but do not calculate a score because dependencies are intentionally not run. Scored runs should normally leave `run.stop_on_first_failure = false`, because that option still aborts the entire run on the first failure. + Canonical task config keys: | Attribute | Type | Description | Default value | @@ -171,7 +197,17 @@ Canonical task config keys: | `generation.seed` | `string` | Generator seed. If empty, cpcli creates one. | `""` | | `generation.args` | `string` | Extra arguments passed to `gen` after seed and count. | `""` | | `generation.expected_output_from_slow` | `boolean` | Use `slow` to produce expected output for generated tests. | `false` | +| `subtasks[].name` | `string` | Unique subtask name used by tests and `--subtask`. | `""` for the implicit subtask | +| `subtasks[].enabled` | `boolean` | Include the subtask in a normal run. An explicit `--subtask` selection can still run it. | `true` | +| `subtasks[].points` | `int` | Optional all-or-nothing score. | unset | +| `subtasks[].depends_on` | `string[]` | Scoring dependencies that must also pass. | `[]` | +| `subtasks[].gen` | `string` | Generator source stem. | `gen` | +| `subtasks[].slow` | `string` | Slow-solution source stem. | `slow` | +| `subtasks[].checker` | `string` | Checker override. | `run.checker` | +| `subtasks[].time_limit_ms` | `int` | Time-limit override. | `run.time_limit_ms` | +| `subtasks[].generation` | `table` | Per-subtask generation settings, with the same fields as `generation`. | inherited from `generation` | | `tests[].enabled` | `boolean` | Whether this sample test runs. | `true` | +| `tests[].subtask` | `string` | Owning subtask name. | first subtask | | `tests[].has_expected_output` | `boolean` | Whether `output` should be checked. | derived from `output` | | `tests[].input` | `string` | Test input. | required | | `tests[].output` | `string` | Expected output. | optional | diff --git a/core/bin/cpcli_app.cpp b/core/bin/cpcli_app.cpp index c6d4297..4e6bbfa 100644 --- a/core/bin/cpcli_app.cpp +++ b/core/bin/cpcli_app.cpp @@ -8,39 +8,68 @@ #include "template_manager.hpp" #include "utils.hpp" +#include +#include #include -#include +#include #include +#include +#include #include -#include +#include #include #include #include +#include using std::cout; using std::endl; using std::string; using json = nlohmann::json; -int cpcli_process(int argc, char *argv[]) { - signal(SIGINT, [](int) { handle_sigint(); }); // implement SIGINT - auto parser_result = parse_args(argc, argv); - std::chrono::high_resolution_clock::time_point t_start = std::chrono::high_resolution_clock::now(); - - // project_config's path and project_config json object - std::filesystem::path project_conf_path = parser_result.project_config_path; - json project_conf = read_project_config(project_conf_path); +namespace { +struct TestResult { + bool passed = true; + bool rte = false; + bool tle = false; + bool wa = false; + long long runtime = 0; +}; + +void merge_result(TestResult &total, const TestResult &test) { + total.passed &= test.passed; + total.rte |= test.rte; + total.tle |= test.tle; + total.wa |= test.wa; + total.runtime = std::max(total.runtime, test.runtime); +} - std::filesystem::path root_dir; // where source files and problem_config file located - std::filesystem::path output_dir; // where source will be put for submission +string shell_quote(const std::filesystem::path &path) { + string value = path.string(); + string quoted = "'"; + for (const char ch : value) { + if (ch == '\'') { + quoted += "'\\''"; + } else { + quoted += ch; + } + } + return quoted + "'"; +} - // problem's config path and config json object - std::filesystem::path problem_conf_path; - json problem_conf; +string subtask_label(const json &subtask) { + const auto name = subtask.value("name", string()); + return name.empty() ? "default" : name; +} +} // namespace - std::filesystem::path solution_file_path; +int cpcli_process(int argc, char *argv[]) { + signal(SIGINT, [](int) { handle_sigint(); }); + auto parser_result = parse_args(argc, argv); + auto t_start = std::chrono::high_resolution_clock::now(); - string generator_seed; + const std::filesystem::path project_conf_path = parser_result.project_config_path; + json project_conf = read_project_config(project_conf_path); PathManager path_manager; auto status = path_manager.init(project_conf); @@ -50,60 +79,51 @@ int cpcli_process(int argc, char *argv[]) { } TemplateManager template_manager(path_manager, project_conf); - std::filesystem::path local_share_dir = path_manager.get_local_share(); + const auto local_share_dir = path_manager.get_local_share(); spdlog::debug("local_share_dir directory is: " + local_share_dir.string()); - std::filesystem::path checker_dir = local_share_dir / "checkers"; + const auto checker_dir = local_share_dir / "checkers"; if (parser_result.operation == ParserOperations::NewTask) { create_new_task(project_conf_path); return 0; } - root_dir = *parser_result.root_dir; - std::filesystem::current_path(root_dir); // change directory to root_dir - clean_up(); // clean up the root directory for the first time + const std::filesystem::path root_dir = *parser_result.root_dir; + std::filesystem::current_path(root_dir); + clean_up(); - if (parser_result.operation == ParserOperations::EditTaskConfig) { // edit config + if (parser_result.operation == ParserOperations::EditTaskConfig) { string task_editor_exec = project_conf["task_editor_exec"].get(); edit_config(root_dir, project_conf_path, template_manager, task_editor_exec); return 0; } - std::filesystem::path temp_config_path = template_manager.get_problem_config(); - problem_conf_path = resolve_problem_config_path(root_dir); - problem_conf = read_problem_config(problem_conf_path, temp_config_path); + const auto problem_conf_path = resolve_problem_config_path(root_dir); + json problem_conf = read_problem_config(problem_conf_path, template_manager.get_problem_config()); path_manager.init_problem_conf(problem_conf); - output_dir = path_manager.get_output(); - bool is_debug = false; if (parser_result.operation == ParserOperations::Build) { - // do nothing - } else if (parser_result.operation == ParserOperations::BuildWithDebug) { // run with debug flags + } else if (parser_result.operation == ParserOperations::BuildWithDebug) { is_debug = true; } else if (parser_result.operation == ParserOperations::BuildWithTerm) { - // Run with terminal. This option only uses the project config file for - // compiler flags. No problem config will be used. Compiler compiler(project_conf, path_manager, false); compiler.compile(path_manager.get_solution_path(root_dir)); - int status = system_warper("./solution"); - cout << '\n'; // add an empty line before printing the status - if (status != 0) { - cout << termcolor::red << "[Process exited " << status << "]" << termcolor::reset << "\n"; + int process_status = system_warper("./solution"); + cout << '\n'; + if (process_status != 0) { + cout << termcolor::red << "[Process exited " << process_status << "]" << termcolor::reset << "\n"; } else { cout << "[Process exited 0]\n"; } clean_up(); print_duration(t_start); return 0; - } else if (parser_result.operation == ParserOperations::Archive) { // archive + } else if (parser_result.operation == ParserOperations::Archive) { string name = problem_conf["name"].get(); string group = problem_conf["group"].get(); - - // We move back to the parent directory of current task in order to copy it std::filesystem::current_path(root_dir.parent_path()); - - auto archive_dir = path_manager.get_archive(); + const auto archive_dir = path_manager.get_archive(); if (group.empty()) { group = "Unsorted"; } @@ -118,255 +138,328 @@ int cpcli_process(int argc, char *argv[]) { return OPERATION_ERR; } - if (problem_conf["group"] != nullptr && problem_conf["group"].get().size() != 0) { + if (problem_conf["group"] != nullptr && !problem_conf["group"].get().empty()) { cout << problem_conf["group"].get() << '\n'; } cout << problem_conf["name"].get() << '\n'; - // ----------------------------- COMPILE START ---------------------------- + std::vector subtasks; + std::set names; + bool selected_subtask_found = false; + for (const auto &subtask : problem_conf["subtasks"]) { + const auto name = subtask.value("name", string()); + if (!names.insert(name).second) { + cout << termcolor::red << "Duplicate subtask name: " << name << termcolor::reset << '\n'; + return INVALID_CONFIG_ERROR; + } + if (parser_result.subtask) { + if (name == *parser_result.subtask) { + subtasks.push_back(subtask); + selected_subtask_found = true; + } + } else if (subtask.value("enabled", true)) { + subtasks.push_back(subtask); + } + } + if (parser_result.subtask && !selected_subtask_found) { + cout << termcolor::red << "Unknown subtask: " << *parser_result.subtask << termcolor::reset << '\n'; + return INVALID_CONFIG_ERROR; + } + if (subtasks.empty()) { + cout << termcolor::yellow << "No subtasks are enabled" << termcolor::reset << '\n'; + return 0; + } + for (const auto &subtask : problem_conf["subtasks"]) { + for (const auto &dependency : subtask.value("dependsOn", json::array())) { + const auto dependency_name = dependency.get(); + if (names.find(dependency_name) == names.end()) { + cout << termcolor::red << "Subtask " << subtask_label(subtask) << " depends on unknown subtask '" + << dependency_name << "'" << termcolor::reset << '\n'; + return INVALID_CONFIG_ERROR; + } + } + } + for (const auto &test : problem_conf["tests"]) { + if (test.value("subtaskIndex", -1) < 0) { + cout << termcolor::red << "Test #" << test.value("index", -1) << " refers to unknown subtask '" + << test.value("subtask", string()) << "'" << termcolor::reset << '\n'; + return INVALID_CONFIG_ERROR; + } + } + + std::map generator_executables; + std::map slow_executables; + std::map checker_executables; + std::map generator_seeds; + std::set compiled_artifacts; + auto cleanup_task = [&]() { + std::filesystem::current_path(root_dir); + for (const auto &artifact : compiled_artifacts) { + std::filesystem::remove(artifact); + } + clean_up(); + }; + Compiler compiler(project_conf, path_manager, is_debug); { auto t0 = std::chrono::high_resolution_clock::now(); - std::filesystem::path cache_dir = ""; + std::set compiled_sources; + auto compile_once = [&](const std::filesystem::path &source) { + const auto resolved = std::filesystem::weakly_canonical(source); + if (compiled_sources.insert(resolved).second) { + compiler.compile(source); + } + const auto executable = root_dir / source.stem(); + compiled_artifacts.insert(executable); + return executable; + }; if (problem_conf["interactive"]) { - problem_conf["knowGenAns"] = false; - compiler.compile(path_manager.get_interactor_path(root_dir)); + compile_once(path_manager.get_interactor_path(root_dir)); cout << termcolor::cyan << termcolor::bold << "Interactive task" << termcolor::reset << '\n'; - } else { - if (problem_conf["checker"] != "custom") { - std::filesystem::path checker_bin_path = checker_dir / problem_conf["checker"]; - check_file(checker_bin_path, "checker binary not found!"); - copy_file(checker_bin_path, root_dir / "checker", std::filesystem::copy_options::overwrite_existing); - } else { - compiler.compile(path_manager.get_checker_path(root_dir)); - } - cout << termcolor::cyan << termcolor::bold << "Using " << problem_conf["checker"].get() << " checker!" - << termcolor::reset << '\n'; - } - - // use slow solution for generate correct output - if (problem_conf["knowGenAns"]) { - compiler.compile(path_manager.get_slow_path(root_dir)); } - if (problem_conf["useGeneration"]) { - compiler.compile(path_manager.get_task_gen_path(root_dir)); - generator_seed = problem_conf["generatorSeed"]; - if (problem_conf["generatorSeed"].get().size() == 0) { - generator_seed = gen_string_length_20(); + for (const auto &subtask : subtasks) { + const auto index = subtask["index"].get(); + if (!problem_conf["interactive"]) { + const auto checker = subtask.value("checker", string("token_checker")); + if (checker == "custom") { + checker_executables[index] = compile_once(path_manager.get_checker_path(root_dir)); + } else { + const auto checker_path = checker_dir / checker; + check_file(checker_path, "checker binary not found!"); + checker_executables[index] = checker_path; + } + cout << termcolor::cyan << termcolor::bold << "Subtask " << subtask_label(subtask) << ": using " << checker + << " checker" << termcolor::reset << '\n'; + } + if (!problem_conf["interactive"] && subtask.value("knowGenAns", false)) { + const auto source = path_manager.get_slow_path(root_dir, subtask.value("slow", string("slow"))); + slow_executables[index] = compile_once(source); + } + if (subtask.value("useGeneration", false)) { + const auto source = path_manager.get_task_gen_path(root_dir, subtask.value("gen", string("gen"))); + generator_executables[index] = compile_once(source); + auto seed = subtask.value("generatorSeed", string()); + if (seed.empty()) { + seed = gen_string_length_20(); + } + generator_seeds[index] = seed; + cout << termcolor::yellow << termcolor::bold << "Subtask " << subtask_label(subtask) + << ": stress testing with seed '" << seed << "'" << termcolor::reset << '\n'; } - cout << termcolor::yellow << termcolor::bold << "Stress testing with seed \'" << generator_seed << "\'" - << termcolor::reset << '\n'; } - // compile solution file compiler.compile(path_manager.get_solution_path(root_dir)); - auto t1 = std::chrono::high_resolution_clock::now(); - long long time = std::chrono::duration_cast(t1 - t0).count(); + const auto time = std::chrono::duration_cast(t1 - t0).count(); cout << termcolor::magenta << termcolor::bold << "Compilation finished in " << time << " ms" << endl; - cout << DASH_SEPERATOR << '\n'; } - // ------------------------------ COMPILE END ----------------------------- - // ------------------------ GENERATING TESTS START ------------------------ { std::filesystem::current_path(root_dir); - std::filesystem::create_directory("___test_case"); - std::filesystem::current_path("___test_case"); - for (json test : problem_conf["tests"]) { - if (test["active"]) { - spdlog::debug("Generating test {}", test["index"].get()); - if (test["input"] != nullptr) { - std::ofstream inf(std::to_string(test["index"].get()) + ".in"); - inf << test["input"].get(); - inf.close(); - } - if (test["answer"].get()) { - std::ofstream ouf(std::to_string(test["index"].get()) + ".out"); - ouf << test["output"].get(); - ouf.close(); - } + std::filesystem::create_directories("___test_case"); + std::set selected_indices; + for (const auto &subtask : subtasks) { + selected_indices.insert(subtask["index"].get()); + } + for (const auto &test : problem_conf["tests"]) { + const auto subtask_index = test["subtaskIndex"].get(); + if (!test["active"] || selected_indices.find(subtask_index) == selected_indices.end()) { + continue; + } + const auto test_dir = std::filesystem::path("___test_case") / std::to_string(subtask_index); + std::filesystem::create_directories(test_dir); + if (test["input"] != nullptr) { + std::ofstream(test_dir / (std::to_string(test["index"].get()) + ".in")) + << test["input"].get(); + } + if (test["answer"].get()) { + std::ofstream(test_dir / (std::to_string(test["index"].get()) + ".out")) + << test["output"].get(); } } - if (problem_conf["useGeneration"]) { - string command = - "../gen " + generator_seed + " " + std::to_string(problem_conf["numTest"].get()); // NOTE careful with .. - if (problem_conf.contains("genParameters") && problem_conf["genParameters"].is_string() && - !problem_conf["genParameters"].get().empty()) { - command += " " + problem_conf["genParameters"].get(); + for (const auto &subtask : subtasks) { + if (!subtask.value("useGeneration", false)) { + continue; + } + const auto index = subtask["index"].get(); + const auto test_dir = root_dir / "___test_case" / std::to_string(index); + std::filesystem::create_directories(test_dir); + std::filesystem::current_path(test_dir); + string command = shell_quote(std::filesystem::path("../..") / generator_executables[index].filename()) + " " + + generator_seeds[index] + " " + std::to_string(subtask.value("numTest", 0)); + const auto parameters = subtask.value("genParameters", string()); + if (!parameters.empty()) { + command += " " + parameters; } - int status = system_warper(command); - if (status != 0) { - cout << termcolor::red << termcolor::bold << "generator run time error" << termcolor::reset << endl; - clean_up(); - exit(1); + if (system_warper(command) != 0) { + cout << termcolor::red << termcolor::bold << "Subtask " << subtask_label(subtask) << " generator run time error" + << termcolor::reset << endl; + cleanup_task(); + return 1; } } } - // ------------------------ GENERATING TESTS END -------------------------- - bool all_passed = 1, all_rte = 0, all_tle = 0, all_wa = 0; - long long all_runtime = 0; - - // ----------------------------- TESTS START ------------------------------ - { - spdlog::debug("Start testing"); - std::filesystem::current_path(root_dir); - long long time_limit = problem_conf["timeLimit"].get(); - spdlog::debug("time limit is {} ms", time_limit); - auto tests_folder_dir = std::filesystem::path("___test_case"); // set the root directory to argv[1] + TestResult all_results; + std::vector subtask_results; + std::filesystem::current_path(root_dir); + for (const auto &subtask : subtasks) { + TestResult subtask_result; + const auto subtask_index = subtask["index"].get(); + const auto time_limit = subtask.value("timeLimit", 10000LL); + const auto tests_folder_dir = std::filesystem::path("___test_case") / std::to_string(subtask_index); + std::filesystem::create_directories(tests_folder_dir); + cout << termcolor::cyan << termcolor::bold << "Subtask " << subtask_label(subtask) << termcolor::reset << '\n'; std::vector> sorted_by_name; - for (auto &entry : std::filesystem::directory_iterator(tests_folder_dir)) { - if (entry.path().extension() == ".in") { - const auto test_id = entry.path().stem().string(); - int num = 0; - if (test_id[0] == 'S') { - num = 1000000000 + std::stoi(test_id.substr(1)); // funny trick to ensure stress tests come after - } else { - num = std::stoi(test_id); - } - sorted_by_name.push_back({num, entry.path()}); + for (const auto &entry : std::filesystem::directory_iterator(tests_folder_dir)) { + if (entry.path().extension() != ".in") { + continue; } + const auto test_id = entry.path().stem().string(); + const auto order = test_id[0] == 'S' ? 1000000000 + std::stoi(test_id.substr(1)) : std::stoi(test_id); + sorted_by_name.push_back({order, entry.path()}); } - sort(sorted_by_name.begin(), sorted_by_name.end()); - spdlog::debug("Found {} tests", sorted_by_name.size()); + std::sort(sorted_by_name.begin(), sorted_by_name.end()); - for (const auto &set_entry : sorted_by_name) { - auto entry = set_entry.second; - bool passed = 1, undecided = 0, rte = 0, tle = 0, wa = 0; - long long runtime = 0; + for (const auto &[_, entry] : sorted_by_name) { + TestResult test_result; + bool undecided = false; const auto test_id = entry.stem().string(); const auto actual_file = tests_folder_dir / (test_id + ".actual"); const auto out_file = tests_folder_dir / (test_id + ".out"); const auto res_file = tests_folder_dir / (test_id + ".res"); - bool truncate = problem_conf["truncateLongTest"].get(); - + const bool truncate = problem_conf["truncateLongTest"].get(); create_empty_file(res_file); - // --------- test id ------------ - if (test_id[0] != 'S') { - cout << termcolor::cyan << termcolor::bold << "Test #" << test_id << ": " << termcolor::reset; - } else { + if (test_id[0] == 'S') { cout << termcolor::yellow << termcolor::bold << "Test #" << test_id << ": " << termcolor::reset; + } else { + cout << termcolor::cyan << termcolor::bold << "Test #" << test_id << ": " << termcolor::reset; } if (problem_conf["interactive"]) { - string command = "./interactor " + entry.string() + " " + actual_file.string() + " " + res_file.string(); - int status = system_warper(command); - if (status != 0) { - passed = 0; - if (status != 1) { - rte = 1; - } + const string command = + "./interactor " + shell_quote(entry) + " " + shell_quote(actual_file) + " " + shell_quote(res_file); + const int process_status = system_warper(command); + if (process_status != 0) { + test_result.passed = false; + test_result.rte = process_status != 1; } - wa = !passed && !undecided; + test_result.wa = !test_result.passed && !test_result.rte; } else { - { - string command = "./solution < " + entry.string() + " > " + actual_file.string(); - - auto t0 = std::chrono::high_resolution_clock::now(); - int status = system_warper(command); - auto t1 = std::chrono::high_resolution_clock::now(); - runtime = std::chrono::duration_cast(t1 - t0).count(); - if (runtime > time_limit) { - tle = 1; - passed = 0; - } - if (status != 0) { - rte = 1; - passed = 0; - } + const string solution_command = "./solution < " + shell_quote(entry) + " > " + shell_quote(actual_file); + auto t0 = std::chrono::high_resolution_clock::now(); + const int solution_status = system_warper(solution_command); + auto t1 = std::chrono::high_resolution_clock::now(); + test_result.runtime = std::chrono::duration_cast(t1 - t0).count(); + if (test_result.runtime > time_limit) { + test_result.tle = true; + test_result.passed = false; + } + if (solution_status != 0) { + test_result.rte = true; + test_result.passed = false; } - { - if (problem_conf["knowGenAns"]) { - string command = "./slow < " + entry.string() + " > " + out_file.string(); - int status = system_warper(command); - if (status != 0) { - cout << "Input:" << '\n'; - print_file(entry.string(), truncate); - cout.flush(); - cout << DASH_SEPERATOR << '\n'; - cout << termcolor::red << termcolor::bold << "slow solution run time error" << termcolor::reset << endl; - clean_up(); - exit(1); - } + if (subtask.value("knowGenAns", false)) { + const string slow_command = + shell_quote(slow_executables[subtask_index]) + " < " + shell_quote(entry) + " > " + shell_quote(out_file); + if (system_warper(slow_command) != 0) { + cout << termcolor::red << termcolor::bold << "slow solution run time error" << termcolor::reset << endl; + cleanup_task(); + return 1; } } - { - if (!check_file(out_file, "")) { - undecided = 1; - passed = 0; - } else { - string jans_file = out_file.string(); - // ./checker - string command = "./checker " + entry.string() + " " + actual_file.string() + " " + jans_file + " " + - res_file.string() + " > /dev/null 2>&1"; - passed = system_warper(command) == 0; - } - wa = !passed && !undecided && !tle && !rte; + if (!check_file(out_file, "")) { + undecided = true; + test_result.passed = false; + } else { + const string checker_command = shell_quote(checker_executables[subtask_index]) + " " + shell_quote(entry) + + " " + shell_quote(actual_file) + " " + shell_quote(out_file) + " " + + shell_quote(res_file) + " > /dev/null 2>&1"; + test_result.passed = system_warper(checker_command) == 0; } + test_result.wa = !test_result.passed && !undecided && !test_result.tle && !test_result.rte; } - all_passed &= passed; - all_rte |= rte; - all_tle |= tle; - all_wa |= wa; - all_runtime = std::max(all_runtime, runtime); - if (passed && problem_conf["hideAcceptedTest"]) { + + merge_result(subtask_result, test_result); + merge_result(all_results, test_result); + if (test_result.passed && problem_conf["hideAcceptedTest"]) { cout << termcolor::green << termcolor::bold << "accepted" << termcolor::reset << '\n'; } else { - cout << '\n'; - // --------- input -------------- - cout << "Input:" << '\n'; + cout << '\n' << "Input:" << '\n'; print_file(entry.string(), truncate); - cout.flush(); - - // --------- expected output -------------- if (check_file(out_file, "")) { cout << "Expected output:" << '\n'; print_file(out_file, truncate); } - - // --------- exe output -------------- - { - cout << "Execution output:" << '\n'; - cout.flush(); - print_file(actual_file, truncate); - } - - // --------- Verdict -------------- - print_report("Verdict", passed, rte, tle, wa, runtime); + cout << "Execution output:" << '\n'; + print_file(actual_file, truncate); + print_report( + "Verdict", test_result.passed, test_result.rte, test_result.tle, test_result.wa, test_result.runtime); if (!is_empty_file(res_file.string())) { print_file(res_file, false); } } cout << DASH_SEPERATOR << '\n'; - std::filesystem::current_path(root_dir); - if (problem_conf["stopAtWrongAnswer"] && (wa || rte || tle)) { - print_report("Fail detected", all_passed, all_rte, all_tle, all_wa, all_runtime); - clean_up(); + if (problem_conf["stopAtWrongAnswer"] && (test_result.wa || test_result.rte || test_result.tle)) { + print_report( + "Fail detected", all_results.passed, all_results.rte, all_results.tle, all_results.wa, all_results.runtime); + cleanup_task(); print_duration(t_start); return 0; } } + subtask_results.push_back(subtask_result); } - // ------------------------------ TESTS END ------------------------------- - // ------------------------------ PRINT REPORT START ------------------------------- - { - cout << EQUA_SEPERATOR << '\n'; - print_report("Results", all_passed, all_rte, all_tle, all_wa, all_runtime); - clean_up(); - print_duration(t_start); + cout << EQUA_SEPERATOR << '\n'; + std::map passed_by_name; + for (size_t i = 0; i < subtasks.size(); ++i) { + passed_by_name[subtasks[i].value("name", string())] = subtask_results[i].passed; + print_report("Subtask " + subtask_label(subtasks[i]), + subtask_results[i].passed, + subtask_results[i].rte, + subtask_results[i].tle, + subtask_results[i].wa, + subtask_results[i].runtime); + } + print_report("Results", all_results.passed, all_results.rte, all_results.tle, all_results.wa, all_results.runtime); + + long long earned_points = 0; + long long available_points = 0; + bool has_points = false; + for (size_t i = 0; i < subtasks.size(); ++i) { + if (!subtasks[i].contains("points") || subtasks[i]["points"].is_null()) { + continue; + } + has_points = true; + const auto points = subtasks[i]["points"].get(); + available_points += points; + bool dependencies_passed = true; + for (const auto &dependency : subtasks[i].value("dependsOn", json::array())) { + const auto dependency_result = passed_by_name.find(dependency.get()); + dependencies_passed &= dependency_result != passed_by_name.end() && dependency_result->second; + } + if (subtask_results[i].passed && dependencies_passed) { + earned_points += points; + } } - // ------------------------------ PRINT REPORT END ------------------------------- + if (has_points && parser_result.subtask) { + cout << "Score: not calculated for --subtask runs\n"; + } else if (has_points) { + cout << "Score: " << earned_points << "/" << available_points << '\n'; + } + + cleanup_task(); + print_duration(t_start); return 0; } diff --git a/core/libs/operations/include/operations.hpp b/core/libs/operations/include/operations.hpp index bcd66f8..3c61717 100644 --- a/core/libs/operations/include/operations.hpp +++ b/core/libs/operations/include/operations.hpp @@ -54,6 +54,7 @@ enum class ParserOperations { class ParserReturnValues { public: std::optional root_dir; + std::optional subtask; std::filesystem::path project_config_path; ParserOperations operation; }; diff --git a/core/libs/operations/src/parse_args.cpp b/core/libs/operations/src/parse_args.cpp index 720f636..49b463f 100644 --- a/core/libs/operations/src/parse_args.cpp +++ b/core/libs/operations/src/parse_args.cpp @@ -51,6 +51,7 @@ ParserReturnValues parse_args(int argc, char *argv[]) { ->required(true) ->check(CLI::ExistingDirectory) ->transform(convert_to_canonical); + task_subcommand->add_option("--subtask", return_value.subtask, "Run only the named subtask")->type_name("NAME"); auto task_operations = task_subcommand->add_option_group("task", "Operation on task"); task_operations->add_flag_function( "-a,--archive", diff --git a/core/libs/operations/src/parse_config.cpp b/core/libs/operations/src/parse_config.cpp index 1251a32..0472f05 100644 --- a/core/libs/operations/src/parse_config.cpp +++ b/core/libs/operations/src/parse_config.cpp @@ -150,8 +150,60 @@ nlohmann::json normalize_problem_config(nlohmann::json j) { copy_if_present(normalized, j, "hideAcceptedTestCases", "hideAcceptedTest"); copy_if_present(normalized, j, "stopOnFirstFail", "stopAtWrongAnswer"); copy_if_present(normalized, j, "generatorParams", "genParameters"); + copy_if_present(normalized, j, "language_config", "languageConfig"); + + const bool explicit_subtasks = j.contains("explicitSubtasks") ? j.value("explicitSubtasks", false) + : j.contains("subtasks") && j["subtasks"].is_array(); + normalized["explicitSubtasks"] = explicit_subtasks; + nlohmann::json subtasks = explicit_subtasks ? j["subtasks"] : nlohmann::json::array({nlohmann::json::object()}); + for (size_t i = 0; i < subtasks.size(); ++i) { + auto &subtask = subtasks[i]; + subtask["index"] = i; + if (!subtask.contains("name") || subtask["name"].is_null()) { + subtask["name"] = ""; + } + if (!subtask.contains("enabled") || subtask["enabled"].is_null()) { + subtask["enabled"] = true; + } + if (subtask.contains("depends_on")) { + subtask["dependsOn"] = subtask["depends_on"]; + } + if (!subtask.contains("dependsOn") || !subtask["dependsOn"].is_array()) { + subtask["dependsOn"] = nlohmann::json::array(); + } + if (!subtask.contains("gen") || subtask["gen"].is_null()) { + subtask["gen"] = "gen"; + } + if (!subtask.contains("slow") || subtask["slow"].is_null()) { + subtask["slow"] = "slow"; + } + if (!subtask.contains("checker") || subtask["checker"].is_null()) { + subtask["checker"] = normalized.value("checker", "token_checker"); + } + if (subtask.contains("time_limit_ms")) { + subtask["timeLimit"] = subtask["time_limit_ms"]; + } + if (!subtask.contains("timeLimit") || subtask["timeLimit"].is_null()) { + subtask["timeLimit"] = normalized.value("timeLimit", 10000LL); + } + + nlohmann::json generation = nlohmann::json::object(); + if (subtask.contains("generation") && subtask["generation"].is_object()) { + generation = subtask["generation"]; + } + subtask["useGeneration"] = generation.value("enabled", normalized.value("useGeneration", false)); + subtask["numTest"] = generation.value("test_count", normalized.value("numTest", 0)); + subtask["generatorSeed"] = generation.value("seed", normalized.value("generatorSeed", std::string())); + subtask["genParameters"] = generation.value("args", normalized.value("genParameters", std::string())); + subtask["knowGenAns"] = generation.value("expected_output_from_slow", normalized.value("knowGenAns", false)); + } + normalized["subtasks"] = subtasks; if (normalized.contains("tests") && normalized["tests"].is_array()) { + std::map subtask_indices; + for (size_t i = 0; i < subtasks.size(); ++i) { + subtask_indices[subtasks[i].value("name", std::string())] = i; + } for (size_t i = 0; i < normalized["tests"].size(); ++i) { auto &test = normalized["tests"][i]; if (test.contains("enabled")) { @@ -169,34 +221,44 @@ nlohmann::json normalize_problem_config(nlohmann::json j) { if (!test.contains("answer")) { test["answer"] = test.contains("output") && !test["output"].is_null(); } + if (!test.contains("subtask") || test["subtask"].is_null()) { + test["subtask"] = subtasks[0]["name"]; + } + const auto subtask_name = test["subtask"].get(); + const auto subtask_it = subtask_indices.find(subtask_name); + test["subtaskIndex"] = subtask_it != subtask_indices.end() ? static_cast(subtask_it->second) : -1; } } - normalized.erase("problem"); - normalized.erase("run"); - normalized.erase("display"); - normalized.erase("generation"); return normalized; } nlohmann::json problem_config_to_toml_schema(const nlohmann::json &config) { - nlohmann::json out; - out["problem"] = nlohmann::json::object(); + nlohmann::json out = config; + if (!has_object(out, "problem")) { + out["problem"] = nlohmann::json::object(); + } copy_if_present(out["problem"], config, "name", "name"); copy_if_present(out["problem"], config, "group", "group"); copy_if_present(out["problem"], config, "url", "url"); - out["run"] = nlohmann::json::object(); + if (!has_object(out, "run")) { + out["run"] = nlohmann::json::object(); + } copy_if_present(out["run"], config, "timeLimit", "time_limit_ms"); copy_if_present(out["run"], config, "checker", "checker"); copy_if_present(out["run"], config, "interactive", "interactive"); copy_if_present(out["run"], config, "stopAtWrongAnswer", "stop_on_first_failure"); - out["display"] = nlohmann::json::object(); + if (!has_object(out, "display")) { + out["display"] = nlohmann::json::object(); + } copy_if_present(out["display"], config, "hideAcceptedTest", "hide_accepted_tests"); copy_if_present(out["display"], config, "truncateLongTest", "truncate_long_output"); - out["generation"] = nlohmann::json::object(); + if (!has_object(out, "generation")) { + out["generation"] = nlohmann::json::object(); + } copy_if_present(out["generation"], config, "useGeneration", "enabled"); copy_if_present(out["generation"], config, "numTest", "test_count"); copy_if_present(out["generation"], config, "generatorSeed", "seed"); @@ -206,18 +268,80 @@ nlohmann::json problem_config_to_toml_schema(const nlohmann::json &config) { out["tests"] = nlohmann::json::array(); if (config.contains("tests") && config["tests"].is_array()) { for (const auto &test : config["tests"]) { - nlohmann::json out_test; + nlohmann::json out_test = test; copy_if_present(out_test, test, "active", "enabled"); copy_if_present(out_test, test, "answer", "has_expected_output"); - copy_if_present(out_test, test, "input", "input"); - copy_if_present(out_test, test, "output", "output"); + if (config.value("explicitSubtasks", false)) { + copy_if_present(out_test, test, "subtask", "subtask"); + } else { + out_test.erase("subtask"); + } + out_test.erase("active"); + out_test.erase("answer"); + out_test.erase("index"); + out_test.erase("subtaskIndex"); out["tests"].push_back(out_test); } } + if (config.value("explicitSubtasks", false) && config.contains("subtasks") && config["subtasks"].is_array()) { + out["subtasks"] = nlohmann::json::array(); + for (const auto &subtask : config["subtasks"]) { + nlohmann::json out_subtask = subtask; + copy_if_present(out_subtask, subtask, "name", "name"); + copy_if_present(out_subtask, subtask, "enabled", "enabled"); + copy_if_present(out_subtask, subtask, "points", "points"); + copy_if_present(out_subtask, subtask, "dependsOn", "depends_on"); + copy_if_present(out_subtask, subtask, "timeLimit", "time_limit_ms"); + + nlohmann::json subtask_generation = + has_object(subtask, "generation") ? subtask["generation"] : nlohmann::json::object(); + copy_if_present(subtask_generation, subtask, "useGeneration", "enabled"); + copy_if_present(subtask_generation, subtask, "numTest", "test_count"); + copy_if_present(subtask_generation, subtask, "generatorSeed", "seed"); + copy_if_present(subtask_generation, subtask, "genParameters", "args"); + copy_if_present(subtask_generation, subtask, "knowGenAns", "expected_output_from_slow"); + out_subtask["generation"] = subtask_generation; + for (const auto &key : {"index", + "dependsOn", + "timeLimit", + "useGeneration", + "numTest", + "generatorSeed", + "genParameters", + "knowGenAns"}) { + out_subtask.erase(key); + } + out["subtasks"].push_back(out_subtask); + } + } else { + out.erase("subtasks"); + } + if (config.contains("languageConfig") && config["languageConfig"].is_object()) { out["language_config"] = config["languageConfig"]; } + for (const auto &key : {"name", + "group", + "url", + "timeLimit", + "checker", + "interactive", + "stopAtWrongAnswer", + "hideAcceptedTest", + "truncateLongTest", + "useGeneration", + "numTest", + "generatorSeed", + "genParameters", + "knowGenAns", + "languageConfig", + "explicitSubtasks", + "hideAcceptedTestCases", + "stopOnFirstFail", + "generatorParams"}) { + out.erase(key); + } return out; } diff --git a/core/libs/path_manager/include/path_manager.hpp b/core/libs/path_manager/include/path_manager.hpp index c3d1d12..4f6e96a 100644 --- a/core/libs/path_manager/include/path_manager.hpp +++ b/core/libs/path_manager/include/path_manager.hpp @@ -50,11 +50,11 @@ class PathManager { // Required optional dir std::filesystem::path get_template(); - std::filesystem::path get_solution_path(std::filesystem::path root_dir); - std::filesystem::path get_slow_path(std::filesystem::path root_dir); - std::filesystem::path get_task_gen_path(std::filesystem::path root_dir); - std::filesystem::path get_checker_path(std::filesystem::path root_dir); - std::filesystem::path get_interactor_path(std::filesystem::path root_dir); + std::filesystem::path get_solution_path(std::filesystem::path root_dir, std::string name = "solution"); + std::filesystem::path get_slow_path(std::filesystem::path root_dir, std::string name = "slow"); + std::filesystem::path get_task_gen_path(std::filesystem::path root_dir, std::string name = "gen"); + std::filesystem::path get_checker_path(std::filesystem::path root_dir, std::string name = "checker"); + std::filesystem::path get_interactor_path(std::filesystem::path root_dir, std::string name = "interactor"); bool check_task_path_exist(std::filesystem::path root_dir, std::string filetype); @@ -63,7 +63,8 @@ class PathManager { json problem_config; std::map path_mp; std::filesystem::path get(std::string key); - std::filesystem::path get_full_path_with_file_type(std::filesystem::path root_dir, std::string filetype); + std::filesystem::path + get_full_path_with_file_type(std::filesystem::path root_dir, std::string filetype, std::string override_key); std::vector get_all_task_path_filetype(std::filesystem::path root_dir, std::string filetype); }; diff --git a/core/libs/path_manager/src/path_manager.cpp b/core/libs/path_manager/src/path_manager.cpp index 0d39f6b..cfd36b4 100644 --- a/core/libs/path_manager/src/path_manager.cpp +++ b/core/libs/path_manager/src/path_manager.cpp @@ -180,7 +180,9 @@ bool PathManager::check_task_path_exist(std::filesystem::path root_dir, std::str return PathManager::get_all_task_path_filetype(root_dir, filetype).size() > 0; } -std::filesystem::path PathManager::get_full_path_with_file_type(std::filesystem::path root_dir, std::string filetype) { +std::filesystem::path PathManager::get_full_path_with_file_type(std::filesystem::path root_dir, + std::string filetype, + std::string override_key) { spdlog::debug("Getting task file for root_dir={}, filetype={}", root_dir.c_str(), filetype.c_str()); std::vector path_list = PathManager::get_all_task_path_filetype(root_dir, filetype); if (path_list.size() == 0) { @@ -196,8 +198,8 @@ std::filesystem::path PathManager::get_full_path_with_file_type(std::filesystem: // project config override if (language_config.contains("override")) { - if (language_config["override"].contains(filetype)) { - ex = language_config["override"][filetype].get(); + if (language_config["override"].contains(override_key)) { + ex = language_config["override"][override_key].get(); has_explicit_override = true; } spdlog::debug("Project config contains a override map, new extension {}", ex); @@ -206,8 +208,12 @@ std::filesystem::path PathManager::get_full_path_with_file_type(std::filesystem: // problem config override if (!PathManager::problem_config.empty() && PathManager::problem_config.contains("languageConfig")) { language_config = PathManager::problem_config["languageConfig"]; - if (language_config.contains(filetype) && !language_config[filetype].is_null()) { - ex = language_config[filetype].get(); + if (language_config.contains("default") && language_config["default"].is_string()) { + ex = language_config["default"].get(); + has_explicit_override = true; + } + if (language_config.contains(override_key) && !language_config[override_key].is_null()) { + ex = language_config[override_key].get(); has_explicit_override = true; } spdlog::debug("Problem config contains a override map, new extension is '.{}'", ex); @@ -228,18 +234,18 @@ std::filesystem::path PathManager::get_full_path_with_file_type(std::filesystem: exit(PathManagerMultipleTaskFilesFound); } -std::filesystem::path PathManager::get_solution_path(std::filesystem::path root_dir) { - return PathManager::get_full_path_with_file_type(root_dir, "solution"); +std::filesystem::path PathManager::get_solution_path(std::filesystem::path root_dir, std::string name) { + return PathManager::get_full_path_with_file_type(root_dir, name, "solution"); } -std::filesystem::path PathManager::get_slow_path(std::filesystem::path root_dir) { - return PathManager::get_full_path_with_file_type(root_dir, "slow"); +std::filesystem::path PathManager::get_slow_path(std::filesystem::path root_dir, std::string name) { + return PathManager::get_full_path_with_file_type(root_dir, name, "slow"); } -std::filesystem::path PathManager::get_task_gen_path(std::filesystem::path root_dir) { - return PathManager::get_full_path_with_file_type(root_dir, "gen"); +std::filesystem::path PathManager::get_task_gen_path(std::filesystem::path root_dir, std::string name) { + return PathManager::get_full_path_with_file_type(root_dir, name, "gen"); } -std::filesystem::path PathManager::get_checker_path(std::filesystem::path root_dir) { - return PathManager::get_full_path_with_file_type(root_dir, "checker"); +std::filesystem::path PathManager::get_checker_path(std::filesystem::path root_dir, std::string name) { + return PathManager::get_full_path_with_file_type(root_dir, name, "checker"); } -std::filesystem::path PathManager::get_interactor_path(std::filesystem::path root_dir) { - return PathManager::get_full_path_with_file_type(root_dir, "interactor"); +std::filesystem::path PathManager::get_interactor_path(std::filesystem::path root_dir, std::string name) { + return PathManager::get_full_path_with_file_type(root_dir, name, "interactor"); } diff --git a/core/test/src/test_parse_config.cpp b/core/test/src/test_parse_config.cpp new file mode 100644 index 0000000..3d45ebf --- /dev/null +++ b/core/test/src/test_parse_config.cpp @@ -0,0 +1,292 @@ +#include "operations.hpp" +#include "utils.hpp" + +#include +#include +#include +#include +#include + +namespace { +class TemporaryConfig { +public: + explicit TemporaryConfig(const std::string &contents) { + directory = std::filesystem::temp_directory_path() / gen_string_length_20(); + std::filesystem::create_directories(directory); + path = directory / "config.toml"; + std::ofstream(path) << contents; + } + + ~TemporaryConfig() { std::filesystem::remove_all(directory); } + + std::filesystem::path path; + + std::filesystem::path output_path() const { return directory / "written.toml"; } + +private: + std::filesystem::path directory; +}; +} // namespace + +TEST(TestParseConfig, LegacyConfigNormalizesToImplicitSubtask) { + TemporaryConfig config(R"( +[problem] +name = "Legacy" +group = "Tests" + +[run] +checker = "double_6" +interactive = false +time_limit_ms = 2500 +stop_on_first_failure = false + +[generation] +enabled = true +test_count = 7 +seed = "fixed" +args = "--small" +expected_output_from_slow = true + +[[tests]] +enabled = true +input = "1\n" +output = "1\n" +has_expected_output = true +)"); + + const auto parsed = parse_problem_config_file(config.path); + + ASSERT_EQ(parsed["subtasks"].size(), 1); + const auto &subtask = parsed["subtasks"][0]; + EXPECT_EQ(subtask["index"], 0); + EXPECT_EQ(subtask["name"], ""); + EXPECT_TRUE(subtask["enabled"]); + EXPECT_EQ(subtask["checker"], "double_6"); + EXPECT_EQ(subtask["timeLimit"], 2500); + EXPECT_EQ(subtask["gen"], "gen"); + EXPECT_EQ(subtask["slow"], "slow"); + EXPECT_TRUE(subtask["useGeneration"]); + EXPECT_EQ(subtask["numTest"], 7); + EXPECT_EQ(subtask["generatorSeed"], "fixed"); + EXPECT_EQ(subtask["genParameters"], "--small"); + EXPECT_TRUE(subtask["knowGenAns"]); + EXPECT_EQ(parsed["tests"][0]["subtaskIndex"], 0); + EXPECT_EQ(parsed["tests"][0]["subtask"], ""); +} + +TEST(TestParseConfig, ExplicitSubtasksInheritDefaultsAndOwnTests) { + TemporaryConfig config(R"( +[problem] +name = "Subtasks" +group = "Tests" + +[run] +checker = "token_checker" +interactive = false +time_limit_ms = 3000 +stop_on_first_failure = false + +[generation] +enabled = false +test_count = 0 +seed = "root-seed" +args = "" +expected_output_from_slow = false + +[[subtasks]] +name = "base" +enabled = true +points = 30 +gen = "gen_base" + +[subtasks.generation] +enabled = true +test_count = 2 +seed = "base-seed" +args = "--base" +expected_output_from_slow = true + +[[subtasks]] +name = "full" +enabled = false +points = 70 +depends_on = ["base"] +slow = "slow_full" +checker = "double_6" +time_limit_ms = 5000 + +[[tests]] +enabled = true +input = "1\n" +output = "1\n" +has_expected_output = true + +[[tests]] +subtask = "full" +enabled = true +input = "2\n" +output = "2\n" +has_expected_output = true +)"); + + const auto parsed = parse_problem_config_file(config.path); + + ASSERT_EQ(parsed["subtasks"].size(), 2); + const auto &base = parsed["subtasks"][0]; + EXPECT_EQ(base["index"], 0); + EXPECT_EQ(base["name"], "base"); + EXPECT_EQ(base["points"], 30); + EXPECT_EQ(base["gen"], "gen_base"); + EXPECT_EQ(base["slow"], "slow"); + EXPECT_EQ(base["checker"], "token_checker"); + EXPECT_EQ(base["timeLimit"], 3000); + EXPECT_TRUE(base["useGeneration"]); + EXPECT_EQ(base["numTest"], 2); + EXPECT_EQ(base["generatorSeed"], "base-seed"); + + const auto &full = parsed["subtasks"][1]; + EXPECT_EQ(full["index"], 1); + EXPECT_EQ(full["dependsOn"], nlohmann::json::array({"base"})); + EXPECT_EQ(full["slow"], "slow_full"); + EXPECT_EQ(full["checker"], "double_6"); + EXPECT_EQ(full["timeLimit"], 5000); + EXPECT_FALSE(full["useGeneration"]); + + EXPECT_EQ(parsed["tests"][0]["subtaskIndex"], 0); + EXPECT_EQ(parsed["tests"][0]["subtask"], "base"); + EXPECT_EQ(parsed["tests"][1]["subtaskIndex"], 1); + EXPECT_EQ(parsed["tests"][1]["subtask"], "full"); +} + +TEST(TestParseConfig, ExplicitSubtasksSurviveTomlWrite) { + TemporaryConfig config(R"( +[problem] +name = "Round trip" +group = "Tests" + +[run] +checker = "token_checker" +time_limit_ms = 3000 + +[language_config] +default = "py" + +[[subtasks]] +name = "base" +points = 25 +gen = "gen_base" + +[subtasks.generation] +enabled = true +test_count = 3 +seed = "seed" +expected_output_from_slow = true + +[[subtasks]] +name = "full" +points = 75 +depends_on = ["base"] +checker = "double_6" + +[[tests]] +subtask = "full" +enabled = true +input = "1\n" +output = "1\n" +has_expected_output = true +)"); + const auto output = config.output_path(); + + write_problem_config(output, parse_problem_config_file(config.path)); + const auto rewritten = parse_problem_config_file(output); + + EXPECT_TRUE(rewritten["explicitSubtasks"]); + ASSERT_EQ(rewritten["subtasks"].size(), 2); + EXPECT_EQ(rewritten["subtasks"][0]["gen"], "gen_base"); + EXPECT_EQ(rewritten["subtasks"][0]["numTest"], 3); + EXPECT_EQ(rewritten["subtasks"][1]["dependsOn"], nlohmann::json::array({"base"})); + EXPECT_EQ(rewritten["subtasks"][1]["checker"], "double_6"); + EXPECT_EQ(rewritten["tests"][0]["subtask"], "full"); + EXPECT_EQ(rewritten["languageConfig"]["default"], "py"); +} + +TEST(TestParseConfig, ImplicitSubtaskIsNotWrittenToLegacyConfig) { + TemporaryConfig config(R"( +[problem] +name = "Legacy round trip" +group = "Tests" + +[run] +checker = "token_checker" +time_limit_ms = 3000 + +[[tests]] +enabled = true +input = "1\n" +output = "1\n" +has_expected_output = true +)"); + const auto output = config.output_path(); + + write_problem_config(output, parse_problem_config_file(config.path)); + const auto rewritten = parse_problem_config_file(output); + + EXPECT_FALSE(rewritten["explicitSubtasks"]); + ASSERT_EQ(rewritten["subtasks"].size(), 1); + EXPECT_EQ(rewritten["tests"][0]["subtask"], ""); +} + +TEST(TestParseConfig, CoreWriterPreservesUnrecognizedConfigData) { + TemporaryConfig config(R"( +custom_top_level = "keep" + +[problem] +name = "Unknown keys" +custom_problem_key = 17 + +[run] +checker = "token_checker" +time_limit_ms = 3000 +custom_run_key = "keep" + +[[tests]] +enabled = true +input = "1\n" +output = "1\n" +has_expected_output = true +custom_test_key = 42 +)"); + const auto output = config.output_path(); + + write_problem_config(output, parse_problem_config_file(config.path)); + const auto rewritten = parse_problem_config_file(output); + + EXPECT_EQ(rewritten["custom_top_level"], "keep"); + EXPECT_EQ(rewritten["problem"]["custom_problem_key"], 17); + EXPECT_EQ(rewritten["run"]["custom_run_key"], "keep"); + EXPECT_EQ(rewritten["tests"][0]["custom_test_key"], 42); +} + +TEST(TestParseConfig, ParsesConsumerConfigsWhenTaskRootIsProvided) { + const char *task_root = std::getenv("CPCLI_TEST_TASK_ROOT"); + if (task_root == nullptr) { + GTEST_SKIP() << "Set CPCLI_TEST_TASK_ROOT to run consumer config regression coverage"; + } + + size_t config_count = 0; + for (const auto &entry : std::filesystem::directory_iterator(task_root)) { + if (!entry.is_directory()) { + continue; + } + const auto config_path = resolve_problem_config_path(entry.path()); + if (!std::filesystem::exists(config_path)) { + continue; + } + SCOPED_TRACE(config_path.string()); + const auto parsed = parse_problem_config_file(config_path); + EXPECT_TRUE(parsed.contains("subtasks")); + EXPECT_FALSE(parsed["subtasks"].empty()); + ++config_count; + } + EXPECT_GT(config_count, 0); +} diff --git a/core/test/src/test_path_manager.cpp b/core/test/src/test_path_manager.cpp index 517edcd..b0cceec 100644 --- a/core/test/src/test_path_manager.cpp +++ b/core/test/src/test_path_manager.cpp @@ -101,8 +101,7 @@ TEST(TestPathManager, SelectsCudaSolutionFromProblemOverride) { std::ofstream(task_dir / "solution.cpp") << "int main() {}\n"; std::ofstream(task_dir / "solution.cu") << "__global__ void kernel() {}\n"; - json project_config = { - {"root", root}, {"language_config", {{"default", "cpp"}, {"override", json::object()}}}}; + json project_config = {{"root", root}, {"language_config", {{"default", "cpp"}, {"override", json::object()}}}}; json problem_config = {{"languageConfig", {{"solution", "cu"}}}}; PathManager manager; @@ -112,6 +111,24 @@ TEST(TestPathManager, SelectsCudaSolutionFromProblemOverride) { std::filesystem::remove_all(test_dir); } +TEST(TestPathManager, SelectsTaskDefaultLanguage) { + auto test_dir = std::filesystem::temp_directory_path() / gen_string_length_20(); + auto task_dir = test_dir / "task" / "python_task"; + std::filesystem::create_directories(task_dir); + std::filesystem::create_directory(test_dir / "archive"); + std::filesystem::create_directory(test_dir / "output"); + std::ofstream(task_dir / "solution.cpp") << "int main() {}\n"; + std::ofstream(task_dir / "solution.py") << "print()\n"; + + json project_config = {{"root", test_dir}, {"language_config", {{"default", "cpp"}, {"override", json::object()}}}}; + json problem_config = {{"languageConfig", {{"default", "py"}}}}; + + PathManager manager; + ASSERT_EQ(manager.init(project_config, problem_config), PathManagerStatus::Success); + EXPECT_EQ(manager.get_solution_path(task_dir), task_dir / "solution.py"); + std::filesystem::remove_all(test_dir); +} + TEST(TestPathManager, FindsSupportedMultiDotTaskFile) { auto test_dir = std::filesystem::temp_directory_path() / gen_string_length_20(); auto task_dir = test_dir / "task"; @@ -121,8 +138,7 @@ TEST(TestPathManager, FindsSupportedMultiDotTaskFile) { auto solution_path = task_dir / "solution.reference.cpp"; std::ofstream(solution_path) << "int main() {}\n"; - json project_config = { - {"root", test_dir}, {"language_config", {{"default", "cpp"}, {"override", json::object()}}}}; + json project_config = {{"root", test_dir}, {"language_config", {{"default", "cpp"}, {"override", json::object()}}}}; PathManager manager; ASSERT_EQ(manager.init(project_config), PathManagerStatus::Success); @@ -130,6 +146,27 @@ TEST(TestPathManager, FindsSupportedMultiDotTaskFile) { std::filesystem::remove_all(test_dir); } +TEST(TestPathManager, ResolvesNamedSubtaskFiles) { + auto test_dir = std::filesystem::temp_directory_path() / gen_string_length_20(); + auto task_dir = test_dir / "task"; + std::filesystem::create_directories(task_dir); + std::filesystem::create_directory(test_dir / "archive"); + std::filesystem::create_directory(test_dir / "output"); + std::ofstream(task_dir / "gen.py") << "print()\n"; + std::ofstream(task_dir / "gen_base.py") << "print()\n"; + std::ofstream(task_dir / "slow_full.cpp") << "int main() {}\n"; + + json project_config = {{"root", test_dir}, {"language_config", {{"default", "cpp"}, {"override", json::object()}}}}; + json problem_config = {{"languageConfig", {{"default", "py"}, {"slow", "cpp"}}}}; + PathManager manager; + ASSERT_EQ(manager.init(project_config, problem_config), PathManagerStatus::Success); + + EXPECT_EQ(manager.get_task_gen_path(task_dir, "gen_base"), task_dir / "gen_base.py"); + EXPECT_EQ(manager.get_slow_path(task_dir, "slow_full"), task_dir / "slow_full.cpp"); + EXPECT_EQ(manager.get_task_gen_path(task_dir), task_dir / "gen.py"); + std::filesystem::remove_all(test_dir); +} + TEST(TestPathManager, TestInitConfigRootNotExist) { json project_config; auto str = gen_string_length_20(); diff --git a/core/test/test_subtasks.sh b/core/test/test_subtasks.sh new file mode 100644 index 0000000..0aa1d27 --- /dev/null +++ b/core/test/test_subtasks.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cpcli_app="$1" +token_checker="$2" +workdir="$(mktemp -d)" +trap 'rm -rf "$workdir"' EXIT + +mkdir -p "$workdir/task/example" "$workdir/archive" "$workdir/output" \ + "$workdir/data/checkers" "$workdir/data/templates/common" +cp "$token_checker" "$workdir/data/checkers/token_checker" +cp "$(dirname "$0")/../../default/templates/common/problem_config.template" \ + "$workdir/data/templates/common/problem_config.template" + +cat >"$workdir/project_config.toml" <"$workdir/task/example/config.toml" <<'EOF' +tests = [] + +[problem] +name = "Subtask integration" +group = "Tests" + +[run] +checker = "token_checker" +time_limit_ms = 3000 +stop_on_first_failure = false +interactive = false + +[display] +hide_accepted_tests = true +truncate_long_output = false + +[generation] +enabled = false +test_count = 0 +seed = "" +args = "" +expected_output_from_slow = false + +[[subtasks]] +name = "alpha" +points = 30 +gen = "gen_alpha" + +[subtasks.generation] +enabled = true +test_count = 1 +seed = "alpha-seed" + +[[subtasks]] +name = "beta" +points = 70 +depends_on = ["alpha"] +gen = "gen_beta" + +[subtasks.generation] +enabled = true +test_count = 1 +seed = "beta-seed" +EOF + +cat >"$workdir/task/example/solution.cpp" <<'EOF' +#include +int main() { + int value; + std::cin >> value; + std::cout << value << '\n'; +} +EOF + +cat >"$workdir/task/example/gen_alpha.cpp" <<'EOF' +#include +#include +int main() { + if (std::filesystem::current_path().filename() != "0") return 2; + std::ofstream("S0.in") << "1\n"; + std::ofstream("S0.out") << "1\n"; +} +EOF + +cat >"$workdir/task/example/gen_beta.cpp" <<'EOF' +#include +#include +int main() { + if (std::filesystem::current_path().filename() != "1") return 2; + std::ofstream("S0.in") << "2\n"; + std::ofstream("S0.out") << "2\n"; +} +EOF + +full_output="$workdir/full-output" +if ! CPCLI_DATA_DIR="$workdir/data" "$cpcli_app" -p "$workdir/project_config.toml" \ + task -r "$workdir/task/example" -b >"$full_output"; then + cat "$full_output" >&2 + exit 1 +fi +grep -q "Subtask alpha: accepted" "$full_output" +grep -q "Subtask beta: accepted" "$full_output" +grep -q "Score: 100/100" "$full_output" + +focused_output="$workdir/focused-output" +if ! CPCLI_DATA_DIR="$workdir/data" "$cpcli_app" -p "$workdir/project_config.toml" \ + task -r "$workdir/task/example" -b --subtask beta >"$focused_output"; then + cat "$focused_output" >&2 + exit 1 +fi +grep -q "Subtask beta: accepted" "$focused_output" +grep -q "Score: not calculated for --subtask runs" "$focused_output" +if grep -q "Subtask alpha: accepted" "$focused_output"; then + echo "focused run unexpectedly executed alpha" >&2 + exit 1 +fi diff --git a/default/task_editor/tauri_task_editor/index.html b/default/task_editor/tauri_task_editor/index.html index 99decc2..6037688 100644 --- a/default/task_editor/tauri_task_editor/index.html +++ b/default/task_editor/tauri_task_editor/index.html @@ -55,6 +55,10 @@ +
@@ -99,6 +103,11 @@
+ +
diff --git a/default/task_editor/tauri_task_editor/src-tauri/src/main.rs b/default/task_editor/tauri_task_editor/src-tauri/src/main.rs index d4d3897..b4ea8b1 100644 --- a/default/task_editor/tauri_task_editor/src-tauri/src/main.rs +++ b/default/task_editor/tauri_task_editor/src-tauri/src/main.rs @@ -1,8 +1,8 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use serde_json::{Map, Value}; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::fs; use std::path::PathBuf; @@ -10,68 +10,6 @@ use std::process::Command; use std::sync::Mutex; use tauri::State; -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct Test { - input: String, - output: String, - index: i32, - active: bool, - answer: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -struct LanguageConfig { - #[serde(skip_serializing_if = "Option::is_none")] - solution: Option, - #[serde(skip_serializing_if = "Option::is_none")] - slow: Option, - #[serde(skip_serializing_if = "Option::is_none")] - gen: Option, - #[serde(skip_serializing_if = "Option::is_none")] - checker: Option, - #[serde(skip_serializing_if = "Option::is_none")] - interactor: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -struct ProblemConfig { - #[serde(skip_serializing_if = "Option::is_none")] - gen_parameters: Option, - #[serde(skip_serializing_if = "Option::is_none")] - name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - group: Option, - #[serde(skip_serializing_if = "Option::is_none")] - url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - checker: Option, - #[serde(skip_serializing_if = "Option::is_none")] - num_test: Option, - #[serde(skip_serializing_if = "Option::is_none")] - time_limit: Option, - #[serde(skip_serializing_if = "Option::is_none")] - generator_seed: Option, - #[serde(skip_serializing_if = "Option::is_none")] - use_generation: Option, - #[serde(skip_serializing_if = "Option::is_none")] - know_gen_ans: Option, - #[serde(skip_serializing_if = "Option::is_none")] - hide_accepted_test: Option, - #[serde(skip_serializing_if = "Option::is_none")] - truncate_long_test: Option, - #[serde(skip_serializing_if = "Option::is_none")] - stop_at_wrong_answer: Option, - #[serde(skip_serializing_if = "Option::is_none")] - interactive: Option, - #[serde(default)] - tests: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - language_config: Option, -} - struct AppState { config_read_path: Mutex, config_write_path: Mutex, @@ -121,7 +59,12 @@ fn problem_config_write_path(read_path: &PathBuf) -> PathBuf { write_path } -fn copy_if_present(dst: &mut Map, src: &Map, src_key: &str, dst_key: &str) { +fn copy_if_present( + dst: &mut Map, + src: &Map, + src_key: &str, + dst_key: &str, +) { if let Some(value) = src.get(src_key) { if !value.is_null() { dst.insert(dst_key.to_string(), value.clone()); @@ -144,11 +87,26 @@ fn normalize_problem_config_value(value: Value) -> Value { copy_if_present(&mut normalized, run, "checker", "checker"); copy_if_present(&mut normalized, run, "interactive", "interactive"); copy_if_present(&mut normalized, run, "time_limit_ms", "timeLimit"); - copy_if_present(&mut normalized, run, "stop_on_first_failure", "stopAtWrongAnswer"); + copy_if_present( + &mut normalized, + run, + "stop_on_first_failure", + "stopAtWrongAnswer", + ); } if let Some(display) = root.get("display").and_then(|value| value.as_object()) { - copy_if_present(&mut normalized, display, "hide_accepted_tests", "hideAcceptedTest"); - copy_if_present(&mut normalized, display, "truncate_long_output", "truncateLongTest"); + copy_if_present( + &mut normalized, + display, + "hide_accepted_tests", + "hideAcceptedTest", + ); + copy_if_present( + &mut normalized, + display, + "truncate_long_output", + "truncateLongTest", + ); } if let Some(generation) = root.get("generation").and_then(|value| value.as_object()) { copy_if_present(&mut normalized, generation, "enabled", "useGeneration"); @@ -163,11 +121,163 @@ fn normalize_problem_config_value(value: Value) -> Value { ); } - copy_if_present(&mut normalized, root, "hideAcceptedTestCases", "hideAcceptedTest"); - copy_if_present(&mut normalized, root, "stopOnFirstFail", "stopAtWrongAnswer"); + copy_if_present( + &mut normalized, + root, + "hideAcceptedTestCases", + "hideAcceptedTest", + ); + copy_if_present( + &mut normalized, + root, + "stopOnFirstFail", + "stopAtWrongAnswer", + ); copy_if_present(&mut normalized, root, "generatorParams", "genParameters"); + copy_if_present(&mut normalized, root, "language_config", "languageConfig"); + + let explicit_subtasks = root + .get("explicitSubtasks") + .and_then(Value::as_bool) + .unwrap_or_else(|| root.get("subtasks").is_some_and(Value::is_array)); + normalized.insert( + "explicitSubtasks".to_string(), + Value::Bool(explicit_subtasks), + ); + + let default_checker = normalized + .get("checker") + .and_then(Value::as_str) + .unwrap_or("token_checker") + .to_string(); + let default_time_limit = normalized + .get("timeLimit") + .and_then(Value::as_i64) + .unwrap_or(10_000); + let default_use_generation = normalized + .get("useGeneration") + .and_then(Value::as_bool) + .unwrap_or(false); + let default_num_test = normalized + .get("numTest") + .and_then(Value::as_i64) + .unwrap_or(0); + let default_generator_seed = normalized + .get("generatorSeed") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let default_gen_parameters = normalized + .get("genParameters") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let default_know_gen_ans = normalized + .get("knowGenAns") + .and_then(Value::as_bool) + .unwrap_or(false); + + let mut subtasks = if explicit_subtasks { + root.get("subtasks") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + } else { + vec![Value::Object(Map::new())] + }; + for (index, subtask) in subtasks.iter_mut().enumerate() { + let Some(subtask) = subtask.as_object_mut() else { + continue; + }; + subtask.insert("index".to_string(), Value::from(index as i64)); + subtask + .entry("name".to_string()) + .or_insert_with(|| Value::String(String::new())); + subtask + .entry("enabled".to_string()) + .or_insert(Value::Bool(true)); + if let Some(depends_on) = subtask.get("depends_on").cloned() { + subtask.insert("dependsOn".to_string(), depends_on); + } + subtask + .entry("dependsOn".to_string()) + .or_insert_with(|| Value::Array(Vec::new())); + subtask + .entry("gen".to_string()) + .or_insert_with(|| Value::String("gen".to_string())); + subtask + .entry("slow".to_string()) + .or_insert_with(|| Value::String("slow".to_string())); + subtask + .entry("checker".to_string()) + .or_insert_with(|| Value::String(default_checker.clone())); + if let Some(time_limit) = subtask.get("time_limit_ms").cloned() { + subtask.insert("timeLimit".to_string(), time_limit); + } + subtask + .entry("timeLimit".to_string()) + .or_insert(Value::from(default_time_limit)); + + let generation = subtask + .get("generation") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + subtask.insert( + "useGeneration".to_string(), + generation + .get("enabled") + .cloned() + .unwrap_or(Value::Bool(default_use_generation)), + ); + subtask.insert( + "numTest".to_string(), + generation + .get("test_count") + .cloned() + .unwrap_or(Value::from(default_num_test)), + ); + subtask.insert( + "generatorSeed".to_string(), + generation + .get("seed") + .cloned() + .unwrap_or_else(|| Value::String(default_generator_seed.clone())), + ); + subtask.insert( + "genParameters".to_string(), + generation + .get("args") + .cloned() + .unwrap_or_else(|| Value::String(default_gen_parameters.clone())), + ); + subtask.insert( + "knowGenAns".to_string(), + generation + .get("expected_output_from_slow") + .cloned() + .unwrap_or(Value::Bool(default_know_gen_ans)), + ); + } + normalized.insert("subtasks".to_string(), Value::Array(subtasks.clone())); - if let Some(tests) = normalized.get_mut("tests").and_then(|value| value.as_array_mut()) { + if let Some(tests) = normalized + .get_mut("tests") + .and_then(|value| value.as_array_mut()) + { + let subtask_indices: BTreeMap = subtasks + .iter() + .enumerate() + .filter_map(|(index, subtask)| { + Some((subtask.get("name")?.as_str()?.to_string(), index)) + }) + .collect(); + let first_subtask_name = subtasks + .first() + .and_then(|subtask| subtask.get("name")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); for (index, test) in tests.iter_mut().enumerate() { let Some(test_obj) = test.as_object_mut() else { continue; @@ -178,53 +288,95 @@ fn normalize_problem_config_value(value: Value) -> Value { if let Some(has_expected_output) = test_obj.get("has_expected_output").cloned() { test_obj.insert("answer".to_string(), has_expected_output); } - test_obj.entry("index".to_string()).or_insert(Value::from(index as i64)); - test_obj.entry("active".to_string()).or_insert(Value::Bool(true)); + test_obj + .entry("index".to_string()) + .or_insert(Value::from(index as i64)); + test_obj + .entry("active".to_string()) + .or_insert(Value::Bool(true)); let has_output = test_obj.get("output").is_some_and(|value| !value.is_null()); - test_obj.entry("answer".to_string()).or_insert(Value::Bool(has_output)); + test_obj + .entry("answer".to_string()) + .or_insert(Value::Bool(has_output)); + let subtask_name = test_obj + .entry("subtask".to_string()) + .or_insert_with(|| Value::String(first_subtask_name.clone())) + .as_str() + .unwrap_or_default() + .to_string(); + let subtask_index = subtask_indices + .get(&subtask_name) + .map(|index| *index as i64) + .unwrap_or(-1); + test_obj.insert("subtaskIndex".to_string(), Value::from(subtask_index)); } } - normalized.remove("problem"); - normalized.remove("run"); - normalized.remove("display"); - normalized.remove("generation"); Value::Object(normalized) } -fn problem_config_to_toml_value(config: &ProblemConfig) -> Result { - let old = serde_json::to_value(config) - .map_err(|e| format!("Failed to convert problem config: {}", e))?; - let old = normalize_problem_config_value(old); +fn problem_config_to_toml_value(config: &Value) -> Result { + let old = normalize_problem_config_value(config.clone()); let root = old .as_object() .ok_or_else(|| "Problem config is not an object".to_string())?; - let mut out = Map::new(); - let mut problem = Map::new(); + let mut out = root.clone(); + let mut problem = root + .get("problem") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); copy_if_present(&mut problem, root, "name", "name"); copy_if_present(&mut problem, root, "group", "group"); copy_if_present(&mut problem, root, "url", "url"); out.insert("problem".to_string(), Value::Object(problem)); - let mut run = Map::new(); + let mut run = root + .get("run") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); copy_if_present(&mut run, root, "timeLimit", "time_limit_ms"); copy_if_present(&mut run, root, "checker", "checker"); copy_if_present(&mut run, root, "interactive", "interactive"); copy_if_present(&mut run, root, "stopAtWrongAnswer", "stop_on_first_failure"); out.insert("run".to_string(), Value::Object(run)); - let mut display = Map::new(); - copy_if_present(&mut display, root, "hideAcceptedTest", "hide_accepted_tests"); - copy_if_present(&mut display, root, "truncateLongTest", "truncate_long_output"); + let mut display = root + .get("display") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + copy_if_present( + &mut display, + root, + "hideAcceptedTest", + "hide_accepted_tests", + ); + copy_if_present( + &mut display, + root, + "truncateLongTest", + "truncate_long_output", + ); out.insert("display".to_string(), Value::Object(display)); - let mut generation = Map::new(); + let mut generation = root + .get("generation") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); copy_if_present(&mut generation, root, "useGeneration", "enabled"); copy_if_present(&mut generation, root, "numTest", "test_count"); copy_if_present(&mut generation, root, "generatorSeed", "seed"); copy_if_present(&mut generation, root, "genParameters", "args"); - copy_if_present(&mut generation, root, "knowGenAns", "expected_output_from_slow"); + copy_if_present( + &mut generation, + root, + "knowGenAns", + "expected_output_from_slow", + ); out.insert("generation".to_string(), Value::Object(generation)); let mut tests = Vec::new(); @@ -233,11 +385,20 @@ fn problem_config_to_toml_value(config: &ProblemConfig) -> Result let Some(test_obj) = test.as_object() else { continue; }; - let mut out_test = Map::new(); + let mut out_test = test_obj.clone(); copy_if_present(&mut out_test, test_obj, "active", "enabled"); copy_if_present(&mut out_test, test_obj, "answer", "has_expected_output"); - copy_if_present(&mut out_test, test_obj, "input", "input"); - copy_if_present(&mut out_test, test_obj, "output", "output"); + out_test.remove("active"); + out_test.remove("answer"); + out_test.remove("index"); + out_test.remove("subtaskIndex"); + if !root + .get("explicitSubtasks") + .and_then(Value::as_bool) + .unwrap_or(false) + { + out_test.remove("subtask"); + } tests.push(Value::Object(out_test)); } } @@ -247,10 +408,86 @@ fn problem_config_to_toml_value(config: &ProblemConfig) -> Result out.insert("language_config".to_string(), language_config.clone()); } + if root + .get("explicitSubtasks") + .and_then(Value::as_bool) + .unwrap_or(false) + { + let mut out_subtasks = Vec::new(); + if let Some(subtasks) = root.get("subtasks").and_then(Value::as_array) { + for subtask in subtasks { + let Some(subtask) = subtask.as_object() else { + continue; + }; + let mut out_subtask = subtask.clone(); + copy_if_present(&mut out_subtask, subtask, "dependsOn", "depends_on"); + copy_if_present(&mut out_subtask, subtask, "timeLimit", "time_limit_ms"); + + let mut generation = subtask + .get("generation") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + copy_if_present(&mut generation, subtask, "useGeneration", "enabled"); + copy_if_present(&mut generation, subtask, "numTest", "test_count"); + copy_if_present(&mut generation, subtask, "generatorSeed", "seed"); + copy_if_present(&mut generation, subtask, "genParameters", "args"); + copy_if_present( + &mut generation, + subtask, + "knowGenAns", + "expected_output_from_slow", + ); + out_subtask.insert("generation".to_string(), Value::Object(generation)); + + for key in [ + "index", + "dependsOn", + "timeLimit", + "useGeneration", + "numTest", + "generatorSeed", + "genParameters", + "knowGenAns", + ] { + out_subtask.remove(key); + } + out_subtasks.push(Value::Object(out_subtask)); + } + } + out.insert("subtasks".to_string(), Value::Array(out_subtasks)); + } else { + out.remove("subtasks"); + } + + for key in [ + "name", + "group", + "url", + "timeLimit", + "checker", + "interactive", + "stopAtWrongAnswer", + "hideAcceptedTest", + "truncateLongTest", + "useGeneration", + "numTest", + "generatorSeed", + "genParameters", + "knowGenAns", + "languageConfig", + "hideAcceptedTestCases", + "stopOnFirstFail", + "generatorParams", + "explicitSubtasks", + ] { + out.remove(key); + } + Ok(Value::Object(out)) } -fn parse_problem_config(path: &PathBuf, content: &str) -> Result { +fn parse_problem_config(path: &PathBuf, content: &str) -> Result { let value = match path.extension().and_then(|ext| ext.to_str()) { Some("toml") => { let value: toml::Value = toml::from_str(content) @@ -261,18 +498,17 @@ fn parse_problem_config(path: &PathBuf, content: &str) -> Result serde_json::from_str(content) .map_err(|e| format!("Failed to parse problem JSON config: {}", e))?, }; - serde_json::from_value(normalize_problem_config_value(value)) - .map_err(|e| format!("Failed to normalize problem config: {}", e)) + Ok(normalize_problem_config_value(value)) } -fn serialize_problem_config(config: &ProblemConfig) -> Result { +fn serialize_problem_config(config: &Value) -> Result { let value = problem_config_to_toml_value(config)?; toml::to_string_pretty(&value) .map_err(|e| format!("Failed to serialize problem TOML config: {}", e)) } #[tauri::command] -fn load_config(state: State) -> Result { +fn load_config(state: State) -> Result { let config_path = state.config_read_path.lock().unwrap(); let content = fs::read_to_string(&*config_path) .map_err(|e| format!("Failed to read config file: {}", e))?; @@ -281,7 +517,7 @@ fn load_config(state: State) -> Result { } #[tauri::command] -fn save_config(state: State, config: ProblemConfig) -> Result<(), String> { +fn save_config(state: State, config: Value) -> Result<(), String> { let config_path = state.config_write_path.lock().unwrap(); let content = serialize_problem_config(&config)?; fs::write(&*config_path, content).map_err(|e| format!("Failed to write config file: {}", e))?; @@ -447,3 +683,115 @@ fn main() { .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn editor_round_trip_preserves_unrecognized_config_data() { + let input = r#" +custom_top_level = "keep me" + +[problem] +name = "Python task" +group = "Tests" +custom_problem_key = 17 + +[run] +checker = "token_checker" +time_limit_ms = 3000 +custom_run_key = "keep me too" + +[language_config] +default = "py" +solution = "py" +custom_language_key = "still here" + +[[tests]] +enabled = true +has_expected_output = true +input = "1\n" +output = "1\n" +custom_test_key = 42 +"#; + let path = PathBuf::from("config.toml"); + + let config = parse_problem_config(&path, input).expect("config parses"); + let serialized = serialize_problem_config(&config).expect("config serializes"); + let value: toml::Value = toml::from_str(&serialized).expect("serialized TOML parses"); + + assert_eq!(value["custom_top_level"].as_str(), Some("keep me")); + assert_eq!( + value["problem"]["custom_problem_key"].as_integer(), + Some(17) + ); + assert_eq!(value["run"]["custom_run_key"].as_str(), Some("keep me too")); + assert_eq!(value["language_config"]["default"].as_str(), Some("py")); + assert_eq!( + value["language_config"]["custom_language_key"].as_str(), + Some("still here") + ); + assert_eq!(value["tests"][0]["custom_test_key"].as_integer(), Some(42)); + } + + #[test] + fn editor_normalizes_and_writes_subtasks() { + let input = r#" +[problem] +name = "Subtasks" + +[run] +checker = "token_checker" +time_limit_ms = 3000 + +[generation] +enabled = false +test_count = 0 +seed = "root" +expected_output_from_slow = false + +[[subtasks]] +name = "base" +points = 30 +gen = "gen_base" + +[subtasks.generation] +enabled = true +test_count = 4 +seed = "base" +expected_output_from_slow = true + +[[subtasks]] +name = "full" +enabled = false +points = 70 +depends_on = ["base"] + +[[tests]] +subtask = "full" +enabled = true +input = "1\n" +output = "1\n" +has_expected_output = true +"#; + let path = PathBuf::from("config.toml"); + + let config = parse_problem_config(&path, input).expect("config parses"); + assert_eq!(config["explicitSubtasks"], true); + assert_eq!(config["subtasks"][0]["index"], 0); + assert_eq!(config["subtasks"][0]["numTest"], 4); + assert_eq!(config["subtasks"][1]["checker"], "token_checker"); + assert_eq!(config["tests"][0]["subtaskIndex"], 1); + + let serialized = serialize_problem_config(&config).expect("config serializes"); + let value: toml::Value = toml::from_str(&serialized).expect("serialized TOML parses"); + assert_eq!(value["subtasks"][0]["gen"].as_str(), Some("gen_base")); + assert_eq!( + value["subtasks"][0]["generation"]["test_count"].as_integer(), + Some(4) + ); + assert_eq!(value["subtasks"][1]["depends_on"][0].as_str(), Some("base")); + assert_eq!(value["tests"][0]["subtask"].as_str(), Some("full")); + } +} diff --git a/default/task_editor/tauri_task_editor/src/main.js b/default/task_editor/tauri_task_editor/src/main.js index 4bd350e..e622de0 100644 --- a/default/task_editor/tauri_task_editor/src/main.js +++ b/default/task_editor/tauri_task_editor/src/main.js @@ -10,6 +10,7 @@ import { vim } from '@replit/codemirror-vim'; // State let config = null; let tests = []; +let subtasks = []; let currentTestIndex = -1; let inputEditor = null; let outputEditor = null; @@ -174,6 +175,8 @@ const elements = { testInputContainer: document.getElementById('testInput'), testOutputContainer: document.getElementById('testOutput'), knowAnswer: document.getElementById('knowAnswer'), + testSubtask: document.getElementById('testSubtask'), + testSubtaskField: document.getElementById('testSubtaskField'), outputContainer: document.getElementById('outputContainer'), // Options @@ -195,6 +198,10 @@ const elements = { overrideSolution: document.getElementById('overrideSolution'), overrideSlow: document.getElementById('overrideSlow'), overrideGen: document.getElementById('overrideGen'), + + // Subtasks + subtaskPanel: document.getElementById('subtaskPanel'), + subtaskList: document.getElementById('subtaskList'), }; // Initialize the app @@ -212,7 +219,9 @@ async function init() { invoke('load_language_options'), ]); tests = config.tests || []; + subtasks = config.subtasks || []; populateForm(); + renderSubtasks(); renderTestList(); if (tests.length > 0) { selectTest(0); @@ -264,6 +273,34 @@ function populateForm() { populateLanguageSelect(elements.overrideSolution, langConfig.solution || ''); populateLanguageSelect(elements.overrideSlow, langConfig.slow || ''); populateLanguageSelect(elements.overrideGen, langConfig.gen || ''); + + const hasSubtasks = config.explicitSubtasks === true; + elements.subtaskPanel.classList.toggle('hidden', !hasSubtasks); + elements.testSubtaskField.classList.toggle('hidden', !hasSubtasks); + elements.testSubtask.innerHTML = ''; + subtasks.forEach((subtask) => { + const option = document.createElement('option'); + option.value = subtask.name; + option.textContent = subtask.name || 'default'; + elements.testSubtask.appendChild(option); + }); +} + +function renderSubtasks() { + elements.subtaskList.innerHTML = ''; + subtasks.forEach((subtask) => { + const row = document.createElement('label'); + row.className = 'subtask-item'; + const points = subtask.points == null ? '' : ` (${subtask.points} pts)`; + row.innerHTML = ` + + ${subtask.name || 'default'}${points} + `; + row.querySelector('input').addEventListener('change', (event) => { + subtask.enabled = event.target.checked; + }); + elements.subtaskList.appendChild(row); + }); } function renderTestList() { @@ -303,6 +340,7 @@ function selectTest(index) { setEditorContent(inputEditor, test.input || ''); setEditorContent(outputEditor, test.output || ''); elements.knowAnswer.checked = test.answer !== false; + elements.testSubtask.value = test.subtask || subtasks[0]?.name || ''; elements.outputContainer.classList.toggle('hidden', !elements.knowAnswer.checked); } renderTestList(); @@ -315,6 +353,7 @@ function saveCurrentTest() { input: getEditorContent(inputEditor), output: getEditorContent(outputEditor), answer: elements.knowAnswer.checked, + subtask: elements.testSubtask.value, }; } } @@ -335,6 +374,7 @@ function setupEventListeners() { index: tests.length, active: true, answer: true, + subtask: subtasks[0]?.name || '', }; tests.push(newTest); selectTest(tests.length - 1); @@ -553,6 +593,7 @@ async function saveConfig() { saveCurrentTest(); const updatedConfig = { + ...config, name: elements.taskName.value, group: elements.groupName.value, interactive: elements.interactive.checked, @@ -567,7 +608,9 @@ async function saveConfig() { generatorSeed: elements.generatorSeed.value, genParameters: elements.genParameters.value, tests: tests, + subtasks: subtasks, languageConfig: { + ...(config.languageConfig || {}), solution: elements.overrideSolution.value || null, slow: elements.overrideSlow.value || null, gen: elements.overrideGen.value || null, diff --git a/default/task_editor/tauri_task_editor/src/styles.css b/default/task_editor/tauri_task_editor/src/styles.css index bab7f5c..31f06cb 100644 --- a/default/task_editor/tauri_task_editor/src/styles.css +++ b/default/task_editor/tauri_task_editor/src/styles.css @@ -230,6 +230,24 @@ textarea { border-radius: 4px; } +.subtask-panel { + flex: 1; + min-width: 160px; +} + +.subtask-list { + display: flex; + flex-direction: column; + gap: 5px; + margin-top: 6px; +} + +.subtask-item { + display: flex; + align-items: center; + gap: 6px; +} + .generator-panel { flex: 2; } @@ -242,6 +260,10 @@ textarea { display: none; } +.hidden { + display: none !important; +} + .gen-row { display: flex; gap: 10px;