mirror of
https://github.com/MaSzyna-EU07/maszyna.git
synced 2026-10-30 13:10:29 +01:00
Test/physics unit tests (#213)
* build: doctest 2.4.11 in ref/
Single header test framework (MIT), vendored like the other libraries in
ref/ so the tests need nothing extra installed on the CI runners.
* refactor(physics): read vehicle definitions from text as well as files
TMoverParameters::LoadFIZ(path) opens the file and hands the parser over
to LoadFIZ_Parse(), which holds what LoadFIZ() did with it before. The
new LoadFIZ_Text() reads the same format from a string, so a definition
can be made up in memory, e.g. by a unit test. No change in behaviour for
files.
* build: the simulation core as a library the game links
The vehicle physics (McZapkie), the vehicle definition parser and the
helpers they use build as eu07_core, a static library the game links,
so the unit tests to come can link the very same objects instead of
compiling their own copy with their own settings.
The game's build settings (include dirs, definitions, compile options,
libraries) move from the executable to eu07_settings, an interface
target the library links publicly, so the game, the library and later
the tests all build with the same ones. What only the executable needs
stays with it: /MAP, /LARGEADDRESSAWARE, output dirs, the precompiled
header, runtime DLL copying, the crash reporter and the experimental
renderer. No change in what the game builds or links.
* test: unit tests of the simulation core, run as part of the build
eu07_unit_tests: C++ tests (doctest) of the simulation, which set up the
conditions, run the code and check the outcome. No game data, no window.
- the code under test is the core library (eu07_core), the very objects
the game links, built with the game's settings; engine_stubs.cpp stands
in for the logs, settings and scenery layers the library reaches for.
python gets linked with the settings but isn't needed to run the tests
- test vehicles are JSON definitions in tests/unit/data, with the sections
and keys of the game's vehicle definitions (tables as "rows", _keys are
comments), changed by the tests where needed and loaded from memory by
the game's own loader; an unknown section is an error rather than
silently skipped
- test::vehicle creates the mover the way TDynamicObject::Init() does and
steps it the way the simulation loop does, on a straight track of given
friction and gradient, optionally holding the brake pipe pressure the
way a coupled locomotive would
- the random numbers of the simulation are reseeded before every test
case, so a test gives the same result alone (IDE) or among the others
- building the game runs the tests at the end, once the game is linked,
and prints a one line summary ("Unit tests: 11/11 passed (80 checks)").
A failure prints the full report and fails the build, in CI and locally
alike, and removes the game binary so the next build reruns them. A
change of the tests alone relinks the game so they run (Ninja and
Makefile generators). -DWITH_TESTS=OFF leaves them out
- first tests: definitions reach the vehicle, KE service application and
release, deeper reduction brakes harder, every implemented distributor
valve charges/applies/releases, service braking stops a moving wagon
* test: stub basic_node::radius_() for GCC and Clang
The Linux build failed to link the unit tests: undefined reference to
scene::basic_node::radius_() from basic_node's vtable in engine_stubs.o.
basic_node has no key function for the Itanium C++ ABI (tooltip() is
declared plain but defined inline in the header), so GCC and Clang emit
its vtable in every unit using scenenode.h, and the vtable needs
radius_(), defined in scenenode.cpp, which the tests don't link. MSVC
emits vtables differently, so the Windows build linked.
* test: test vehicle definitions load with GCC and Clang
All the tests reading a definition failed on Linux with "unknown vehicle
definition section 0": the parsed definition was brace-initialised
(auto const json{parse(...)}), which GCC and Clang take for the
initializer list constructor of nlohmann::json, making an array holding
the definition, while MSVC copies it. Initialise it with =, and require
the definition to be a JSON object.
* test: SonarCloud issues of the unit tests
- test::definition_error for unusable test vehicle definitions instead of
std::runtime_error (S112)
- the rows of a table section written by a function of their own, less
nesting in definition::text() (S134)
- std::less<> for the section table (S6045)
- comments in the empty methods of the reseeding listener and stub (S1186)
- NOSONAR where the engine's headers fix the stubs: the extern globals
and the member function signatures (S5421, S5817, S1238)
- NOSONAR on LoadFIZ_Parse(): the cognitive complexity is the former body
of LoadFIZ(), moved as it was (S3776)
- a comment Sonar took for code reworded (S125)
This commit is contained in:
31
tests/unit/CMakeLists.txt
Normal file
31
tests/unit/CMakeLists.txt
Normal file
@@ -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)
|
||||
68
tests/unit/README.md
Normal file
68
tests/unit/README.md
Normal file
@@ -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 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_<topic>.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("<name>")` reads `data/<name>.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`.
|
||||
12
tests/unit/data/wagon.json
Normal file
12
tests/unit/data/wagon.json
Normal file
@@ -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 }
|
||||
}
|
||||
81
tests/unit/engine_stubs.cpp
Normal file
81
tests/unit/engine_stubs.cpp
Normal file
@@ -0,0 +1,81 @@
|
||||
// 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 <cstdlib>
|
||||
#include <iostream>
|
||||
|
||||
// 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 --------------------------------
|
||||
// the declarations come from the engine's headers, the stubs can't change them (hence the NOSONARs)
|
||||
|
||||
namespace scene {
|
||||
|
||||
node_groups Groups; // NOSONAR the engine's extern global, scene/scenenodegroups.h
|
||||
node_layers Layers; // NOSONAR the engine's extern global, scene/scenelayers.h
|
||||
|
||||
group_handle node_groups::create(bool const) { // NOSONAR signature from scene/scenenodegroups.h
|
||||
return null_handle;
|
||||
}
|
||||
|
||||
group_handle node_groups::close() { // NOSONAR signature from scene/scenenodegroups.h
|
||||
return null_handle;
|
||||
}
|
||||
|
||||
layer_handle node_layers::open(std::string const &, include_site) { // NOSONAR signature from scene/scenelayers.h
|
||||
return null_handle;
|
||||
}
|
||||
|
||||
layer_handle node_layers::close() { // NOSONAR signature from scene/scenelayers.h
|
||||
return null_handle;
|
||||
}
|
||||
|
||||
void node_layers::instance_begin(std::string const &, source_span const &) { // NOSONAR signature from scene/scenelayers.h
|
||||
// no layers in the tests, so nothing to keep track of
|
||||
}
|
||||
|
||||
// basic_node has no key function for the Itanium C++ ABI (tooltip() is declared plain but defined inline), so
|
||||
// GCC and Clang emit its vtable wherever scenenode.h is used, here too, and the vtable wants radius_().
|
||||
// the same as the game's, in scene/scenenode.cpp
|
||||
float basic_node::radius_() {
|
||||
return 0.f;
|
||||
}
|
||||
|
||||
} // namespace scene
|
||||
40
tests/unit/main.cpp
Normal file
40
tests/unit/main.cpp
Normal file
@@ -0,0 +1,40 @@
|
||||
#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(); }
|
||||
|
||||
// the rest of doctest's reporter interface: a listener reports nothing, the console reporter does that
|
||||
void report_query(doctest::QueryData const &) override { /* nothing to report */ }
|
||||
void test_run_start() override { /* nothing to report */ }
|
||||
void test_run_end(doctest::TestRunStats const &) override { /* nothing to report */ }
|
||||
void test_case_end(doctest::CurrentTestCaseStats const &) override { /* nothing to report */ }
|
||||
void test_case_exception(doctest::TestCaseException const &) override { /* nothing to report */ }
|
||||
void subcase_start(doctest::SubcaseSignature const &) override { /* nothing to report */ }
|
||||
void subcase_end() override { /* nothing to report */ }
|
||||
void log_assert(doctest::AssertData const &) override { /* nothing to report */ }
|
||||
void log_message(doctest::MessageData const &) override { /* nothing to report */ }
|
||||
void test_case_skipped(doctest::TestCaseData const &) override { /* nothing to report */ }
|
||||
|
||||
private:
|
||||
static void reseed() {
|
||||
Global.random_engine.seed(1);
|
||||
Global.local_random_engine.seed(1);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
DOCTEST_REGISTER_LISTENER("deterministic_random", 1, deterministic_random);
|
||||
27
tests/unit/run_tests.cmake
Normal file
27
tests/unit/run_tests.cmake
Normal file
@@ -0,0 +1,27 @@
|
||||
# runs the unit tests at the end of building the game: cmake -DTESTS=<test binary> -DGAME=<game binary> -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()
|
||||
93
tests/unit/test_brakes.cpp
Normal file
93
tests/unit/test_brakes.cpp
Normal file
@@ -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);
|
||||
}
|
||||
45
tests/unit/test_definition.cpp
Normal file
45
tests/unit/test_definition.cpp
Normal file
@@ -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);
|
||||
}
|
||||
187
tests/unit/vehicle.cpp
Normal file
187
tests/unit/vehicle.cpp
Normal file
@@ -0,0 +1,187 @@
|
||||
#include "stdafx.h"
|
||||
#include "vehicle.h"
|
||||
|
||||
#include <fstream>
|
||||
#include <stdexcept>
|
||||
|
||||
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<std::string, section_format, std::less<>> 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 definition_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<std::string>();
|
||||
}
|
||||
if (Value.is_boolean()) {
|
||||
return Value.get<bool>() ? "Yes" : "No";
|
||||
}
|
||||
return Value.dump();
|
||||
}
|
||||
|
||||
// the rows of a table section, each a list of values, and the line closing them
|
||||
std::string table_text(std::string const &Section, section_format const &Format, nlohmann::ordered_json const &Rows) {
|
||||
if (Format.table_end == nullptr) {
|
||||
throw definition_error("vehicle definition section " + Section + " has no rows");
|
||||
}
|
||||
std::string text;
|
||||
for (auto const &row : Rows) {
|
||||
for (auto const &value : row) {
|
||||
text += value_text(value) + " ";
|
||||
}
|
||||
text += "\n";
|
||||
}
|
||||
return text + Format.table_end + "\n";
|
||||
}
|
||||
|
||||
} // 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 definition_error("no test vehicle definition " + path);
|
||||
}
|
||||
definition result;
|
||||
// not brace-initialised: json{other} makes an array of other with GCC and Clang (the initializer list constructor wins)
|
||||
auto const json = nlohmann::ordered_json::parse(file, nullptr, true, true);
|
||||
if (false == json.is_object()) {
|
||||
throw definition_error("test vehicle definition " + path + " isn't a JSON object");
|
||||
}
|
||||
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";
|
||||
if (keys.contains("rows")) {
|
||||
text += table_text(section, format, keys["rows"]);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
vehicle::vehicle(definition const &Definition, double Velocity, std::string const &Name) {
|
||||
// the same steps as TDynamicObject::Init()
|
||||
m_mover = std::make_unique<TMoverParameters>(Velocity, Name, Name, 0);
|
||||
if (false == m_mover->LoadFIZ_Text(Definition.text())) {
|
||||
throw definition_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 definition_error("parameters mismatch in the test vehicle definition");
|
||||
}
|
||||
m_mover->MainCtrlPos = m_mover->MainCtrlNoPowerPos();
|
||||
m_mover->LocalBrakePosA = 0.0;
|
||||
m_mover->BrakeCtrlPos = static_cast<int>(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
|
||||
71
tests/unit/vehicle.h
Normal file
71
tests/unit/vehicle.h
Normal file
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "McZapkie/MOVER.h"
|
||||
|
||||
namespace test {
|
||||
|
||||
// thrown for a test vehicle definition which can't be used: missing, malformed, or not taken by the loader
|
||||
class definition_error : public std::runtime_error {
|
||||
public:
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
// 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/<Name>.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 in kilometres per hour, negative when moving backwards
|
||||
double speed() const { return m_mover->V * 3.6; }
|
||||
|
||||
private:
|
||||
std::unique_ptr<TMoverParameters> m_mover;
|
||||
double m_pipepressure{-1.0};
|
||||
};
|
||||
|
||||
} // namespace test
|
||||
Reference in New Issue
Block a user