elevator-core 15.15.2

Engine-agnostic elevator simulation library with pluggable dispatch strategies
Documentation
//! Tests for [`crate::arrival_log::ArrivalLog`] and its exposure through
//! [`DispatchManifest::arrivals_at`].
//!
//! Real elevator controllers observe recent arrival rates per floor to
//! switch modes (up-peak, down-peak) and to pre-position idle cars
//! (predictive parking). This module pins the core data structure that
//! surfaces that signal to strategies.

use crate::arrival_log::{ArrivalLog, CurrentTick, DEFAULT_ARRIVAL_WINDOW_TICKS};
use crate::sim::Simulation;
use crate::stop::StopId;
use crate::world::World;

use super::helpers::{default_config, scan};

#[test]
fn arrival_log_counts_events_within_window() {
    let mut world = World::new();
    let stop_a = world.spawn();
    let stop_b = world.spawn();

    let mut log = ArrivalLog::default();
    log.record(100, stop_a);
    log.record(150, stop_a);
    log.record(200, stop_b);
    log.record(300, stop_a);

    // Window of 200 ticks ending at tick 300 covers [100, 300].
    assert_eq!(log.arrivals_in_window(stop_a, 300, 200), 3);
    assert_eq!(log.arrivals_in_window(stop_b, 300, 200), 1);

    // Narrow window of 150 ticks ending at tick 300 covers [150, 300].
    assert_eq!(log.arrivals_in_window(stop_a, 300, 150), 2);

    // Window of 0 → empty.
    assert_eq!(log.arrivals_in_window(stop_a, 300, 0), 0);
}

#[test]
fn arrival_log_prunes_old_events() {
    let mut world = World::new();
    let stop = world.spawn();

    let mut log = ArrivalLog::default();
    for tick in 0..1_000u64 {
        log.record(tick, stop);
    }
    log.prune_before(900);

    // After pruning everything before tick 900, only [900, 999] remain.
    assert_eq!(log.arrivals_in_window(stop, 999, 1_000), 100);
    assert_eq!(log.arrivals_in_window(stop, 999, 200), 100);
}

#[test]
fn simulation_records_spawns_in_arrival_log() {
    let config = default_config();
    let mut sim = Simulation::new(&config, scan()).unwrap();
    let origin = sim.stop_entity(StopId(0)).unwrap();
    let _ = sim.spawn_rider(StopId(0), StopId(2), 70.0).unwrap();
    let _ = sim.spawn_rider(StopId(0), StopId(2), 70.0).unwrap();

    // Both spawns land at tick 0 → a generous window centered on the
    // current tick must find them both.
    let count = sim
        .world()
        .resource::<ArrivalLog>()
        .expect("ArrivalLog resource must be registered at construction")
        .arrivals_in_window(origin, sim.current_tick(), 60);
    assert_eq!(count, 2);
}

#[test]
fn arrival_log_survives_snapshot_round_trip() {
    let config = default_config();
    let mut sim = Simulation::new(&config, scan()).unwrap();
    // Configure a non-default retention window so the round-trip
    // actually exercises the retention-persistence path.
    let custom_retention = 72_000;
    sim.set_arrival_log_retention_ticks(custom_retention);
    sim.spawn_rider(StopId(0), StopId(2), 70.0).unwrap();
    sim.spawn_rider(StopId(0), StopId(2), 70.0).unwrap();
    sim.step();

    let origin = sim.stop_entity(StopId(0)).unwrap();
    let before = sim
        .world()
        .resource::<ArrivalLog>()
        .unwrap()
        .arrivals_in_window(origin, sim.current_tick(), 120);
    assert!(before >= 2, "precondition: snapshot source has arrivals");

    let snap = sim.snapshot();
    let restored = snap.restore(None).unwrap();

    let origin_after = restored.stop_entity(StopId(0)).unwrap();
    let log_after = restored
        .world()
        .resource::<ArrivalLog>()
        .expect("restore must reinstall the ArrivalLog resource");
    assert_eq!(
        log_after.arrivals_in_window(origin_after, restored.current_tick(), 120),
        before,
        "arrival counts must survive snapshot → restore"
    );
    // The tick mirror must also be re-installed and in sync.
    let ct = restored
        .world()
        .resource::<CurrentTick>()
        .expect("restore must reinstall the CurrentTick resource");
    assert_eq!(ct.0, restored.current_tick());
    // Retention survives round-trip too — otherwise
    // `set_arrival_log_retention_ticks` silently no-ops post-restore.
    let retention = restored
        .world()
        .resource::<crate::arrival_log::ArrivalLogRetention>()
        .expect("restore must reinstall the ArrivalLogRetention resource");
    assert_eq!(retention.0, custom_retention);
}

