diff --git a/src/jres_solver_base.cpp b/src/jres_solver_base.cpp index bff1044..af98cc3 100644 --- a/src/jres_solver_base.cpp +++ b/src/jres_solver_base.cpp @@ -4,12 +4,20 @@ * @brief Base class for the JRES Solver. */ #include "jres_solver_base.hpp" +#include +#include JresSolverBase::JresSolverBase(const jres::internal::SolverInput& input, const JresSolverOptions& options) : m_input(input), m_options(options) { // Filter Participant Pools + std::set seenNames; for (const auto& member : m_input.teamMembers) { + if (seenNames.count(member.name)) { + throw std::runtime_error("Duplicate team member name: " + member.name); + } + seenNames.insert(member.name); + if (member.isDriver) m_driverPool.push_back(member); if (member.isSpotter) m_spotterPool.push_back(member); } diff --git a/src/jres_standard_solver.cpp b/src/jres_standard_solver.cpp index 1f98c94..1863e2a 100644 --- a/src/jres_standard_solver.cpp +++ b/src/jres_standard_solver.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include "Highs.h" @@ -143,6 +144,16 @@ jres::internal::SolverOutput JresStandardSolver::solve() output.config.maximumBusyHours = m_input.maximumBusyHours; output.config.firstStintDriver = m_input.firstStintDriver; + // --- Duplicate Name Check --- + std::set namesSeen; + for (const auto& m : m_input.teamMembers) { + if (namesSeen.count(m.name)) { + std::string err = "Duplicate team member name: " + m.name; + throw std::runtime_error(err); + } + namesSeen.insert(m.name); + } + // --- Arithmetic Pre-flight Check --- int totalStints = (int)m_input.stints.size(); auto capAnalysis = jres::internal::CapacityAnalyzer::calculate_max_potential_capacity(m_driverPool, m_input); @@ -557,6 +568,18 @@ jres::internal::SolverOutput JresStandardSolver::solve() } } + // --- Final Validation --- + for (size_t s = 0; s < output.schedule.size(); ++s) { + if (output.schedule[s].driver == "N/A") { + output.diagnosis.push_back("Stint " + std::to_string(s) + " (" + output.schedule[s].startTime + ") has no assigned driver."); + } + + bool spotterRequired = (m_options.spotterMode != JRES_SPOTTER_MODE_NONE && !m_options.allowNoSpotter); + if (spotterRequired && output.schedule[s].spotter == "N/A") { + output.diagnosis.push_back("Stint " + std::to_string(s) + " (" + output.schedule[s].startTime + ") has no assigned spotter."); + } + } + output.teamMembers = m_input.teamMembers; return output; } \ No newline at end of file diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index abdd119..d928c0b 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -41,6 +41,7 @@ add_executable(solver_tests test_max_busy_defaults.cpp test_availability_overlap.cpp test_availability_boundaries.cpp + test_validation.cpp ) add_dependencies(solver_tests jres_solver_lib) diff --git a/test/test_validation.cpp b/test/test_validation.cpp new file mode 100644 index 0000000..52b495e --- /dev/null +++ b/test/test_validation.cpp @@ -0,0 +1,78 @@ +#include "gtest/gtest.h" +#include "jres_solver/jres_solver.hpp" +#include + +namespace { + + const char* DUPLICATE_NAMES_JSON = R"({ + "teamMembers": [ + { "name": "DriverA", "isDriver": true }, + { "name": "DriverA", "isDriver": true } + ], + "availability": {}, + "stints": [ { "id": 1, "startTime": "2024-01-01T12:00:00.000Z", "endTime": "2024-01-01T13:00:00.000Z" } ] + })"; + + TEST(ValidationTest, DuplicateNames) { + JresSolverOptions options = {}; + options.spotterMode = JRES_SPOTTER_MODE_NONE; + + JresSolverInput* input = jres_input_from_json(DUPLICATE_NAMES_JSON); + ASSERT_NE(input, nullptr); + + JresSolverOutput* output = solve_race_schedule(input, &options); + ASSERT_NE(output, nullptr); + + // Expect diagnosis + ASSERT_GT(output->diagnosis_len, 0); + bool foundDuplicateMsg = false; + for (int i = 0; i < output->diagnosis_len; ++i) { + std::string msg = output->diagnosis[i]; + if (msg.find("Duplicate team member name") != std::string::npos) { + foundDuplicateMsg = true; + break; + } + } + EXPECT_TRUE(foundDuplicateMsg) << "Should diagnose duplicate names"; + + free_jres_solver_input(input); + free_jres_solver_output(output); + } + + const char* NO_SPOTTERS_SEQUENTIAL_JSON = R"({ + "teamMembers": [ + { "name": "DriverA", "isDriver": true, "isSpotter": false }, + { "name": "SpotterA", "isDriver": false, "isSpotter": false } + ], + "availability": {}, + "stints": [ { "id": 1, "startTime": "2024-01-01T12:00:00.000Z", "endTime": "2024-01-01T13:00:00.000Z" } ] + })"; + + TEST(ValidationTest, UnassignedSpotterSequential) { + JresSolverOptions options = {}; + options.spotterMode = JRES_SPOTTER_MODE_SEQUENTIAL; + options.allowNoSpotter = false; + + JresSolverInput* input = jres_input_from_json(NO_SPOTTERS_SEQUENTIAL_JSON); + ASSERT_NE(input, nullptr); + + JresSolverOutput* output = solve_race_schedule(input, &options); + ASSERT_NE(output, nullptr); + + // Expect diagnosis about unassigned spotter + ASSERT_GT(output->diagnosis_len, 0); + bool foundSpecificMsg = false; + for (int i = 0; i < output->diagnosis_len; ++i) { + std::string msg = output->diagnosis[i]; + if (msg.find("Stint 0") != std::string::npos && msg.find("has no assigned spotter") != std::string::npos) { + foundSpecificMsg = true; + break; + } + } + + EXPECT_TRUE(foundSpecificMsg) << "Should diagnose specific unassigned stint for spotter"; + + free_jres_solver_input(input); + free_jres_solver_output(output); + } +}