elevator-core 5.2.0

Engine-agnostic elevator simulation library with pluggable dispatch strategies
Documentation
use crate::components::RiderPhase;
use crate::events::Event;
use crate::sim::Simulation;
use crate::stop::StopId;

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

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

    let max_ticks = 10_000;
    for _ in 0..max_ticks {
        sim.step();
        if all_riders_arrived(&sim) {
            break;
        }
    }

    assert!(all_riders_arrived(&sim));
    assert!(
        sim.current_tick() < max_ticks,
        "Should complete before timeout"
    );
}

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

    let max_ticks = 20_000;
    for _ in 0..max_ticks {
        sim.step();
        if all_riders_arrived(&sim) {
            break;
        }
    }

    assert!(
        all_riders_arrived(&sim),
        "All riders should arrive. Phases: {:?}",
        sim.world()
            .iter_riders()
            .map(|(_, r)| r.phase)
            .collect::<Vec<_>>()
    );
    assert!(
        sim.current_tick() < max_ticks,
        "Should complete before timeout"
    );
}

#[test]
fn two_riders_exceeding_capacity_delivered_in_two_trips() {
    let mut config = default_config();
    config.elevators[0].weight_capacity = 100.0;

    let mut sim = Simulation::new(&config, scan()).unwrap();
    sim.spawn_rider_by_stop_id(StopId(0), StopId(1), 70.0)
        .unwrap();
    sim.spawn_rider_by_stop_id(StopId(0), StopId(1), 70.0)
        .unwrap();

    let max_ticks = 20_000;
    for _ in 0..max_ticks {
        sim.step();
        sim.drain_events();
        if all_riders_arrived(&sim) {
            break;
        }
    }

    assert!(
        all_riders_arrived(&sim),
        "All riders should eventually arrive"
    );
}

#[test]
fn overweight_rider_rejected() {
    let mut config = default_config();
    config.elevators[0].weight_capacity = 50.0;

    let mut sim = Simulation::new(&config, scan()).unwrap();
    let light = sim
        .spawn_rider_by_stop_id(StopId(0), StopId(1), 40.0)
        .unwrap();
    sim.spawn_rider_by_stop_id(StopId(0), StopId(1), 60.0)
        .unwrap();

    let mut all_events = Vec::new();
    let max_ticks = 20_000;
    for _ in 0..max_ticks {
        sim.step();
        all_events.extend(sim.drain_events());
        if sim.world().rider(light).map(|r| r.phase) == Some(RiderPhase::Arrived) {
            break;
        }
    }

    assert_eq!(
        sim.world().rider(light).map(|r| r.phase),
        Some(RiderPhase::Arrived)
    );

    let has_rejection = all_events
        .iter()
        .any(|e| matches!(e, Event::RiderRejected { .. }));
    assert!(
        has_rejection,
        "Should have at least one rejection for the 60kg rider"
    );
}

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

    let mut all_events = Vec::new();
    let max_ticks = 10_000;
    for _ in 0..max_ticks {
        sim.step();
        all_events.extend(sim.drain_events());
        if all_riders_arrived(&sim) {
            break;
        }
    }

    let event_names: Vec<&str> = all_events
        .iter()
        .map(|e| match e {
            Event::RiderSpawned { .. } => "spawned",
            Event::ElevatorDeparted { .. } => "departed",
            Event::ElevatorArrived { .. } => "arrived",
            Event::DoorOpened { .. } => "door_opened",
            Event::DoorClosed { .. } => "door_closed",
            Event::RiderBoarded { .. } => "boarded",
            Event::RiderExited { .. } => "exited",
            _ => "other",
        })
        .collect();

    assert!(event_names.contains(&"spawned"));
    assert!(event_names.contains(&"departed"));
    assert!(event_names.contains(&"arrived"));
    assert!(event_names.contains(&"door_opened"));
    assert!(event_names.contains(&"boarded"));
    assert!(event_names.contains(&"exited"));
    assert!(event_names.contains(&"door_closed"));

    let spawned_idx = event_names.iter().position(|e| *e == "spawned").unwrap();
    let boarded_idx = event_names.iter().position(|e| *e == "boarded").unwrap();
    assert!(
        spawned_idx < boarded_idx,
        "Spawned should come before boarded"
    );
}

