diff --git a/CMakeLists.txt b/CMakeLists.txt index 56528444..22982e64 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -77,6 +77,7 @@ option(WITH_ZMQ "Compile with cppzmq" OFF) option(WITH_CRASHPAD "Compile with crashpad" OFF) option(WITH_DISCORD_RPC "Compile with DISCORD RICH PRESENCE" ON) option(USE_LTO "Use link-time optimization" OFF) +option(WITH_TESTS "Build and run the unit tests with the game, a failing test fails the build" ON) option(WITH_BETTER_RENDERER "Experimental multi-backend renderer based on NVRHI" OFF) option(GENERATE_PDB "Generate executable with program debugging symbols" ON) option(ENABLE_MCC "Enable multicore compilation" ON) @@ -715,3 +716,22 @@ if(ENABLE_AVX2) target_compile_options(eu07_settings INTERFACE -mavx2) endif() endif() + +if(WITH_TESTS) + enable_testing() + add_subdirectory(tests/unit) + # the unit tests run at the end of building the game, once it's linked: a failing one fails the build + # (and removes the game binary, so the next build links it again and reruns them) + add_dependencies(${PROJECT_NAME} eu07_unit_tests) + add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} + -DTESTS=$ + -DGAME=$ + -P "${CMAKE_CURRENT_SOURCE_DIR}/tests/unit/run_tests.cmake" + COMMENT "Running the unit tests" + VERBATIM) + # a change of the tests alone links the game again, so they run (Ninja and Makefile generators; + # Visual Studio builds rerun them with the next change of the game) + set_property(TARGET ${PROJECT_NAME} APPEND PROPERTY LINK_DEPENDS + $ ${UNIT_TEST_DATA} "${CMAKE_CURRENT_SOURCE_DIR}/tests/unit/run_tests.cmake") +endif() \ No newline at end of file diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt new file mode 100644 index 00000000..7bb62410 --- /dev/null +++ b/tests/unit/CMakeLists.txt @@ -0,0 +1,31 @@ +# Unit tests of the simulation, built and run without the game data: the test vehicles are JSON +# definitions in data/ (see vehicle.h), loaded from memory by the game's own vehicle loader. The code +# under test is the game's core library (eu07_core), the very objects the game links; the rest of the +# engine it reaches for is replaced with stubs (engine_stubs.cpp). They run as part of the build, +# from the IDE, or with ctest. + +set(UNIT_TESTS_NAME eu07_unit_tests) + +# a new test_*.cpp file or data/*.json definition is picked up on its own +file(GLOB UNIT_TEST_CASES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/test_*.cpp") +file(GLOB UNIT_TEST_DATA CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/data/*.json") + +add_executable(${UNIT_TESTS_NAME} + main.cpp + vehicle.cpp + engine_stubs.cpp + ${UNIT_TEST_CASES}) + +# the core library brings the game's build settings along +target_link_libraries(${UNIT_TESTS_NAME} PRIVATE eu07_core) +target_include_directories(${UNIT_TESTS_NAME} PRIVATE "${DEPS_DIR}/doctest") +target_compile_definitions(${UNIT_TESTS_NAME} PRIVATE EU07_UNIT_TESTS_DATA="${CMAKE_CURRENT_SOURCE_DIR}/data") +if (MSVC) + target_compile_options(${UNIT_TESTS_NAME} PRIVATE /bigobj) +endif() +set_target_properties(${UNIT_TESTS_NAME} PROPERTIES FOLDER "tests") + +add_test(NAME ${UNIT_TESTS_NAME} COMMAND ${UNIT_TESTS_NAME}) + +# for running the tests at the end of building the game, see the main CMakeLists.txt +set(UNIT_TEST_DATA ${UNIT_TEST_DATA} PARENT_SCOPE) diff --git a/tests/unit/README.md b/tests/unit/README.md new file mode 100644 index 00000000..5eebe563 --- /dev/null +++ b/tests/unit/README.md @@ -0,0 +1,68 @@ +# Unit tests + +C++ tests of the simulation logic: set up the conditions, run the code, check the outcome. They run +as part of every build of the game, in CI and locally alike, and from the IDE. They need no game +data and no window: the test vehicles are JSON definitions in `data/`, loaded from memory by the +game's own vehicle loader. + +The code under test is `eu07_core`, the library of the simulation parts the game links as well +(physics, vehicle definition loader, ...): built once, with the game's settings, and tested as the +game uses it. The python library gets linked along with the game's settings, but the tests don't +use it and run without it. + +## Running + +- **With the build**: building the game (`WITH_TESTS`, on by default) runs them at the end, once the + game is linked, and prints `Unit tests: 11/11 passed (80 checks)`. A failing test prints the full + report and fails the build; the game binary gets removed, so the next build links it again and + reruns them. They also run when only the tests, a definition in `data/` or `run_tests.cmake` + changed (Ninja and Makefile generators). `-DWITH_TESTS=OFF` builds the game without them. +- **From the IDE**: run or debug the `eu07_unit_tests` target. CLion recognises doctest and lists the + test cases, so a single one can be run or debugged from the gutter. +- **By hand**: `ctest --test-dir --build-config Release`, or the binary itself, which takes + [doctest options](https://github.com/doctest/doctest/blob/master/doc/markdown/commandline.md), e.g. + `eu07_unit_tests -tc="brakes*"`. `EU07_UNIT_TESTS_LOG=1` shows the engine log, quiet otherwise. + +## Writing a test + +Add a `test_.cpp` here, it's picked up on its own. + +```cpp +#include "stdafx.h" +#include "doctest.h" +#include "vehicle.h" + +TEST_CASE("brakes: service braking stops a heavier wagon too") { + // conditions: the wagon from data/wagon.json, heavier, running at 40 km/h + test::vehicle wagon{test::definition::load("wagon").set("Param", "M", 60000), 40.0 /* km/h */}; + // action: full service application, as a coupled locomotive would make it + wagon.hold_brake_pipe(3.5); // bar + wagon.run(40.0 /* s */, test::track{0.15 /* friction */, 0.0 /* gradient */}); + // outcome + CHECK(std::abs(wagon.speed()) < 0.1); +} +``` + +- `definition::load("")` reads `data/.json`, with the sections and keys of the game's + vehicle definitions; `set()` and `remove()` change it for the test. Table sections give their + rows as `"rows": [[...], ...]`, keys starting with `_` are comments. A section the loader doesn't + know is an error (the table of them is in `vehicle.cpp`); a misspelt key isn't caught, so check + in the test that the value the test is about made it to the vehicle. Keep the definitions + physically sensible: some parts need keys others don't (the ESt brake valves need `Brake.Size`), + and a test on a half-defined vehicle tests the definition rather than the simulation. +- The random numbers of the simulation are reseeded before every test case, so a test gives the + same result alone or among the others. +- `vehicle` creates the vehicle the way `TDynamicObject::Init()` does and steps it the way the + simulation loop does, on a straight track of the given friction and gradient. Coupled vehicles + aren't supported yet. +- Check behaviour (speeds, pressures, states the player sees) rather than internal flags, and make + the test check its own preconditions (`REQUIRE` that the braking skidded, before checking what a + skid leads to). + +## Code to test, and when the tests fail to link + +A source joins the tested code by moving from `SOURCES` to `CORE_SOURCES` in the main +`CMakeLists.txt`; the game links it from the library from then on. The library reaches for a little +of the rest of the engine, which `engine_stubs.cpp` stands in for in the tests (logs, settings, +scenery layers). If a change makes it use something new, either move the real source into the +library too, when it stands on its own, or add a stub to `engine_stubs.cpp`. diff --git a/tests/unit/data/wagon.json b/tests/unit/data/wagon.json new file mode 100644 index 00000000..8fac72bb --- /dev/null +++ b/tests/unit/data/wagon.json @@ -0,0 +1,12 @@ +{ + "_comment": "Passenger wagon with a KE distributor valve, made after a real one so the defaults make physical sense. Sections and keys are those of the game's vehicle definitions; tests change only the keys they're about.", + "Param": { "Category": "train", "M": 42200, "Vmax": 120 }, + "Dimensions": { "L": 26.8, "H": 4.7, "W": 2.88, "Cx": 0.15 }, + "Wheels": { "D": 0.96, "Tw": 1.435, "Axle": "2'2'", "Ad": 2.5, "Bd": 19.5, "Rmin": 80, "BearingType": "Roll" }, + "Brake": { + "BrakeValve": "KE", "NBpA": 6, "MBF": 15, "MaxBP": 3.8, "TareMaxBP": 2.3, + "BCN": 12, "BCR": 0.127, "BCD": 0.04, "BCM": 1.5, "BCS": 1.2, "BSA": 0, "BM": "Disk1", "BVV": 125 + }, + "BuffCoupl": { "CType": "Screw", "kB": 9200, "DmaxB": 0.11, "FmaxB": 320, "kC": 4500, "DmaxC": 0.1, "FmaxC": 850, "beta": 0.58, "AllowedFlag": 115 }, + "Cntrl": { "BrakeSystem": "Pneumatic", "LocalBrake": "ManualBrake", "BrakeDelays": "P", "MaxBPMass": 63 } +} diff --git a/tests/unit/engine_stubs.cpp b/tests/unit/engine_stubs.cpp new file mode 100644 index 00000000..abe5614a --- /dev/null +++ b/tests/unit/engine_stubs.cpp @@ -0,0 +1,71 @@ +// stand-ins for the parts of the engine which the core library (eu07_core) links against, but which the +// unit tests don't exercise. when a change makes the library use something new from the engine and the +// test build fails to link, either move the real source into the library (CORE_SOURCES in the main +// CMakeLists.txt, if it stands on its own) or add a stub here. + +#include "stdafx.h" +#include "utilities/Globals.h" +#include "utilities/Logs.h" +#include "scene/scenenodegroups.h" +#include "scene/scenelayers.h" + +#include +#include + +// settings --------------------------------------------------------------------------------------- + +global_settings &GetGlobalSettings() { + // defaults, as before reading eu07.ini + static global_settings settings; + return settings; +} + +// logs: quiet, unless EU07_UNIT_TESTS_LOG is set -------------------------------------------------- + +namespace { +bool log_enabled() { + static bool const enabled{std::getenv("EU07_UNIT_TESTS_LOG") != nullptr}; + return enabled; +} +} // namespace + +void WriteLog(const char *str, logtype const, bool const isError) { + if (log_enabled() && str != nullptr) { + (isError ? std::cerr : std::cout) << str << "\n"; + } +} + +void WriteLog(const std::string &str, logtype const Type) { + WriteLog(str.c_str(), Type, false); +} + +void ErrorLog(const std::string &str, logtype const Type) { + WriteLog(str.c_str(), Type, true); +} + +// scenery structure, which the parser reports include directives to -------------------------------- + +namespace scene { + +node_groups Groups; +node_layers Layers; + +group_handle node_groups::create(bool const) { + return null_handle; +} + +group_handle node_groups::close() { + return null_handle; +} + +layer_handle node_layers::open(std::string const &, include_site) { + return null_handle; +} + +layer_handle node_layers::close() { + return null_handle; +} + +void node_layers::instance_begin(std::string const &, source_span const &) {} + +} // namespace scene diff --git a/tests/unit/main.cpp b/tests/unit/main.cpp new file mode 100644 index 00000000..a8f25064 --- /dev/null +++ b/tests/unit/main.cpp @@ -0,0 +1,39 @@ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include "stdafx.h" +#include "doctest.h" + +#include "utilities/Globals.h" + +namespace { + +// the simulation draws random numbers (brake delays, pressures, wheel damage) from engines shared by +// everything. reseeding them before every run of a test case, subcases included, gives each test the +// same numbers whether it runs alone from the IDE or among all the others +class deterministic_random : public doctest::IReporter { +public: + explicit deterministic_random(doctest::ContextOptions const &) {} + + void test_case_start(doctest::TestCaseData const &) override { reseed(); } + void test_case_reenter(doctest::TestCaseData const &) override { reseed(); } + + void report_query(doctest::QueryData const &) override {} + void test_run_start() override {} + void test_run_end(doctest::TestRunStats const &) override {} + void test_case_end(doctest::CurrentTestCaseStats const &) override {} + void test_case_exception(doctest::TestCaseException const &) override {} + void subcase_start(doctest::SubcaseSignature const &) override {} + void subcase_end() override {} + void log_assert(doctest::AssertData const &) override {} + void log_message(doctest::MessageData const &) override {} + void test_case_skipped(doctest::TestCaseData const &) override {} + +private: + static void reseed() { + Global.random_engine.seed(1); + Global.local_random_engine.seed(1); + } +}; + +} // namespace + +DOCTEST_REGISTER_LISTENER("deterministic_random", 1, deterministic_random); diff --git a/tests/unit/run_tests.cmake b/tests/unit/run_tests.cmake new file mode 100644 index 00000000..6cf6f508 --- /dev/null +++ b/tests/unit/run_tests.cmake @@ -0,0 +1,27 @@ +# runs the unit tests at the end of building the game: cmake -DTESTS= -DGAME= -P run_tests.cmake +# prints a one line summary when they pass, the full report when they don't. a failure fails the build and +# removes the game binary, so the next build links it again and reruns the tests + +execute_process( + COMMAND "${TESTS}" --no-intro --no-version + RESULT_VARIABLE result + OUTPUT_VARIABLE report + ERROR_VARIABLE report) + +string(REGEX MATCH "test cases: *([0-9]+) *\\| *([0-9]+) passed *\\| *([0-9]+) failed" _ "${report}") +set(cases "${CMAKE_MATCH_1}") +set(passed "${CMAKE_MATCH_2}") +set(failed "${CMAKE_MATCH_3}") +string(REGEX MATCH "assertions: *([0-9]+)" _ "${report}") +set(checks "${CMAKE_MATCH_1}") + +if(result EQUAL 0) + message("Unit tests: ${passed}/${cases} passed (${checks} checks)") +else() + message("${report}") + file(REMOVE "${GAME}") + if(cases STREQUAL "") + message(FATAL_ERROR "Unit tests: didn't run (${result}), see above. -DWITH_TESTS=OFF builds the game without them") + endif() + message(FATAL_ERROR "Unit tests: ${failed} of ${cases} FAILED, see above. -DWITH_TESTS=OFF builds the game without them") +endif() diff --git a/tests/unit/test_brakes.cpp b/tests/unit/test_brakes.cpp new file mode 100644 index 00000000..83416807 --- /dev/null +++ b/tests/unit/test_brakes.cpp @@ -0,0 +1,93 @@ +#include "stdafx.h" +#include "doctest.h" +#include "vehicle.h" + +using test::definition; +using test::vehicle; + +// the brake pipe of a lone wagon is held at a pressure the way a coupled locomotive would hold it. +// pressures are in bar, the brake pipe runs at 5.0, a 1.5 bar reduction is a full service application. + +TEST_CASE("brakes: KE wagon applies up to its tare limit on a service application and releases again") { + vehicle wagon{definition::load("wagon")}; // empty wagon, TareMaxBP=2.3 + + wagon.hold_brake_pipe(5.0); + wagon.run(60.0); + CHECK(wagon->BrakePress < 0.1); + CHECK(wagon->Hamulec->GetBRP() > 4.5); // auxiliary reservoir charged + + wagon.hold_brake_pipe(3.5); + wagon.run(10.0); + CHECK(wagon->BrakePress == doctest::Approx(2.3).epsilon(0.05)); + + wagon.hold_brake_pipe(5.0); + wagon.run(60.0); + CHECK(wagon->BrakePress < 0.1); +} + +TEST_CASE("brakes: a deeper brake pipe reduction brakes harder") { + auto applied = [](double Pipe) { + vehicle wagon{definition::load("wagon").set("Brake", "TareMaxBP", 3.8)}; // no tare limit in the way + wagon.hold_brake_pipe(5.0); + wagon.run(60.0); + wagon.hold_brake_pipe(Pipe); + wagon.run(20.0); + return wagon->BrakePress; + }; + auto const light{applied(4.6)}; + auto const medium{applied(4.2)}; + auto const full{applied(3.5)}; + CHECK(light > 0.1); + CHECK(light < medium); + CHECK(medium < full); + CHECK(full <= 3.8 + 0.1); // MaxBP +} + +// every distributor valve the engine implements goes through the same cycle; the ESt family needs the size +// of its reservoirs, as in the real .fiz files using it +TEST_CASE("brakes: every implemented distributor valve charges, applies and releases") { + for (auto const *valve : {"W", "K", "KE", "ESt", "ESt3", "NESt3", "ESt4", "ESt3AL2", "LSt", "EStED", "EP1", "EP2", "CV1"}) { + CAPTURE(valve); + vehicle wagon{definition::load("wagon").set("Brake", "BrakeValve", valve).set("Brake", "Size", 16)}; + wagon.hold_brake_pipe(5.0); + wagon.run(90.0); + CHECK(wagon->BrakePress < 0.1); + wagon.hold_brake_pipe(3.5); + wagon.run(20.0); + CHECK(wagon->BrakePress > 1.0); + CHECK(wagon->BrakePress <= 3.8 + 0.1); // MaxBP + wagon.hold_brake_pipe(5.0); + wagon.run(90.0); + CHECK(wagon->BrakePress < 0.1); + } +} + +TEST_CASE("brakes: service braking stops a wagon running at 60 km/h and keeps it stopped") { + vehicle wagon{definition::load("wagon"), 60.0}; + wagon.hold_brake_pipe(5.0); + wagon.run(10.0); + REQUIRE(wagon.speed() > 50.0); // still running before the braking + + wagon.hold_brake_pipe(3.5); + wagon.run(30.0); + CHECK(std::abs(wagon.speed()) < 0.1); + wagon.run(30.0); + CHECK(std::abs(wagon.speed()) < 0.1); +} + +TEST_CASE("brakes: a released wagon rolls on") { + vehicle wagon{definition::load("wagon"), 60.0}; + wagon.hold_brake_pipe(5.0); + wagon.run(30.0); + CHECK(wagon.speed() > 50.0); + CHECK(wagon.speed() < 60.0); // running resistance slows it down a little +} + +// the example in README.md +TEST_CASE("brakes: service braking stops a heavier wagon too") { + // the default wagon, with the keys the test is about changed + test::vehicle wagon{test::definition::load("wagon").set("Param", "M", 60000), 40.0 /* km/h */}; + wagon.hold_brake_pipe(3.5); // bar, as a coupled locomotive would hold it + wagon.run(40.0 /* s */, test::track{0.15 /* friction */, 0.0 /* gradient */}); + CHECK(std::abs(wagon.speed()) < 0.1); +} diff --git a/tests/unit/test_definition.cpp b/tests/unit/test_definition.cpp new file mode 100644 index 00000000..6b2d446c --- /dev/null +++ b/tests/unit/test_definition.cpp @@ -0,0 +1,45 @@ +#include "stdafx.h" +#include "doctest.h" +#include "vehicle.h" + +using test::definition; +using test::vehicle; + +TEST_CASE("definition: the wagon from data/wagon.json loads with its values") { + vehicle wagon{definition::load("wagon")}; + + CHECK(wagon->Mass == doctest::Approx(42200.0)); + CHECK(wagon->Vmax == doctest::Approx(120.0)); + CHECK(wagon->Dim.L == doctest::Approx(26.8)); + CHECK(wagon->WheelDiameter == doctest::Approx(0.96)); + CHECK(wagon->NAxles == 4); + CHECK(wagon->MaxBrakePress[3] == doctest::Approx(3.8)); + CHECK(wagon->BrakeSystem == TBrakeSystem::Pneumatic); +} + +TEST_CASE("definition: keys set by the test reach the vehicle") { + vehicle heavy{definition::load("wagon").set("Param", "M", 60000).set("Wheels", "D", 1.0)}; + + CHECK(heavy->Mass == doctest::Approx(60000.0)); + CHECK(heavy->WheelDiameter == doctest::Approx(1.0)); +} + +TEST_CASE("definition: an unknown test vehicle is an error, not a silently empty one") { + CHECK_THROWS(definition::load("no such vehicle")); +} + +TEST_CASE("definition: a misspelt section is an error, not silently skipped by the loader") { + CHECK_THROWS(definition::load("wagon").set("Brakes", "MBF", 30)); +} + +TEST_CASE("definition: rows of a table section reach the vehicle") { + auto const pc1{light::headlight_left | light::headlight_upper | light::headlight_right}; + vehicle wagon{definition::load("wagon") + .set("LightsList", "Size", 2) + .set("LightsList", "Default", 1) + .set("LightsList", "rows", {{0, 0}, {pc1, 0}})}; + + CHECK(wagon->LightsPosNo == 2); + CHECK(wagon->LightsPos == 1); + CHECK(wagon->Lights[end::front][1] == pc1); +} diff --git a/tests/unit/vehicle.cpp b/tests/unit/vehicle.cpp new file mode 100644 index 00000000..721fab58 --- /dev/null +++ b/tests/unit/vehicle.cpp @@ -0,0 +1,178 @@ +#include "stdafx.h" +#include "vehicle.h" + +#include +#include + +namespace test { + +namespace { + +struct section_format { + char separator; // the game's definitions end section names with either '.' or ':' + char const *table_end{nullptr}; // the line closing the rows of a table section +}; + +// the sections TMoverParameters::LoadFIZ() reads, see its issection() calls. a section missing here +// would be silently skipped by the loader, and the test would run on the defaults instead +std::map const sections{ + {"Param", {'.'}}, + {"Load", {':'}}, + {"Dimensions", {':'}}, + {"Wheels", {':'}}, + {"Brake", {':'}}, + {"Doors", {':'}}, + {"BuffCoupl", {'.'}}, + {"BuffCoupl1", {'.'}}, + {"BuffCoupl2", {'.'}}, + {"TurboPos", {':'}}, + {"Cntrl", {'.'}}, + {"Blending", {':'}}, + {"DCEMUED", {':'}}, + {"SpringBrake", {':'}}, + {"Light", {':'}}, + {"Headlights", {':'}}, + {"Clima", {':'}}, + {"Power", {':'}}, + {"SpeedControl", {':'}}, + {"Engine", {':'}}, + {"Switches", {':'}}, + {"Security", {':'}}, + {"Circuit", {':'}}, + {"AI", {':'}}, + {"LightsList", {':', "endL"}}, + {"DimmerList", {':', "endDimmerList"}}, + {"WiperList", {':', "endwl"}}, + {"CompressorList", {':', "endCL"}}, +}; + +section_format const &format_of(std::string const &Section) { + auto const lookup{sections.find(Section)}; + if (lookup == sections.end()) { + throw std::runtime_error("unknown vehicle definition section " + Section + ", see tests/unit/vehicle.cpp"); + } + return lookup->second; +} + +std::string value_text(nlohmann::ordered_json const &Value) { + if (Value.is_string()) { + return Value.get(); + } + if (Value.is_boolean()) { + return Value.get() ? "Yes" : "No"; + } + return Value.dump(); +} + +} // namespace + +definition definition::load(std::string const &Name) { + auto const path{std::string{EU07_UNIT_TESTS_DATA} + "/" + Name + ".json"}; + std::ifstream file(path); + if (false == file.is_open()) { + throw std::runtime_error("no test vehicle definition " + path); + } + definition result; + auto const json{nlohmann::ordered_json::parse(file, nullptr, true, true)}; + for (auto const &[section, keys] : json.items()) { + // keys starting with _ are comments + if (section.starts_with('_')) { + continue; + } + format_of(section); + result.m_sections[section] = keys; + } + return result; +} + +definition &definition::set(std::string const &Section, std::string const &Key, nlohmann::ordered_json const &Value) { + format_of(Section); + m_sections[Section][Key] = Value; + return *this; +} + +definition &definition::remove(std::string const &Section) { + m_sections.erase(Section); + return *this; +} + +definition &definition::remove(std::string const &Section, std::string const &Key) { + if (m_sections.contains(Section)) { + m_sections[Section].erase(Key); + } + return *this; +} + +std::string definition::text() const { + std::string text; + for (auto const &[section, keys] : m_sections.items()) { + auto const &format{format_of(section)}; + text += section + format.separator; + for (auto const &[key, value] : keys.items()) { + if (key != "rows") { + text += " " + key + "=" + value_text(value); + } + } + text += "\n"; + // table sections give their rows as a list of rows, each a list of values + if (keys.contains("rows")) { + if (format.table_end == nullptr) { + throw std::runtime_error("vehicle definition section " + section + " has no rows"); + } + for (auto const &row : keys["rows"]) { + for (auto const &value : row) { + text += value_text(value) + " "; + } + text += "\n"; + } + text += std::string{format.table_end} + "\n"; + } + } + return text; +} + +vehicle::vehicle(definition const &Definition, double Velocity, std::string const &Name) { + // the same steps as TDynamicObject::Init() + m_mover = std::make_unique(Velocity, Name, Name, 0); + if (false == m_mover->LoadFIZ_Text(Definition.text())) { + throw std::runtime_error("the test vehicle definition didn't load"); + } + m_mover->AssignLoad("", 0.f); + m_mover->ComputeMass(); + auto const driveractive{Velocity != 0.0}; + if (false == m_mover->CheckLocomotiveParameters(driveractive, Velocity >= 0.0 ? 1 : -1)) { + throw std::runtime_error("parameters mismatch in the test vehicle definition"); + } + m_mover->MainCtrlPos = m_mover->MainCtrlNoPowerPos(); + m_mover->LocalBrakePosA = 0.0; + m_mover->BrakeCtrlPos = static_cast(std::floor(m_mover->Handle->GetPos(driveractive ? bh_RP : bh_NP))); + m_mover->BrakeLevelSet(m_mover->BrakeCtrlPos); +} + +void vehicle::step(track const &Track, double Dt) { + TTrackShape shape; + shape.Len = 1000.0; + shape.dHtrack = Track.gradient; + TTrackParam param; + param.Width = 1.435; + param.friction = Track.friction; + param.CategoryFlag = 1; + TTractionParam const traction; + TLocation const location{0.0, 0.0, 0.0}; + TRotation const rotation{0.0, 0.0, 0.0}; + if (m_pipepressure >= 0.0) { + m_mover->Pipe->CreatePress(m_pipepressure); + } + m_mover->ComputeTotalForce(Dt); + m_mover->ComputeMovement(Dt, Dt, shape, param, traction, location, rotation); +} + +void vehicle::run(double Seconds, track const &Track, double Dt) { + // whole steps: summing up Dt would make the step count depend on the rounding + auto const steps{std::lround(Seconds / Dt)}; + for (long i = 0; i < steps; ++i) { + step(Track, Dt); + } +} + +} // namespace test diff --git a/tests/unit/vehicle.h b/tests/unit/vehicle.h new file mode 100644 index 00000000..593bb21b --- /dev/null +++ b/tests/unit/vehicle.h @@ -0,0 +1,64 @@ +#pragma once + +#include +#include + +#include + +#include "McZapkie/MOVER.h" + +namespace test { + +// definition of a test vehicle: one of the JSON files in tests/unit/data, with the sections and keys +// of the game's vehicle definitions, changed by the test where it needs to: +// auto heavy = definition::load("wagon").set("Param", "M", 60000).set("Brake", "BrakeValve", "ESt3"); +class definition { +public: + // tests/unit/data/.json + static definition load(std::string const &Name); + + // sets the key of the section, adding either one when it's missing + definition &set(std::string const &Section, std::string const &Key, nlohmann::ordered_json const &Value); + // drops the whole section, or one key of it + definition &remove(std::string const &Section); + definition &remove(std::string const &Section, std::string const &Key); + + // the definition in the form the game's vehicle loader reads + std::string text() const; + +private: + nlohmann::ordered_json m_sections; +}; + +// conditions the vehicle runs in: straight track of the given friction and gradient (rise/run) +struct track { + double friction{0.15}; + double gradient{0.0}; +}; + +// one vehicle created the way TDynamicObject::Init() creates it, from the given definition +class vehicle { +public: + explicit vehicle(definition const &Definition, double Velocity = 0.0, std::string const &Name = "test"); + + TMoverParameters &mover() { return *m_mover; } + TMoverParameters *operator->() { return m_mover.get(); } + + // keeps the brake pipe at the given pressure [bar], as a locomotive coupled to the vehicle would; + // a negative value leaves the pipe alone + void hold_brake_pipe(double Pressure) { m_pipepressure = Pressure; } + + // advances the physics by one step of Dt, the way the simulation loop does + void step(track const &Track = {}, double Dt = 0.01); + // advances the physics by Seconds in steps of Dt + void run(double Seconds, track const &Track = {}, double Dt = 0.01); + + // vehicle speed [km/h], signed + double speed() const { return m_mover->V * 3.6; } + +private: + std::unique_ptr m_mover; + double m_pipepressure{-1.0}; +}; + +} // namespace test