#[test]
fn arrival_log_is_pruned_by_step() {
    let config = default_config();
    let mut sim = Simulation::new(&config, scan()).unwrap();
    sim.spawn_rider(StopId(0), StopId(2), 70.0).unwrap();

    // Step long enough that the original spawn ages out of the default
    // rolling window. The log must have been pruned, not just ignored.
    let target = DEFAULT_ARRIVAL_WINDOW_TICKS + 100;
    while sim.current_tick() < target {
        sim.step();
    }

    let log = sim.world().resource::<ArrivalLog>().unwrap();
    assert_eq!(
        log.len(),
        0,
        "entries older than the rolling window must be pruned, not merely filtered"
    );
}

#[test]
fn reroute_records_arrival_and_resets_spawn_tick() {
    // When `reroute_rider` flips a `Resident` back to `Waiting`, the
    // controller should see the change as a fresh arrival — otherwise
    // predictive parking and the dispatch manifest's `arrivals_at`
    // signal undercount real demand (the resident pressed the button
    // and is waiting *now*, not at the tick they were settled). The
    // rider's `spawn_tick` must also be reset so ETD's wait-squared
    // bonus doesn't treat them as if they'd been waiting since original
    // spawn, which would saturate the fairness penalty.
    use crate::components::{RiderPhase, Route};
    use crate::ids::GroupId;

    let config = default_config();
    let mut sim = Simulation::new(&config, scan()).unwrap();
    let rider = sim.spawn_rider(StopId(0), StopId(2), 70.0).unwrap();

    // Take the rider to arrival and settle them at StopId(2).
    for _ in 0..10_000 {
        sim.step();
        if sim
            .world()
            .rider(rider.entity())
            .is_some_and(|r| r.phase() == RiderPhase::Arrived)
        {
            break;
        }
    }
    sim.settle_rider(rider).unwrap();

    let stop2 = sim.stop_entity(StopId(2)).unwrap();
    let stop0 = sim.stop_entity(StopId(0)).unwrap();
    let settle_tick = sim.current_tick();

    // Drop the arrival log so we measure only the reroute-driven entry.
    if let Some(log) = sim.world_mut().resource_mut::<ArrivalLog>() {
        log.prune_before(settle_tick + 1);
    }

    // Reroute: head back down to StopId(0).
    let route = Route::direct(stop2, stop0, GroupId(0));
    sim.reroute_rider(rider.entity(), route).unwrap();

    // ArrivalLog must have a fresh entry at StopId(2) — this is where
    // the rider "appeared" as waiting demand.
    let count = sim
        .world()
        .resource::<ArrivalLog>()
        .unwrap()
        .arrivals_in_window(stop2, sim.current_tick(), 10);
    assert_eq!(count, 1, "reroute must record an arrival at the new origin");

    // spawn_tick advanced so downstream wait-time calcs use the reroute
    // boundary as their reference, not the original spawn.
    let r = sim.world().rider(rider.entity()).unwrap();
    assert_eq!(r.spawn_tick(), sim.current_tick());

    // The new destination (stop0 — the lobby) must be logged so down-peak
    // classification counts the rerouted rider. Before this fix the reroute
    // path only touched ArrivalLog, silently undercounting lobby-bound
    // multi-leg traffic.
    let dest_count = sim
        .world()
        .resource::<crate::arrival_log::DestinationLog>()
        .unwrap()
        .destinations_in_window(stop0, sim.current_tick(), 10);
    assert_eq!(
        dest_count, 1,
        "reroute must record the new destination in the destination log"
    );
}

#[test]
fn dispatch_manifest_exposes_recent_arrivals() {
    let config = default_config();
    let mut sim = Simulation::new(&config, scan()).unwrap();
    let origin = sim.stop_entity(StopId(0)).unwrap();
    sim.spawn_rider(StopId(0), StopId(2), 70.0).unwrap();
    sim.spawn_rider(StopId(0), StopId(2), 70.0).unwrap();

    // Advance a few ticks so the sim builds its own manifest.
    for _ in 0..5 {
        sim.step();
    }

    // Peek the manifest the dispatch phase would see this tick.
    let manifest = sim.peek_dispatch_manifest();
    assert_eq!(
        manifest.arrivals_at(origin),
        2,
        "manifest must surface recent-arrival counts for strategies"
    );
    // A stop with no spawns reports zero (not missing / not panic).
    let other = sim.stop_entity(StopId(2)).unwrap();
    assert_eq!(manifest.arrivals_at(other), 0);
}