/// Documented invariant (metrics-and-events.md): for any given rider,
/// `RiderBoarded` always fires before `RiderExited`. Exercised with multiple
/// riders so per-rider ordering is the actual thing being tested (a global
/// "any boarded before any exited" check would be weaker).
#[test]
fn rider_boarded_precedes_rider_exited_per_rider() {
    let config = default_config();
    let mut sim = Simulation::new(&config, scan()).unwrap();

    let r1 = sim
        .spawn_rider_by_stop_id(StopId(0), StopId(2), 70.0)
        .unwrap();
    let r2 = sim
        .spawn_rider_by_stop_id(StopId(2), StopId(0), 60.0)
        .unwrap();
    let r3 = sim
        .spawn_rider_by_stop_id(StopId(1), StopId(2), 65.0)
        .unwrap();

    let mut boarded_at: std::collections::HashMap<_, usize> = std::collections::HashMap::new();
    let mut exited_at: std::collections::HashMap<_, usize> = std::collections::HashMap::new();
    let mut idx = 0usize;

    for _ in 0..20_000 {
        sim.step();
        for ev in sim.drain_events() {
            match ev {
                Event::RiderBoarded { rider, .. } => {
                    boarded_at.entry(rider).or_insert(idx);
                }
                Event::RiderExited { rider, .. } => {
                    exited_at.insert(rider, idx);
                }
                _ => {}
            }
            idx += 1;
        }
        if all_riders_arrived(&sim) {
            break;
        }
    }

    for rid in [r1, r2, r3] {
        let b = boarded_at
            .get(&rid)
            .unwrap_or_else(|| panic!("rider {rid:?} never boarded"));
        let e = exited_at
            .get(&rid)
            .unwrap_or_else(|| panic!("rider {rid:?} never exited"));
        assert!(
            b < e,
            "RiderBoarded (idx {b}) must precede RiderExited (idx {e}) for {rid:?}",
        );
    }
}

/// Documented invariant (metrics-and-events.md): for a given elevator visit
/// to a stop, `DoorOpened` always precedes the matching `DoorClosed`. Locks
/// this pairing down so future refactors can't silently violate it.
#[test]
fn door_opened_precedes_door_closed() {
    let config = default_config();
    let mut sim = Simulation::new(&config, scan()).unwrap();
    sim.spawn_rider_by_stop_id(StopId(0), StopId(2), 70.0)
        .unwrap();
    sim.spawn_rider_by_stop_id(StopId(2), StopId(0), 60.0)
        .unwrap();

    // Per-elevator open/close sequence; pairs must alternate open→close.
    let mut per_elevator: std::collections::HashMap<_, Vec<&'static str>> =
        std::collections::HashMap::new();

    for _ in 0..20_000 {
        sim.step();
        for ev in sim.drain_events() {
            match ev {
                Event::DoorOpened { elevator, .. } => {
                    per_elevator.entry(elevator).or_default().push("open");
                }
                Event::DoorClosed { elevator, .. } => {
                    per_elevator.entry(elevator).or_default().push("close");
                }
                _ => {}
            }
        }
        if all_riders_arrived(&sim) {
            break;
        }
    }

    assert!(!per_elevator.is_empty(), "expected at least one door event");
    for (eid, seq) in per_elevator {
        // Every even index must be "open", every odd "close" — strict pairing.
        for (i, kind) in seq.iter().enumerate() {
            let expected = if i % 2 == 0 { "open" } else { "close" };
            assert_eq!(
                *kind, expected,
                "elevator {eid:?} door sequence violated at index {i}: {seq:?}",
            );
        }
    }
}

#[test]
fn deterministic_replay() {
    let config = default_config();

    let mut sim1 = Simulation::new(&config, scan()).unwrap();
    sim1.spawn_rider_by_stop_id(StopId(0), StopId(2), 70.0)
        .unwrap();
    sim1.spawn_rider_by_stop_id(StopId(1), StopId(0), 60.0)
        .unwrap();

    let mut ticks1 = 0u64;
    for _ in 0..20_000 {
        sim1.step();
        ticks1 += 1;
        if all_riders_arrived(&sim1) {
            break;
        }
    }

    let mut sim2 = Simulation::new(&config, scan()).unwrap();
    sim2.spawn_rider_by_stop_id(StopId(0), StopId(2), 70.0)
        .unwrap();
    sim2.spawn_rider_by_stop_id(StopId(1), StopId(0), 60.0)
        .unwrap();

    let mut ticks2 = 0u64;
    for _ in 0..20_000 {
        sim2.step();
        ticks2 += 1;
        if all_riders_arrived(&sim2) {
            break;
        }
    }

    assert_eq!(
        ticks1, ticks2,
        "Deterministic simulation should take identical tick counts"
    );
}