From f90ccbf7bcf086c95d5eccb7cb6182daa5745d75 Mon Sep 17 00:00:00 2001 From: Jim Schreckengast Date: Wed, 11 Nov 2026 09:27:11 -0700 Subject: [PATCH] Measure test coverage in CI and cover the app and tools The tests ran about 54% of the crate's lines: nothing exercised the app, the bench and train tools, or the neural network directly. New tests bring line coverage, every file counted, above 80%: * tests/gui.rs drives the app without a window (egui_kittest) through its accessibility tree, the keyboard and the pointer: every layout, control mode and controller, the comparison, corridors and the green wave, every setting, overlay and coloring, placing and selecting vehicles, pan and zoom, both benchmark windows and About. It also checks that every control has a name a screen reader can say. * tests/scripted.rs opens each TS_* scene in a process of its own, as the app reads the variables when it starts, and checks that screenshots are valid PNG files. * tests/tools.rs runs bench and train briefly, in scratch directories with their own home directories. * tests/networks.rs checks backpropagation against the numerical gradient, Adam, the shape checks and Double DQN. A new Coverage job runs cargo-llvm-cov and fails below 80% of lines. Code built for coverage counts what it runs in counters that every thread updates, so threads running the same code at once slow one another down many times over: tests/safety.rs took 51 s instead of 7 s, with 21 times the CPU time. The job runs the tests one at a time, and under cfg(coverage) the tests that run many simulations at once run them in turn (tests/common/mod.rs). Promoting counters to registers (-do-counter-promotion) didn't help. egui_kittest's toml needs toml_datetime 1.1.3 rather than 1.1.2. Some of the app's proc macros use that crate too, so THIRD_PARTY_LICENSES.md now lists the new version. CONTRIBUTING.md says how to measure coverage locally, and docs/architecture.md and the README describe the new tests and job. Signed-off-by: Jim Schreckengast --- .github/workflows/ci.yml | 28 +- CONTRIBUTING.md | 25 +- Cargo.lock | 51 ++- Cargo.toml | 10 + README.md | 4 +- THIRD_PARTY_LICENSES.md | 2 +- docs/architecture.md | 31 +- tests/common/mod.rs | 19 + tests/cooperative.rs | 12 +- tests/gui.rs | 841 +++++++++++++++++++++++++++++++++++++++ tests/networks.rs | 191 +++++++++ tests/safety.rs | 8 +- tests/scripted.rs | 338 ++++++++++++++++ tests/tools.rs | 168 ++++++++ 14 files changed, 1701 insertions(+), 27 deletions(-) create mode 100644 tests/common/mod.rs create mode 100644 tests/gui.rs create mode 100644 tests/networks.rs create mode 100644 tests/scripted.rs create mode 100644 tests/tools.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e41d602..20acc5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,10 @@ # Copyright (c) 2026 Jim Schreckengast # SPDX-License-Identifier: MIT OR Apache-2.0 # -# Build and test on every platform, build the browser version, check formatting and lint -# with clippy, build the crate from the files crates.io would get, check licenses, -# advisories and attributions, and fuzz the loading of weights files. +# Build and test on every platform, measure how much of the crate the tests run, build +# the browser version, check formatting and lint with clippy, build the crate from the +# files crates.io would get, check licenses, advisories and attributions, and fuzz the +# loading of weights files. name: CI on: @@ -34,6 +35,27 @@ jobs: - run: cargo build --locked --release --all-targets - run: cargo test --locked --release + # The share of the crate's lines the tests run, every file counted: the app's panels + # and the developer tools in src/bin too. Fails below 80%. + coverage: + name: Coverage + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + components: llvm-tools-preview + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + - uses: taiki-e/install-action@3a3daafd3b7f23b8f65d9f5ba2db203fb59b79e2 # v2.90.2 + with: + tool: cargo-llvm-cov@0.9.1 + # One test at a time: code built for coverage counts what it runs in counters every + # thread updates, so threads running the same code at once slow one another down + # many times over (tests/common/mod.rs). + - run: cargo llvm-cov --locked --release --fail-under-lines 80 -- --test-threads=1 + web: name: Web (wasm32) runs-on: ubuntu-22.04 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 62fba16..7180222 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,6 +59,23 @@ cargo clippy --locked --release --target wasm32-unknown-unknown --bin intersecti cargo deny --locked check ``` +CI also measures how much of the crate the tests run, with +[cargo-llvm-cov](https://github.com/taiki-e/cargo-llvm-cov), and fails if they run less +than 80% of its lines. Every file counts, including the app's panels and the developer +tools in `src/bin`, so new code needs tests that reach it. To measure locally: + +```bash +rustup component add llvm-tools-preview +cargo install cargo-llvm-cov --locked +cargo llvm-cov --locked --release --fail-under-lines 80 -- --test-threads=1 +``` + +The tests run one at a time, as in CI: code built for coverage slows down many times +over when several threads run it at once. For the same reason, a test that runs many +simulations at once should start them with `spawn` from +[tests/common/mod.rs](tests/common/mod.rs), which runs them in turn when measuring +coverage. Add `--open` to see, file by file, the lines no test runs. + If you add or update a dependency, also regenerate the third-party license list with `packaging/licenses.sh` (it needs `cargo-about` 0.9.1). CI fails if [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) is out of date. @@ -95,9 +112,11 @@ pass that file's path in place of the two folders. check it, and a bug fix comes with a test that fails without the fix. Put tests that use the public API in [tests/](tests/) (see [tests/pedestrian_rule.rs](tests/pedestrian_rule.rs) for an example) and narrower ones - in a `#[cfg(test)]` module next to the code. Use fixed seeds so tests are - deterministic, and keep them fast enough to run on every pull request. Changes to - documentation, UI text or styling don't need tests. + in a `#[cfg(test)]` module next to the code. Test the app the way + [tests/gui.rs](tests/gui.rs) does: without a window, through the names its controls + give screen readers. Use fixed seeds so tests are deterministic, and keep them fast + enough to run on every pull request. Changes to documentation, UI text or styling + don't need tests. * **Report results with your change.** If it changes how traffic behaves, include before and after numbers from `cargo run --release --bin bench all 20 6` (and the corridor or sensor study where relevant), and update the tables in the README and diff --git a/Cargo.lock b/Cargo.lock index e29beeb..c3549de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -890,6 +890,20 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_kittest" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0979c2e37860fcf13a2bea77508607f067a9a6a84ba9bd47b5e298582fb59b86" +dependencies = [ + "eframe", + "egui", + "kittest", + "log", + "serde", + "toml", +] + [[package]] name = "egui_system_font_provider" version = "0.37.0" @@ -1575,6 +1589,7 @@ version = "0.5.1" dependencies = [ "eframe", "egui", + "egui_kittest", "serde", "serde_json", "wasm-bindgen-futures", @@ -1692,6 +1707,16 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kittest" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" +dependencies = [ + "accesskit", + "accesskit_consumer", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -2868,6 +2893,15 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "serde_spanned" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c187e0a369471c550af028cba56d4f4fa2a489ad28151ae95a4d340a2a8b782" +dependencies = [ + "serde_core", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3204,11 +3238,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "toml" +version = "1.2.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5755c51863cdac711f1956baf8eb9588e060755f7d3c4c979e70ea1e1f32cd7" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + [[package]] name = "toml_datetime" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49" dependencies = [ "serde_core", ] diff --git a/Cargo.toml b/Cargo.toml index 6c42d07..fc8d06d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,16 @@ serde_json = "1.0.154" wasm-bindgen-futures = "0.4.79" web-time = "1.1.0" +# The GUI tests (tests/gui.rs) run the app without a window and drive it through its +# accessibility tree. +[dev-dependencies] +egui_kittest = { version = "0.37", features = ["eframe"] } + +# cargo-llvm-cov sets cfg(coverage) when it measures how much of the crate the tests run +# (see tests/common). +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ["cfg(coverage)"] } + [profile.release] opt-level = 3 debug = false diff --git a/README.md b/README.md index 89e7c3d..63d65f2 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ cargo run --release --example lpi # pedestrian safety treatments cargo run --release --example fya # left-turn phasing modes cargo run --release --example satflow # calibration against HCM saturation flow cargo run --release --example cooperative # capacity of the signal-free cooperative crossing -cargo test --release # safety + throughput regression tests +cargo test --release # regression tests: safety, throughput, app, tools ``` The browser version is the same app compiled to WebAssembly. Build and serve it locally @@ -473,7 +473,7 @@ src/rl/ MLP + Adam, Double DQN with replay src/gui/ eframe/egui app: renderer, charts, panels, self-screenshot (TS_SHOT=…) src/bin/ bench (comparisons, sensor study), train (RL training) weights/ trained DQN and hybrid weights per layout -tests/ safety + throughput regression tests +tests/ regression tests: safety, throughput, the app (headless) and the tools examples/ corridor, lpi, fya, satflow, preempt, sweep (multi-seed safety sweep), cooperative docs/ architecture.md (how the code fits together, with a tour of the app), insights.md (every finding), cooperative-intersections.md (design notes for the diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index ce6fab9..28dbd6f 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -3841,7 +3841,7 @@ DEALINGS IN THE SOFTWARE. Used by: -* toml_datetime 1.1.2+spec-1.1.0 (https://github.com/toml-rs/toml) +* toml_datetime 1.1.3+spec-1.1.0 (https://github.com/toml-rs/toml) * toml_edit 0.25.17+spec-1.1.0 (https://github.com/toml-rs/toml) * toml_parser 1.1.5+spec-1.1.0 (https://github.com/toml-rs/toml) diff --git a/docs/architecture.md b/docs/architecture.md index 65ac683..0cc68d1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -108,7 +108,7 @@ flowchart TB SIM <-->|"Ctx in, stage out"| CTRL CTRL --> RL CTRL --> W - STUDY -->|"fuzz/: rl::dqn"| RL + STUDY -->|"fuzz/, tests/: rl"| RL RL -.->|"random numbers"| SIM classDef ui fill:#fff3e0,stroke:#c05621,color:#3b1d06 classDef sim fill:#e7f5ec,stroke:#2f855a,color:#123524 @@ -132,7 +132,7 @@ Each front end uses only the library's public modules: | `bin/bench.rs` | `lib.rs` (`scenario`, `run_scenario`), `sim` and `control` | | `bin/train.rs` | `sim` and `control` (it drives `Sim` itself) | | `examples/` | `sim` and `control`; `make_icons` uses only `gui::shot` | -| `tests/` | `sim` and `control` | +| `tests/` | `sim`, `control`, `rl` and `gui` (the app, run without a window); `bench` and `train` as programs | | `fuzz/` | `sim`, `control` and `rl::dqn` | ### 2.1 Responsibilities by layer @@ -214,7 +214,8 @@ src/ bin/train.rs headless training of the Deep RL and Hybrid networks examples/ studies: cooperative, corridor, fya, lpi, preempt, satflow, sweep, plus make_icons -tests/ safety, cooperative, pedestrian_rule, weights_files, green_wave +tests/ safety, cooperative, pedestrian_rule, weights_files, green_wave, + networks, gui, scripted, tools, and common/ (shared by the tests) fuzz/ cargo-fuzz target for weights files (a workspace of its own) weights/ trained networks (JSON), also compiled into the binary web/index.html page that hosts the browser build (Trunk) @@ -235,7 +236,7 @@ docs/ this document, insights.md, cooperative-intersections.md (wi | `src/bin/` | 2 | 590 | | `src/lib.rs`, `src/main.rs` | 2 | 78 | | `examples/` | 8 | 575 | -| `tests/` | 5 | 873 | +| `tests/` | 10 | 2,557 | | `fuzz/fuzz_targets/` | 1 | 54 | | Target | Built from | Purpose | @@ -250,7 +251,8 @@ docs/ this document, insights.md, cooperative-intersections.md (wi The crate is edition 2024 and has four dependencies: `eframe` and `egui` 0.37, `serde` and `serde_json`. The browser build adds two more, `web-time` and -`wasm-bindgen-futures`. The crate has no features and no dev-dependencies. The package +`wasm-bindgen-futures`. The crate has no features. Its one dev-dependency, +`egui_kittest` 0.37, runs the app without a window for the tests (§10.3). The package published to crates.io holds only the library, the app, the weights files, the icon, the licenses and the README, so `bench`, `train`, the examples and the tests are not in it. @@ -3085,9 +3087,9 @@ The examples are the studies behind `docs/insights.md` and ### 10.3 Tests -`cargo test` runs five integration test files and the unit tests in `sim/coop.rs`, +`cargo test` runs nine integration test files and the unit tests in `sim/coop.rs`, `sim/signal.rs` and `gui/mod.rs`. CI runs them in release mode on Linux, macOS and -Windows (§11). +Windows, and measures how much of the crate they cover (§11). | File | Tests | What they require | |---|---:|---| @@ -3096,6 +3098,10 @@ Windows (§11). | `tests/pedestrian_rule.rs` | 4 | Deep RL's shield serves a crosswalk whose pedestrians have waited longer than `PED_MAX_WAIT`, longest wait first, and leaves shorter waits to the controller; the reward counts waiting pedestrians | | `tests/weights_files.rs` | 6 | The shipped weights load, misshapen networks are rejected, a Hybrid without a fitting network keeps its base tuning and says so, Hybrid files saved with a score that isn't finite load again, a copy that doesn't load is skipped for the next one and networks that overflow don't crash (§8.7) | | `tests/green_wave.rs` | 2 | The green wave runs every signal of a corridor fixed-time, and ending it gives back the controllers they ran before, except where another was chosen during the wave, without collisions; a single intersection isn't coordinated | +| `tests/networks.rs` | 7 | Backpropagation agrees with the gradient measured numerically, Adam fits a line, the shape check rejects every malformed network (sizes whose product overflows included), the target network moves part way toward the online one, the action mask is respected, Double DQN learns which action pays in which state and the replay memory keeps the latest 60,000 transitions | +| `tests/gui.rs` | 16 | The app, run without a window by `egui_kittest` and driven through its accessibility tree, the keyboard and the pointer: every panel opens and every control has a name; Space pauses and Step advances one second; every layout, control mode and controller runs, side by side too and along a corridor with the green wave; the demand, sensor and operations settings apply; the learning controllers show what they learn; every overlay and coloring draws; vehicles and pedestrians placed by hand, movements an approach lacks refused; a clicked vehicle's details; pan, zoom and *Fit view*; both benchmark windows; *About* | +| `tests/scripted.rs` | 8 | Each `TS_*` variable of the scripted mode sets up its scene, a scripted screenshot stops the clock at its moment and screenshots are valid PNG files. The app reads the variables as it starts, so each scene opens in a process of its own | +| `tests/tools.rs` | 7 | `bench` compares every strategy on every layout without collisions, takes each arrival pattern and runs the sensor study; `train` trains, saves and resumes both learning controllers, in a scratch directory with its own home directory | | `src/sim/coop.rs` | 4 | Approach profiles arrive on time and at speed, a short run-up enters slower but on time, the gap policies give the gaps in §6.11 and the following law holds its time gap | | `src/sim/signal.rs` | 1 | The signal history records a change's yellow and all-red between the two greens, with no gaps | | `src/gui/mod.rs` | 1 | The safety monitors' margin over the gap keeps its sign | @@ -3117,6 +3123,7 @@ flowchart TB PR["Pull request"] CI["Checks
CI, CodeQL and DCO;
10 are required"] FZ["Fuzz (weights), 60 s:
reported, not required"] + CV["Coverage, at least 80%
of lines: reported,
not required"] M["Merge to main
branch up to date"] PG["Pages: the browser
build, deployed"] SC["Scorecard
also weekly"] @@ -3126,13 +3133,14 @@ flowchart TB C["publish-crate
cargo publish to
crates.io"] PR --> CI --> M --> T --> R --> P --> C PR -.-> FZ + PR -.-> CV M --> PG M --> SC classDef ui fill:#fff3e0,stroke:#c05621,color:#3b1d06 classDef data fill:#f1f3f5,stroke:#5f6b7a,color:#1f2933 classDef safe fill:#fdecec,stroke:#c53030,color:#4a0f0f class PR,M,T,P ui - class CI,FZ safe + class CI,FZ,CV safe class PG,SC,R,C data ``` @@ -3151,6 +3159,13 @@ date: * **Fuzz (weights)** in `ci.yml` runs the fuzz target for 60 s from its seeds on the nightly toolchain, and uploads any crash. It isn't required. +* **Coverage** in `ci.yml` runs the tests on Linux with + [cargo-llvm-cov](https://github.com/taiki-e/cargo-llvm-cov) and fails if they run less + than 80% of the crate's lines, every file counted (CONTRIBUTING.md says how to measure + it locally). The tests run one at a time, and under `cfg(coverage)` the ones that run + many simulations at once run them in turn: code built for coverage counts what it runs + in counters that every thread updates, so threads running the same code at once slow + one another down many times over. It isn't required. * **`pages.yml`** builds the browser version with Trunk on every push to `main` and deploys it to GitHub Pages. * **`release.yml`** runs on a `v*` tag, or by hand to build unsigned packages: diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..b04eba8 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,19 @@ +// Copyright (c) 2026 Jim Schreckengast +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Shared by the integration tests that run many simulations at once. + +use std::thread::JoinHandle; + +/// Run `job` on a thread of its own, except when measuring test coverage, which runs it +/// right away. Code built for coverage counts what it runs in counters that all threads +/// update, so threads running the same code at once slow one another down many times +/// over (cargo-llvm-cov sets `cfg(coverage)`; see CONTRIBUTING.md). +pub fn spawn(job: impl FnOnce() -> T + Send + 'static) -> JoinHandle { + if cfg!(coverage) { + let done = job(); + std::thread::spawn(move || done) + } else { + std::thread::spawn(job) + } +} diff --git a/tests/cooperative.rs b/tests/cooperative.rs index c3bdd23..66023f0 100644 --- a/tests/cooperative.rs +++ b/tests/cooperative.rs @@ -14,6 +14,8 @@ use intersection_lab::sim::stats::Summary; use intersection_lab::sim::vtypes::VKind; use intersection_lab::sim::{ControlMode, Sim}; +mod common; + /// A crossing with `n_left` left-turn lanes per approach and `total` veh/h spread evenly /// over the four approaches, with the safety checks on. fn crossing(n_left: usize, total: f64, cars_only: bool, policy: GapPolicy, seed: u64) -> Sim { @@ -150,7 +152,7 @@ fn every_gap_policy_is_safe_under_heavy_demand() { for (n_left, total) in [(2, 18_000.0), (1, 12_000.0)] { for cars in [false, true] { for seed in 31..34 { - jobs.push(std::thread::spawn(move || { + jobs.push(common::spawn(move || { let mut sim = crossing(n_left, total, cars, policy, seed); sim.run_for(1200.0); let fleet = if cars { "cars" } else { "mixed" }; @@ -205,7 +207,7 @@ fn bursts_heavy_vehicles_and_uneven_demand_are_served_safely() { let jobs: Vec<_> = cases .into_iter() .map(|(name, d, limits)| { - std::thread::spawn(move || { + common::spawn(move || { let mut sim = Sim::with_demand(build_cooperative(2), ControlMode::Cooperative, CtrlKind::Fixed, d, 41); sim.check_safety = true; @@ -259,7 +261,7 @@ fn weaving_one_at_a_time_carries_far_less() { // quicker than waiting for a batch; but every switch between crossing streams costs // a full safety gap, which a batch pays once, so it saturates far below the batches. let run = |sched: Scheduling, total: f64, seed: u64| { - std::thread::spawn(move || { + common::spawn(move || { let mut sim = crossing(2, total, false, GapPolicy::FailSafe, seed); sim.set_scheduling(sched); sim.run_for(120.0 + 900.0); @@ -305,7 +307,7 @@ fn dual_left_design_serves_18000_vehicles_per_hour() { // arrives is served, nobody waits long, and the monitors see nothing wrong. let jobs: Vec<_> = (0..3) .map(|seed| { - std::thread::spawn(move || { + common::spawn(move || { let mut sim = crossing(2, 18_000.0, false, GapPolicy::FailSafe, 7000 + seed); sim.run_for(120.0 + 1200.0); let s = summary(&sim, 120.0); @@ -348,7 +350,7 @@ fn oversaturated_capacity_is_about_19000_vehicles_per_hour() { #[test] fn single_left_lanes_fall_short_of_the_design_demand() { let run = |n_left: usize| { - std::thread::spawn(move || { + common::spawn(move || { let mut sim = crossing(n_left, 16_000.0, false, GapPolicy::FailSafe, 3); sim.run_for(900.0); (summary(&sim, 120.0).throughput_vph, queued(&sim)) diff --git a/tests/gui.rs b/tests/gui.rs new file mode 100644 index 0000000..2f000f9 --- /dev/null +++ b/tests/gui.rs @@ -0,0 +1,841 @@ +// Copyright (c) 2026 Jim Schreckengast +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! The app, driven the way a person drives it: through the controls its accessibility +//! tree names (what a screen reader sees), the keyboard, and the pointer on the map. It +//! runs without a window (egui_kittest), so the tests read what the panels say and what +//! the map paints as text, not how anything looks. +//! +//! While it runs, the simulation advances with real time, so tests that count +//! simulated seconds pause it first and advance it with the Step button. + +use egui::accesskit::{Role, Toggled}; +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, Shape, Vec2, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use intersection_lab::control::CtrlKind; +use intersection_lab::gui::App; +use intersection_lab::gui::render::Camera; +use intersection_lab::sim::ControlMode; +use intersection_lab::sim::LeftMode; +use intersection_lab::sim::coop::{GapPolicy, Scheduling}; +use intersection_lab::sim::demand::{DemandConfig, Pattern}; +use intersection_lab::sim::geom::{V2, v2}; +use intersection_lab::sim::network::{LayoutKind, Network, Turn}; +use intersection_lab::sim::sensors::Conditions; +use intersection_lab::sim::vtypes::{ALL_KINDS, VKind}; +use std::time::{Duration, Instant}; + +type Window = Harness<'static, App>; + +/// The app as it opens, in a window of its default size. +fn open() -> Window { + Harness::builder() + .with_size([1600.0, 1000.0]) + // The desktop app draws emoji with the system's fonts, which a test doesn't load. + .allow_missing_glyphs() + .build_eframe(|cc| App::new(cc)) +} + +/// Every control has a name a screen reader can say (see ACCESSIBILITY.md). +#[track_caller] +fn assert_named(w: &Window) { + let unnamed = w.unnamed_widgets(); + assert!(unnamed.is_empty(), "controls without a name: {unnamed:#?}"); +} + +/// Activate the control named `label` the way a screen reader does, which works whether +/// or not it's scrolled into view, and draw the result. +#[track_caller] +fn press(w: &mut Window, label: &str) { + w.get_by_label(label).click_accesskit(); + w.run_steps(2); +} + +/// Pick `option` from the drop-down list named `list`. +#[track_caller] +fn choose(w: &mut Window, list: &str, option: &str) { + w.get_by_role_and_label(Role::ComboBox, list).click_accesskit(); + w.run_steps(2); + w.get_by_role(Role::ListBox).get_by_label(option).click_accesskit(); + w.run_steps(2); + // Choosing with a screen reader leaves the list open, as it would for a keyboard user + // until Escape. + w.key_press(egui::Key::Escape); + w.run_steps(2); + assert!(w.query_by_role(Role::ListBox).is_none(), "the {list} list stayed open"); +} + +/// What the drop-down list named `list` shows as chosen. +#[track_caller] +fn chosen(w: &Window, list: &str) -> String { + w.get_by_role_and_label(Role::ComboBox, list).value().unwrap_or_default() +} + +/// Move the slider named `slider` to `value`. +#[track_caller] +fn slide(w: &mut Window, slider: &str, value: f64) { + w.get_by_role_and_label(Role::Slider, slider).set_value_accesskit(value); + w.run_steps(2); +} + +/// Whether the checkbox named `label` is ticked. +#[track_caller] +fn ticked(w: &Window, label: &str) -> bool { + w.get_by_role_and_label(Role::CheckBox, label).accesskit_node().toggled() == Some(Toggled::True) +} + +/// Tick or clear the checkbox named `label`. +#[track_caller] +fn tick(w: &mut Window, label: &str, on: bool) { + if ticked(w, label) != on { + press(w, label); + } + assert_eq!(ticked(w, label), on, "{label}"); +} + +/// The text of every label in the panels and windows. +fn texts(w: &Window) -> Vec { + w.query_all_by(|n| n.role() == Role::Label).filter_map(|n| n.value()).collect() +} + +/// Whether a label reads exactly `text`. +fn has(w: &Window, text: &str) -> bool { + texts(w).iter().any(|t| t == text) +} + +/// Whether a label contains `text`. +fn shows(w: &Window, text: &str) -> bool { + texts(w).iter().any(|t| t.contains(text)) +} + +/// Text the last frame painted, on the map and in the charts. +fn painted(w: &Window) -> Vec { + fn walk(shape: &Shape, out: &mut Vec) { + match shape { + Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, out)), + Shape::Text(t) => out.push(t.galley.text().to_owned()), + _ => {} + } + } + let mut out = vec![]; + for c in &w.output().shapes { + walk(&c.shape, &mut out); + } + out +} + +/// Whether the last frame painted text containing `text`. +fn paints(w: &Window, text: &str) -> bool { + painted(w).iter().any(|t| t.contains(text)) +} + +/// The status line at the top left of the map: clock, speed and strategy. +#[track_caller] +fn status(w: &Window) -> String { + painted(w) + .into_iter() + .find(|t| t.len() > 8 && t.as_bytes()[2] == b':' && t.as_bytes()[5] == b':') + .expect("the map's status line") +} + +/// The simulated time on the map's clock, in seconds. +#[track_caller] +fn clock(w: &Window) -> u32 { + let line = status(w); + let field = |r: std::ops::Range| line[r].parse::().expect("hh:mm:ss"); + field(0..2) * 3600 + field(3..5) * 60 + field(6..8) +} + +/// A count from the line under the results ("3 completed · 12 in network · …"). +#[track_caller] +fn count(w: &Window, what: &str) -> usize { + let line = texts(w).into_iter().find(|t| t.contains(" waiting to enter · ")).expect("the counts line"); + let part = line.split(" · ").find(|p| p.ends_with(what)).unwrap_or_else(|| panic!("no {what} in {line}")); + part.split(' ').next().unwrap().parse().unwrap() +} + +#[track_caller] +fn pause(w: &mut Window) { + press(w, "⏸ Pause"); + assert!(w.query_by_label("▶ Run").is_some(), "the button offers to run again"); + assert!(status(w).contains("⏸ paused")); +} + +/// Advance the paused simulation `seconds` seconds with the Step button. +#[track_caller] +fn step(w: &mut Window, seconds: usize) { + for _ in 0..seconds { + w.get_by_label("⏭ Step").click_accesskit(); + w.step(); + } + w.step(); +} + +/// The map: the only control that's neither labeled nor a container. +#[track_caller] +fn map(w: &Window) -> Rect { + w.query_all_by_role(Role::Unknown) + .map(|n| n.rect()) + .max_by(|a, b| a.area().total_cmp(&b.area())) + .expect("the map") +} + +/// Where the map draws point `p` (meters, centered on the first intersection) with the +/// view as Fit view leaves it: the intersection in the middle, scaled so the approaches +/// fit 55 m beyond the farthest stop line (80 m on the cooperative crossing). With the +/// comparison on, A has the left half. +#[track_caller] +fn on_map(w: &Window, net: &Network, p: V2, compare: bool) -> Pos2 { + let rect = map(w); + let margin = if net.coop.is_some() { 80.0 } else { 55.0 }; + let extent = net.legs.iter().map(|l| l.stop_dist).fold(0.0, f64::max) + margin; + let width = if compare { rect.width() * 0.5 } else { rect.width() }; + let scale = (width / (2.0 * extent) as f32).min(rect.height() / (2.0 * extent as f32)).max(0.6); + let half = + if compare { Rect::from_min_max(rect.min, pos2(rect.center().x - 1.0, rect.max.y)) } else { rect }; + Camera { center: v2(0.0, 0.0), scale, rect: half }.to_screen(p) +} + +/// A click of the pointer at `pos`. +#[track_caller] +fn click_at(w: &mut Window, pos: Pos2) { + w.event(Event::PointerMoved(pos)); + for pressed in [true, false] { + w.event(Event::PointerButton { + pos, + button: PointerButton::Primary, + pressed, + modifiers: Modifiers::NONE, + }); + } + w.run_steps(2); +} + +/// On an inbound lane of `leg`, `r` meters from the center. +fn approach(net: &Network, leg: usize, r: f64) -> V2 { + let l = &net.legs[leg]; + l.d * r + l.in_side() * (l.in_width * 0.5) +} + +/// Points along every inbound lane where vehicles wait, nearest the stop lines first. +fn queue_spots(net: &Network) -> Vec { + let mut spots = vec![]; + for k in 0..14 { + for leg in &net.legs { + for &lane in &leg.in_lanes { + let path = &net.links[lane].path; + let s = path.length - 2.0 - 3.0 * k as f64; + if s > 0.0 { + spots.push(path.point(s)); + } + } + } + } + spots +} + +/// Click along the queues until a vehicle is selected; whether one was. +#[track_caller] +fn select_a_vehicle(w: &mut Window, net: &Network) -> bool { + for p in queue_spots(net) { + let pos = on_map(w, net, p, false); + click_at(w, pos); + if has(w, "doing") { + return true; + } + } + false +} + +#[test] +fn opens_with_every_panel_and_every_control_named() { + let w = open(); + for heading in [ + "Scenario", + "Demand", + "Sensors", + "Operations", + "View", + "Vehicle experience", + "Controller brain", + "Selected vehicle", + "Benchmark", + "Signal timing (last 3 min)", + ] { + assert!(has(&w, heading), "no {heading}"); + } + assert_eq!(chosen(&w, "Intersection"), LayoutKind::Arterial4.name()); + assert_eq!(chosen(&w, "Control"), ControlMode::Signal.name()); + assert_eq!(chosen(&w, "Controller"), CtrlKind::Predictive.name()); + assert!(shows(&w, CtrlKind::Predictive.description())); + assert!(shows(&w, "Click a vehicle to inspect it.")); + assert!(status(&w).ends_with("Predictive"), "{}", status(&w)); + assert!(paints(&w, "Stage: "), "the signal's stage is on the map"); + assert_named(&w); +} + +#[test] +fn space_pauses_and_step_advances_one_second() { + let mut w = open(); + // With nothing focused, Space pauses and resumes. + w.key_press(egui::Key::Space); + w.run_steps(2); + assert!(w.query_by_label("▶ Run").is_some()); + let t = clock(&w); + step(&mut w, 3); + assert_eq!(clock(&w), t + 3); + // Paused, the clock stands still however many frames pass. + w.run_steps(20); + assert_eq!(clock(&w), t + 3); + w.key_press(egui::Key::Space); + w.run_steps(2); + assert!(w.query_by_label("⏸ Pause").is_some()); + + // Speeds, and Reset back to the start. + for speed in ["×0.5", "×2", "×5", "×10", "×30", "×100", "×1"] { + press(&mut w, speed); + assert!(status(&w).contains(&format!("{speed} ")), "{}", status(&w)); + } + pause(&mut w); + step(&mut w, 2); + press(&mut w, "⟲ Reset"); + assert_eq!(clock(&w), 0); + press(&mut w, "Restart"); + assert_eq!(clock(&w), 0); +} + +#[test] +fn every_layout_control_mode_and_controller_runs() { + let mut w = open(); + pause(&mut w); + for kind in LayoutKind::ALL { + choose(&mut w, "Intersection", kind.name()); + assert_eq!(chosen(&w, "Intersection"), kind.name()); + for mode in ControlMode::options(kind) { + if ControlMode::options(kind).len() > 1 { + choose(&mut w, "Control", mode.name()); + } + assert_eq!(chosen(&w, "Control"), mode.name()); + if mode == ControlMode::Signal { + for c in CtrlKind::ALL { + choose(&mut w, "Controller", c.name()); + assert_eq!(chosen(&w, "Controller"), c.name()); + assert!(shows(&w, c.description())); + step(&mut w, 2); + assert!(status(&w).ends_with(c.short()), "{}", status(&w)); + assert!(has(&w, "Controller brain")); + } + // Each change was announced at the bottom of the window. + assert!(shows(&w, "Controller switched live to")); + } else { + assert!(w.query_by_role_and_label(Role::ComboBox, "Controller").is_none()); + step(&mut w, 3); + let strategy = match mode { + ControlMode::Cooperative => { + format!("Intersection manager · {} gaps", GapPolicy::FailSafe.label()) + } + _ => mode.name().to_string(), + }; + assert!(status(&w).ends_with(&strategy), "{}", status(&w)); + } + assert_eq!(has(&w, "Sensors"), kind != LayoutKind::Cooperative); + assert_eq!(has(&w, "Intersection manager"), kind == LayoutKind::Cooperative); + assert_named(&w); + } + assert!(shows(&w, "safety overlaps: 0"), "vehicles overlapped on {kind:?}"); + } +} + +#[test] +fn the_cooperative_crossing_shows_its_intersection_manager() { + let mut w = open(); + pause(&mut w); + choose(&mut w, "Intersection", LayoutKind::Cooperative.name()); + step(&mut w, 30); + // Settings, then what the manager is doing. + assert!( + w.query_by_role_and_label(Role::Slider, "signals along main road").is_none(), + "it always stands alone" + ); + assert!(shows(&w, "Stop-line crossings by movement")); + assert!(shows(&w, "Serving ")); + assert!(shows(&w, "Phases in service order")); + assert!(shows(&w, "Safety monitors (what vehicles actually did)")); + assert!(paints(&w, "vehicles scheduled"), "the manager's state is on the map"); + for p in GapPolicy::ALL { + let before = chosen(&w, "Safety gap"); + choose(&mut w, "Safety gap", p.name()); + assert_eq!(chosen(&w, "Safety gap"), p.name()); + if before != p.name() { + assert!(shows(&w, &format!("Gap policy switched live to {}", p.label())), "{:?}", texts(&w)); + } + } + slide(&mut w, "max cycle s", 60.0); + slide(&mut w, "batch join window s", 2.5); + step(&mut w, 20); + choose(&mut w, "Scheduling", Scheduling::FirstCome.name()); + assert!(shows(&w, &format!("Scheduling switched live to {}", Scheduling::FirstCome.label()))); + step(&mut w, 20); + assert!(shows(&w, "First come, first served: each vehicle gets the earliest gap that fits")); + assert!(paints(&w, "First come, first served")); + // The batch settings don't apply to first come, first served. + assert!(w.get_by_role_and_label(Role::Slider, "max cycle s").accesskit_node().is_disabled()); + choose(&mut w, "Scheduling", Scheduling::Batches.name()); + step(&mut w, 5); + assert!(shows(&w, "safety overlaps: 0")); + assert_named(&w); +} + +#[test] +fn comparison_runs_a_second_strategy_on_the_same_arrivals() { + let mut w = open(); + pause(&mut w); + tick(&mut w, "Compare side by side", true); + assert_eq!(chosen(&w, "B control"), ControlMode::Signal.name()); + assert_eq!(chosen(&w, "B controller"), CtrlKind::Fixed.short()); + choose(&mut w, "B controller", CtrlKind::Actuated.name()); + assert_eq!(chosen(&w, "B controller"), CtrlKind::Actuated.short()); + step(&mut w, 10); + assert!(shows(&w, "Side by side (same arrivals)")); + assert!(shows(&w, "A: Predictive") && shows(&w, "B: Actuated")); + assert!(paints(&w, "A · Predictive") && paints(&w, "B · Actuated"), "each half of the map is labeled"); + choose(&mut w, "B control", ControlMode::AllWayStop.name()); + assert!(w.query_by_role_and_label(Role::ComboBox, "B controller").is_none()); + step(&mut w, 5); + assert!(shows(&w, &format!("B: {}", ControlMode::AllWayStop.name()))); + assert_named(&w); + + // On the cooperative crossing, B differs in its scheduling and gaps. + choose(&mut w, "Intersection", LayoutKind::Cooperative.name()); + assert!(ticked(&w, "Compare side by side")); + choose(&mut w, "B scheduling", Scheduling::Batches.name()); + choose(&mut w, "B gap policy", GapPolicy::Nominal.name()); + assert_eq!(chosen(&w, "B gap policy"), format!("{} gaps", GapPolicy::Nominal.label())); + step(&mut w, 10); + assert!(shows(&w, &format!("B: Intersection manager · {} gaps", GapPolicy::Nominal.label()))); + choose(&mut w, "B scheduling", Scheduling::FirstCome.name()); + assert!(shows(&w, "B: First come, first served")); + assert_named(&w); + tick(&mut w, "Compare side by side", false); + assert!(!shows(&w, "Side by side (same arrivals)")); +} + +#[test] +fn a_corridor_shows_each_signal_and_the_green_wave() { + let mut w = open(); + pause(&mut w); + slide(&mut w, "signals along main road", 3.0); + // The panels start at the middle intersection. + assert!(shows(&w, "Intersection 2 of 3 (west → east)")); + assert!(shows(&w, "Main-road trips end to end")); + // Turning the comparison on restarts both sides, every signal with the panel's + // controller: turn it on first. + tick(&mut w, "Compare side by side", true); + choose(&mut w, "Controller", CtrlKind::Actuated.name()); + press(&mut w, "◀"); + assert!(shows(&w, "Intersection 1 of 3")); + // Each intersection keeps its own controller, and the panel shows the focused one's. + assert_eq!(chosen(&w, "Controller"), CtrlKind::Predictive.name()); + press(&mut w, "▶"); + assert_eq!(chosen(&w, "Controller"), CtrlKind::Actuated.name()); + tick(&mut w, "Upstream signal shares its outflow", true); + step(&mut w, 30); + + tick(&mut w, "Fixed-time green wave", true); + assert!(shows(&w, "Green wave on: every signal runs fixed-time")); + assert_eq!(chosen(&w, "Controller"), CtrlKind::Fixed.name()); + step(&mut w, 30); + tick(&mut w, "Fixed-time green wave", false); + assert!(shows(&w, "Green wave off: the signals run their previous controllers again")); + assert_eq!(chosen(&w, "Controller"), CtrlKind::Actuated.name()); + press(&mut w, "◀"); + assert_eq!(chosen(&w, "Controller"), CtrlKind::Predictive.name()); + assert_named(&w); +} + +#[test] +fn demand_sensor_and_operations_settings_apply() { + let mut w = open(); + pause(&mut w); + // Fixed-time plans are re-optimized when the demand changes. + choose(&mut w, "Controller", CtrlKind::Fixed.name()); + slide(&mut w, "demand ×", 1.5); + let rates = DemandConfig::default_for(&Network::build(LayoutKind::Arterial4)).rate; + assert!(shows(&w, &format!("{:.0} veh/h arriving in all", rates.iter().sum::() * 1.5))); + for p in Pattern::ALL { + choose(&mut w, "Arrivals", p.name()); + assert_eq!(chosen(&w, "Arrivals"), p.name()); + step(&mut w, 2); + } + let southbound = w.get_by(|n| { + n.role() == Role::DisclosureTriangle && n.label().is_some_and(|l| l.starts_with("Southbound")) + }); + southbound.click_accesskit(); + w.run_steps(2); + slide(&mut w, "veh/h", 900.0); + slide(&mut w, &format!("{} share", Turn::L.name()), 0.4); + assert!( + w.query_by(|n| n.role() == Role::DisclosureTriangle + && n.label().is_some_and(|l| l.starts_with("Southbound — 1350 veh/h"))) + .is_some() + ); + press(&mut w, "Vehicle mix"); + for k in ALL_KINDS { + slide(&mut w, k.name(), 0.2); + } + slide(&mut w, "peds/h", 300.0); + step(&mut w, 10); + + for label in [ + "Inductive loops (stop-bar + advance)", + "Intersection cameras (video analytics)", + "Advance radar (long range)", + "Field-realistic sensor errors", + ] { + tick(&mut w, label, false); + step(&mut w, 2); + tick(&mut w, label, true); + } + tick(&mut w, "Connected vehicles (V2I)", true); + slide(&mut w, "CV penetration", 0.6); + slide(&mut w, "advance loop m", 70.0); + slide(&mut w, "camera range m", 100.0); + slide(&mut w, "radar range m", 200.0); + for c in Conditions::ALL { + choose(&mut w, "conditions", c.name()); + assert_eq!(chosen(&w, "conditions"), c.name()); + step(&mut w, 2); + } + slide(&mut w, "faulty loop stations", 0.3); + slide(&mut w, "turn-signal use", 0.5); + step(&mut w, 30); + tick(&mut w, "Field-realistic sensor errors", false); + assert!(w.get_by_role_and_label(Role::ComboBox, "conditions").accesskit_node().is_disabled()); + + tick(&mut w, "Emergency-vehicle preemption", false); + tick(&mut w, "Right turn on red", false); + assert!( + w.get_by_role_and_label(Role::CheckBox, "…except while pedestrians are present") + .accesskit_node() + .is_disabled() + ); + tick(&mut w, "Right turn on red", true); + tick(&mut w, "…except while pedestrians are present", true); + slide(&mut w, "leading ped interval s", 3.0); + slide(&mut w, "max-wait guard s", 90.0); + for m in LeftMode::ALL { + choose(&mut w, "left turns", m.name()); + assert_eq!(chosen(&w, "left turns"), m.name()); + // Lefts that aren't flashing-yellow say which run protected only. + assert_eq!(shows(&w, "protected-only now: "), m != LeftMode::Fya); + step(&mut w, 5); + } + assert_named(&w); +} + +#[test] +fn the_learning_controllers_show_what_they_learned() { + let mut w = open(); + pause(&mut w); + choose(&mut w, "Controller", CtrlKind::DeepRl.name()); + assert!(has(&w, "AI (Deep RL)")); + // The shipped network makes the decisions; it learns only when asked to. + assert!(shows(&w, "pretrained weights: ")); + assert!(!ticked(&w, "Keep learning online")); + assert!(w.get_by_role_and_label(Role::Slider, "exploration ε").accesskit_node().is_disabled()); + tick(&mut w, "Keep learning online", true); + slide(&mut w, "exploration ε", 0.3); + step(&mut w, 30); + let line = texts(&w).into_iter().find(|t| t.starts_with("decisions ")).expect("the learning figures"); + assert!(!line.starts_with("decisions 0 "), "{line}"); + assert!(shows(&w, "Q-values (expected future reward per action)")); + assert!(shows(&w, "online learning: ON")); + press(&mut w, "⟳ Reload pretrained"); + assert!(!ticked(&w, "Keep learning online"), "the reloaded network isn't learning"); + + choose(&mut w, "Controller", CtrlKind::Hybrid.name()); + assert!(has(&w, "AI (RL-tuned predictive)")); + tick(&mut w, "Keep learning online", true); + slide(&mut w, "exploration ε", 0.2); + step(&mut w, 30); + assert!(texts(&w).iter().any(|t| t.starts_with("tuning decisions "))); + assert_named(&w); +} + +#[test] +fn every_view_overlay_and_coloring_draws() { + let mut w = open(); + pause(&mut w); + slide(&mut w, "peds/h", 600.0); + tick(&mut w, "Connected vehicles (V2I)", true); + step(&mut w, 40); + for label in ["sensors", "detections", "conflict points", "paths", "delay labels", "night"] { + tick(&mut w, label, true); + w.run_steps(2); + } + // The legend names what the overlays draw. + for item in ["loop detector", "camera track", "radar track", "connected vehicle"] { + assert!(paints(&w, item), "no {item} in the legend"); + } + for by in ["delay", "turn", "speed", "type"] { + press(&mut w, by); + step(&mut w, 3); + } + for label in ["sensors", "detections", "conflict points", "paths", "delay labels", "night"] { + tick(&mut w, label, false); + } + assert!(!paints(&w, "camera track")); + // The cooperative crossing's view shows its reservation zones instead. + choose(&mut w, "Intersection", LayoutKind::Cooperative.name()); + tick(&mut w, "reservation zones", true); + tick(&mut w, "paths", true); + step(&mut w, 20); + assert!(paints(&w, "has its trajectory (reservation)")); + assert!(paints(&w, "pedestrian underpass")); + // And the roundabout, the one layout left. + choose(&mut w, "Intersection", LayoutKind::Roundabout.name()); + step(&mut w, 20); + press(&mut w, "Fit view"); + assert_named(&w); +} + +#[test] +fn vehicles_and_pedestrians_can_be_placed_by_hand() { + let mut w = open(); + pause(&mut w); + let net = Network::build(LayoutKind::Arterial4); + // One of every kind, on every approach, each way it can turn. + let waiting = count(&w, "waiting to enter"); + let mut placed = 0; + for (k, kind) in ALL_KINDS.into_iter().enumerate() { + let tool = + w.get_by(|n| n.role() == Role::Button && n.label().is_some_and(|l| l.ends_with(kind.name()))); + tool.click_accesskit(); + w.run_steps(2); + press(&mut w, ["↰ Left", "↑ Through", "↱ Right"][k % 3]); + for leg in &net.legs { + press(&mut w, &format!("+ {}", leg.compass.letter())); + placed += 1; + } + } + assert_eq!(count(&w, "waiting to enter"), waiting + placed); + // The vehicle tool shows what a click places, and a click on an approach places it. + press(&mut w, &format!("🚗 {}", VKind::Car.name())); + press(&mut w, "↑ Through"); + let target = on_map(&w, &net, approach(&net, 0, net.legs[0].stop_dist + 25.0), false); + w.hover_at(target); + w.run_steps(2); + assert!(paints(&w, "click an approach: Car Through")); + click_at(&mut w, target); + let message = format!( + "{} {} queued on {} (enters 260 m upstream)", + VKind::Car.name(), + Turn::T.name(), + net.legs[0].compass.bound() + ); + assert!(shows(&w, &message), "{:?}", texts(&w)); + step(&mut w, 60); + assert!(count(&w, "in network") > 0); + // Choosing the tool again puts it away. + press(&mut w, &format!("🚗 {}", VKind::Car.name())); + assert!(w.query_by_label("+ N").is_none()); + + // A pedestrian, at the corner of a crosswalk. + press(&mut w, "🚶 Pedestrian"); + let corner = on_map(&w, &net, net.crosswalks[0].a, false); + w.hover_at(corner); + w.run_steps(2); + assert!(paints(&w, "click a crosswalk corner")); + let peds = count(&w, "peds"); + click_at(&mut w, corner); + assert_eq!(count(&w, "peds"), peds + 1); + press(&mut w, "🚶 Pedestrian"); + step(&mut w, 60); + assert!(shows(&w, "safety overlaps: 0"), "vehicles overlapped"); +} + +#[test] +fn a_movement_an_approach_lacks_is_refused() { + let mut w = open(); + pause(&mut w); + choose(&mut w, "Intersection", LayoutKind::Tee.name()); + let net = Network::build(LayoutKind::Tee); + press(&mut w, &format!("🚌 {}", VKind::Bus.name())); + press(&mut w, "↑ Through"); + // The stem of the T has no through movement. + let stem = net.legs.iter().find(|l| !l.turns().contains(&Turn::T)).expect("the stem"); + let before = count(&w, "waiting to enter"); + press(&mut w, &format!("+ {}", stem.compass.letter())); + assert!(shows(&w, &format!("{} has no {} movement", stem.compass.bound(), Turn::T.name()))); + assert_eq!(count(&w, "waiting to enter"), before); +} + +#[test] +fn layouts_without_crosswalks_say_why_there_are_no_pedestrians() { + let mut w = open(); + for (kind, why) in [ + (LayoutKind::Roundabout, "No crosswalks here: the roundabout is simulated without pedestrians"), + (LayoutKind::Cooperative, "No crosswalks here: pedestrians use underground passages"), + ] { + choose(&mut w, "Intersection", kind.name()); + let tool = w.get_by_label("🚶 Pedestrian"); + assert!(tool.accesskit_node().is_disabled()); + tool.hover(); + w.run_steps(6); + assert!(has(&w, why), "{kind:?}: no tooltip {why:?}"); + assert!(w.query_by_role_and_label(Role::Slider, "peds/h").is_none()); + } +} + +#[test] +fn clicking_a_vehicle_shows_what_it_is_doing() { + let mut w = open(); + pause(&mut w); + step(&mut w, 60); + let net = Network::build(LayoutKind::Arterial4); + assert!(select_a_vehicle(&mut w, &net), "no vehicle where they queue"); + for row in [ + "movement", + "speed", + "doing", + "delay so far", + "stops", + "stopped time", + "fuel", + "occupants", + "seen by", + ] { + assert!(has(&w, row), "no {row}"); + } + assert!(!has(&w, "reservation")); + // The vehicle drives on and leaves; the panel says so. + let mut left = false; + for _ in 0..30 { + step(&mut w, 10); + if shows(&w, "(left the network)") { + left = true; + break; + } + } + assert!(left, "the vehicle never left"); + + // On the cooperative crossing, a vehicle shows its reservation instead. + choose(&mut w, "Intersection", LayoutKind::Cooperative.name()); + step(&mut w, 30); + let coop = Network::build(LayoutKind::Cooperative); + assert!(select_a_vehicle(&mut w, &coop), "no vehicle on the approaches"); + assert!(has(&w, "reservation")); + assert!(!has(&w, "seen by")); +} + +#[test] +fn the_map_pans_zooms_and_fits() { + let mut w = open(); + pause(&mut w); + let net = Network::build(LayoutKind::Arterial4); + let center = map(&w).center(); + // Drag the map diagonally, then zoom with the wheel and with a pinch. + let to = center + vec2(250.0, 250.0); + w.hover_at(center); + w.step(); + w.drag_at(center); + w.step(); + w.hover_at(center.lerp(to, 0.5)); + w.step(); + w.hover_at(to); + w.step(); + w.drop_at(to); + w.step(); + w.hover_at(center); + w.event(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: Vec2::new(0.0, 200.0), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + modifiers: Modifiers::NONE, + }); + w.event(Event::Zoom(1.5)); + w.run_steps(4); + // A click where the approach was fitted no longer lands on it... + press(&mut w, &format!("🚗 {}", VKind::Car.name())); + let target = on_map(&w, &net, approach(&net, 0, net.legs[0].stop_dist + 25.0), false); + let before = count(&w, "waiting to enter"); + click_at(&mut w, target); + assert_eq!(count(&w, "waiting to enter"), before); + // ...until Fit view puts it back. + press(&mut w, "Fit view"); + click_at(&mut w, target); + assert_eq!(count(&w, "waiting to enter"), before + 1); +} + +/// Let the in-app benchmark finish, drawing frames as a person would watch it. +#[track_caller] +fn wait_for_benchmark(w: &mut Window) { + let start = Instant::now(); + while shows(w, "running ") { + assert!(start.elapsed() < Duration::from_secs(600), "the benchmark didn't finish"); + std::thread::sleep(Duration::from_millis(20)); + w.step(); + } + w.run_steps(2); +} + +#[test] +fn the_benchmark_compares_every_strategy() { + let mut w = open(); + pause(&mut w); + // No traffic, so the runs take moments: the test is of the window, not the results. + slide(&mut w, "demand ×", 0.0); + press(&mut w, "▶ Compare controllers"); + assert!( + shows(&w, &format!("Controller comparison — {}", LayoutKind::Arterial4.name())) + || w.query_by_label(&format!("Controller comparison — {}", LayoutKind::Arterial4.name())) + .is_some() + ); + // One run at a time per strategy: the buttons wait until it's done. + assert!(w.get_by_label("▶ Compare controllers").accesskit_node().is_disabled()); + wait_for_benchmark(&mut w); + assert!(shows(&w, "Person delay incl. unserved (s) — lower is better")); + for c in CtrlKind::ALL { + assert!(has(&w, c.name()), "no row for {}", c.name()); + } + assert!(has(&w, ControlMode::AllWayStop.name())); + assert_named(&w); + press(&mut w, "Hide results window"); + assert!(!shows(&w, "lower is better")); + press(&mut w, "Show results window"); + assert!(shows(&w, "lower is better")); + // Escape closes it too. + w.key_press(egui::Key::Escape); + w.run_steps(2); + assert!(!shows(&w, "lower is better")); + + // The cooperative crossing compares its schedules and gap policies. + choose(&mut w, "Intersection", LayoutKind::Cooperative.name()); + slide(&mut w, "demand ×", 0.0); + press(&mut w, "▶ Compare schedules"); + wait_for_benchmark(&mut w); + for p in GapPolicy::ALL { + assert!(has(&w, &format!("Batches · {} gaps", p.label()))); + } + assert!(has(&w, &format!("First come, first served · {} gaps", GapPolicy::FailSafe.label()))); + assert!(w.query_by_label("▶ Sensor study").is_none(), "the crossing has no sensors to study"); +} + +#[test] +fn about_lists_the_version_and_licenses() { + let mut w = open(); + press(&mut w, "About"); + assert!(has(&w, &format!("Intersection Lab {}", env!("CARGO_PKG_VERSION")))); + assert!(shows(&w, "Third-party software")); + assert!(w.query_by_label(env!("CARGO_PKG_REPOSITORY")).is_some()); + assert_named(&w); + // Escape closes it; so does the button. + w.key_press(egui::Key::Escape); + w.run_steps(2); + assert!(!shows(&w, "Third-party software")); + press(&mut w, "About"); + press(&mut w, "About"); + assert!(!shows(&w, "Third-party software")); +} diff --git a/tests/networks.rs b/tests/networks.rs new file mode 100644 index 0000000..218a45e --- /dev/null +++ b/tests/networks.rs @@ -0,0 +1,191 @@ +// Copyright (c) 2026 Jim Schreckengast +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! The neural network behind the learning controllers: backpropagation agrees with the +//! gradient measured numerically, Adam fits a line, the shape check turns away every +//! malformed network, and Double DQN learns which action pays in which state. + +use intersection_lab::rl::dqn::{Dqn, SavedModel, Transition, argmax, argmax_masked}; +use intersection_lab::rl::nn::{Adam, Grads, Mlp}; +use intersection_lab::sim::rng::Rng; + +/// A weighted sum of the network's outputs: its gradient with respect to the outputs +/// is `c`. +fn weighted_output(net: &Mlp, x: &[f32], c: &[f32]) -> f32 { + net.forward(x).iter().zip(c).map(|(y, c)| y * c).sum() +} + +#[test] +fn backpropagation_matches_the_numerical_gradient() { + let net = Mlp::new(&[4, 6, 5, 3], &mut Rng::new(7)); + assert_eq!((net.n_in(), net.n_out()), (4, 3)); + let x = [0.5, -1.0, 0.25, 2.0]; + let c = [1.0, -0.5, 2.0]; + let mut grads = Grads::zeros(&net); + net.backward(&net.forward_all(&x), &c, &mut grads); + // The network is piecewise linear in each parameter, so a central difference is + // exact up to rounding unless a ReLU switches within the step. + let h = 1e-2; + let numerical = |edit: &dyn Fn(&mut Mlp, f32)| { + let (mut up, mut down) = (net.clone(), net.clone()); + edit(&mut up, h); + edit(&mut down, -h); + (weighted_output(&up, &x, &c) - weighted_output(&down, &x, &c)) / (2.0 * h) + }; + for li in 0..net.layers.len() { + for (i, &g) in grads.w[li].iter().enumerate() { + let n = numerical(&|m, d| m.layers[li].w[i] += d); + assert!( + (n - g).abs() < 1e-3 * (1.0 + g.abs()), + "layer {li} weight {i}: backprop {g}, numerical {n}" + ); + } + for (i, &g) in grads.b[li].iter().enumerate() { + let n = numerical(&|m, d| m.layers[li].b[i] += d); + assert!( + (n - g).abs() < 1e-3 * (1.0 + g.abs()), + "layer {li} bias {i}: backprop {g}, numerical {n}" + ); + } + } + assert!(grads.w.iter().flatten().any(|g| *g != 0.0), "some weight has a gradient"); + + // Gradients add up over samples until cleared. + let once = grads.w.clone(); + net.backward(&net.forward_all(&x), &c, &mut grads); + for (a, b) in grads.w.iter().flatten().zip(once.iter().flatten()) { + assert_eq!(*a, 2.0 * b); + } + grads.clear(); + assert!(grads.w.iter().chain(&grads.b).flatten().all(|g| *g == 0.0)); +} + +#[test] +fn adam_fits_a_line() { + // A single layer has no ReLU: it learns y = 2x - 1 exactly. + let mut net = Mlp::new(&[1, 1], &mut Rng::new(3)); + let mut adam = Adam::new(&net, 0.02); + let mut grads = Grads::zeros(&net); + let xs: Vec = (0..8).map(|k| k as f32 / 4.0 - 1.0).collect(); + for _ in 0..3000 { + grads.clear(); + for &x in &xs { + let acts = net.forward_all(&[x]); + let err = acts[1][0] - (2.0 * x - 1.0); + net.backward(&acts, &[err], &mut grads); + } + adam.apply(&mut net, &grads, 1.0 / xs.len() as f32); + } + let l = &net.layers[0]; + assert!( + (l.w[0] - 2.0).abs() < 0.01 && (l.b[0] + 1.0).abs() < 0.01, + "learned y = {}x + {}", + l.w[0], + l.b[0] + ); +} + +/// A change that leaves a network malformed. +type Damage = fn(&mut Mlp); + +#[test] +fn the_shape_check_rejects_every_malformed_network() { + let good = Mlp::new(&[4, 6, 3], &mut Rng::new(1)); + assert!(good.fits(4, 3)); + assert!(!good.fits(5, 3), "wrong number of inputs"); + assert!(!good.fits(4, 2), "wrong number of outputs"); + let broken: [(&str, Damage); 7] = [ + ("a weight missing", |m| { + m.layers[0].w.pop(); + }), + ("a bias missing", |m| { + m.layers[1].b.pop(); + }), + ("layers that don't chain", |m| m.layers[1].n_in = 7), + ("a layer with no outputs", |m| m.layers[1].n_out = 0), + ("a weight that isn't a number", |m| m.layers[0].w[3] = f32::NAN), + ("an infinite bias", |m| m.layers[1].b[0] = f32::INFINITY), + ("no layers at all", |m| m.layers.clear()), + ]; + for (what, edit) in broken { + let mut m = good.clone(); + edit(&mut m); + assert!(!m.fits(4, 3), "accepted a network with {what}"); + } + let mut huge = good.clone(); + huge.layers[0].n_in = usize::MAX; + assert!(!huge.fits(usize::MAX, 3), "accepted sizes whose product overflows"); + + let saved = + |n_in: usize| SavedModel { layout: "tee".into(), n_in, n_out: 3, episodes: 0, net: good.clone() }; + assert!(saved(4).fits(4, 3)); + assert!(!saved(4).fits(5, 3), "the controller expects other sizes"); + assert!(!saved(5).fits(5, 3), "the file declares sizes its network doesn't have"); +} + +#[test] +fn the_target_network_moves_part_way() { + let mut rng = Rng::new(11); + let src = Mlp::new(&[3, 4, 2], &mut rng); + let start = Mlp::new(&[3, 4, 2], &mut rng); + let mut target = start.clone(); + target.soft_update(&src, 0.25); + for ((t, a), b) in target.layers.iter().zip(&start.layers).zip(&src.layers) { + for ((t, a), b) in t.w.iter().chain(&t.b).zip(a.w.iter().chain(&a.b)).zip(b.w.iter().chain(&b.b)) { + assert!((t - (a + 0.25 * (b - a))).abs() < 1e-6); + } + } +} + +#[test] +fn argmax_respects_the_mask() { + assert_eq!(argmax(&[0.1, 0.5, 0.3]), 1); + assert_eq!(argmax(&[2.0, 2.0]), 0, "ties go to the first"); + assert_eq!(argmax_masked(&[0.1, 0.5, 0.3], &[true, false, true]), 2); + assert_eq!(argmax_masked(&[0.1, 0.5, 0.3], &[false; 3]), 1, "no allowed action: plain argmax"); + assert_eq!(argmax_masked(&[0.1, 0.5, 0.3], &[false]), 1, "actions past the mask are allowed"); +} + +/// One step of a two-armed bandit with two states: the action that matches the state +/// pays 1, the other nothing, and the episode ends. +fn bandit(state: usize, action: usize) -> Transition { + let mut s = vec![0.0; 2]; + s[state] = 1.0; + let r = if action == state { 1.0 } else { 0.0 }; + Transition { s: s.clone(), a: action, r, s2: s, disc: 0.0, mask2: vec![true; 2] } +} + +#[test] +fn double_dqn_learns_which_action_pays() { + let mut dqn = Dqn::new(2, 2, &[8], 5); + let mut rng = Rng::new(9); + for k in 0..1200 { + assert_eq!(dqn.train_step().is_some(), k >= 1000, "training waits for 1,000 transitions"); + dqn.push(bandit(rng.below(2), rng.below(2))); + } + assert_eq!(dqn.replay_len(), 1200); + dqn.set_lr(1e-2); + for _ in 0..600 { + dqn.train_step(); + } + assert_eq!(dqn.updates, 600 + 200); + assert_eq!(dqn.losses.len(), 500, "the loss history keeps the latest 500"); + for state in 0..2 { + let mut s = [0.0; 2]; + s[state] = 1.0; + assert_eq!(dqn.greedy(&s), state); + let q = dqn.q(&s); + assert!((q[state] - 1.0).abs() < 0.1 && q[1 - state].abs() < 0.1, "state {state}: Q = {q:?}"); + // The target network trails the online one but has learned the same thing. + assert_eq!(argmax(&dqn.target.forward(&s)), state); + } +} + +#[test] +fn the_replay_memory_keeps_the_latest_transitions() { + let mut dqn = Dqn::new(2, 2, &[4], 1); + for k in 0..60_010 { + dqn.push(bandit(k % 2, 0)); + } + assert_eq!(dqn.replay_len(), 60_000); +} diff --git a/tests/safety.rs b/tests/safety.rs index 39c38fc..a269753 100644 --- a/tests/safety.rs +++ b/tests/safety.rs @@ -8,6 +8,8 @@ use intersection_lab::control::CtrlKind; use intersection_lab::sim::network::LayoutKind; use intersection_lab::sim::{ControlMode, Sim}; +mod common; + #[test] fn all_scenarios_safe_and_flowing() { let mut handles = vec![]; @@ -16,7 +18,7 @@ fn all_scenarios_safe_and_flowing() { let ctrls: Vec = if mode == ControlMode::Signal { CtrlKind::ALL.to_vec() } else { vec![CtrlKind::Fixed] }; for c in ctrls { - handles.push(std::thread::spawn(move || { + handles.push(common::spawn(move || { let mut sim = Sim::new(kind, mode, c, 77); sim.check_safety = true; sim.run_for(900.0); @@ -81,7 +83,7 @@ fn no_gridlock_when_oversaturated() { let handles: Vec<_> = cases .into_iter() .map(|(c, seed, scale, until)| { - std::thread::spawn(move || { + common::spawn(move || { let net = Network::build(LayoutKind::Arterial4); let mut demand = DemandConfig::default_for(&net); demand.scale = scale; @@ -126,7 +128,7 @@ fn no_collision_between_late_yellow_and_clearing_turn() { let handles: Vec<_> = [(1, 5400.0), (3, 9900.0), (4, 7700.0)] .into_iter() .map(|(seed, until)| { - std::thread::spawn(move || { + common::spawn(move || { let net = Network::build(LayoutKind::Tee); let mut demand = DemandConfig::default_for(&net); demand.scale = 1.25; diff --git a/tests/scripted.rs b/tests/scripted.rs new file mode 100644 index 0000000..8ac7d41 --- /dev/null +++ b/tests/scripted.rs @@ -0,0 +1,338 @@ +// Copyright (c) 2026 Jim Schreckengast +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! The app's scripted mode, which sets up the documentation's screenshots: `TS_*` +//! variables describe the scene when the app starts. The app reads them once, as it +//! starts, and tests in one process share one environment, so each scene opens in a +//! process of its own: this test binary again, running only `scene`, which prints what +//! the window shows for the test that started it to check. + +use egui::accesskit::{Role, Toggled}; +use egui::{Shape, ViewportCommand, ViewportId}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use intersection_lab::control::CtrlKind; +use intersection_lab::gui::App; +use intersection_lab::gui::shot::write_png; +use intersection_lab::sim::ControlMode; +use intersection_lab::sim::coop::{GapPolicy, Scheduling}; +use intersection_lab::sim::network::LayoutKind; +use intersection_lab::sim::sensors::Conditions; +use std::path::Path; +use std::process::Command; +use std::time::{Duration, Instant}; + +type Window = Harness<'static, App>; + +/// Whether the app asked for a screenshot in the last frame. +fn screenshot_requested(w: &Window) -> bool { + w.output() + .viewport_output + .get(&ViewportId::ROOT) + .is_some_and(|v| v.commands.iter().any(|c| matches!(c, ViewportCommand::Screenshot(_)))) +} + +/// The scene the `TS_*` variables describe, run for 30 simulated seconds (or, for a +/// screenshot, until the app takes it), then printed: one line per drop-down list, +/// number field, ticked checkbox, label and text painted on the map. +#[test] +#[ignore = "a scene for the tests below, each in a process of its own"] +fn scene() { + let mut w: Window = Harness::builder() + .with_size([1600.0, 1000.0]) + // The desktop app draws emoji with the system's fonts, which a test doesn't load. + .allow_missing_glyphs() + .build_eframe(|cc| App::new(cc)); + w.run_steps(2); + let mut shot = false; + if std::env::var_os("TS_SHOT").is_some() { + // The clock runs in real time up to the shot's moment, and the app gives the + // panels a moment to catch up before it asks for the capture. + let start = Instant::now(); + while !shot && start.elapsed() < Duration::from_secs(30) { + std::thread::sleep(Duration::from_millis(20)); + w.step(); + shot = screenshot_requested(&w); + } + } else { + w.get_by_label("⏸ Pause").click_accesskit(); + w.run_steps(2); + for _ in 0..30 { + w.get_by_label("⏭ Step").click_accesskit(); + w.step(); + } + w.run_steps(2); + } + // The test harness has printed the test's name without ending the line. + println!(); + println!("shot: {shot}"); + for n in w.query_all_by_role(Role::ComboBox) { + println!( + "list {}: {}", + n.accesskit_node().label().unwrap_or_default(), + n.value().unwrap_or_default() + ); + } + for n in w.query_all_by_role(Role::SpinButton) { + println!( + "number {}: {}", + n.accesskit_node().label().unwrap_or_default(), + n.value().unwrap_or_default() + ); + } + for n in w.query_all_by_role(Role::CheckBox) { + if n.accesskit_node().toggled() == Some(Toggled::True) { + println!("ticked {}", n.accesskit_node().label().unwrap_or_default()); + } + } + for n in w.query_all_by_role(Role::Label) { + println!("label {}", n.value().unwrap_or_default()); + } + fn walk(shape: &Shape) { + match shape { + Shape::Vec(shapes) => shapes.iter().for_each(walk), + Shape::Text(t) => println!("painted {}", t.galley.text()), + _ => {} + } + } + for c in &w.output().shapes { + walk(&c.shape); + } +} + +/// What the scene's window showed. +struct Scene(String); + +impl Scene { + fn lines(&self) -> impl Iterator { + self.0.lines() + } + /// The value of the drop-down list (or number field, `what` "number") `name`. + fn value(&self, what: &str, name: &str) -> Option<&str> { + let prefix = format!("{what} {name}: "); + self.lines().find_map(|l| l.strip_prefix(prefix.as_str())) + } + fn list(&self, name: &str) -> &str { + self.value("list", name).unwrap_or_else(|| panic!("no {name} list:\n{}", self.0)) + } + fn ticked(&self, name: &str) -> bool { + self.lines().any(|l| l == format!("ticked {name}")) + } + fn shows(&self, text: &str) -> bool { + self.lines().any(|l| l.starts_with("label ") && l.contains(text)) + } + fn paints(&self, text: &str) -> bool { + self.lines().any(|l| l.starts_with("painted ") && l.contains(text)) + } + /// The status line at the top left of the map. + fn status(&self) -> &str { + self.lines() + .filter_map(|l| l.strip_prefix("painted ")) + .find(|t| t.len() > 8 && t.as_bytes()[2] == b':' && t.as_bytes()[5] == b':') + .unwrap_or_else(|| panic!("no status line:\n{}", self.0)) + } +} + +/// Open the scene `vars` describe, in a process of its own. +fn scene_of(vars: &[(&str, &str)]) -> Scene { + let mut cmd = Command::new(std::env::current_exe().unwrap()); + cmd.args(["scene", "--exact", "--ignored", "--nocapture", "--test-threads=1"]); + // Only the variables of this scene. + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("TS_") { + cmd.env_remove(k); + } + } + let out = cmd.envs(vars.iter().copied()).output().unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + assert!(out.status.success(), "{vars:?}:\n{stdout}\n{}", String::from_utf8_lossy(&out.stderr)); + Scene(stdout) +} + +#[test] +fn a_signal_with_every_overlay_in_snow_at_night() { + let s = scene_of(&[ + ("TS_LAYOUT", "arterial4"), + ("TS_CTRL", "MaxPressure"), + ("TS_SPEED", "5"), + ("TS_ZOOM", "1.4"), + ("TS_COLOR", "speed"), + ("TS_NIGHT", "1"), + ("TS_COND", "snow"), + ("TS_LOOP_FAULT", "0.2"), + ("TS_OVERLAYS", "sensors, detections,conflicts,paths,labels"), + ("TS_SELECT", "1"), + ("TS_SCROLL", "200"), + ("TS_SCROLL_LEFT", "100"), + ]); + assert_eq!(s.list("Intersection"), LayoutKind::Arterial4.name()); + assert_eq!(s.list("Controller"), CtrlKind::MaxPressure.name()); + assert_eq!(s.list("conditions"), Conditions::Snow.name()); + assert!(s.value("number", "faulty loop stations").is_some_and(|v| v.starts_with("0.2")), "{}", s.0); + for overlay in ["sensors", "detections", "conflict points", "paths", "delay labels", "night"] { + assert!(s.ticked(overlay), "{overlay} is off:\n{}", s.0); + } + assert!(s.status().contains("×5"), "{}", s.status()); + // Vehicle 1 is selected, whether it is still on the map or not. + assert!(!s.shows("Click a vehicle to inspect it."), "{}", s.0); + assert!(s.shows("(left the network)") || s.lines().any(|l| l == "label doing"), "{}", s.0); +} + +#[test] +fn the_cooperative_crossing_with_both_schedules_and_the_about_window() { + let s = scene_of(&[ + ("TS_LAYOUT", "cooperative"), + ("TS_POLICY", "p1"), + ("TS_SCHED", "fcfs"), + ("TS_TWIN", "ph"), + ("TS_COLOR", "turn"), + ("TS_ABOUT", "1"), + ]); + assert_eq!(s.list("Safety gap"), GapPolicy::Nominal.name()); + assert_eq!(s.list("Scheduling"), Scheduling::FirstCome.name()); + assert_eq!(s.list("B scheduling"), Scheduling::Batches.label()); + assert_eq!(s.list("B gap policy"), format!("{} gaps", GapPolicy::Mitigated.label())); + assert!(s.paints("A · First come, first served · nominal gaps"), "{}", s.0); + assert!(s.paints("B · Intersection manager · mitigated gaps"), "{}", s.0); + assert!(s.shows("Third-party software"), "the About window is closed:\n{}", s.0); +} + +#[test] +fn the_comparison_can_serve_first_come_first_served() { + let s = scene_of(&[("TS_LAYOUT", "cooperative"), ("TS_TWIN", "fcfs")]); + assert!(s.paints("A · Intersection manager · fail-safe gaps"), "{}", s.0); + assert!(s.paints("B · First come, first served · fail-safe gaps"), "{}", s.0); +} + +#[test] +fn each_control_mode_can_be_chosen() { + for (layout, mode, twin, expect, b) in [ + ("majorminor4", "twoway", "allway", ControlMode::TwoWayStop, Some(ControlMode::AllWayStop)), + ("tee", "allway", "twoway", ControlMode::AllWayStop, Some(ControlMode::TwoWayStop)), + ("roundabout", "roundabout", "", ControlMode::Roundabout, None), + ("cooperative", "cooperative", "", ControlMode::Cooperative, None), + ("town4", "signal", "Hybrid", ControlMode::Signal, None), + ] { + let mut vars = vec![("TS_LAYOUT", layout), ("TS_MODE", mode), ("TS_COLOR", "delay")]; + if !twin.is_empty() { + vars.push(("TS_TWIN", twin)); + } + let s = scene_of(&vars); + assert_eq!(s.list("Control"), expect.name(), "{layout}"); + if let Some(b) = b { + assert_eq!(s.list("B control"), b.name(), "{layout}"); + assert!(s.paints(&format!("B · {}", b.name())), "{}", s.0); + } + if twin == "Hybrid" { + assert_eq!(s.list("B controller"), CtrlKind::Hybrid.short()); + } + } +} + +#[test] +fn a_corridor_on_a_green_wave() { + let s = scene_of(&[ + ("TS_LAYOUT", "arterial4"), + ("TS_CORRIDOR", "3"), + ("TS_WAVE", "1"), + ("TS_TWIN", "Predictive"), + ("TS_COLOR", "type"), + ]); + assert!(s.shows("Intersection 2 of 3 (west → east)"), "{}", s.0); + assert!(s.ticked("Fixed-time green wave")); + assert_eq!(s.list("Controller"), CtrlKind::Fixed.name()); + assert_eq!(s.value("number", "signals along main road"), Some("3")); +} + +#[test] +fn a_benchmark_can_start_with_the_app() { + for (which, title) in [("controllers", "Controller comparison — "), ("sensors", "Sensor study — ")] { + let s = scene_of(&[("TS_LAYOUT", "tee"), ("TS_BENCH", which)]); + let title = format!("{title}{}", LayoutKind::Tee.name()); + assert!(s.shows(&title) || s.0.contains(&title), "{which}:\n{}", s.0); + assert!(s.shows("running "), "{which}:\n{}", s.0); + } +} + +#[test] +fn a_scripted_screenshot_stops_the_clock_at_its_moment() { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("scripted"); + std::fs::create_dir_all(&dir).unwrap(); + let png = dir.join("shot.png"); + let s = scene_of(&[("TS_SHOT", png.to_str().unwrap()), ("TS_SHOT_AFTER", "2"), ("TS_SPEED", "100")]); + assert!(s.lines().any(|l| l == "shot: true"), "no screenshot:\n{}", s.0); + assert!(s.status().starts_with("00:00:02 "), "{}", s.status()); +} + +/// The image in a PNG that `write_png` wrote: width, height and RGBA pixels. Checks +/// every chunk's checksum and the compressed data's. +fn read_png(bytes: &[u8]) -> (usize, usize, Vec) { + fn crc32(data: &[u8]) -> u32 { + let mut c = 0xFFFF_FFFFu32; + for &b in data { + c ^= b as u32; + for _ in 0..8 { + c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; + } + } + !c + } + let be = |b: &[u8]| u32::from_be_bytes(b.try_into().unwrap()) as usize; + assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n"); + let (mut at, mut chunks) = (8, vec![]); + while at < bytes.len() { + let len = be(&bytes[at..at + 4]); + let body = &bytes[at + 4..at + 8 + len]; + assert_eq!(be(&bytes[at + 8 + len..at + 12 + len]) as u32, crc32(body), "chunk checksum"); + chunks.push((&body[..4], &body[4..])); + at += 12 + len; + } + let kinds: Vec<&[u8]> = chunks.iter().map(|c| c.0).collect(); + assert_eq!(kinds, [b"IHDR", b"IDAT", b"IEND"]); + let ihdr = chunks[0].1; + let (w, h) = (be(&ihdr[0..4]), be(&ihdr[4..8])); + assert_eq!(&ihdr[8..], [8, 6, 0, 0, 0], "8-bit RGBA, no interlacing"); + // zlib with stored (uncompressed) blocks. + let z = chunks[1].1; + assert_eq!(&z[..2], [0x78, 0x01]); + let (mut raw, mut at) = (vec![], 2); + loop { + let last = z[at] & 1 == 1; + let len = u16::from_le_bytes([z[at + 1], z[at + 2]]); + assert_eq!(u16::from_le_bytes([z[at + 3], z[at + 4]]), !len); + raw.extend_from_slice(&z[at + 5..at + 5 + len as usize]); + at += 5 + len as usize; + if last { + break; + } + } + let (mut a, mut b) = (1u32, 0u32); + for &x in &raw { + a = (a + x as u32) % 65_521; + b = (b + a) % 65_521; + } + assert_eq!(be(&z[at..at + 4]) as u32, (b << 16) | a, "zlib checksum"); + // Each row: filter type 0, then the pixels. + let mut rgba = vec![]; + for row in raw.chunks(w * 4 + 1) { + assert_eq!(row[0], 0); + rgba.extend_from_slice(&row[1..]); + } + assert_eq!(rgba.len(), w * h * 4); + (w, h, rgba) +} + +#[test] +fn a_screenshot_is_a_valid_png() { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("scripted"); + std::fs::create_dir_all(&dir).unwrap(); + // Small, and big enough to need several compressed blocks. + for (w, h) in [(3, 2), (300, 200)] { + let rgba: Vec = (0..w * h * 4).map(|i| (i * 7 % 251) as u8).collect(); + let path = dir.join(format!("{w}x{h}.png")); + write_png(path.to_str().unwrap(), w, h, &rgba).unwrap(); + assert_eq!(read_png(&std::fs::read(&path).unwrap()), (w, h, rgba)); + } + // A path that can't be written is an error, not a panic. + assert!(write_png(dir.join("missing/shot.png").to_str().unwrap(), 1, 1, &[0; 4]).is_err()); +} diff --git a/tests/tools.rs b/tests/tools.rs new file mode 100644 index 0000000..caedce5 --- /dev/null +++ b/tests/tools.rs @@ -0,0 +1,168 @@ +// Copyright (c) 2026 Jim Schreckengast +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! The developer tools in src/bin, run briefly: `bench` compares every strategy and +//! studies the sensor packages, and `train` trains, saves and resumes both learning +//! controllers. Each runs in a scratch directory of its own, with its home directory +//! there too, so nothing reads or writes the repository's weights or the user's. + +use intersection_lab::control::CtrlKind; +use intersection_lab::sim::ControlMode; +use intersection_lab::sim::network::LayoutKind; +use intersection_lab::sim::sensors::Conditions; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// An empty scratch directory for test `name`. +fn scratch(name: &str) -> PathBuf { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("tools").join(name); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Run `exe` with `args` and `vars` in `dir`, with its per-user data directory there +/// too; what it printed. +fn run(exe: &str, dir: &Path, args: &[&str], vars: &[(&str, &str)]) -> String { + let out = Command::new(exe) + .args(args) + .current_dir(dir) + .env("HOME", dir) + .env("XDG_DATA_HOME", dir) + .env("APPDATA", dir) + .env_remove("RESUME") + .env_remove("SEED") + .env_remove("HIDDEN") + .envs(vars.iter().copied()) + .output() + .unwrap(); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!( + out.status.success(), + "{exe} {args:?} failed:\n{stdout}\n{}", + String::from_utf8_lossy(&out.stderr) + ); + stdout +} + +fn bench(dir: &Path, args: &[&str]) -> String { + run(env!("CARGO_BIN_EXE_bench"), dir, args, &[]) +} + +/// The rows of a strategy table, with the number of collisions each run had. +fn collisions(table: &str) -> Vec<(String, f64)> { + table + .lines() + .filter(|l| l.ends_with("s)")) + .map(|l| { + let fields: Vec<&str> = l.split_whitespace().collect(); + let coll = fields[fields.len() - 2].parse().unwrap(); + (l[..34].trim().to_string(), coll) + }) + .collect() +} + +#[test] +fn bench_compares_every_strategy_on_every_layout() { + let dir = scratch("bench_all"); + let out = bench(&dir, &["all", "0.2", "1", "0.3", "surges"]); + for kind in LayoutKind::ALL { + assert!( + out.contains(&format!("=== {} | 0 min x 1 seeds | demand x0.30 | Surges", kind.name())), + "{out}" + ); + } + let rows = collisions(&out); + for c in CtrlKind::ALL { + assert!(rows.iter().any(|r| r.0 == c.name()), "no row for {}:\n{out}", c.name()); + } + for m in + [ControlMode::AllWayStop, ControlMode::TwoWayStop, ControlMode::Roundabout, ControlMode::Cooperative] + { + assert!(rows.iter().any(|r| r.0 == m.name()), "no row for {}:\n{out}", m.name()); + } + for (name, coll) in rows { + assert_eq!(coll, 0.0, "{name} had collisions:\n{out}"); + } +} + +#[test] +fn bench_takes_each_arrival_pattern() { + let dir = scratch("bench_patterns"); + for (arg, pattern) in [("platoons", "Platoons"), ("rush", "RushHour"), ("random", "Random")] { + let out = bench(&dir, &["tee", "0.2", "1", "0.5", arg]); + assert!(out.contains(&format!("| demand x0.50 | {pattern} |")), "{out}"); + assert_eq!( + collisions(&out).len(), + CtrlKind::ALL.len() + ControlMode::options(LayoutKind::Tee).len() - 1 + ); + } +} + +#[test] +fn bench_studies_the_sensor_packages() { + let dir = scratch("bench_sensors"); + let out = bench(&dir, &["sensors", "tee", "0.2", "1"]); + assert!( + out.contains("Sensor packages (realistic errors, clear day; last column ideal sensors)"), + "{out}" + ); + assert!(out.contains("Weather and detector faults"), "{out}"); + for c in Conditions::ALL { + assert!(out.contains(c.name()), "no column for {}:\n{out}", c.name()); + } + // One row per sensor-driven controller in each table. + for c in + [CtrlKind::Actuated, CtrlKind::MaxPressure, CtrlKind::Predictive, CtrlKind::DeepRl, CtrlKind::Hybrid] + { + assert_eq!(out.lines().filter(|l| l.starts_with(&format!("{} ", c.short()))).count(), 2, "{out}"); + } +} + +/// Train on the T-intersection: one short episode, small networks. +fn train(dir: &Path, agent: &str, vars: &[(&str, &str)]) -> String { + run(env!("CARGO_BIN_EXE_train"), dir, &["tee", "1", "150", agent], vars) +} + +#[test] +fn train_saves_a_new_deep_rl_network() { + let dir = scratch("train_dqn"); + let out = train(&dir, "dqn", &[("HIDDEN", "8")]); + assert!(out.contains("[tee] validation baselines (person delay s): fixed "), "{out}"); + assert!(out.contains("[tee:dqn] ep 1 "), "{out}"); + assert!(out.contains("[tee:dqn] done. best validation person-delay"), "{out}"); + assert!(out.contains("[tee:dqn] TEST person delay / pedestrian wait (s): fixed "), "{out}"); + // Saved only if it kept pedestrian waits near the predictive controller's. + let saved = dir.join("weights/dqn_tee.json"); + assert_eq!(saved.exists(), out.contains("(saved)"), "{out}"); + if !saved.exists() { + assert!(out.contains("no checkpoint met the pedestrian limit, weights not written"), "{out}"); + } +} + +#[test] +fn train_resumes_from_the_built_in_deep_rl_network() { + let dir = scratch("train_dqn_resume"); + let out = train(&dir, "dqn", &[("RESUME", "1"), ("SEED", "3")]); + assert!(out.contains("[tee:dqn] resumed from saved weights, validation pd "), "{out}"); + assert!(out.contains("[tee:dqn] TEST person delay"), "{out}"); +} + +#[test] +fn train_saves_the_hybrid_tuning() { + let dir = scratch("train_hybrid"); + let out = train(&dir, "hybrid", &[("HIDDEN", "8")]); + assert!(out.contains("[tee:hybrid] best static tuning: "), "{out}"); + assert!(out.contains("[tee:hybrid] TEST person delay / pedestrian wait (s): fixed "), "{out}"); + assert!(out.contains(" static "), "{out}"); + // The best fixed tuning is saved before training, as the fallback. + assert!(dir.join("weights/hybrid_tee.json").exists(), "{out}"); +} + +#[test] +fn train_resumes_the_built_in_hybrid() { + let dir = scratch("train_hybrid_resume"); + let out = train(&dir, "hybrid", &[("RESUME", "1")]); + assert!(out.contains("[tee:hybrid] resumed from saved weights, validation pd "), "{out}"); + assert!(dir.join("weights/hybrid_tee.json").exists(), "{out}"); +}