use crate::components::CallDirection;
use crate::entity::EntityId;
use crate::events::Event;
use crate::sim::Simulation;
use crate::stop::StopId;
use super::helpers::{default_config, scan};
#[test]
fn spawn_rider_auto_presses_hall_button() {
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
let rid = sim
.spawn_rider_by_stop_id(StopId(0), StopId(2), 70.0)
.unwrap();
let origin = sim.stop_entity(StopId(0)).unwrap();
let call = sim.world().hall_call(origin, CallDirection::Up).unwrap();
assert_eq!(call.direction, CallDirection::Up);
assert!(
call.pending_riders.contains(&rid),
"rider should be aggregated into the hall call's pending list"
);
let events = sim.drain_events();
assert!(
events.iter().any(|e| matches!(
e,
Event::HallButtonPressed {
direction: CallDirection::Up,
..
}
)),
"spawning a rider should emit HallButtonPressed"
);
}
#[test]
fn multiple_riders_aggregate_into_one_hall_call() {
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
let r1 = sim
.spawn_rider_by_stop_id(StopId(0), StopId(2), 70.0)
.unwrap();
sim.drain_events();
let r2 = sim
.spawn_rider_by_stop_id(StopId(0), StopId(2), 70.0)
.unwrap();
let origin = sim.stop_entity(StopId(0)).unwrap();
let call = sim.world().hall_call(origin, CallDirection::Up).unwrap();
assert!(call.pending_riders.contains(&r1));
assert!(call.pending_riders.contains(&r2));
let extra_events = sim.drain_events();
let press_count = extra_events
.iter()
.filter(|e| matches!(e, Event::HallButtonPressed { .. }))
.count();
assert_eq!(
press_count, 0,
"second rider should not re-press the same call"
);
}
#[test]
fn explicit_press_hall_button_without_rider() {
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
let stop = sim.stop_entity(StopId(1)).unwrap();
sim.press_hall_button(stop, CallDirection::Down).unwrap();
let call = sim.world().hall_call(stop, CallDirection::Down).unwrap();
assert!(call.pending_riders.is_empty());
assert_eq!(call.direction, CallDirection::Down);
}
#[test]
fn pin_assignment_pins_and_assigns() {
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
let stop = sim.stop_entity(StopId(1)).unwrap();
let car = sim.world().elevator_ids()[0];
sim.press_hall_button(stop, CallDirection::Up).unwrap();
sim.pin_assignment(car, stop, CallDirection::Up).unwrap();
let call = sim.world().hall_call(stop, CallDirection::Up).unwrap();
assert_eq!(call.assigned_car, Some(car));
assert!(call.pinned);
sim.unpin_assignment(stop, CallDirection::Up);
let call = sim.world().hall_call(stop, CallDirection::Up).unwrap();
assert!(!call.pinned);
}
#[test]
fn nonzero_ack_latency_delays_acknowledgement() {
use crate::dispatch::HallCallMode;
use crate::ids::GroupId;
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
for g in sim.groups_mut() {
if g.id() == GroupId(0) {
g.set_ack_latency_ticks(5);
g.set_hall_call_mode(HallCallMode::Classic);
}
}
let press_tick = sim.current_tick();
let stop = sim.stop_entity(StopId(1)).unwrap();
sim.press_hall_button(stop, CallDirection::Up).unwrap();
let call = sim.world().hall_call(stop, CallDirection::Up).unwrap();
assert_eq!(call.press_tick, press_tick);
assert_eq!(call.acknowledged_at, None);
sim.drain_events();
let mut ack_tick: Option<u64> = None;
for _ in 0..10 {
sim.step();
let call = sim.world().hall_call(stop, CallDirection::Up).unwrap();
if let Some(t) = call.acknowledged_at {
ack_tick = Some(t);
break;
}
}
let ack_tick = ack_tick.expect("ack should fire within 10 steps");
assert_eq!(
ack_tick.saturating_sub(press_tick),
5,
"ack should fire exactly `ack_latency_ticks` ticks after the press"
);
let events = sim.drain_events();
let acks = events
.iter()
.filter(|e| matches!(e, Event::HallCallAcknowledged { .. }))
.count();
assert!(acks <= 1, "HallCallAcknowledged should fire at most once");
}
#[test]
fn destination_mode_records_destination_on_call() {
use crate::dispatch::HallCallMode;
use crate::ids::GroupId;
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
for g in sim.groups_mut() {
if g.id() == GroupId(0) {
g.set_hall_call_mode(HallCallMode::Destination);
}
}
let origin = sim.stop_entity(StopId(0)).unwrap();
let dest = sim.stop_entity(StopId(2)).unwrap();
sim.spawn_rider_by_stop_id(StopId(0), StopId(2), 70.0)
.unwrap();
let call = sim.world().hall_call(origin, CallDirection::Up).unwrap();
assert_eq!(
call.destination,
Some(dest),
"DCS kiosk entry should populate the hall call's destination"
);
}
#[test]
fn public_call_queries_return_active_calls() {
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
sim.spawn_rider_by_stop_id(StopId(0), StopId(2), 70.0)
.unwrap();
let count = sim.hall_calls().count();
assert_eq!(count, 1);
let car = sim.world().elevator_ids()[0];
assert!(sim.car_calls(car).is_empty());
}
#[test]
fn car_call_removed_on_exit() {
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
sim.spawn_rider_by_stop_id(StopId(0), StopId(2), 70.0)
.unwrap();
let car = sim.world().elevator_ids()[0];
let mut boarded = false;
for _ in 0..2000 {
sim.step();
if !boarded && !sim.car_calls(car).is_empty() {
boarded = true;
}
if boarded && sim.car_calls(car).is_empty() {
break;
}
}
assert!(
sim.car_calls(car).is_empty(),
"car_calls should be drained once the rider exits"
);
}
#[test]
fn press_car_button_without_rider_emits_none_rider() {
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
let car = sim.world().elevator_ids()[0];
let floor = sim.stop_entity(StopId(2)).unwrap();
sim.press_car_button(car, floor).unwrap();
let events = sim.drain_events();
let pressed = events
.iter()
.find_map(|e| match e {
Event::CarButtonPressed { rider, .. } => Some(*rider),
_ => None,
})
.expect("CarButtonPressed should fire");
assert_eq!(
pressed, None,
"synthetic press should emit None rider, not EntityId::default()"
);
}
#[test]
fn pinned_pin_does_not_clobber_loading_car() {
use crate::components::ElevatorPhase;
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
sim.spawn_rider_by_stop_id(StopId(0), StopId(2), 70.0)
.unwrap();
let car = sim.world().elevator_ids()[0];
let mut loading_stop: Option<EntityId> = None;
for _ in 0..2000 {
sim.step();
if let Some(c) = sim.world().elevator(car)
&& c.phase == ElevatorPhase::Loading
{
loading_stop = c.target_stop;
break;
}
}
let loading_stop = loading_stop.expect("car should reach Loading phase within 2000 ticks");
let other = sim.stop_entity(StopId(1)).unwrap();
if other != loading_stop {
sim.press_hall_button(other, CallDirection::Down).ok();
let _ = sim.pin_assignment(car, other, CallDirection::Down);
sim.drain_events();
sim.step();
let phase_after = sim.world().elevator(car).map(|c| c.phase);
assert!(
!matches!(phase_after, Some(ElevatorPhase::MovingToStop(s)) if s == other),
"pin should not override a Loading car mid-door-cycle"
);
}
}
#[test]
fn rebalk_on_full_abandons_immediately() {
use crate::components::Preferences;
let mut config = default_config();
config.elevators[0].weight_capacity = 100.0;
let mut sim = Simulation::new(&config, scan()).unwrap();
let picky = sim
.build_rider_by_stop_id(StopId(0), StopId(2))
.unwrap()
.weight(30.0)
.preferences(Preferences::default().with_rebalk_on_full(true))
.spawn()
.unwrap();
sim.world_mut().set_preferences(
picky,
Preferences {
skip_full_elevator: true,
max_crowding_factor: 0.5,
balk_threshold_ticks: None,
rebalk_on_full: true,
},
);
let elev = sim.world().elevator_ids()[0];
let stop0 = sim.stop_entity(StopId(0)).unwrap();
let stop0_pos = sim.world().stop(stop0).unwrap().position;
{
let w = sim.world_mut();
if let Some(pos) = w.position_mut(elev) {
pos.value = stop0_pos;
}
if let Some(vel) = w.velocity_mut(elev) {
vel.value = 0.0;
}
if let Some(car) = w.elevator_mut(elev) {
car.phase = crate::components::ElevatorPhase::Loading;
car.current_load = 60.0;
car.target_stop = None;
}
}
sim.run_loading();
sim.advance_tick();
let phase = sim.world().rider(picky).map(|r| r.phase);
assert_eq!(
phase,
Some(crate::components::RiderPhase::Abandoned),
"rebalk_on_full should escalate the balk into Abandoned"
);
}
#[test]
fn pin_across_lines_is_rejected() {
use crate::components::Orientation;
use crate::config::{ElevatorConfig, GroupConfig, LineConfig};
use crate::dispatch::BuiltinStrategy;
use crate::dispatch::scan::ScanDispatch;
use crate::stop::StopConfig;
let mut config = default_config();
config.building.stops = vec![
StopConfig {
id: StopId(0),
name: "Ground".into(),
position: 0.0,
},
StopConfig {
id: StopId(1),
name: "Mid".into(),
position: 10.0,
},
StopConfig {
id: StopId(2),
name: "Top".into(),
position: 20.0,
},
];
let mk_elev = |id: u32, name: &str, start: StopId| ElevatorConfig {
id,
name: name.into(),
starting_stop: start,
..ElevatorConfig::default()
};
config.building.lines = Some(vec![
LineConfig {
id: 1,
name: "Low".into(),
serves: vec![StopId(0), StopId(1)],
elevators: vec![mk_elev(1, "L1", StopId(0))],
orientation: Orientation::Vertical,
position: None,
min_position: None,
max_position: None,
max_cars: None,
},
LineConfig {
id: 2,
name: "High".into(),
serves: vec![StopId(1), StopId(2)],
elevators: vec![mk_elev(2, "H1", StopId(1))],
orientation: Orientation::Vertical,
position: None,
min_position: None,
max_position: None,
max_cars: None,
},
]);
config.building.groups = Some(vec![GroupConfig {
id: 0,
name: "SplitGroup".into(),
lines: vec![1, 2],
dispatch: BuiltinStrategy::Scan,
reposition: None,
}]);
config.elevators = Vec::new();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let top = sim.stop_entity(StopId(2)).unwrap();
sim.press_hall_button(top, CallDirection::Down).unwrap();
let low_car = sim
.world()
.elevator_ids()
.into_iter()
.find(|&e| {
let Some(line) = sim.world().elevator(e).map(|c| c.line) else {
return false;
};
sim.groups()
.iter()
.flat_map(|g| g.lines().iter())
.find(|li| li.entity() == line)
.is_some_and(|li| !li.serves().contains(&top))
})
.expect("Low elevator should exist and not serve Top");
let err = sim.pin_assignment(low_car, top, CallDirection::Down);
assert!(
matches!(err, Err(crate::error::SimError::InvalidState { .. })),
"cross-line pin should return InvalidState, got {err:?}"
);
let call = sim.world().hall_call(top, CallDirection::Down).unwrap();
assert!(!call.pinned, "failed pin must not flag the call pinned");
}
#[test]
fn shared_stop_attributes_call_to_first_group() {
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
sim.spawn_rider_by_stop_id(StopId(1), StopId(2), 70.0)
.unwrap();
let origin = sim.stop_entity(StopId(1)).unwrap();
let calls: Vec<_> = sim.hall_calls().collect();
assert_eq!(calls.len(), 1, "one call per (stop, direction)");
assert_eq!(calls[0].stop, origin);
assert_eq!(calls[0].direction, CallDirection::Up);
}
#[test]
fn reassignment_does_not_spam_elevator_assigned() {
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
sim.spawn_rider_by_stop_id(StopId(0), StopId(2), 70.0)
.unwrap();
sim.drain_events();
let mut assigned_events = 0usize;
for _ in 0..20 {
sim.step();
for e in sim.drain_events() {
if matches!(e, Event::ElevatorAssigned { .. }) {
assigned_events += 1;
}
}
}
assert!(
assigned_events <= 1,
"ElevatorAssigned should fire at most once per trip, got {assigned_events}"
);
}
#[test]
fn zero_latency_acknowledges_immediately() {
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
sim.spawn_rider_by_stop_id(StopId(0), StopId(2), 70.0)
.unwrap();
let origin = sim.stop_entity(StopId(0)).unwrap();
let call = sim.world().hall_call(origin, CallDirection::Up).unwrap();
assert_eq!(
call.acknowledged_at,
Some(sim.current_tick()),
"zero-latency controller should ack on press tick"
);
let events = sim.drain_events();
assert!(
events
.iter()
.any(|e| matches!(e, Event::HallCallAcknowledged { .. })),
"zero-latency press should emit HallCallAcknowledged immediately"
);
}
#[test]
fn pinned_call_forces_specific_car() {
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
sim.spawn_rider_by_stop_id(StopId(1), StopId(2), 70.0)
.unwrap();
let origin = sim.stop_entity(StopId(1)).unwrap();
let cars = sim.world().elevator_ids();
assert!(!cars.is_empty());
let pinned_car = cars[0];
sim.pin_assignment(pinned_car, origin, CallDirection::Up)
.unwrap();
for _ in 0..10 {
sim.step();
}
let car = sim.world().elevator(pinned_car).unwrap();
assert!(
matches!(
car.phase,
crate::components::ElevatorPhase::MovingToStop(_)
| crate::components::ElevatorPhase::DoorOpening
| crate::components::ElevatorPhase::Loading
| crate::components::ElevatorPhase::DoorClosing
) || car.target_stop == Some(origin),
"pinned car should be committed to the pinned stop"
);
}
#[test]
fn door_opening_clears_hall_call() {
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
sim.spawn_rider_by_stop_id(StopId(0), StopId(2), 70.0)
.unwrap();
let origin = sim.stop_entity(StopId(0)).unwrap();
assert!(sim.world().hall_call(origin, CallDirection::Up).is_some());
let mut cleared = false;
for _ in 0..500 {
sim.step();
for e in sim.drain_events() {
if let Event::HallCallCleared {
stop,
direction: CallDirection::Up,
..
} = e
&& stop == origin
{
cleared = true;
}
}
if cleared {
break;
}
}
assert!(cleared, "HallCallCleared should fire when car opens doors");
assert!(
sim.world().hall_call(origin, CallDirection::Up).is_none(),
"hall call should be removed once cleared"
);
}