From c23d5397c2337451ef150109b89decd3458b604e Mon Sep 17 00:00:00 2001 From: popmonkey Date: Sat, 6 Dec 2025 17:33:17 -0800 Subject: [PATCH 01/11] Gemini grounding --- GEMINI.md | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 GEMINI.md diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..47f279c --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,97 @@ +# Gemini Project: JRES Solver C++ + +This document provides instructions for understanding, building, and contributing to the JRES Solver C++ project. + +## Project Overview + +JRES Solver is a C++ library designed to optimize endurance racing schedules. It uses the HiGHS Mixed Integer Programming (MIP) solver to assign drivers and optional spotters to race stints while satisfying various constraints. + +The project is structured as a C-API library (`libjres_solver.a`) with two command-line interface (CLI) clients: +* `jres_solver`: A tool that uses the library to solve race schedules. +* `jres_formatter`: A tool to format the output of the solver. + +The library provides helper functions to convert between its C-API data structures and JSON, making it easier to integrate with other systems. + +## Building and Running + +This project uses CMake for building. + +### Dependencies + +* **CMake (version 3.15+)** +* **Git** (for managing submodules) +* **HiGHS Optimization Solver**: This must be installed on your system. + * **macOS (Homebrew):** `brew install highs` + * **Linux:** Build from source (see `CONTRIBUTING.md`). + * **Windows (vcpkg):** `vcpkg install highs:x64-windows-static` + +### Build Steps + +1. **Clone the repository and initialize submodules:** + ```bash + git clone jres_solver_cpp + cd jres_solver_cpp + git submodule update --init --recursive + ``` + +2. **Configure the project with CMake:** + ```bash + mkdir build + cd build + # Add platform-specific flags if needed, e.g., for macOS with Homebrew: + # cmake .. -DCMAKE_PREFIX_PATH=/opt/homebrew + cmake .. + ``` + +3. **Build the project:** + ```bash + cmake --build . + ``` + This will generate the library and executables in the `build/` directory. + +### Running Tests + +The project uses GoogleTest for its test suite. To run the tests, execute the following command from the `build` directory: + +```bash +ctest +``` + +### Running the CLI Tools + +The compiled executables are located in the `build/bin` directory. + +**Solver (`jres_solver`):** + +```bash +# Run with an input file +./bin/jres_solver -i ../data/short_race.json -s integrated + +# Pipe data from stdin +cat ../data/24h_race.json | ./bin/jres_solver -s sequential + +# Run diagnostics on an infeasible schedule +./bin/jres_solver -i ../data/no_solution.json --diagnose +``` + +**Formatter (`jres_formatter`):** + +The formatter takes the JSON output from the solver and can generate different report formats. + +```bash +# Get help for the formatter +./bin/jres_formatter --help +``` + +## Development Conventions + +* **Build System:** The project uses CMake for building and configuration. +* **Dependencies:** C++ library dependencies are managed as Git submodules (`cxxopts`, `nlohmann/json`). The HiGHS solver is an external dependency. +* **Testing:** The test suite is built with GoogleTest and run via CTest. New tests should be added to the `test/` directory. +* **API Design:** The core logic is exposed as a C-API for wider compatibility. Helper functions are provided for JSON serialization and deserialization. +* **Code Style:** The codebase is written in C++. Please follow the existing coding style when contributing. + +# MODEL INSTRUCTIONS +- **Verbosity:** Low. Do not explain the code unless asked. Just output the diff or the file. +- **Reasoning:** Perform deep reasoning internally, but output only the final solution. +- **Execution:** Don't stage commits or otherwise try to manage the repo. From 12fa59f42e22af10e99924bb31850a18d16bd4bd Mon Sep 17 00:00:00 2001 From: popmonkey Date: Sun, 7 Dec 2025 08:33:32 -0800 Subject: [PATCH 02/11] update GEMINI.md to clarify public headers and correct CLI tool paths --- GEMINI.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 47f279c..780050e 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -12,6 +12,13 @@ The project is structured as a C-API library (`libjres_solver.a`) with two comma The library provides helper functions to convert between its C-API data structures and JSON, making it easier to integrate with other systems. +### Public headers + +The library exposes its API via headers in `include/jres_solver/`. Designed for seamless Foreign Function Interface (FFI) integration with languages like Python, Ruby, and Go, these headers are strictly: +* **C-Compatible:** All exported symbols utilize extern "C" linkage. +* **Self-Contained:** The interface uses standard C types and opaque handles only, ensuring no dependency on the C++ Standard Library (STL) at the boundary. +* **Standalone:** Each header is fully self-sufficient and requires no prior includes. + ## Building and Running This project uses CMake for building. @@ -59,19 +66,19 @@ ctest ### Running the CLI Tools -The compiled executables are located in the `build/bin` directory. +The compiled executables are located in the `build/` directory. **Solver (`jres_solver`):** ```bash # Run with an input file -./bin/jres_solver -i ../data/short_race.json -s integrated +./jres_solver -i ../data/short_race.json -s integrated -# Pipe data from stdin -cat ../data/24h_race.json | ./bin/jres_solver -s sequential +# Pipe data from stdin and output to a file +cat ../data/24h_race.json | ./jres_solver -s sequential -o /tmp/24_race_solution.json # Run diagnostics on an infeasible schedule -./bin/jres_solver -i ../data/no_solution.json --diagnose +./jres_solver -i ../data/short_race_no_solution.json --diagnose ``` **Formatter (`jres_formatter`):** @@ -79,8 +86,8 @@ cat ../data/24h_race.json | ./bin/jres_solver -s sequential The formatter takes the JSON output from the solver and can generate different report formats. ```bash -# Get help for the formatter -./bin/jres_formatter --help +# Load a solution and generate a txt summary +./jres_formatter -i /tmp/24_race_solution.json -o /tmp/summary.txt ``` ## Development Conventions From 6b7b3a5ad36ae6c190e04bf32725fc984a7d7862 Mon Sep 17 00:00:00 2001 From: popmonkey Date: Sun, 7 Dec 2025 08:43:14 -0800 Subject: [PATCH 03/11] formatter refactored for new stint list input --- README.md | 7 +- cmd/formatter/cli.cpp | 4 +- cmd/solver/cli.cpp | 2 +- include/jres_solver/jres_solver.hpp | 14 +- src/formatter/formatter_core.cpp | 209 ++++++++++++++-------------- src/formatter/formatter_core.hpp | 10 +- src/jres_diagnostic_solver.cpp | 2 +- src/jres_internal_types.cpp | 14 +- src/jres_internal_types.hpp | 5 +- src/jres_json_converter.cpp | 44 +++++- src/jres_standard_solver.cpp | 5 +- test/test_formatter_csv.cpp | 16 +-- test/test_formatter_itinerary.cpp | 97 ++++++------- test/test_formatter_summary.cpp | 9 +- 14 files changed, 258 insertions(+), 180 deletions(-) diff --git a/README.md b/README.md index cfd282b..dcd5fb8 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,9 @@ The C-API uses the following structs to pass data to and from the solver. | `name` | `const char*` | Unique identifier for the member. | | `isDriver` | `int` | `1` if the member can drive, `0` otherwise. | | `isSpotter` | `int` | `1` if the member can spot, `0` otherwise. | -| `preferredStints`| `int` | Soft constraint: solver attempts to limit consecutive stints to this number. | +| `maxStints`| `int` | Hard constraint: Maximum number of consecutive stints a member can perform. | | `minimumRestHours` | `int` | Hard constraint: Minimum rest time required after a driving shift before driving again. | +| `tzOffset` | `double` | Timezone offset in hours from UTC. | `JresStint` @@ -72,6 +73,8 @@ These structs are used to represent the availability of team members. | `schedule_len` | `int` | The number of schedule entries. | | `diagnosis` | `const char**` | An array of strings with diagnostic information. Empty on success. | | `diagnosis_len` | `int` | The number of diagnosis strings. | +| `teamMembers` | `JresTeamMember*` | A pointer to an array of team members, including their tzOffset. | +| `teamMembers_len` | `int` | The number of team members. | `JresScheduleEntry` @@ -174,6 +177,7 @@ The `raceDataJson` string passed to `jres_input_from_json` must strictly follow | `isSpotter` | Boolean | `false` | Can this member spot? | | `maxStints` | Integer| `1` | Hard constraint: Maximum number of consecutive stints a member can perform. | | `minimumRestHours` | Integer| `0` | Hard constraint: Minimum rest time required after a driving shift before driving again. | +| `tzOffset` | Number | `0.0` | Timezone offset in hours from UTC. | #### Availability Map & Time Formatting @@ -233,6 +237,7 @@ The `jres_output_to_json` function returns a JSON string containing the solution | `schedule` | Array | List of optimized stint assignments. | | `diagnosis`| Array | List of strings with diagnostic information. Empty on success. | | `stats` | Object | Solver performance and complexity metrics. | +| `teamMembers` | Array | List of team members and their properties. | ##### Stats Object diff --git a/cmd/formatter/cli.cpp b/cmd/formatter/cli.cpp index 03c2088..ae0b2a5 100644 --- a/cmd/formatter/cli.cpp +++ b/cmd/formatter/cli.cpp @@ -106,8 +106,8 @@ int main(int argc, char* argv[]) { return 1; } - if (!solved_data.contains("schedule") || !solved_data.contains("raceData")) { - std::cerr << "Error: Invalid JSON. Expected keys 'schedule' and 'raceData'." << std::endl; + if (!solved_data.contains("schedule") || !solved_data.contains("teamMembers")) { + std::cerr << "Error: Invalid JSON. Expected keys 'schedule' and 'teamMembers'." << std::endl; return 1; } diff --git a/cmd/solver/cli.cpp b/cmd/solver/cli.cpp index ad81a70..123a1b6 100644 --- a/cmd/solver/cli.cpp +++ b/cmd/solver/cli.cpp @@ -173,7 +173,7 @@ int main(int argc, char **argv) if (solverOutput->schedule_len > 0) { for (int i = 0; i < solverOutput->schedule_len; ++i) { std::stringstream ss; - ss << "Stint " << std::setw(3) << solverOutput->schedule[i].stintId + ss << "Stint " << std::setw(3) << solverOutput->schedule[i].id << ": Driver: " << std::setw(15) << std::left << solverOutput->schedule[i].driver; if (hasSpotters) { diff --git a/include/jres_solver/jres_solver.hpp b/include/jres_solver/jres_solver.hpp index 1c212af..bd9f043 100644 --- a/include/jres_solver/jres_solver.hpp +++ b/include/jres_solver/jres_solver.hpp @@ -76,6 +76,8 @@ struct JresTeamMember { int maxStints; /** @brief Minimum rest time in hours required after a shift. */ int minimumRestHours; + /** @brief Timezone offset, in hours from UTC. */ + double tzOffset; }; /** @@ -135,7 +137,11 @@ struct JresSolverInput { */ struct JresScheduleEntry { /** @brief The ID of the stint. */ - int stintId; + int id; + /** @brief ISO 8601 timestamp for the start of the stint. */ + const char* startTime; + /** @brief ISO 8601 timestamp for the end of the stint. */ + const char* endTime; /** @brief Name of the assigned driver. */ const char* driver; /** @brief Name of the assigned spotter. */ @@ -176,6 +182,12 @@ struct JresSolverOutput { int diagnosis_len; /** @brief Solver performance and complexity metrics. */ JresSolverStats* stats; + /** @brief The options used to generate this solution. */ + JresSolverOptions* options; + /** @brief A pointer to an array of team members, including their tzOffset. */ + JresTeamMember* teamMembers; + /** @brief The number of team members. */ + int teamMembers_len; }; // --- Public C-API Functions --- diff --git a/src/formatter/formatter_core.cpp b/src/formatter/formatter_core.cpp index 75cfab8..1317ab3 100644 --- a/src/formatter/formatter_core.cpp +++ b/src/formatter/formatter_core.cpp @@ -29,10 +29,9 @@ struct DutyBlock { // --- Logic Implementation --- -std::map> jres::generate_member_itineraries( +std::map jres::generate_member_itineraries( const std::vector& schedule, const json& data, - int pit_time_seconds, bool has_spotters ) { std::map> raw_duties; @@ -42,20 +41,34 @@ std::map> jres::generate_member_itinerar for (const auto& m : data["teamMembers"]) { std::string name = m.value("name", "Unknown"); raw_duties[name] = {}; - tz_map[name] = m.value("timezone", 0); + tz_map[name] = m.value("tzOffset", 0); } } + DateTime race_start_utc; + DateTime race_end_utc; + bool first_stint = true; + + // Populate duties using the solved schedule and determine race boundaries for (const auto& entry : schedule) { - DateTime start = DateTime::parse(entry.value("startTimeUTC", "")); - DateTime end = DateTime::parse(entry.value("endTimeUTC", "")); - int stint_num = entry.value("stint", 0); + DateTime start = DateTime::parse(entry.value("startTime", "")); + DateTime end = DateTime::parse(entry.value("endTime", "")); + int stint_num = entry.value("id", 0); + + if (first_stint) { + race_start_utc = start; + race_end_utc = end; + first_stint = false; + } else { + if (start < race_start_utc) race_start_utc = start; + if (end > race_end_utc) race_end_utc = end; + } + std::string driver = entry.value("driver", "N/A"); - if (driver != "N/A") { if (raw_duties.find(driver) == raw_duties.end()) { raw_duties[driver] = {}; - tz_map[driver] = 0; + tz_map[driver] = 0; // Default tz } raw_duties[driver].push_back({start, end, "Driving", {stint_num}}); } @@ -65,18 +78,21 @@ std::map> jres::generate_member_itinerar if (spotter != "N/A") { if (raw_duties.find(spotter) == raw_duties.end()) { raw_duties[spotter] = {}; - tz_map[spotter] = 0; + tz_map[spotter] = 0; // Default tz } raw_duties[spotter].push_back({start, end, "Spotting", {stint_num}}); } } } + + if (schedule.empty()) { // Handle case with no schedule + return {}; + } - DateTime race_start_utc = DateTime::parse(data.value("raceStartUTC", "1970-01-01T00:00:00Z")); - std::map> final_itineraries; + std::map final_itineraries; for (auto& [name, duties] : raw_duties) { - final_itineraries[name] = {}; + final_itineraries[name] = {{}, tz_map[name]}; std::sort(duties.begin(), duties.end(), [](const DutyBlock& a, const DutyBlock& b) { return a.start_utc < b.start_utc; @@ -86,14 +102,14 @@ std::map> jres::generate_member_itinerar if (!duties.empty()) { DutyBlock current = duties[0]; for (size_t i = 1; i < duties.size(); ++i) { - DutyBlock next = duties[i]; + const DutyBlock& next = duties[i]; double gap = next.start_utc.diff_seconds(current.end_utc); - bool is_contiguous = (next.activity_type == current.activity_type) && - (std::abs(gap - pit_time_seconds) < 2.0); + + bool is_contiguous = (next.activity_type == current.activity_type) && (std::abs(gap) < 2.0); if (is_contiguous) { current.end_utc = next.end_utc; - current.stints.push_back(next.stints[0]); + current.stints.insert(current.stints.end(), next.stints.begin(), next.stints.end()); } else { consolidated.push_back(current); current = next; @@ -106,12 +122,14 @@ std::map> jres::generate_member_itinerar DateTime last_duty_end_local = race_start_utc.add_hours(tz_offset); for (auto& duty : consolidated) { + std::sort(duty.stints.begin(), duty.stints.end()); + DateTime start_local = duty.start_utc.add_hours(tz_offset); DateTime end_local = duty.end_utc.add_hours(tz_offset); double gap_seconds = start_local.diff_seconds(last_duty_end_local); - if (gap_seconds > 1.0 && std::abs(gap_seconds - pit_time_seconds) > 2.0) { - final_itineraries[name].push_back({last_duty_end_local, start_local, "Resting"}); + if (gap_seconds > 1.0) { + final_itineraries[name].items.push_back({last_duty_end_local, start_local, "Resting"}); } std::string activity_str; @@ -121,18 +139,14 @@ std::map> jres::generate_member_itinerar activity_str = duty.activity_type + " Stints #" + std::to_string(duty.stints.front()) + "-" + std::to_string(duty.stints.back()); } - final_itineraries[name].push_back({start_local, end_local, activity_str}); + final_itineraries[name].items.push_back({start_local, end_local, activity_str}); last_duty_end_local = end_local; } - double duration_h = data.value("durationHours", 24.0); - DateTime race_end_local = race_start_utc - .add_hours(static_cast(duration_h)) - .add_seconds(static_cast((duration_h - static_cast(duration_h)) * 3600)) - .add_hours(tz_offset); + DateTime race_end_local = race_end_utc.add_hours(tz_offset); if (race_end_local.diff_seconds(last_duty_end_local) > 1.0) { - final_itineraries[name].push_back({last_duty_end_local, race_end_local, "Resting"}); + final_itineraries[name].items.push_back({last_duty_end_local, race_end_local, "Resting"}); } } @@ -143,118 +157,100 @@ std::string jres::generate_schedule_csv_string(const std::vector& schedule std::ostringstream oss; oss << "Stint,Start Time (UTC),End Time (UTC),Assigned Driver"; if (has_spotters) oss << ",Assigned Spotter"; - oss << ",Laps\n"; + oss << "\n"; for (const auto& entry : schedule) { - oss << entry.value("stint", 0) << "," - << entry.value("startTimeUTC", "") << "," - << entry.value("endTimeUTC", "") << "," + oss << entry.value("id", 0) << "," + << entry.value("startTime", "") << "," + << entry.value("endTime", "") << "," << entry.value("driver", "N/A"); if (has_spotters) { oss << "," << (entry.contains("spotter") ? entry.value("spotter", "N/A") : "N/A"); } - oss << "," << entry.value("laps", 0) << "\n"; + oss << "\n"; } return oss.str(); } -// --- NEW FUNCTION: ASCII Table Generator --- std::string jres::generate_schedule_ascii_table(const std::vector& schedule, bool has_spotters) { if (schedule.empty()) return "No schedule data.\n"; - // Calculate Widths (initialize with header lengths) size_t w_stint = 5; // "Stint" size_t w_start = 13; // "Start (UTC)" size_t w_end = 11; // "End (UTC)" size_t w_driver = 6; // "Driver" size_t w_spot = 7; // "Spotter" - size_t w_laps = 4; // "Laps" for (const auto& entry : schedule) { - std::string s_stint = std::to_string(entry.value("stint", 0)); - w_stint = std::max(w_stint, s_stint.length()); - - std::string s_driver = entry.value("driver", "N/A"); - w_driver = std::max(w_driver, s_driver.length()); - - std::string s_laps = std::to_string(entry.value("laps", 0)); - w_laps = std::max(w_laps, s_laps.length()); - - std::string s_start = entry.value("startTimeUTC", ""); - w_start = std::max(w_start, s_start.length()); - - std::string s_end = entry.value("endTimeUTC", ""); - w_end = std::max(w_end, s_end.length()); - + w_stint = std::max(w_stint, std::to_string(entry.value("id", 0)).length()); + w_driver = std::max(w_driver, entry.value("driver", "N/A").length()); + w_start = std::max(w_start, entry.value("startTime", "").length()); + w_end = std::max(w_end, entry.value("endTime", "").length()); if (has_spotters) { - std::string s_spot = entry.value("spotter", "N/A"); - w_spot = std::max(w_spot, s_spot.length()); + w_spot = std::max(w_spot, entry.value("spotter", "N/A").length()); } } - // Add slight padding size_t pad = 2; - w_stint += pad; w_start += pad; w_end += pad; w_driver += pad; w_spot += pad; w_laps += pad; + w_stint += pad; w_start += pad; w_end += pad; w_driver += pad; w_spot += pad; std::ostringstream oss; - // Header oss << std::left << std::setw(w_stint) << "Stint" << std::setw(w_start) << "Start (UTC)" << std::setw(w_end) << "End (UTC)" << std::setw(w_driver) << "Driver"; if (has_spotters) oss << std::setw(w_spot) << "Spotter"; - oss << std::setw(w_laps) << "Laps" << "\n"; + oss << "\n"; - // Divider - size_t total_width = w_stint + w_start + w_end + w_driver + (has_spotters ? w_spot : 0) + w_laps; + size_t total_width = w_stint + w_start + w_end + w_driver + (has_spotters ? w_spot : 0); oss << std::string(total_width, '-') << "\n"; - // Data for (const auto& entry : schedule) { oss << std::left - << std::setw(w_stint) << entry.value("stint", 0) - << std::setw(w_start) << entry.value("startTimeUTC", "") - << std::setw(w_end) << entry.value("endTimeUTC", "") + << std::setw(w_stint) << entry.value("id", 0) + << std::setw(w_start) << entry.value("startTime", "") + << std::setw(w_end) << entry.value("endTime", "") << std::setw(w_driver) << entry.value("driver", "N/A"); if (has_spotters) { oss << std::setw(w_spot) << (entry.contains("spotter") ? entry.value("spotter", "N/A") : "N/A"); } - - oss << std::setw(w_laps) << entry.value("laps", 0) << "\n"; + oss << "\n"; } return oss.str(); } std::string jres::generate_summary_csv_string( - const std::map>& driver_stats, + const std::map& driver_stats, const std::map& spotter_stats, bool has_spotters ) { std::ostringstream oss; - oss << "Role,Name,Total Stints,Total Laps\n"; + oss << "Role,Name,Total Stints\n"; - for (const auto& [name, stats] : driver_stats) { - oss << "Driver," << name << "," << stats.first << "," << stats.second << "\n"; + for (const auto& [name, stint_count] : driver_stats) { + oss << "Driver," << name << "," << stint_count << "\n"; } if (has_spotters) { for (const auto& [name, count] : spotter_stats) { if (count > 0) { - oss << "Spotter," << name << "," << count << ",-\n"; + oss << "Spotter," << name << "," << count << "\n"; } } } return oss.str(); } -std::string generate_itinerary_csv_string(const std::vector& itinerary) { +std::string generate_itinerary_csv_string(const MemberItinerary& itinerary) { std::ostringstream oss; - oss << "Start Local,End Local,Duration,Activity\n"; - for (const auto& item : itinerary) { + std::string tz_string = std::string("UTC") + (itinerary.tz_offset >= 0 ? "+" : "") + std::to_string(itinerary.tz_offset); + oss << "Start Time (" << tz_string << "),End Time (" << tz_string << "),Duration,Activity\n"; + + for (const auto& item : itinerary.items) { double dur = item.end_local.diff_seconds(item.start_local); oss << item.start_local.to_string() << "," << item.end_local.to_string() << "," @@ -264,18 +260,17 @@ std::string generate_itinerary_csv_string(const std::vector& itin return oss.str(); } -// --- FULL TEXT REPORT --- std::string generate_full_text_report( const std::vector& schedule, - const std::map>& driver_stats, + const std::map& driver_stats, const std::map& spotter_stats, - const std::map>& itineraries, + const std::map& itineraries, bool has_spotters ) { std::ostringstream f; f << "--- DRIVER SUMMARY ---\n"; - for (const auto& [name, stats] : driver_stats) { - f << name << ": " << stats.first << " stints, " << stats.second << " laps\n"; + for (const auto& [name, stint_count] : driver_stats) { + f << name << ": " << stint_count << " stints\n"; } if (has_spotters) { @@ -286,14 +281,16 @@ std::string generate_full_text_report( } f << "\n--- SCHEDULE ---\n"; - // UPDATED: Now uses ASCII table f << jres::generate_schedule_ascii_table(schedule, has_spotters); f << "\n--- ITINERARIES ---\n"; - for (const auto& [name, items] : itineraries) { - if (items.empty()) continue; - f << "\nSchedule for " << name << ":\n"; - for (const auto& item : items) { + for (const auto& [name, itinerary] : itineraries) { + if (itinerary.items.empty()) continue; + + std::string tz_string = std::string("UTC") + (itinerary.tz_offset >= 0 ? "+" : "") + std::to_string(itinerary.tz_offset); + f << "\nSchedule for " << name << " (" << tz_string << "):\n"; + + for (const auto& item : itinerary.items) { double dur = item.end_local.diff_seconds(item.start_local); f << " " << item.start_local.to_string() << " to " << item.end_local.time_string() << " (" << DateTime::format_duration((long long)dur) << "): " << item.activity << "\n"; @@ -306,9 +303,9 @@ std::string generate_full_text_report( void _write_to_txt( const std::vector& schedule, - const std::map>& driver_stats, + const std::map& driver_stats, const std::map& spotter_stats, - const std::map>& itineraries, + const std::map& itineraries, const std::string& filename, bool has_spotters ) { @@ -318,21 +315,19 @@ void _write_to_txt( void _write_to_zip( const std::vector& schedule, - const std::map>& driver_stats, + const std::map& driver_stats, const std::map& spotter_stats, - const std::map>& itineraries, + const std::map& itineraries, const std::string& filename, bool has_spotters ) { ZipWriter zip(filename); zip.add_file("master_schedule.csv", jres::generate_schedule_csv_string(schedule, has_spotters)); zip.add_file("summaries.csv", jres::generate_summary_csv_string(driver_stats, spotter_stats, has_spotters)); - - // UPDATED: Always include summary in ZIP zip.add_file("summary.txt", generate_full_text_report(schedule, driver_stats, spotter_stats, itineraries, has_spotters)); for (const auto& [name, itinerary] : itineraries) { - if (itinerary.empty()) continue; + if (itinerary.items.empty()) continue; std::string safe_name = name; std::replace(safe_name.begin(), safe_name.end(), ' ', '_'); std::replace(safe_name.begin(), safe_name.end(), '/', '_'); @@ -351,25 +346,34 @@ void jres::write_output( const std::string& output_file, const std::string& format ) { - // SAFETY: Check keys before accessing - if (!solved_data.contains("schedule") || !solved_data.contains("raceData")) { - std::cerr << "Error: JSON missing 'schedule' or 'raceData' keys." << std::endl; + if (!solved_data.contains("schedule") || !solved_data.contains("teamMembers")) { + std::cerr << "Error: JSON missing 'schedule' or 'teamMembers' keys." << std::endl; return; } const auto& schedule = solved_data["schedule"]; - const auto& data = solved_data["raceData"]; + const auto& data = solved_data; bool has_spotters = false; - if (!schedule.empty()) has_spotters = schedule[0].contains("spotter"); + if (!schedule.empty()) { + const auto& first_entry = schedule[0]; + // Check if spotter field is present and not null/empty-string. + has_spotters = first_entry.contains("spotter") && + (!first_entry["spotter"].is_null()) && + (!first_entry["spotter"].is_string() || !first_entry["spotter"].get().empty()); + } - std::map> driver_stats; + std::map driver_stats; std::map spotter_stats; if (data.contains("teamMembers")) { for (const auto& m : data["teamMembers"]) { - if (m.value("isDriver", false)) driver_stats[m.value("name", "Unknown")] = {0, 0}; - if (m.value("isSpotter", false)) spotter_stats[m.value("name", "Unknown")] = 0; + // Read as int and convert to bool + bool is_driver = m.value("isDriver", 0) == 1; + bool is_spotter = m.value("isSpotter", 0) == 1; + + if (is_driver) driver_stats[m.value("name", "Unknown")] = 0; + if (is_spotter) spotter_stats[m.value("name", "Unknown")] = 0; } } @@ -378,12 +382,9 @@ void jres::write_output( sched_vec.push_back(item); std::string driver = item.value("driver", "N/A"); - int laps = item.value("laps", 0); - if (driver != "N/A") { - if (driver_stats.find(driver) == driver_stats.end()) driver_stats[driver] = {0, 0}; - driver_stats[driver].first++; - driver_stats[driver].second += laps; + if (driver_stats.find(driver) == driver_stats.end()) driver_stats[driver] = 0; + driver_stats[driver]++; } if (has_spotters && item.contains("spotter")) { @@ -395,18 +396,16 @@ void jres::write_output( } } - int pit_time = data.value("pitTimeInSeconds", 0); - auto member_itineraries = generate_member_itineraries(sched_vec, data, pit_time, has_spotters); + auto member_itineraries = generate_member_itineraries(sched_vec, data, has_spotters); if (format == "csv") { _write_to_csv_file(sched_vec, output_file, has_spotters); } else if (format == "txt") { _write_to_txt(sched_vec, driver_stats, spotter_stats, member_itineraries, output_file, has_spotters); } else { - // Default to ZIP std::string zip_file = output_file; - if (format == "xlsx") { - std::cerr << "Warning: XLSX support has been removed. Generating ZIP instead." << std::endl; + if (format != "zip") { + std::cerr << "Warning: Unsupported format '" << format << "'. Defaulting to ZIP." << std::endl; size_t lastindex = output_file.find_last_of("."); if (lastindex != std::string::npos) { zip_file = output_file.substr(0, lastindex) + ".zip"; diff --git a/src/formatter/formatter_core.hpp b/src/formatter/formatter_core.hpp index 8761ac1..36d1f26 100644 --- a/src/formatter/formatter_core.hpp +++ b/src/formatter/formatter_core.hpp @@ -21,6 +21,11 @@ namespace jres { std::string activity; }; + struct MemberItinerary { + std::vector items; + int tz_offset; + }; + // --- Core File Generation Function --- void write_output(const nlohmann::json& solved_data, const std::string& output_file, @@ -40,15 +45,14 @@ namespace jres { ); std::string generate_summary_csv_string( - const std::map>& driver_stats, + const std::map& driver_stats, const std::map& spotter_stats, bool has_spotters ); - std::map> generate_member_itineraries( + std::map generate_member_itineraries( const std::vector& schedule, const nlohmann::json& data, - int pit_time_seconds, bool has_spotters ); diff --git a/src/jres_diagnostic_solver.cpp b/src/jres_diagnostic_solver.cpp index 9712174..e38f68c 100644 --- a/src/jres_diagnostic_solver.cpp +++ b/src/jres_diagnostic_solver.cpp @@ -271,6 +271,6 @@ jres::internal::SolverOutput JresDiagnosticSolver::diagnose() if (output.diagnosis.empty()) { output.diagnosis.push_back("Diagnosis complete."); } - + output.teamMembers = m_input.teamMembers; return output; } diff --git a/src/jres_internal_types.cpp b/src/jres_internal_types.cpp index 82dfebd..0c42078 100644 --- a/src/jres_internal_types.cpp +++ b/src/jres_internal_types.cpp @@ -108,7 +108,9 @@ JresSolverOutput* to_c_output(const SolverOutput& output) { c_output->schedule = new JresScheduleEntry[c_output->schedule_len]; for (size_t i = 0; i < output.schedule.size(); ++i) { - c_output->schedule[i].stintId = output.schedule[i].stintId; + c_output->schedule[i].id = output.schedule[i].id; + c_output->schedule[i].startTime = allocate_and_copy(output.schedule[i].startTime); + c_output->schedule[i].endTime = allocate_and_copy(output.schedule[i].endTime); c_output->schedule[i].driver = allocate_and_copy(output.schedule[i].driver); c_output->schedule[i].spotter = allocate_and_copy(output.schedule[i].spotter); } @@ -128,6 +130,16 @@ JresSolverOutput* to_c_output(const SolverOutput& output) { c_output->stats->driverSolveDurationMs = output.stats.driverSolveDurationMs; c_output->stats->spotterSolveDurationMs = output.stats.spotterSolveDurationMs; + c_output->teamMembers_len = output.teamMembers.size(); + c_output->teamMembers = new JresTeamMember[c_output->teamMembers_len]; + for (size_t i = 0; i < output.teamMembers.size(); ++i) { + c_output->teamMembers[i].name = allocate_and_copy(output.teamMembers[i].name); + c_output->teamMembers[i].isDriver = output.teamMembers[i].isDriver; + c_output->teamMembers[i].isSpotter = output.teamMembers[i].isSpotter; + c_output->teamMembers[i].maxStints = output.teamMembers[i].maxStints; + c_output->teamMembers[i].minimumRestHours = output.teamMembers[i].minimumRestHours; + } + return c_output; } diff --git a/src/jres_internal_types.hpp b/src/jres_internal_types.hpp index adceae2..c66ae89 100644 --- a/src/jres_internal_types.hpp +++ b/src/jres_internal_types.hpp @@ -52,7 +52,9 @@ struct SolverInput }; struct ScheduleEntry { - int stintId; + int id; + std::string startTime; + std::string endTime; std::string driver; std::string spotter; }; @@ -72,6 +74,7 @@ struct SolverOutput std::vector schedule; std::vector diagnosis; SolverStats stats; + std::vector teamMembers; // Add any other output fields here, like diagnosis or metrics }; diff --git a/src/jres_json_converter.cpp b/src/jres_json_converter.cpp index 1c643f2..ecf885c 100644 --- a/src/jres_json_converter.cpp +++ b/src/jres_json_converter.cpp @@ -55,6 +55,7 @@ JresSolverInput* jres_input_from_json(const char* jsonData) { input->teamMembers[i].isSpotter = member_json.value("isSpotter", false); input->teamMembers[i].maxStints = member_json.value("maxStints", 1); input->teamMembers[i].minimumRestHours = member_json.value("minimumRestHours", 0); + input->teamMembers[i].tzOffset = member_json.value("tzOffset", 0.0); } // Stints @@ -106,7 +107,9 @@ char* jres_output_to_json(const JresSolverOutput* output) { j["schedule"] = json::array(); for (int i = 0; i < output->schedule_len; ++i) { json entry; - entry["stintId"] = output->schedule[i].stintId; + entry["id"] = output->schedule[i].id; + entry["startTime"] = output->schedule[i].startTime; + entry["endTime"] = output->schedule[i].endTime; entry["driver"] = output->schedule[i].driver; entry["spotter"] = output->schedule[i].spotter; j["schedule"].push_back(entry); @@ -117,6 +120,20 @@ char* jres_output_to_json(const JresSolverOutput* output) { j["diagnosis"].push_back(output->diagnosis[i]); } + if (output->teamMembers) { + j["teamMembers"] = json::array(); + for (int i = 0; i < output->teamMembers_len; ++i) { + json member_entry; + member_entry["name"] = output->teamMembers[i].name; + member_entry["isDriver"] = output->teamMembers[i].isDriver; + member_entry["isSpotter"] = output->teamMembers[i].isSpotter; + member_entry["maxStints"] = output->teamMembers[i].maxStints; + member_entry["minimumRestHours"] = output->teamMembers[i].minimumRestHours; + member_entry["tzOffset"] = output->teamMembers[i].tzOffset; + j["teamMembers"].push_back(member_entry); + } + } + if (output->stats) { json stats_json; stats_json["modelColumns"] = output->stats->modelColumns; @@ -129,6 +146,15 @@ char* jres_output_to_json(const JresSolverOutput* output) { j["stats"] = stats_json; } + if (output->options) { + json options_json; + options_json["timeLimit"] = output->options->timeLimit; + options_json["spotterMode"] = output->options->spotterMode; + options_json["allowNoSpotter"] = output->options->allowNoSpotter; + options_json["optimalityGap"] = output->options->optimalityGap; + j["options"] = options_json; + } + std::string json_str = j.dump(); return allocate_and_copy(json_str); @@ -144,6 +170,8 @@ void free_jres_solver_output(JresSolverOutput* output) { } for (int i = 0; i < output->schedule_len; ++i) { + delete[] output->schedule[i].startTime; + delete[] output->schedule[i].endTime; delete[] output->schedule[i].driver; delete[] output->schedule[i].spotter; } @@ -154,7 +182,19 @@ void free_jres_solver_output(JresSolverOutput* output) { } delete[] output->diagnosis; - delete output->stats; + if (output->teamMembers) { + for (int i = 0; i < output->teamMembers_len; ++i) { + delete[] output->teamMembers[i].name; + } + delete[] output->teamMembers; + } + + if (output->stats) { + delete output->stats; + } + if (output->options) { + delete output->options; + } delete output; } diff --git a/src/jres_standard_solver.cpp b/src/jres_standard_solver.cpp index 585303c..f340b96 100644 --- a/src/jres_standard_solver.cpp +++ b/src/jres_standard_solver.cpp @@ -193,7 +193,9 @@ jres::internal::SolverOutput JresStandardSolver::solve() const std::vector& colValues = solution.col_value; for (size_t s = 0; s < m_input.stints.size(); ++s) { jres::internal::ScheduleEntry entry; - entry.stintId = m_input.stints[s].id; + entry.id = m_input.stints[s].id; + entry.startTime = m_input.stints[s].startTime; + entry.endTime = m_input.stints[s].endTime; entry.driver = "N/A"; entry.spotter = "N/A"; for (const auto& p : m_driverPool) { @@ -268,5 +270,6 @@ jres::internal::SolverOutput JresStandardSolver::solve() } else if (status == HighsModelStatus::kInfeasible) { throw std::runtime_error("Model is infeasible."); } + output.teamMembers = m_input.teamMembers; return output; } \ No newline at end of file diff --git a/test/test_formatter_csv.cpp b/test/test_formatter_csv.cpp index 712adb1..101d431 100644 --- a/test/test_formatter_csv.cpp +++ b/test/test_formatter_csv.cpp @@ -9,22 +9,20 @@ TEST(FormatterCSVTest, ScheduleGeneration) { // 1. Create Mock Data std::vector schedule; schedule.push_back({ - {"stint", 1}, - {"startTimeUTC", "2023-01-01 12:00:00"}, - {"endTimeUTC", "2023-01-01 13:00:00"}, + {"id", 1}, + {"startTime", "2023-01-01T12:00:00Z"}, + {"endTime", "2023-01-01T13:00:00Z"}, {"driver", "DriverA"}, - {"spotter", "SpotterB"}, - {"laps", 20} + {"spotter", "SpotterB"} }); // 2. Run Function std::string result = jres::generate_schedule_csv_string(schedule, true); // 3. Assertions - EXPECT_NE(result.find("Stint,Start Time (UTC)"), std::string::npos) << "Header missing"; + EXPECT_NE(result.find("Stint,Start Time (UTC),End Time (UTC)"), std::string::npos) << "Header is incorrect"; EXPECT_NE(result.find("Assigned Spotter"), std::string::npos) << "Spotter column missing"; + EXPECT_EQ(result.find("Laps"), std::string::npos) << "Laps column should not exist"; - EXPECT_NE(result.find("DriverA"), std::string::npos) << "Driver data missing"; - EXPECT_NE(result.find("SpotterB"), std::string::npos) << "Spotter data missing"; - EXPECT_NE(result.find("20"), std::string::npos) << "Laps data missing"; + EXPECT_NE(result.find("1,2023-01-01T12:00:00Z,2023-01-01T13:00:00Z,DriverA,SpotterB"), std::string::npos) << "CSV data row is incorrect"; } diff --git a/test/test_formatter_itinerary.cpp b/test/test_formatter_itinerary.cpp index 29b163d..70c1f58 100644 --- a/test/test_formatter_itinerary.cpp +++ b/test/test_formatter_itinerary.cpp @@ -6,61 +6,62 @@ using json = nlohmann::json; TEST(FormatterItineraryTest, ConsolidationAndTimezones) { - // Mock Data: Race starts at 12:00 UTC - // DriverA does Stint 1 (12:00-13:00) and Stint 2 (13:05-14:05) (5 min pit). - // DriverA is in Timezone +2. - json race_data = { - {"raceStartUTC", "2023-01-01 12:00:00"}, - {"durationHours", 6}, - {"pitTimeInSeconds", 300}, // 5 mins - {"teamMembers", { - { {"name", "DriverA"}, {"isDriver", true}, {"timezone", 2} } - }} + // Mock Data: DriverA does Stint 1 (12:00-13:00 UTC) and Stint 2 (13:05-14:05 UTC). + // DriverA is in Timezone +2 (tzOffset is in hours). + json solved_data = { + {"teamMembers", {{ + {"name", "DriverA"}, {"isDriver", true}, {"tzOffset", 2} + }}}, + {"schedule", json::array({ + { + {"id", 1}, + {"startTime", "2023-01-01T12:00:00Z"}, + {"endTime", "2023-01-01T13:00:00Z"}, + {"driver", "DriverA"}, + {"spotter", "N/A"} + }, + { + {"id", 2}, + {"startTime", "2023-01-01T13:05:00Z"}, + {"endTime", "2023-01-01T14:05:00Z"}, + {"driver", "DriverA"}, + {"spotter", "N/A"} + } + })} }; - std::vector schedule; - // Stint 1 - schedule.push_back({ - {"stint", 1}, - {"startTimeUTC", "2023-01-01 12:00:00"}, - {"endTimeUTC", "2023-01-01 13:00:00"}, - {"driver", "DriverA"}, - {"spotter", "N/A"} - }); - // Stint 2 (Starts 5 mins later due to pit) - schedule.push_back({ - {"stint", 2}, - {"startTimeUTC", "2023-01-01 13:05:00"}, - {"endTimeUTC", "2023-01-01 14:05:00"}, - {"driver", "DriverA"}, - {"spotter", "N/A"} - }); + std::vector schedule_vec; + for(const auto& s : solved_data["schedule"]) { + schedule_vec.push_back(s); + } - auto itineraries = jres::generate_member_itineraries(schedule, race_data, 300, false); + auto itineraries = jres::generate_member_itineraries(schedule_vec, solved_data, false); // Assertions for DriverA ASSERT_TRUE(itineraries.count("DriverA")); - const auto& items = itineraries["DriverA"]; - - // Expecting: 1. Consolidated Driving, 2. Resting - ASSERT_GE(items.size(), 1); - - // Check Item 1: Driving - const auto& drive_block = items[0]; + const auto& itinerary = itineraries.at("DriverA"); - // Check logic that consolidates adjacent stints - // Note: "Driving Stints #1" might be followed by "-2" depending on exact formatting implementation - EXPECT_NE(drive_block.activity.find("Driving Stints"), std::string::npos); + // Check timezone offset + EXPECT_EQ(itinerary.tz_offset, 2); + + // Expecting: 1. Driving, 2. Resting, 3. Driving + ASSERT_EQ(itinerary.items.size(), 3); - // Check Timezone math: 12:00 UTC + 2h = 14:00 Local - EXPECT_EQ(drive_block.start_local.time_string(), "14:00"); - // End: 14:05 UTC + 2h = 16:05 Local - EXPECT_EQ(drive_block.end_local.time_string(), "16:05"); + // Check Item 1: Driving Stint #1 + const auto& drive_block1 = itinerary.items[0]; + EXPECT_EQ(drive_block1.activity, "Driving Stint #1"); + EXPECT_EQ(drive_block1.start_local.to_string(), "2023-01-01 14:00:00"); // 12:00 UTC + 2h + EXPECT_EQ(drive_block1.end_local.to_string(), "2023-01-01 15:00:00"); // 13:00 UTC + 2h - // Check Item 2: Resting (if present in logic) - if (items.size() > 1) { - const auto& rest_block = items[1]; - EXPECT_EQ(rest_block.activity, "Resting"); - EXPECT_EQ(rest_block.start_local.time_string(), "16:05"); - } + // Check Item 2: Resting block between stints + const auto& rest_block = itinerary.items[1]; + EXPECT_EQ(rest_block.activity, "Resting"); + EXPECT_EQ(rest_block.start_local.to_string(), "2023-01-01 15:00:00"); + EXPECT_EQ(rest_block.end_local.to_string(), "2023-01-01 15:05:00"); // 13:05 UTC + 2h + + // Check Item 3: Driving Stint #2 + const auto& drive_block2 = itinerary.items[2]; + EXPECT_EQ(drive_block2.activity, "Driving Stint #2"); + EXPECT_EQ(drive_block2.start_local.to_string(), "2023-01-01 15:05:00"); + EXPECT_EQ(drive_block2.end_local.to_string(), "2023-01-01 16:05:00"); // 14:05 UTC + 2h } \ No newline at end of file diff --git a/test/test_formatter_summary.cpp b/test/test_formatter_summary.cpp index 97e9b69..023ae83 100644 --- a/test/test_formatter_summary.cpp +++ b/test/test_formatter_summary.cpp @@ -4,14 +4,15 @@ #include TEST(FormatterSummaryTest, BasicStats) { - std::map> driver_stats; - driver_stats["DriverA"] = {2, 40}; // 2 stints, 40 laps + std::map driver_stats; + driver_stats["DriverA"] = 2; // 2 stints std::map spotter_stats; spotter_stats["SpotterB"] = 3; std::string result = jres::generate_summary_csv_string(driver_stats, spotter_stats, true); - EXPECT_NE(result.find("Driver,DriverA,2,40"), std::string::npos); - EXPECT_NE(result.find("Spotter,SpotterB,3,-"), std::string::npos); + EXPECT_NE(result.find("Driver,DriverA,2"), std::string::npos); + EXPECT_NE(result.find("Spotter,SpotterB,3"), std::string::npos); + EXPECT_EQ(result.find("Total Laps"), std::string::npos); // Make sure "Laps" is gone } From 8d7ec47abaf66d9e5e35aec0454ab4999c7e01ab Mon Sep 17 00:00:00 2001 From: popmonkey Date: Sun, 7 Dec 2025 08:43:45 -0800 Subject: [PATCH 04/11] updated example data --- data/24h_race.json | 6 +++ data/short_race.json | 6 +-- data/short_race_no_solution.json | 67 ++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 data/short_race_no_solution.json diff --git a/data/24h_race.json b/data/24h_race.json index 60c17fc..2b78323 100644 --- a/data/24h_race.json +++ b/data/24h_race.json @@ -5,6 +5,7 @@ "name": "Niki", "isDriver": true, "isSpotter": true, + "tzOffset": 1, "maxStints": 1, "minimumRestHours": 8 }, @@ -12,6 +13,7 @@ "name": "Ayrton", "isDriver": true, "isSpotter": false, + "tzOffset": -3, "maxStints": 2, "minimumRestHours": 8 }, @@ -19,6 +21,7 @@ "name": "Jack", "isDriver": true, "isSpotter": true, + "tzOffset": 11, "maxStints": 3, "minimumRestHours": 8 }, @@ -26,6 +29,7 @@ "name": "James", "isDriver": true, "isSpotter": true, + "tzOffset": 0, "maxStints": 2, "minimumRestHours": 8 }, @@ -33,6 +37,7 @@ "name": "Mario", "isDriver": true, "isSpotter": true, + "tzOffset": -5, "maxStints": 1, "minimumRestHours": 8 }, @@ -40,6 +45,7 @@ "name": "Ricky", "isDriver": false, "isSpotter": true, + "tzOffset": -6, "maxStints": 2, "minimumRestHours": 8 } diff --git a/data/short_race.json b/data/short_race.json index a1d6553..7a45d62 100644 --- a/data/short_race.json +++ b/data/short_race.json @@ -3,7 +3,7 @@ "teamMembers": [ { "name": "Niki", - "timezone": "1", + "tzOffset": 1, "isDriver": true, "isSpotter": true, "preferredStints": 2, @@ -11,7 +11,7 @@ }, { "name": "Ayrton", - "timezone": "-3", + "tzOffset": -3, "isDriver": true, "isSpotter": true, "preferredStints": 2, @@ -19,7 +19,7 @@ }, { "name": "Alain", - "timezone": "1", + "tzOffset": 1, "isDriver": false, "isSpotter": true, "preferredStints": 2, diff --git a/data/short_race_no_solution.json b/data/short_race_no_solution.json new file mode 100644 index 0000000..5ec3f58 --- /dev/null +++ b/data/short_race_no_solution.json @@ -0,0 +1,67 @@ +{ + "success": true, + "teamMembers": [ + { + "name": "Niki", + "tzOffset": 1, + "isDriver": true, + "isSpotter": true, + "preferredStints": 2, + "minimumRestHours": 8 + }, + { + "name": "Ayrton", + "tzOffset": -3, + "isDriver": true, + "isSpotter": true, + "preferredStints": 2, + "minimumRestHours": 8 + }, + { + "name": "Alain", + "tzOffset": 1, + "isDriver": false, + "isSpotter": true, + "preferredStints": 2, + "minimumRestHours": 8 + } + ], + "availability": { + "Niki": { + "1973-06-09T14:00:00.000Z": "Unavailable", + "1973-06-09T15:00:00.000Z": "Unavailable", + "1973-06-09T16:00:00.000Z": "Available", + "1973-06-09T17:00:00.000Z": "Available" + }, + "Ayrton": { + "1973-06-09T14:00:00.000Z": "Available", + "1973-06-09T15:00:00.000Z": "Unavailable", + "1973-06-09T16:00:00.000Z": "Available", + "1973-06-09T17:00:00.000Z": "Available" + }, + "Alain": { + "1973-06-09T14:00:00.000Z": "Available", + "1973-06-09T15:00:00.000Z": "Available", + "1973-06-09T16:00:00.000Z": "Available", + "1973-06-09T17:00:00.000Z": "Unavailable" + } + }, + "stints": [ + { + "id": 1, + "startTime": "1973-06-09T14:37:00.000Z", + "endTime": "1973-06-09T15:27:16.500Z" + }, + { + "id": 2, + "startTime": "1973-06-09T15:27:16.500Z", + "endTime": "1973-06-09T16:17:33.000Z" + }, + { + "id": 3, + "startTime": "1973-06-09T16:17:33.000Z", + "endTime": "1973-06-09T16:37:00.000Z" + } + ], + "firstStintDriver": null +} \ No newline at end of file From 7e22dd17200bfebdceddd0e9b861f7cfeded9f29 Mon Sep 17 00:00:00 2001 From: popmonkey Date: Sun, 7 Dec 2025 08:56:06 -0800 Subject: [PATCH 05/11] add options to output of solver --- src/jres_internal_types.cpp | 4 +++- src/jres_internal_types.hpp | 2 +- src/jres_json_converter.cpp | 26 +++++++++++++++++++++++++- src/jres_solver.cpp | 4 ++-- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/jres_internal_types.cpp b/src/jres_internal_types.cpp index 0c42078..63f6973 100644 --- a/src/jres_internal_types.cpp +++ b/src/jres_internal_types.cpp @@ -102,7 +102,7 @@ char* allocate_and_copy(const std::string& s) { return cstr; } -JresSolverOutput* to_c_output(const SolverOutput& output) { +JresSolverOutput* to_c_output(const SolverOutput& output, const JresSolverOptions& options) { JresSolverOutput* c_output = new JresSolverOutput(); c_output->schedule_len = output.schedule.size(); c_output->schedule = new JresScheduleEntry[c_output->schedule_len]; @@ -140,6 +140,8 @@ JresSolverOutput* to_c_output(const SolverOutput& output) { c_output->teamMembers[i].minimumRestHours = output.teamMembers[i].minimumRestHours; } + c_output->options = new JresSolverOptions(options); + return c_output; } diff --git a/src/jres_internal_types.hpp b/src/jres_internal_types.hpp index c66ae89..418c675 100644 --- a/src/jres_internal_types.hpp +++ b/src/jres_internal_types.hpp @@ -82,7 +82,7 @@ struct SolverOutput Availability to_internal_availability(JresAvailability availability); SolverInput from_c_input(const JresSolverInput* c_input); -JresSolverOutput* to_c_output(const SolverOutput& output); +JresSolverOutput* to_c_output(const SolverOutput& output, const JresSolverOptions& options); char* allocate_and_copy(const std::string& s); } // namespace jres::internal diff --git a/src/jres_json_converter.cpp b/src/jres_json_converter.cpp index ecf885c..d4ac939 100644 --- a/src/jres_json_converter.cpp +++ b/src/jres_json_converter.cpp @@ -10,6 +10,30 @@ using json = nlohmann::json; +// Helper function to convert string to JresSpotterMode +JresSpotterMode to_jres_spotter_mode(const std::string& s) { + if (s == "integrated") { + return JRES_SPOTTER_MODE_INTEGRATED; + } else if (s == "sequential") { + return JRES_SPOTTER_MODE_SEQUENTIAL; + } + return JRES_SPOTTER_MODE_NONE; +} + +// Helper function to convert JresSpotterMode to string +std::string to_string(JresSpotterMode mode) { + switch (mode) { + case JRES_SPOTTER_MODE_INTEGRATED: + return "integrated"; + case JRES_SPOTTER_MODE_SEQUENTIAL: + return "sequential"; + case JRES_SPOTTER_MODE_NONE: + return "none"; + default: + return "none"; // Should not happen + } +} + // Helper function to convert string to JresAvailability JresAvailability to_jres_availability(const std::string& s) { if (s == "Available") { @@ -149,7 +173,7 @@ char* jres_output_to_json(const JresSolverOutput* output) { if (output->options) { json options_json; options_json["timeLimit"] = output->options->timeLimit; - options_json["spotterMode"] = output->options->spotterMode; + options_json["spotterMode"] = to_string(output->options->spotterMode); options_json["allowNoSpotter"] = output->options->allowNoSpotter; options_json["optimalityGap"] = output->options->optimalityGap; j["options"] = options_json; diff --git a/src/jres_solver.cpp b/src/jres_solver.cpp index 03dd834..15a0a63 100644 --- a/src/jres_solver.cpp +++ b/src/jres_solver.cpp @@ -14,7 +14,7 @@ JresSolverOutput* solve_race_schedule(const JresSolverInput* input, const JresSo jres::internal::SolverInput internal_input = jres::internal::from_c_input(input); JresStandardSolver solver(internal_input, *options); jres::internal::SolverOutput internal_output = solver.solve(); - return jres::internal::to_c_output(internal_output); + return jres::internal::to_c_output(internal_output, *options); } catch (const std::exception& e) { JresSolverOutput* error_output = new JresSolverOutput(); error_output->schedule_len = 0; @@ -31,7 +31,7 @@ JresSolverOutput* diagnose_race_schedule(const JresSolverInput* input, const Jre jres::internal::SolverInput internal_input = jres::internal::from_c_input(input); JresDiagnosticSolver solver(internal_input, *options); jres::internal::SolverOutput internal_output = solver.diagnose(); - return jres::internal::to_c_output(internal_output); + return jres::internal::to_c_output(internal_output, *options); } catch (const std::exception& e) { JresSolverOutput* error_output = new JresSolverOutput(); error_output->schedule_len = 0; From fa5be6c5d84f160af7489dc90bb6a3bb800d324c Mon Sep 17 00:00:00 2001 From: popmonkey Date: Sun, 7 Dec 2025 09:43:04 -0800 Subject: [PATCH 06/11] fix timezone handling between solver/formatter --- src/jres_internal_types.cpp | 2 ++ src/jres_internal_types.hpp | 1 + test/test_formatter_itinerary.cpp | 40 +++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/src/jres_internal_types.cpp b/src/jres_internal_types.cpp index 63f6973..718c610 100644 --- a/src/jres_internal_types.cpp +++ b/src/jres_internal_types.cpp @@ -72,6 +72,7 @@ SolverInput from_c_input(const JresSolverInput* c_input) { member.isSpotter = c_input->teamMembers[i].isSpotter; member.maxStints = c_input->teamMembers[i].maxStints; member.minimumRestHours = c_input->teamMembers[i].minimumRestHours; + member.tzOffset = c_input->teamMembers[i].tzOffset; input.teamMembers.push_back(member); } @@ -138,6 +139,7 @@ JresSolverOutput* to_c_output(const SolverOutput& output, const JresSolverOption c_output->teamMembers[i].isSpotter = output.teamMembers[i].isSpotter; c_output->teamMembers[i].maxStints = output.teamMembers[i].maxStints; c_output->teamMembers[i].minimumRestHours = output.teamMembers[i].minimumRestHours; + c_output->teamMembers[i].tzOffset = output.teamMembers[i].tzOffset; } c_output->options = new JresSolverOptions(options); diff --git a/src/jres_internal_types.hpp b/src/jres_internal_types.hpp index 418c675..28099e7 100644 --- a/src/jres_internal_types.hpp +++ b/src/jres_internal_types.hpp @@ -36,6 +36,7 @@ struct TeamMember bool isSpotter = false; int maxStints = 1; int minimumRestHours = 0; + double tzOffset = 0.0; }; struct Stint { diff --git a/test/test_formatter_itinerary.cpp b/test/test_formatter_itinerary.cpp index 70c1f58..bdc70d8 100644 --- a/test/test_formatter_itinerary.cpp +++ b/test/test_formatter_itinerary.cpp @@ -64,4 +64,44 @@ TEST(FormatterItineraryTest, ConsolidationAndTimezones) { EXPECT_EQ(drive_block2.activity, "Driving Stint #2"); EXPECT_EQ(drive_block2.start_local.to_string(), "2023-01-01 15:05:00"); EXPECT_EQ(drive_block2.end_local.to_string(), "2023-01-01 16:05:00"); // 14:05 UTC + 2h +} + +TEST(FormatterItineraryTest, NegativeTimezone) { + // Mock Data: Driver with timezone -5 + json solved_data = { + {"teamMembers", {{ + {"name", "DriverB"}, {"isDriver", true}, {"tzOffset", -5} + }}}, + {"schedule", json::array({ + { + {"id", 1}, + {"startTime", "2023-01-01T02:00:00Z"}, + {"endTime", "2023-01-01T03:00:00Z"}, + {"driver", "DriverB"}, + {"spotter", "N/A"} + } + })} + }; + + std::vector schedule_vec; + for(const auto& s : solved_data["schedule"]) { + schedule_vec.push_back(s); + } + + auto itineraries = jres::generate_member_itineraries(schedule_vec, solved_data, false); + + // Assertions for DriverB + ASSERT_TRUE(itineraries.count("DriverB")); + const auto& itinerary = itineraries.at("DriverB"); + + // Check timezone offset + EXPECT_EQ(itinerary.tz_offset, -5); + + ASSERT_EQ(itinerary.items.size(), 1); + + // Check Item 1: Driving Stint #1 + const auto& drive_block1 = itinerary.items[0]; + EXPECT_EQ(drive_block1.activity, "Driving Stint #1"); + EXPECT_EQ(drive_block1.start_local.to_string(), "2022-12-31 21:00:00"); // 02:00 UTC - 5h + EXPECT_EQ(drive_block1.end_local.to_string(), "2022-12-31 22:00:00"); // 03:00 UTC - 5h } \ No newline at end of file From 651b91d58fb1c929b2a5183106885f38fa2737e1 Mon Sep 17 00:00:00 2001 From: popmonkey Date: Sun, 7 Dec 2025 09:49:52 -0800 Subject: [PATCH 07/11] balance schedule and follow iRacing's fair share rule --- src/jres_standard_solver.cpp | 65 ++++++++++++++++++++++++++++++++-- test/CMakeLists.txt | 3 ++ test/test_balancing.cpp | 67 ++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 test/test_balancing.cpp diff --git a/src/jres_standard_solver.cpp b/src/jres_standard_solver.cpp index f340b96..504c32d 100644 --- a/src/jres_standard_solver.cpp +++ b/src/jres_standard_solver.cpp @@ -77,9 +77,9 @@ void JresStandardSolver::add_participant_model( // --- Hard Constraint: Max Consecutive Stints --- int maxConsecutive = p.maxStints; - if (maxConsecutive == 0 || m_input.stints.size() < maxConsecutive) continue; // No limit if maxStints is 0 (or less) + if (maxConsecutive == 0 || m_input.stints.size() < static_cast(maxConsecutive + 1)) continue; // No limit if maxStints is 0 (or less) - for (size_t s = 0; s <= m_input.stints.size() - maxConsecutive; ++s) { + for (size_t s = 0; s <= m_input.stints.size() - (maxConsecutive + 1); ++s) { std::vector consIdx; std::vector consVal; for (size_t i = 0; i < maxConsecutive + 1; ++i) { // Window of maxConsecutive + 1 @@ -107,6 +107,26 @@ jres::internal::SolverOutput JresStandardSolver::solve() // --- Build Driver Model --- add_participant_model(*m_highs, m_driverPool, m_driverWorkVars); + // --- Hard Constraint: Fair Share --- + const double num_stints = m_input.stints.size(); + const double num_drivers = m_driverPool.size(); + if (num_drivers > 0) { + const double min_stints_per_driver = std::floor((num_stints / num_drivers) / 4.0); + for (const auto &p : m_driverPool) { + std::vector driver_stint_indices; + std::vector driver_stint_values; + for (size_t s = 0; s < m_input.stints.size(); ++s) { + if (m_driverWorkVars.count({p.name, s})) { + driver_stint_indices.push_back(m_driverWorkVars.at({p.name, s})); + driver_stint_values.push_back(1.0); + } + } + if (!driver_stint_indices.empty()) { + m_highs->addRow(min_stints_per_driver, kHighsInf, (int)driver_stint_indices.size(), driver_stint_indices.data(), driver_stint_values.data()); + } + } + } + // --- Add Coverage Constraints (One driver per stint) --- for (size_t s = 0; s < m_input.stints.size(); ++s) { @@ -125,6 +145,47 @@ jres::internal::SolverOutput JresStandardSolver::solve() m_highs->addRow(1.0, 1.0, (int)indices.size(), indices.data(), values.data()); } + // --- Add balancing variables and objective --- + const double avg_stints_per_driver = num_stints / num_drivers; + for (const auto &p : m_driverPool) { + std::vector driver_stint_indices; + std::vector driver_stint_values; + for (size_t s = 0; s < m_input.stints.size(); ++s) { + if (m_driverWorkVars.count({p.name, s})) { + driver_stint_indices.push_back(m_driverWorkVars.at({p.name, s})); + driver_stint_values.push_back(1.0); + } + } + + if (driver_stint_indices.empty()) continue; + + // Variable for total stints for this driver + int total_stints_var = m_highs->getNumCol(); + m_highs->addVar(0.0, kHighsInf); + driver_stint_indices.push_back(total_stints_var); + driver_stint_values.push_back(-1.0); + m_highs->addRow(0.0, 0.0, (int)driver_stint_indices.size(), driver_stint_indices.data(), driver_stint_values.data()); + + // Deviation variables + int over_avg_var = m_highs->getNumCol(); + m_highs->addVar(0.0, kHighsInf); + int under_avg_var = m_highs->getNumCol(); + m_highs->addVar(0.0, kHighsInf); + + // over_avg >= total_stints - avg + m_highs->addRow(0.0, kHighsInf, 2, std::vector{over_avg_var, total_stints_var}.data(), std::vector{1.0, -1.0}.data()); + m_highs->changeRowBounds(m_highs->getNumRow() - 1, -avg_stints_per_driver, kHighsInf); + + // under_avg >= avg - total_stints + m_highs->addRow(0.0, kHighsInf, 2, std::vector{under_avg_var, total_stints_var}.data(), std::vector{1.0, 1.0}.data()); + m_highs->changeRowBounds(m_highs->getNumRow() - 1, avg_stints_per_driver, kHighsInf); + + // Add to objective + m_highs->changeColCost(over_avg_var, 1.0); + m_highs->changeColCost(under_avg_var, 1.0); + } + + // --- Add Spotter Model (Integrated Mode) --- if (m_options.spotterMode == JRES_SPOTTER_MODE_INTEGRATED) { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 7d753d7..6a82d3d 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -28,6 +28,7 @@ add_executable(solver_tests test_constraints.cpp test_errors.cpp test_diagnosis.cpp + test_balancing.cpp ) add_dependencies(solver_tests jres_solver_lib) @@ -48,6 +49,8 @@ target_include_directories(solver_tests PRIVATE ${JRES_SOLVER_INCLUDE_DIR} ) +target_compile_definitions(solver_tests PRIVATE TEST_DATA_DIR="${CMAKE_SOURCE_DIR}/data") + set_target_properties(solver_tests PROPERTIES INSTALL_RPATH "${CMAKE_BINARY_DIR}" BUILD_WITH_INSTALL_RPATH TRUE diff --git a/test/test_balancing.cpp b/test/test_balancing.cpp new file mode 100644 index 0000000..c4aab78 --- /dev/null +++ b/test/test_balancing.cpp @@ -0,0 +1,67 @@ +#include "gtest/gtest.h" +#include "jres_solver/jres_solver.hpp" +#include "nlohmann/json.hpp" +#include +#include +#include +#include +#include +#include + +// Use the nlohmann::json namespace +using json = nlohmann::json; + +double calculate_stddev(const std::vector& values) { + if (values.size() < 2) { + return 0.0; + } + + double sum = std::accumulate(values.begin(), values.end(), 0.0); + double mean = sum / values.size(); + + double sq_sum = std::inner_product(values.begin(), values.end(), values.begin(), 0.0); + double stddev = std::sqrt(sq_sum / values.size() - mean * mean); + + return stddev; +} + +#define STRINGIFY(x) #x +#define TOSTRING(x) STRINGIFY(x) + +TEST(BalancingTest, FairBalance) { + std::string data_dir = TOSTRING(TEST_DATA_DIR); + data_dir.erase(std::remove(data_dir.begin(), data_dir.end(), '\"'), data_dir.end()); + std::ifstream f(data_dir + "/24h_race.json"); + ASSERT_TRUE(f.good()); + json data = json::parse(f); + std::string json_str = data.dump(); + + JresSolverOptions options; + options.timeLimit = 10; + options.spotterMode = JRES_SPOTTER_MODE_NONE; + options.allowNoSpotter = false; + options.optimalityGap = 0.0; + + JresSolverInput* input = jres_input_from_json(json_str.c_str()); + ASSERT_NE(input, nullptr); + + JresSolverOutput* output = solve_race_schedule(input, &options); + ASSERT_NE(output, nullptr); + ASSERT_GT(output->schedule_len, 0); + + std::map stints_per_driver; + for (int i = 0; i < output->schedule_len; ++i) { + stints_per_driver[output->schedule[i].driver]++; + } + + std::vector stint_counts; + for (const auto& pair : stints_per_driver) { + stint_counts.push_back(pair.second); + } + + double stddev = calculate_stddev(stint_counts); + EXPECT_LT(stddev, 2.0); + + free_jres_solver_input(input); + free_jres_solver_output(output); +} From 94734fd35d0bf9c07818428f055797e971f7783b Mon Sep 17 00:00:00 2001 From: popmonkey Date: Sun, 7 Dec 2025 11:53:32 -0800 Subject: [PATCH 08/11] enhance date parsing with error handling for invalid formats --- src/utils/date_utils.cpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/utils/date_utils.cpp b/src/utils/date_utils.cpp index d32ddc1..dd318d4 100644 --- a/src/utils/date_utils.cpp +++ b/src/utils/date_utils.cpp @@ -11,6 +11,7 @@ #include #include #include +#include // Cross-platform gmtime std::tm* safe_gmtime(const std::time_t* timer, std::tm* buf) { @@ -48,10 +49,20 @@ namespace jres { ss.clear(); ss.str(iso_str); ss >> std::get_time(&tm, "%Y-%m-%d %H:%M:%S"); + if (ss.fail()) { + throw std::runtime_error("Invalid date-time format: " + iso_str); + } } - // If milliseconds exist (e.g. .000Z), get_time usually stops before them. - // We can ignore fractional seconds for this scheduler's granularity. + // Check for remaining characters which are not a 'Z' or '.000Z' + std::string remaining; + ss >> remaining; + if (!remaining.empty() && remaining != "Z" && remaining != ".000Z") { + // A non-empty remaining string is only ok if it's just whitespace + if (remaining.find_first_not_of(" \t\n\v\f\r") != std::string::npos) { + throw std::runtime_error("Invalid trailing characters in date-time: " + iso_str); + } + } return DateTime(safe_timegm(&tm)); } From d7fa983f276863fbc16c4760f3bcfab60b3ad17e Mon Sep 17 00:00:00 2001 From: popmonkey Date: Sun, 7 Dec 2025 11:54:11 -0800 Subject: [PATCH 09/11] thread safe error reporting --- include/jres_solver/jres_json_converter.hpp | 10 ++++++++++ src/jres_json_converter.cpp | 16 +++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/include/jres_solver/jres_json_converter.hpp b/include/jres_solver/jres_json_converter.hpp index 9846df0..6d758f6 100644 --- a/include/jres_solver/jres_json_converter.hpp +++ b/include/jres_solver/jres_json_converter.hpp @@ -43,6 +43,16 @@ void free_jres_solver_input(JresSolverInput* input); */ void free_jres_solver_output(JresSolverOutput* output); +/** + * @brief Retrieves the last error message that occurred in a C-API function. + * + * The caller does NOT own the returned string and must NOT free it. + * The message is thread-local and valid until the next C-API call on the same thread. + * + * @return A C-string containing the last error message, or an empty string if no error. + */ +const char* jres_get_last_error(); + #ifdef __cplusplus } // extern "C" #endif diff --git a/src/jres_json_converter.cpp b/src/jres_json_converter.cpp index d4ac939..93b729c 100644 --- a/src/jres_json_converter.cpp +++ b/src/jres_json_converter.cpp @@ -7,6 +7,9 @@ #include "nlohmann/json.hpp" #include #include +#include // Needed for std::runtime_error + +thread_local std::string last_error_message; using json = nlohmann::json; @@ -55,6 +58,7 @@ char* allocate_and_copy(const std::string& s) { } JresSolverInput* jres_input_from_json(const char* jsonData) { + last_error_message.clear(); // Clear previous error try { json j = json::parse(jsonData); JresSolverInput* input = new JresSolverInput(); @@ -112,17 +116,19 @@ JresSolverInput* jres_input_from_json(const char* jsonData) { return input; } catch (const json::parse_error& e) { - std::cerr << "JSON parse error: " << e.what() << std::endl; + last_error_message = "JSON parse error: " + std::string(e.what()); return nullptr; } catch (const std::exception& e) { - std::cerr << "Error: " << e.what() << std::endl; + last_error_message = "Error: " + std::string(e.what()); return nullptr; } } char* jres_output_to_json(const JresSolverOutput* output) { + last_error_message.clear(); // Clear previous error if (!output) { + last_error_message = "Output is nullptr."; return nullptr; } @@ -183,11 +189,15 @@ char* jres_output_to_json(const JresSolverOutput* output) { return allocate_and_copy(json_str); } catch (const std::exception& e) { - std::cerr << "Error: " << e.what() << std::endl; + last_error_message = "Error converting output to JSON: " + std::string(e.what()); return nullptr; } } +const char* jres_get_last_error() { + return last_error_message.c_str(); +} + void free_jres_solver_output(JresSolverOutput* output) { if (!output) { return; From 15e186cafcc004320d4c4177efef1d6f05ddcefc Mon Sep 17 00:00:00 2001 From: popmonkey Date: Sun, 7 Dec 2025 17:05:33 -0800 Subject: [PATCH 10/11] fix for times with milliseconds --- src/utils/date_utils.cpp | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/utils/date_utils.cpp b/src/utils/date_utils.cpp index dd318d4..33cc5d3 100644 --- a/src/utils/date_utils.cpp +++ b/src/utils/date_utils.cpp @@ -53,12 +53,23 @@ namespace jres { throw std::runtime_error("Invalid date-time format: " + iso_str); } } + + // Handle optional fractional seconds (e.g., .123) and Z + if (ss.peek() == '.') { + ss.ignore(); // Consume the '.' + int milliseconds; + ss >> milliseconds; // Read and discard + } - // Check for remaining characters which are not a 'Z' or '.000Z' + // Handle trailing Z + if (ss.peek() == 'Z') { + ss.ignore(); + } + + // Check for any other non-whitespace trailing characters std::string remaining; ss >> remaining; - if (!remaining.empty() && remaining != "Z" && remaining != ".000Z") { - // A non-empty remaining string is only ok if it's just whitespace + if (!remaining.empty()) { if (remaining.find_first_not_of(" \t\n\v\f\r") != std::string::npos) { throw std::runtime_error("Invalid trailing characters in date-time: " + iso_str); } From 789c0e7995551fbafdb48464f5712d49e2e6fda3 Mon Sep 17 00:00:00 2001 From: popmonkey Date: Sun, 7 Dec 2025 17:36:39 -0800 Subject: [PATCH 11/11] add integration tests --- .github/workflows/linux-build.yml | 5 ++ .github/workflows/macos-build.yml | 5 ++ .github/workflows/win-build.yml | 5 ++ test/integration/run_test.bat | 79 +++++++++++++++++++++++++++++++ test/integration/run_test.sh | 75 +++++++++++++++++++++++++++++ 5 files changed, 169 insertions(+) create mode 100644 test/integration/run_test.bat create mode 100755 test/integration/run_test.sh diff --git a/.github/workflows/linux-build.yml b/.github/workflows/linux-build.yml index 10161c2..642b4b2 100644 --- a/.github/workflows/linux-build.yml +++ b/.github/workflows/linux-build.yml @@ -132,6 +132,11 @@ jobs: - name: Run Tests run: ctest --test-dir build --output-on-failure -C Release + - name: Run Integration Tests + run: | + chmod +x test/integration/run_test.sh + ./test/integration/run_test.sh + # --------------------------------------------------------- # Install (Stage Files) # --------------------------------------------------------- diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml index 27ca7d6..dbbf290 100644 --- a/.github/workflows/macos-build.yml +++ b/.github/workflows/macos-build.yml @@ -108,6 +108,11 @@ jobs: - name: Run Tests run: ctest --test-dir build --output-on-failure -C Release + - name: Run Integration Tests + run: | + chmod +x test/integration/run_test.sh + ./test/integration/run_test.sh + # --------------------------------------------------------- # Install (Stage Files) # --------------------------------------------------------- diff --git a/.github/workflows/win-build.yml b/.github/workflows/win-build.yml index 2e3d2ff..397f0c9 100644 --- a/.github/workflows/win-build.yml +++ b/.github/workflows/win-build.yml @@ -86,6 +86,11 @@ jobs: cmake --build build --config Release --target formatter_tests ctest --test-dir build --output-on-failure -C Release + - name: Run Integration Tests + if: inputs.run_integration_tests == true + shell: pwsh + run: ./test/integration/run_test.bat + # --------------------------------------------------------- # Install (Stage Files) # --------------------------------------------------------- diff --git a/test/integration/run_test.bat b/test/integration/run_test.bat new file mode 100644 index 0000000..86ad6ee --- /dev/null +++ b/test/integration/run_test.bat @@ -0,0 +1,79 @@ +@echo off +setlocal + +set BUILD_DIR=build +set TEST_DATA=data\short_race.json +set SOLUTION_JSON=%BUILD_DIR%\solution.json +set SUMMARY_TXT=%BUILD_DIR%\summary.txt +set SOLVER_BIN=%BUILD_DIR%\Release\jres_solver.exe +set FORMATTER_BIN=%BUILD_DIR%\Release\jres_formatter.exe + +rem Make sure we are in the project root +if not exist "CMakeLists.txt" ( + echo Error: This script must be run from the project root directory. + exit /b 1 +) + +rem Make sure binaries exist +if not exist "%SOLVER_BIN%" ( + echo Error: Solver binary not found at %SOLVER_BIN%. Build the project first. + exit /b 1 +) +if not exist "%FORMATTER_BIN%" ( + echo Error: Formatter binary not found at %FORMATTER_BIN%. Build the project first. + exit /b 1 +) + +rem Cleanup previous runs +del /f /q %SOLUTION_JSON% %SUMMARY_TXT% > nul 2>&1 + +echo Running solver... +rem Step 1: Run solver +%SOLVER_BIN% -i %TEST_DATA% -s integrated -o %SOLUTION_JSON% --quiet +if %errorlevel% neq 0 ( + echo FAIL: Solver failed. + exit /b 1 +) + +echo Running formatter... +rem Step 2: Run formatter +%FORMATTER_BIN% -i %SOLUTION_JSON% -o %SUMMARY_TXT% +if %errorlevel% neq 0 ( + echo FAIL: Formatter failed. + exit /b 1 +) + + +echo Verifying output... +rem Step 3: Verify output +findstr /c:"--- DRIVER SUMMARY ---" %SUMMARY_TXT% > nul +if %errorlevel% neq 0 ( + echo FAIL: Did not find "--- DRIVER SUMMARY ---" in summary. + exit /b 1 +) + +findstr /c:"--- SPOTTER SUMMARY ---" %SUMMARY_TXT% > nul +if %errorlevel% neq 0 ( + echo FAIL: Did not find "--- SPOTTER SUMMARY ---" in summary. + exit /b 1 +) + +findstr /c:"--- SCHEDULE ---" %SUMMARY_TXT% > nul +if %errorlevel% neq 0 ( + echo FAIL: Did not find "--- SCHEDULE ---" in summary. + exit /b 1 +) + +findstr /c:"--- ITINERARIES ---" %SUMMARY_TXT% > nul +if %errorlevel% neq 0 ( + echo FAIL: Did not find "--- ITINERARIES ---" in summary. + exit /b 1 +) + +echo All checks passed. + +rem Cleanup +del /f /q %SOLUTION_JSON% %SUMMARY_TXT% > nul 2>&1 + +echo Integration test passed! +exit /b 0 \ No newline at end of file diff --git a/test/integration/run_test.sh b/test/integration/run_test.sh new file mode 100755 index 0000000..d07fa25 --- /dev/null +++ b/test/integration/run_test.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +# Exit on error +set -e + +# Set paths +BUILD_DIR="build" +TEST_DATA="data/short_race.json" +SOLUTION_JSON="$BUILD_DIR/solution.json" +SUMMARY_TXT="$BUILD_DIR/summary.txt" +SOLVER_BIN="$BUILD_DIR/jres_solver" +FORMATTER_BIN="$BUILD_DIR/jres_formatter" + +# Make sure we are in the project root +if [ ! -f "CMakeLists.txt" ]; then + echo "Error: This script must be run from the project root directory." + exit 1 +fi + +# Make sure binaries exist +if [ ! -f "$SOLVER_BIN" ]; then + echo "Error: Solver binary not found at $SOLVER_BIN. Build the project first." + exit 1 +fi +if [ ! -f "$FORMATTER_BIN" ]; then + echo "Error: Formatter binary not found at $FORMATTER_BIN. Build the project first." + exit 1 +fi + + +# Cleanup previous runs +rm -f "$SOLUTION_JSON" "$SUMMARY_TXT" + +echo "Running solver..." +# Step 1: Run solver +"$SOLVER_BIN" \ + -i "$TEST_DATA" \ + -s integrated \ + -o "$SOLUTION_JSON" --quiet + +echo "Running formatter..." +# Step 2: Run formatter +"$FORMATTER_BIN" \ + -i "$SOLUTION_JSON" \ + -o "$SUMMARY_TXT" + +echo "Verifying output..." +# Step 3: Verify output +if ! grep -q -e "--- DRIVER SUMMARY ---" "$SUMMARY_TXT"; then + echo "FAIL: Did not find '--- DRIVER SUMMARY ---' in summary." + exit 1 +fi + +if ! grep -q -e "--- SPOTTER SUMMARY ---" "$SUMMARY_TXT"; then + echo "FAIL: Did not find '--- SPOTTER SUMMARY ---' in summary." + exit 1 +fi + +if ! grep -q -e "--- SCHEDULE ---" "$SUMMARY_TXT"; then + echo "FAIL: Did not find '--- SCHEDULE ---' in summary." + exit 1 +fi + +if ! grep -q -e "--- ITINERARIES ---" "$SUMMARY_TXT"; then + echo "FAIL: Did not find '--- ITINERARIES ---' in summary." + exit 1 +fi + +echo "All checks passed." + +# Cleanup +rm "$SOLUTION_JSON" "$SUMMARY_TXT" + +echo "Integration test passed!" +exit 0 \ No newline at end of file