use crate::components::{Accel, ElevatorPhase, Speed, Weight};
use crate::config::{
BuildingConfig, ElevatorConfig, GroupConfig, LineConfig, PassengerSpawnConfig, SimConfig,
SimulationParams,
};
use crate::dispatch::reposition::{ReturnToLobby, SpreadEvenly};
use crate::dispatch::{BuiltinReposition, BuiltinStrategy};
use crate::entity::{ElevatorId, EntityId};
use crate::error::SimError;
use crate::ids::GroupId;
use crate::sim::Simulation;
use crate::stop::{StopConfig, StopId};
use crate::tests::helpers::{default_config, scan};
fn first_elevator(sim: &Simulation) -> ElevatorId {
ElevatorId::from(sim.world().iter_elevators().next().unwrap().0)
}
fn stop_entity(sim: &Simulation, id: StopId) -> EntityId {
let idx = id.0 as usize;
sim.world()
.iter_stops()
.nth(idx)
.expect("stop index out of range")
.0
}
#[test]
fn set_get_clear_round_trip() {
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
let elev = first_elevator(&sim);
assert_eq!(sim.elevator_home_stop(elev).unwrap(), None);
sim.set_elevator_home_stop(elev, StopId(2)).unwrap();
let target = stop_entity(&sim, StopId(2));
assert_eq!(sim.elevator_home_stop(elev).unwrap(), Some(target));
sim.clear_elevator_home_stop(elev).unwrap();
assert_eq!(sim.elevator_home_stop(elev).unwrap(), None);
}
#[test]
fn clear_is_idempotent_when_no_pin_set() {
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
let elev = first_elevator(&sim);
sim.clear_elevator_home_stop(elev).unwrap();
sim.clear_elevator_home_stop(elev).unwrap();
assert_eq!(sim.elevator_home_stop(elev).unwrap(), None);
}
#[test]
fn pin_to_unknown_stop_id_returns_stop_not_found() {
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
let elev = first_elevator(&sim);
let result = sim.set_elevator_home_stop(elev, StopId(99));
assert!(matches!(result, Err(SimError::StopNotFound(_))));
}
#[test]
fn elevator_home_stop_errors_on_non_elevator_entity() {
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
let stop_eid = stop_entity(&sim, StopId(0));
let bogus = ElevatorId::from(stop_eid);
assert!(matches!(
sim.elevator_home_stop(bogus),
Err(SimError::NotAnElevator(_))
));
assert!(matches!(
sim.set_elevator_home_stop(bogus, StopId(0)),
Err(SimError::NotAnElevator(_))
));
assert!(matches!(
sim.clear_elevator_home_stop(bogus),
Err(SimError::NotAnElevator(_))
));
}
#[test]
fn pin_to_stop_not_served_by_line_returns_invalid_config() {
let config = two_line_config();
let mut sim = Simulation::new(&config, scan()).unwrap();
let l1 = sim
.world()
.iter_elevators()
.map(|(eid, _, _)| ElevatorId::from(eid))
.next()
.unwrap();
let err = sim.set_elevator_home_stop(l1, StopId(2)).unwrap_err();
let SimError::InvalidConfig { field, .. } = err else {
panic!("expected InvalidConfig, got {err:?}");
};
assert_eq!(field, "home_stop");
}
#[test]
fn pinned_idle_car_routes_home_each_tick() {
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
sim.set_reposition(
GroupId(0),
Box::new(SpreadEvenly),
BuiltinReposition::SpreadEvenly,
);
let elev = first_elevator(&sim);
let target = stop_entity(&sim, StopId(2));
sim.set_elevator_home_stop(elev, StopId(2)).unwrap();
for _ in 0..400 {
sim.step();
}
let car_pos = sim
.world()
.position(elev.entity())
.map(|p| p.value)
.unwrap();
let home_pos = sim.world().stop_position(target).unwrap();
assert!(
(car_pos - home_pos).abs() < 1e-3,
"pinned car should park at home stop ({home_pos}); got {car_pos}"
);
}
#[test]
fn pin_overrides_strategy_decision() {
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
sim.set_reposition(
GroupId(0),
Box::new(ReturnToLobby::new()),
BuiltinReposition::ReturnToLobby,
);
let elev = first_elevator(&sim);
sim.set_elevator_home_stop(elev, StopId(1)).unwrap();
for _ in 0..400 {
sim.step();
}
let car_pos = sim
.world()
.position(elev.entity())
.map(|p| p.value)
.unwrap();
let s1 = stop_entity(&sim, StopId(1));
let pinned_pos = sim.world().stop_position(s1).unwrap();
assert!(
(car_pos - pinned_pos).abs() < 1e-3,
"pin must beat ReturnToLobby; expected pos {pinned_pos}, got {car_pos}"
);
}
#[test]
fn clearing_pin_returns_strategy_control() {
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
sim.set_reposition(
GroupId(0),
Box::new(ReturnToLobby::new()),
BuiltinReposition::ReturnToLobby,
);
let elev = first_elevator(&sim);
sim.set_elevator_home_stop(elev, StopId(2)).unwrap();
for _ in 0..400 {
sim.step();
}
sim.clear_elevator_home_stop(elev).unwrap();
for _ in 0..600 {
sim.step();
}
let car_pos = sim
.world()
.position(elev.entity())
.map(|p| p.value)
.unwrap();
let s0 = stop_entity(&sim, StopId(0));
let lobby_pos = sim.world().stop_position(s0).unwrap();
assert!(
(car_pos - lobby_pos).abs() < 1e-3,
"after clearing pin, ReturnToLobby should retake control; \
expected {lobby_pos}, got {car_pos}"
);
}
#[test]
fn unpinned_cars_still_use_strategy_when_one_car_is_pinned() {
let config = two_car_one_line_config();
let mut sim = Simulation::new(&config, scan()).unwrap();
sim.set_reposition(
GroupId(0),
Box::new(ReturnToLobby::new()),
BuiltinReposition::ReturnToLobby,
);
let elev_eids: Vec<EntityId> = sim
.world()
.iter_elevators()
.map(|(eid, _, _)| eid)
.collect();
let car_a = ElevatorId::from(elev_eids[0]);
let car_b = ElevatorId::from(elev_eids[1]);
sim.set_elevator_home_stop(car_a, StopId(2)).unwrap();
let s0 = stop_entity(&sim, StopId(0));
let s2 = stop_entity(&sim, StopId(2));
let s0_pos = sim.world().stop_position(s0).unwrap();
let s2_pos = sim.world().stop_position(s2).unwrap();
for _ in 0..600 {
sim.step();
}
let pos_a = sim.world().position(car_a.entity()).unwrap().value;
let pos_b = sim.world().position(car_b.entity()).unwrap().value;
assert!(
(pos_a - s2_pos).abs() < 1e-3,
"pinned car A should be at stop 2 ({s2_pos}); got {pos_a}"
);
assert!(
(pos_b - s0_pos).abs() < 1e-3,
"unpinned car B should be at the strategy's lobby ({s0_pos}); \
got {pos_b}. If this is car A's home (8.0), the override pool \
surgery accidentally hid car B from the strategy."
);
}
#[test]
fn home_stop_survives_snapshot_round_trip() {
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
let elev = first_elevator(&sim);
sim.set_elevator_home_stop(elev, StopId(2)).unwrap();
let target = stop_entity(&sim, StopId(2));
let bytes = sim.snapshot_bytes().expect("snapshot");
let restored = Simulation::restore_bytes(&bytes, None).expect("restore");
assert_eq!(restored.elevator_home_stop(elev).unwrap(), Some(target));
}
#[test]
fn already_at_home_does_not_reposition_redundantly() {
let mut sim = Simulation::new(&default_config(), scan()).unwrap();
let elev = first_elevator(&sim);
sim.set_elevator_home_stop(elev, StopId(0)).unwrap();
for _ in 0..30 {
sim.step();
}
let phase = sim.world().elevator(elev.entity()).unwrap().phase();
assert_eq!(
phase,
ElevatorPhase::Idle,
"car already at home must not be redirected back; got phase {phase:?}"
);
}
fn two_line_config() -> SimConfig {
SimConfig {
building: BuildingConfig {
name: "Two Lines".into(),
stops: vec![
StopConfig {
id: StopId(0),
name: "Ground".into(),
position: 0.0,
},
StopConfig {
id: StopId(1),
name: "Transfer".into(),
position: 4.0,
},
StopConfig {
id: StopId(2),
name: "Top".into(),
position: 8.0,
},
],
lines: Some(vec![
LineConfig {
id: 1,
name: "Low".into(),
serves: vec![StopId(0), StopId(1)],
elevators: vec![bare_elevator(1, "L1", StopId(0))],
..Default::default()
},
LineConfig {
id: 2,
name: "High".into(),
serves: vec![StopId(1), StopId(2)],
elevators: vec![bare_elevator(2, "H1", StopId(1))],
..Default::default()
},
]),
groups: Some(vec![
GroupConfig {
id: 0,
name: "Low Rise".into(),
lines: vec![1],
dispatch: BuiltinStrategy::Scan,
reposition: Some(BuiltinReposition::SpreadEvenly),
hall_call_mode: None,
ack_latency_ticks: None,
},
GroupConfig {
id: 1,
name: "High Rise".into(),
lines: vec![2],
dispatch: BuiltinStrategy::Scan,
reposition: Some(BuiltinReposition::SpreadEvenly),
hall_call_mode: None,
ack_latency_ticks: None,
},
]),
},
elevators: vec![],
simulation: SimulationParams {
ticks_per_second: 60.0,
},
passenger_spawning: PassengerSpawnConfig {
mean_interval_ticks: 120,
weight_range: (50.0, 100.0),
},
}
}
fn two_car_one_line_config() -> SimConfig {
SimConfig {
building: BuildingConfig {
name: "Two Cars".into(),
stops: vec![
StopConfig {
id: StopId(0),
name: "Ground".into(),
position: 0.0,
},
StopConfig {
id: StopId(1),
name: "Mid".into(),
position: 4.0,
},
StopConfig {
id: StopId(2),
name: "Top".into(),
position: 8.0,
},
],
lines: None,
groups: None,
},
elevators: vec![
bare_elevator(1, "A", StopId(0)),
bare_elevator(2, "B", StopId(1)),
],
simulation: SimulationParams {
ticks_per_second: 60.0,
},
passenger_spawning: PassengerSpawnConfig {
mean_interval_ticks: 120,
weight_range: (50.0, 100.0),
},
}
}
fn bare_elevator(id: u32, name: &str, starting: StopId) -> ElevatorConfig {
ElevatorConfig {
id,
name: name.into(),
max_speed: Speed::from(2.0),
acceleration: Accel::from(1.5),
deceleration: Accel::from(2.0),
weight_capacity: Weight::from(800.0),
starting_stop: starting,
door_open_ticks: 10,
door_transition_ticks: 5,
restricted_stops: Vec::new(),
#[cfg(feature = "energy")]
energy_profile: None,
service_mode: None,
inspection_speed_factor: 0.25,
bypass_load_up_pct: None,
bypass_load_down_pct: None,
}
}