17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-11-08 13:19:17 +01:00
Files
maszyna/tests/unit/README.md
jerrrrycho 466cbbbfa1 Test/core unit coverage (#235)
* test: real vehicles and golden traces in the unit tests

The game's own vehicles (dynamic/ of the release used for the tests) as
JSON of what the game's vehicle loader reads of their .fiz files, includes
expanded: test_fiz.cpp converts them on demand and checks the loader reads
the same sections, values and rows as of the game's file (fiz.h). Chosen
to cover every engine type, brake valve, brake handle, local brake,
coupler type, door type and brake delay set the game data uses.

Golden traces (golden.h) characterise the simulation, compared within a
relative tolerance or exactly, re-recorded with
EU07_UNIT_TESTS_UPDATE_GOLDEN=1:
- the parameters of every real vehicle as loaded,
- the brakes of every real vehicle through charge, service steps,
  release, overcharge and emergency, on every brake delay setting,
- every position of every driver's and local brake handle,
- every powered vehicle started up the way the AI does it, driven to
  full power and braked to a stop (drive.h),
- the real electric units and a locomotive with wagons driven whole, as
  trains of coupled vehicles stepped the way the game steps them
  (consist.h),
- scenarios of the controls (scenario.h): doors, reversing and cab
  change, slip and sand, emergency stops, releasers, the ep and the
  electrodynamic brake, heating, loading, a collision, and frames of
  several physics iterations.

* test: commands passed between vehicles

Every command RunCommand knows run on every real vehicle and recorded,
commands travelling through a train with a vehicle facing backwards, a
coupling without the control cable, and loading and unloading by commands.

* test: every switch of the cab, sent to the next vehicle

Each switch sending its state to the train, each way and to each range,
with the controllers and direction among them, on every real vehicle
driving another of its kind, recorded for both.

* test: the switches of the cab worked on a pair under the catenary

The same switches with two seconds for the vehicles to act on each, so
the pantographs rise, the main switch closes and the auxiliaries start.

* test: gauges of trains in multiple and the damage descriptions

The ammeters and the brake force the AI expects, recorded on the electric
units and locomotives in multiple driven, the damage described on the
driver's panel and ammeters showing the current of controlled vehicles.

* test: give load commands their place, as the game does

The loading test set its commands with SetInternalCommand, which leaves
TCommand::Location as it is: uninitialised in a fresh vehicle, so the
distance check of Load= and UnLoad= read garbage and the test failed
now and then. The game gives them through PutCommand with the place of
the platform, and so do the tests now.

* test: brake valves and handles no vehicle of the game data has

Fitted to a wagon and to an EU07: the valve variants and handles the
loader knows that no real vehicle uses, the CV1 ones and the test handle
among them, and a name the loader doesn't know.

* test: braking from speed with the automatic ED brake, and a draisine

Trains with the EP09, the E6ACT and the EW58 braked by ep, light and full
service applications from speed, and the draisine pumped from rest and
rolling. Only the E6ACT brings its electrodynamic brake in here.

* test: the EP09's electrodynamic brake in service braking held to a stop

start_up() took a vehicle for ready once its line breaker was closed and
its reservoir full, which a train made already moving is at once: the
EP09's manually started converter was never switched on, so there was
no 110 V and no electrodynamic brake. It now waits for the converter.

The EP09 with wagons is braked from 120 km/h by light, medium, high and
full service applications held to a stop: the motors brake, the air of
its cylinders is let out and given back as the train slows down. The
flag of the electrodynamic brake stays on at a standstill below 2.1 bar.

C strings given to CAPTURE are shown as text.

* test: the converter switched on before a train is ready, the EW58's ep-staged electrodynamic brake

start_up() called a train ready as soon as its line breaker closed and the air was there, so the converter
of a unit having it in another car than the cab (the EW58's middle car) was never switched on, and its
electrodynamic brake, gated by the 110 V, never came in. The converter and the compressor now always get
switched on first. The EW58 brakes electrically by the stages of the ep brake, not by the pipe alone.

* test: the ET42 and the 214M railbus among the real vehicles

The ET42's halves (its own series motor branch: the motors in series at the high current setting) and the
214MA, a diesel railbus with the hydraulic torque converter, the retarder and the controller of an induction
motor drive, converted from the game data and recorded in every sweep of the real vehicles.

* test: the wagons' hand brakes released with the train's brakes; the 754, SM31 and SU46

release_brakes() released the hand brake of the driver's vehicle only, so every wagon set up braked kept its
hand brake on and got dragged along. It now releases the hand and spring brakes of the whole train, as
TController::AutoRewident() does when the AI takes it over, and the traces of the trains with wagons are
recorded anew.

The 754, the SM31 and the SU46 join the real vehicles for the kinds of the diesel-electrics' field shunting
relays the others lack (RelayType 0, 1 and 46).

* test: traction on a lost and too high catenary voltage, the ET42, the diesel-electrics' shunting relays

The line breaker opened by the voltage relays of a series motor locomotive and by the limits of an induction
motor one. The ET42 at the high current setting, which keeps its halves' motors in series, and its switched
electrodynamic brake, whose current trips the overload relay from the top speed of the low setting. The field
shunting of the diesel-electrics through each kind of their relays, at full power and notched back.

* test: the EW58's electrodynamic brake stages, the EU47's speed control and braking at the voltage's limit

The stages of the EW58's motor brake set by the positions of its ep handle, the first one alone over
100 km/h. The EU47's second controller holding, raising and lowering the speed set, its local brake setting
the flag of the electrodynamic brake without the motors braking, and its brake feeding back less current into
a catenary over the drive's MaxV.

* test: diesel engines too cold and too hot to start, the 214M railbus's controller and retarder

The heat of the diesel engines: at -10 degrees the ones with the lowest water temperature set don't start
until the heater warms them, at 88 degrees the ones with a second water circuit don't start while it cools,
and the SM42 run hot at full power is kept under its highest temperature by its fans and shutters. The 214M
railbus's controller from the air brake through the retarder and neutral to power, its torque converter's
lock-up and its gearbox.

The controller helpers of the traction tests (full power, waiting for the line contactors, and notching back)
move to test::scenario.

* test: couplers coupled, uncoupled, broken and colliding; loads assigned, loaded and unloaded

Coupling by hand touching or apart and of the same kind or not, with the sounds of what changes; uncoupling a
screw coupler pressed together, stretched, with the hoses alone or broken; the automatic couplers of electric
units coupling on contact, without the control cable between two kinds; a coupler broken with its brake
hose; collisions over 15 km/h derailing. The pantographs set up by the pantstate load, a load of one kind at
a time up to the capacity, and the steps of loading and unloading.

* test: the battery drained and charged, the pantographs' tank and pressure switch, the 214M's speed control

The EU07's battery drained without the converter by the lights and the small compressor until it opens the
line breaker and nothing starts, charged by the converter, and switched off. The pantographs on their own
leaking tank until the pressure switch opens the line breaker, and the small compressor pumping it up and
priming the switch. The 214M railbus's speed control set by its buttons, steps and power limit, braking down
with the retarder, and standing by.

* test: the MHZ handles' buttons, the valves at speed, every brake material, the NESt3's relays

The overload and the high pressure buttons of the MHZ handles. The KE and the LSt at speed in the R and P
settings, under the anti-slip request and the releaser button. Every material of the brake shoes and disks
the loader takes, braking a wagon from speed, the ones no game vehicle has among them. The CV1_R and the
Oerlikon valve's relays set by its name (-ED, AL2 without ESt3) fitted to a wagon.

* test: the loader's lists and lines: the 218M, SA134 and 22WEc, a compressor list, Pmax by speed, skipped lines

The 218M (V2NList), the SA134 (HTCList) and the Elf 22WEc (ffBrakeList) join the real vehicles for the lists
the others lack. Definitions made by the tests for what no game file has: a compressor list, whose table of
whole numbers reads a factor of 0.9 as 0; the compressor's limits apart for the rear cab; the highest power
of an induction motor drive by the speed, extrapolated past the table; and the lines the loader skips or
trims (comments, a hash anywhere, leading spaces, carriage returns).

* test: what the cab and the AI call on a vehicle: gauges of the train, the split ED brake, spring brake, cut-off

The engine rotation of up to three vehicles in multiple and the lamps of the overload relay and the starting
resistors read through the control cable; the engine's idle speed and the ET42's second controller shown in
its dynamic brake; the controller of the electrodynamic brake apart from the local brake, which no game
vehicle has yet; the spring brake shut off and released, the ep brake's fuse and the handle stepped by the AI;
damaged motors cut off; the cab activated by itself; a road vehicle moved across a road, for which the tests'
track gets a width; and the lamps and the buzzer of the security system left unattended.

* test: the EP05 and its camshaft among the real vehicles, the headlights' colour and brightness

The EP05, whose camshaft closes the line breaker and the line contactors by the camshaft's own position,
converted from the game data and recorded in every sweep of the real vehicles. The Headlights section, which
only the 214M's own file has, in a definition made by the test.

* test: what the unit tests reach of the simulation core, what's dead or left, and what they found

COVERAGE.md: the blocks of Mover.cpp, hamulce.cpp, Oerlikon_ESt.cpp and friction.cpp the tests run, the code
nothing in the game runs, the code only data no game vehicle has would run, what's reachable and left, and
the behaviour the tests found that may deserve a look.

* test: check invariants of the physics on every step of every golden trace

Rules any vehicle keeps whatever a test does (no NaN, cylinders and pipes
within their limits, no current with the line breaker or contactors open,
no force without power, distance as the speed runs, no acceleration over
5 g), checked on every step, with no comparison to a recorded file. The
traces where a test or a game vehicle breaks one are excused with the
reason; the vehicles' cases are in the findings of COVERAGE.md.

* test: key figures of every golden trace, per part of the run

Duration, speeds, distance, time to stop, the brake pipe's lowest
pressure and time to recover, the peaks of the cylinders, the motor
current and the tractive force, for each part between two events, in
data/golden/<name>.figures. Compared within 0.5% or the last decimal, so
they hold on any compiler, and an intended change of behaviour shows in
a few readable lines. Traces sampled by hand keep none.

* test: keep the golden samples out of the repository, recorded on the code before a change

The samples of the golden traces are 29 MB and only mean something against
the code before the change they check, so they're recorded locally before it
(EU07_UNIT_TESTS_UPDATE_GOLDEN=1) and compared after it, and ignored by git.
The key figures and the invariants stay, and are what the CI checks.

Without samples recorded a trace compares only its key figures, but with
EU07_UNIT_TESTS_GOLDEN_EXACT=1, the check of a change which shouldn't alter
anything, it fails, so the check can't pass by having nothing to compare.

* fix: draw the noise of the control pipe pressure in one order on every compiler

The order of the two Random(10) calls in 'Random(10) - Random(10)' is
unspecified: GCC draws them the other way round than MSVC Release, which
mirrored the control pipe pressure around 5 bar and failed 32 unit tests
on the Linux CI. The draws are sequenced in the order of MSVC Release,
which the golden traces were recorded with.

* test: count the start-up rounds with an integer, not a double (SonarCloud S2193)

* test: behaviour in CHECKs, the key figures out of the repository

The key figures of the golden traces are recorded with their samples on the code
before a change, and compared after it, instead of being kept: a trace without
them checks the invariants and its test's CHECKs, which is what the CI runs.

The scenarios of the controls state what they show in CHECKs, and the runs of
every real vehicle what holds for each of them. Writing them showed scenarios
which didn't do what they were named for: the cab change stopped in the engine
room, the slip runs put the controller to full power at once and never got the
line contactors closed, and the train stopped by the alarm chain never got its
pipe charged again. They do it now; the rest went to the findings.

* test: the SonarCloud findings in the unit tests

std::format for the texts put together, emplace_back, const references, string_view parameters, std::array and std::vector for the C arrays, the ranges algorithms, names no longer shadowing the coupler ends, comments in the empty loops; start_up(), trace::check() and the converter of the definitions split up. BrakeForceR() is const, which it always was in effect, so the gauges test needs no const_cast. The golden traces compare bit for bit.
2026-11-07 12:37:12 +01:00

10 KiB

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.