use crate::components::{Accel, Elevator, ElevatorPhase, Position, Speed, Stop, Velocity, Weight};
use crate::dispatch::etd::EtdDispatch;
use crate::dispatch::{
DispatchManifest, DispatchStrategy, ElevatorGroup, LineInfo, RankContext, RiderInfo,
};
use crate::door::DoorState;
use crate::entity::EntityId;
use crate::ids::GroupId;
use crate::world::World;
use std::collections::HashSet;
fn world_with_stops(positions: &[f64]) -> (World, Vec<EntityId>) {
let mut world = World::new();
let stops = positions
.iter()
.enumerate()
.map(|(i, &p)| {
let eid = world.spawn();
world.set_stop(
eid,
Stop {
name: format!("Stop {i}"),
position: p,
},
);
eid
})
.collect();
(world, stops)
}
fn idle_elevator(world: &mut World, position: f64, max_speed: f64) -> EntityId {
let eid = world.spawn();
world.set_position(eid, Position { value: position });
world.set_velocity(eid, Velocity { value: 0.0 });
world.set_elevator(
eid,
Elevator {
phase: ElevatorPhase::Idle,
door: DoorState::Closed,
max_speed: Speed::from(max_speed),
acceleration: Accel::from(1.5),
deceleration: Accel::from(2.0),
weight_capacity: Weight::from(800.0),
current_load: Weight::from(0.0),
riders: vec![],
target_stop: None,
door_transition_ticks: 0,
door_open_ticks: 0,
line: EntityId::default(),
repositioning: false,
restricted_stops: HashSet::new(),
inspection_speed_factor: 0.25,
going_up: true,
going_down: true,
move_count: 0,
door_command_queue: Vec::new(),
manual_target_velocity: None,
bypass_load_up_pct: None,
bypass_load_down_pct: None,
home_stop: None,
},
);
eid
}
fn group(stops: &[EntityId], elevators: Vec<EntityId>) -> ElevatorGroup {
ElevatorGroup::new(
GroupId(0),
"test".into(),
vec![LineInfo::new(
EntityId::default(),
elevators,
stops.to_vec(),
)],
)
}
#[test]
fn compute_cost_speed_zero_returns_infinity() {
let (mut world, stops) = world_with_stops(&[0.0, 10.0]);
let elev = idle_elevator(&mut world, 0.0, 0.0);
let etd = EtdDispatch::new();
let cost = etd.compute_cost(elev, 0.0, 10.0, &world);
assert_eq!(
cost,
f64::INFINITY,
"zero max_speed must return INFINITY (the `> 0.0` guard, not `>= 0.0`)"
);
let _ = stops; }
#[test]
fn compute_cost_speed_just_above_zero_is_finite() {
let (mut world, stops) = world_with_stops(&[0.0, 10.0]);
let elev = idle_elevator(&mut world, 0.0, 1e-9);
let etd = EtdDispatch::new();
let cost = etd.compute_cost(elev, 0.0, 10.0, &world);
assert!(
cost.is_finite(),
"any positive max_speed must take the finite branch; got {cost}"
);
let _ = stops;
}
#[test]
fn compute_cost_missing_elevator_returns_infinity() {
let world = World::new();
let etd = EtdDispatch::new();
let cost = etd.compute_cost(EntityId::default(), 0.0, 10.0, &world);
assert_eq!(cost, f64::INFINITY);
}
#[test]
fn compute_cost_distance_is_symmetric_in_position_order() {
let (mut world, stops) = world_with_stops(&[]);
let elev = idle_elevator(&mut world, 5.0, 1.0);
{
let mut e = world.elevator(elev).unwrap().clone();
e.phase = ElevatorPhase::Stopped;
world.set_elevator(elev, e);
}
let etd = EtdDispatch::new();
let up = etd.compute_cost(elev, 5.0, 15.0, &world);
let down = etd.compute_cost(elev, 5.0, -5.0, &world);
assert!(
(up - down).abs() < 1e-12,
"distance is |elev - target|, not signed; up={up}, down={down}"
);
let _ = stops;
}
#[test]
fn compute_cost_idle_car_gets_negative_direction_bonus() {
let (mut world, _stops) = world_with_stops(&[]);
let elev = idle_elevator(&mut world, 0.0, 1.0);
let etd = EtdDispatch::new();
let cost = etd.compute_cost(elev, 0.0, 10.0, &world);
assert!(
(cost - 7.0).abs() < 1e-9,
"idle car: cost = travel - 0.3*travel = 0.7*travel = 7.0; got {cost}"
);
}
#[test]
fn compute_cost_non_idle_non_moving_no_direction_bonus() {
let (mut world, _stops) = world_with_stops(&[]);
let elev = idle_elevator(&mut world, 0.0, 1.0);
{
let mut e = world.elevator(elev).unwrap().clone();
e.phase = ElevatorPhase::Stopped;
world.set_elevator(elev, e);
}
let etd = EtdDispatch::new();
let cost = etd.compute_cost(elev, 0.0, 10.0, &world);
assert!(
(cost - 10.0).abs() < 1e-9,
"non-idle non-moving: cost = travel only = 10.0; got {cost}"
);
}
#[test]
fn compute_cost_moving_target_ahead_uses_half_bonus() {
let (mut world, stops) = world_with_stops(&[0.0, 5.0, 10.0]);
let elev = idle_elevator(&mut world, 0.0, 1.0);
{
let mut e = world.elevator(elev).unwrap().clone();
e.phase = ElevatorPhase::MovingToStop(stops[2]);
world.set_elevator(elev, e);
}
let etd = EtdDispatch::new();
let cost = etd.compute_cost(elev, 0.0, 5.0, &world);
assert!(
(cost - 2.5).abs() < 1e-9,
"moving with target ahead: cost = 5 + (-5*0.5) = 2.5; got {cost}"
);
}
#[test]
fn compute_cost_moving_target_behind_no_bonus() {
let (mut world, stops) = world_with_stops(&[0.0, 5.0, 10.0]);
let elev = idle_elevator(&mut world, 5.0, 1.0);
{
let mut e = world.elevator(elev).unwrap().clone();
e.phase = ElevatorPhase::MovingToStop(stops[2]); world.set_elevator(elev, e);
}
let etd = EtdDispatch::new();
let cost = etd.compute_cost(elev, 5.0, 0.0, &world);
assert!(
(cost - 5.0).abs() < 1e-9,
"moving with target behind: cost = travel only = 5.0; got {cost}"
);
}
#[test]
fn compute_cost_moving_target_past_current_target_no_bonus() {
let (mut world, stops) = world_with_stops(&[0.0, 5.0, 10.0]);
let elev = idle_elevator(&mut world, 0.0, 1.0);
{
let mut e = world.elevator(elev).unwrap().clone();
e.phase = ElevatorPhase::MovingToStop(stops[1]); world.set_elevator(elev, e);
}
let etd = EtdDispatch::new();
let cost = etd.compute_cost(elev, 0.0, 10.0, &world);
assert!(
(cost - 10.0).abs() < 1e-9,
"candidate past current_target: cost = travel only = 10.0; got {cost}"
);
}
#[test]
fn compute_cost_moving_target_exactly_at_current_target_gets_bonus() {
let (mut world, stops) = world_with_stops(&[0.0, 5.0, 10.0]);
let elev = idle_elevator(&mut world, 0.0, 1.0);
{
let mut e = world.elevator(elev).unwrap().clone();
e.phase = ElevatorPhase::MovingToStop(stops[1]); world.set_elevator(elev, e);
}
let etd = EtdDispatch::new();
let cost = etd.compute_cost(elev, 0.0, 5.0, &world);
assert!(
(cost - 2.5).abs() < 1e-9,
"candidate exactly at current_target must still get the half-bonus (`<=`, not `<`); got {cost}"
);
}
#[test]
fn compute_cost_moving_down_target_ahead_uses_half_bonus() {
let (mut world, stops) = world_with_stops(&[0.0, 5.0, 10.0]);
let elev = idle_elevator(&mut world, 10.0, 1.0);
{
let mut e = world.elevator(elev).unwrap().clone();
e.phase = ElevatorPhase::MovingToStop(stops[0]); world.set_elevator(elev, e);
}
let etd = EtdDispatch::new();
let cost = etd.compute_cost(elev, 10.0, 5.0, &world);
assert!(
(cost - 2.5).abs() < 1e-9,
"moving down with target ahead: cost = 2.5; got {cost}"
);
}
#[test]
fn compute_cost_moving_down_target_at_current_target_gets_bonus() {
let (mut world, stops) = world_with_stops(&[0.0, 5.0, 10.0]);
let elev = idle_elevator(&mut world, 10.0, 1.0);
{
let mut e = world.elevator(elev).unwrap().clone();
e.phase = ElevatorPhase::MovingToStop(stops[0]);
world.set_elevator(elev, e);
}
let etd = EtdDispatch::new();
let cost = etd.compute_cost(elev, 10.0, 0.0, &world);
assert!(
(cost - 5.0).abs() < 1e-9,
"candidate exactly at current_target (down): expected 5.0; got {cost}"
);
}
#[test]
fn compute_cost_negative_raw_clamped_to_zero() {
let (mut world, _stops) = world_with_stops(&[]);
let elev = idle_elevator(&mut world, 0.0, 1.0);
let mut etd = EtdDispatch::new();
etd.wait_weight = 0.0;
let cost = etd.compute_cost(elev, 0.0, 10.0, &world);
assert_eq!(
cost, 0.0,
"negative raw cost must be clamped to 0.0; got {cost}"
);
}
fn manifest_with_aging_demand(
world: &mut World,
stop: EntityId,
wait_ticks: u64,
) -> DispatchManifest {
let mut m = DispatchManifest::default();
let dummy = world.spawn();
m.waiting_at_stop.entry(stop).or_default().push(RiderInfo {
id: dummy,
destination: None,
weight: Weight::from(70.0),
wait_ticks,
});
m
}
#[test]
fn rank_age_linear_weight_subtracts_from_cost_when_active() {
let (mut world, stops) = world_with_stops(&[0.0, 10.0]);
let elev = idle_elevator(&mut world, 0.0, 1.0);
{
let mut e = world.elevator(elev).unwrap().clone();
e.phase = ElevatorPhase::Stopped;
world.set_elevator(elev, e);
}
let g = group(&stops, vec![elev]);
let m = manifest_with_aging_demand(&mut world, stops[1], 1000);
let mut etd = EtdDispatch::new();
etd.age_linear_weight = 0.001;
etd.pre_dispatch(&g, &m, &mut world);
let ctx = RankContext {
car: elev,
car_position: 0.0,
stop: stops[1],
stop_position: 10.0,
group: &g,
manifest: &m,
world: &world,
};
let cost = etd.rank(&ctx).expect("finite cost");
assert!(
(cost - 9.0).abs() < 1e-9,
"rank with age_linear_weight·1000 should reduce 10.0 by 1.0 to 9.0; got {cost}"
);
}
#[test]
fn rank_zero_age_weight_skips_the_subtraction() {
let (mut world, stops) = world_with_stops(&[0.0, 10.0]);
let elev = idle_elevator(&mut world, 0.0, 1.0);
{
let mut e = world.elevator(elev).unwrap().clone();
e.phase = ElevatorPhase::Stopped;
world.set_elevator(elev, e);
}
let g = group(&stops, vec![elev]);
let m = manifest_with_aging_demand(&mut world, stops[1], 1000);
let mut etd = EtdDispatch::new();
etd.pre_dispatch(&g, &m, &mut world);
let ctx = RankContext {
car: elev,
car_position: 0.0,
stop: stops[1],
stop_position: 10.0,
group: &g,
manifest: &m,
world: &world,
};
let cost = etd.rank(&ctx).expect("finite cost");
assert!(
(cost - 10.0).abs() < 1e-9,
"rank with zero age weight ignores wait_ticks; got {cost}"
);
}
#[test]
fn rank_wait_squared_weight_subtracts_quadratically() {
let (mut world, stops) = world_with_stops(&[0.0, 10.0]);
let elev = idle_elevator(&mut world, 0.0, 1.0);
{
let mut e = world.elevator(elev).unwrap().clone();
e.phase = ElevatorPhase::Stopped;
world.set_elevator(elev, e);
}
let g = group(&stops, vec![elev]);
let mut m = DispatchManifest::default();
for _ in 0..2 {
let dummy = world.spawn();
m.waiting_at_stop
.entry(stops[1])
.or_default()
.push(RiderInfo {
id: dummy,
destination: None,
weight: Weight::from(70.0),
wait_ticks: 50,
});
}
let mut etd = EtdDispatch::new();
etd.wait_squared_weight = 0.001;
etd.pre_dispatch(&g, &m, &mut world);
let ctx = RankContext {
car: elev,
car_position: 0.0,
stop: stops[1],
stop_position: 10.0,
group: &g,
manifest: &m,
world: &world,
};
let cost = etd.rank(&ctx).expect("finite cost");
assert!(
(cost - 5.0).abs() < 1e-9,
"rank with wait_squared_weight·5000 should reduce 10.0 by 5.0 to 5.0; got {cost}"
);
}
#[test]
fn rank_returns_none_when_compute_cost_returns_infinity() {
let (mut world, stops) = world_with_stops(&[0.0, 10.0]);
let elev = idle_elevator(&mut world, 0.0, 0.0);
let g = group(&stops, vec![elev]);
let dummy = world.spawn();
let mut m = DispatchManifest::default();
m.waiting_at_stop
.entry(stops[1])
.or_default()
.push(RiderInfo {
id: dummy,
destination: Some(stops[0]),
weight: Weight::from(70.0),
wait_ticks: 0,
});
let mut etd = EtdDispatch::new();
etd.pre_dispatch(&g, &m, &mut world);
let ctx = RankContext {
car: elev,
car_position: 0.0,
stop: stops[1],
stop_position: 10.0,
group: &g,
manifest: &m,
world: &world,
};
assert_eq!(
etd.rank(&ctx),
None,
"infinity from compute_cost must surface as None at the rank boundary"
);
}