17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-11-08 21:00:03 +01:00
Files
maszyna/tests/unit
..
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00
2026-10-15 17:56:41 +02:00
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00
feat(editor): the download area drawn on the topo map: a polygon is the boundary of what the editor downloads, the orthophoto and the terrain model alike. Drawn from the settings of the orthophoto (Download area, Draw on the map, the top view on): a click puts a point, a point dragged moves, Shift+click removes it, Backspace takes the last one back, Enter closes the area, Esc gives up; it's kept in the scenery as //$a lines in PUWG 1992, so it stays on the ground when the origin moves. Inside it everything the settings ask for, the standard imagery, the 4K one where it's ticked and covers the place, the squares of the terrain model where the scenery has its terrain, goes to the disk cache in the background, nothing decoded nor uploaded, only while no tile around the camera waits and not while the scenery is driven, with its progress, Pause and Retry under it; outside it nothing is asked for, around the camera, along the tracks or ahead of the laying, nor shown. The workers of the orthophoto are 8, at most 3 of them on the tiles around the camera, each holding an image decoded, the rest downloading the area. The 4K tiles: an image the tile no longer wants once it comes, as a 4K one asked for before the camera moved on, isn't uploaded (the 64 MB and the mipmaps of it), nor decoded where a newer job of the tile waits; no two jobs of one tile run at once; a standard tile coming blank twice is remembered by a .blank file, not downloaded again on each return; a 4K tile is uploaded only in a frame with no other upload. Selftests orthoarea, orthostats, orthohires, orthoareapause; unit tests of the tiles a triangle, a concave polygon, a thin band and a polygon about a corner touch (geometry::squares_in, square_touches)
2026-10-26 18:58:09 +01:00
2026-10-15 17:56:41 +02:00
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00
2026-11-07 12:37:12 +01:00

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.

COVERAGE.md says how much of the simulation core the tests reach, what's left and why, and what they found.

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, 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.

#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 fiz.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.
  • definition::fiz("<dir>/<name>") is one of the game's own vehicles, data/fiz/<dir>/<name>.json, in the same form, converted from the game's .fiz with the files it includes (the list and how to add one: data/fiz/README.md). The conversion keeps what the game's loader reads of the file and checks it reads the same of the converted one (fiz.h), so the vehicle is the game's.
  • The random numbers of the simulation are reseeded before every test case, so a test gives the same result alone or among the others. Loading a vehicle draws some too (the scatter of its brake pipe pressure), so a test making vehicles in a loop calls test::reseed_random() before each.
  • Logic which lives in classes too big to set up in a test (the AI driver, the cab) goes into free functions on the vehicle state the class calls, and the test calls them the same way: see vehicle/handover.h, which TController::TakeControl() uses, and test_handover.cpp.
  • 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, gradient, catenary voltage and physics iterations a frame. With a cab (vehicle{definition, 0.0, "name", 1}) it has a driver.
  • consist (consist.h) couples vehicles into a train and steps them together, coupler forces, air, control and power passing between them. The physics reaches a neighbour through its TDynamicObject, for its MoverParameters only; the tests have a stand-in of it with just that.
  • drive.h has the driver's part, made of the AI's steps: start_up() (battery, pumps, pantographs, line breaker, converter, compressor), drive() (running, acknowledging the vigilance), release_brakes(), drive_cycle() (a whole run from start-up to a stop).
  • scenario (scenario.h) puts a train, its driver and a golden trace for each vehicle together, for a test of a feature: act() marks an event in the traces, wait() runs the train.
  • pair (vehicle.h) puts two vehicles on that track facing each other with the given ends and runs them together, as the simulation runs the vehicles of a scene: the coupler and buffer forces, collisions and automatic coupling come from the physics (test_couplers.cpp).
  • 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).

Golden traces

A golden trace pins down how the simulation behaves now, for changes which mustn't alter it, like restructuring a function: the values of some channels of a vehicle over a run, compared with the ones recorded from the code before, in data/golden/<name>.csv. A difference fails the test, naming the channel, the time and both rows.

The samples and their key figures aren't kept in the repository (tens of megabytes, and they only mean something against the code before the change): record them before the change, and compare after it.

git switch master
EU07_UNIT_TESTS_UPDATE_GOLDEN=1 eu07_unit_tests     # records data/golden/**/*.csv, ignored by git
git switch my-refactor
EU07_UNIT_TESTS_GOLDEN_EXACT=1 eu07_unit_tests      # compares every sample, bit for bit

The recorded files stay through switching branches. Without them, a trace checks the invariants and its test's own CHECKs, which is what the CI does: every test states the behaviour it's about in CHECKs, a scenario of a feature what the feature does (the doors open, the train stops), a run of every real vehicle what holds for each of them (it brakes harder by every step, it runs off and stops).

test::trace trace{"eu07/notch up", test::channels::motion() + test::channels::traction()};
eu07.record(trace); // samples every 10th step
eu07->MainSwitch(true);
trace.mark("main switch"); // shows in the event column of the row
eu07.run(30.0);
trace.check();
  • EU07_UNIT_TESTS_UPDATE_GOLDEN=1 records the samples and the key figures, for a new test or a change which alters the behaviour on purpose; the diff of the key figures shows what it altered, review it like code.
  • Without EU07_UNIT_TESTS_GOLDEN_EXACT=1, samples recorded are compared within a relative 1e-9, as GCC and glibc's maths functions can differ from MSVC's in the last bits, and a trace without them passes. With it they're compared exactly, and a trace without them or its key figures fails, so a run of the refactoring check can't pass by having nothing to compare.
  • Keep the runs short (tens of seconds): the simulation feeds back on itself, and a last bit grows.
  • A trace with no assertion about what the behaviour should be is a characterisation, bugs included; put the behaviour the test is about into CHECKs besides.

Key figures

Next to its samples, every trace records the figures a person reads (figures.h), for each part of the run between two events, in data/golden/<name>.figures: the duration, the speeds, the distance, the time to stop, the brake pipe's lowest pressure and the time to get back to 4.9 bar, the peaks of the brake cylinders, the motor current and the tractive force.

[brake]
duration = 60.00 s
speed at the start = 118.5 km/h
distance = 944.3 m
stopped after = 45.97 s
brake cylinders peak = 3.997 bar

They're compared within 0.5% or the last decimal written, loose enough for any compiler, and the differences are told by part and figure:

key figures of "traction/4e_v1/4e" differ:
  [brake] distance: 944.3 m now, 980.0 m recorded

EU07_UNIT_TESTS_UPDATE_GOLDEN=1 records them with the samples. After a change which alters the behaviour, their comparison says in a few lines what the thousands of changed samples mean. A trace sampled by hand, at times of its own (the commands and switches tests), records none.

Invariants

Every trace also checks, on every step of its vehicle and not only on the recorded ones, the rules which hold for any vehicle whatever a test does (invariants.h): no NaN, the brake cylinders under the vehicle's highest pressure, the pipes under the highest governor setting, no motor current with the line breaker open, no tractive force without power, the distance counted as the speed runs, no acceleration over 5 g, and a few more. They compare nothing with a recorded file, so they hold on any compiler, and they still check a behaviour recorded anew on purpose makes sense. A broken rule fails the test, naming the time, the last event and the values:

invariant "no motor current with the line breaker open" broken in traction/et42 fuse/0 at 63.200 s,
after "dynamic brake": Mains=0 Im=-412.5 Itot=-412.5

Where a test makes a vehicle break a rule (a collision), or a game vehicle breaks one as it is, the list of excuses in invariants.cpp names the trace, the rule and the reason; the second kind goes into the findings of COVERAGE.md too.

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.