use crate::components::{Accel, Elevator, ElevatorPhase, Position, Speed, Stop, Velocity, Weight};
use crate::dispatch::reposition::{
DEFAULT_REPOSITION_COOLDOWN_TICKS, RepositionCooldowns, ReturnToLobby, SpreadEvenly,
};
use crate::dispatch::sweep::{self, EPSILON, SweepDirection, SweepMode};
use crate::dispatch::{
DispatchManifest, DispatchStrategy, ElevatorGroup, LineInfo, RankContext, RepositionStrategy,
look::LookDispatch,
};
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_at(world: &mut World, position: 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(2.0),
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: 5,
door_open_ticks: 10,
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 cooldown_one_tick_before_eligible_is_cooling_down() {
let mut cd = RepositionCooldowns::default();
let car = EntityId::default();
cd.record_arrival(car, 0);
let eligible = DEFAULT_REPOSITION_COOLDOWN_TICKS;
assert!(
cd.is_cooling_down(car, eligible - 1),
"tick = eligible - 1 must still be cooling down (`<` boundary)"
);
}
#[test]
fn cooldown_at_exact_eligible_tick_is_no_longer_cooling_down() {
let mut cd = RepositionCooldowns::default();
let car = EntityId::default();
cd.record_arrival(car, 0);
let eligible = DEFAULT_REPOSITION_COOLDOWN_TICKS;
assert!(
!cd.is_cooling_down(car, eligible),
"tick == eligible must NOT be cooling down (strict `<`, not `<=`)"
);
}
#[test]
fn cooldown_one_tick_after_eligible_is_no_longer_cooling_down() {
let mut cd = RepositionCooldowns::default();
let car = EntityId::default();
cd.record_arrival(car, 0);
let eligible = DEFAULT_REPOSITION_COOLDOWN_TICKS;
assert!(
!cd.is_cooling_down(car, eligible + 1),
"tick > eligible must not be cooling down"
);
}
#[test]
fn cooldown_no_entry_is_never_cooling_down() {
let cd = RepositionCooldowns::default();
let car = EntityId::default();
assert!(
!cd.is_cooling_down(car, 0),
"fresh state has no cooldown for any car"
);
assert!(
!cd.is_cooling_down(car, u64::MAX),
"fresh state stays cooldown-free at any tick"
);
}
#[test]
fn spread_evenly_omits_no_op_when_already_at_target_stop() {
let (mut world, stops) = world_with_stops(&[0.0, 10.0]);
let elev_a = idle_elevator_at(&mut world, 10.0);
let elev_b = idle_elevator_at(&mut world, 10.0);
let g = group(&stops, vec![elev_a, elev_b]);
let stop_pos = vec![(stops[0], 0.0), (stops[1], 10.0)];
let idle = vec![(elev_a, 10.0), (elev_b, 10.0)];
let mut out = Vec::new();
SpreadEvenly.reposition(&idle, &stop_pos, &g, &world, &mut out);
assert_eq!(
out.len(),
1,
"two cars at stop[1] must spread one to stop[0]; got {out:?}"
);
let (_, target) = out[0];
assert_eq!(
target, stops[0],
"the emitted move must target the empty stop[0], not a no-op back to stop[1]"
);
}
#[test]
fn spread_evenly_within_epsilon_of_stop_omits_move() {
let (mut world, stops) = world_with_stops(&[0.0, 10.0]);
let elev = idle_elevator_at(&mut world, 5e-7);
let g = group(&stops[..1], vec![elev]);
let stop_pos = vec![(stops[0], 0.0)];
let idle = vec![(elev, 5e-7)];
let mut out = Vec::new();
SpreadEvenly.reposition(&idle, &stop_pos, &g, &world, &mut out);
assert!(
out.is_empty(),
"elevator within reposition threshold (5e-7 < 1e-6) of stop must not emit a move; got {out:?}"
);
}
#[test]
fn spread_evenly_just_past_epsilon_emits_move() {
let (mut world, stops) = world_with_stops(&[0.0, 10.0]);
let elev = idle_elevator_at(&mut world, 2e-6);
let g = group(&stops[..1], vec![elev]);
let stop_pos = vec![(stops[0], 0.0)];
let idle = vec![(elev, 2e-6)];
let mut out = Vec::new();
SpreadEvenly.reposition(&idle, &stop_pos, &g, &world, &mut out);
assert_eq!(
out.len(),
1,
"elevator 2e-6 from stop (> 1e-6 boundary) must emit one move; got {out:?}"
);
assert_eq!(out[0], (elev, stops[0]));
}
#[test]
fn return_to_lobby_within_epsilon_of_home_omits_move() {
let (mut world, stops) = world_with_stops(&[0.0, 10.0]);
let elev = idle_elevator_at(&mut world, 5e-7);
let g = group(&stops, vec![elev]);
let stop_pos = vec![(stops[0], 0.0), (stops[1], 10.0)];
let idle = vec![(elev, 5e-7)];
let mut out = Vec::new();
ReturnToLobby::new().reposition(&idle, &stop_pos, &g, &world, &mut out);
assert!(
out.is_empty(),
"car within 1e-6 of home stop must not be repositioned; got {out:?}"
);
}
#[test]
fn return_to_lobby_just_past_epsilon_emits_move() {
let (mut world, stops) = world_with_stops(&[0.0, 10.0]);
let elev = idle_elevator_at(&mut world, 2e-6);
let g = group(&stops, vec![elev]);
let stop_pos = vec![(stops[0], 0.0), (stops[1], 10.0)];
let idle = vec![(elev, 2e-6)];
let mut out = Vec::new();
ReturnToLobby::new().reposition(&idle, &stop_pos, &g, &world, &mut out);
assert_eq!(out, vec![(elev, stops[0])]);
}
#[test]
fn spread_evenly_empty_idle_returns_empty() {
let (world, stops) = world_with_stops(&[0.0, 10.0]);
let g = group(&stops, vec![]);
let stop_pos = vec![(stops[0], 0.0), (stops[1], 10.0)];
let mut out = Vec::new();
SpreadEvenly.reposition(&[], &stop_pos, &g, &world, &mut out);
assert!(out.is_empty());
}
#[test]
fn spread_evenly_empty_stops_returns_empty() {
let (mut world, _stops) = world_with_stops(&[]);
let elev = idle_elevator_at(&mut world, 0.0);
let g = group(&[], vec![elev]);
let idle = vec![(elev, 0.0)];
let mut out = Vec::new();
SpreadEvenly.reposition(&idle, &[], &g, &world, &mut out);
assert!(out.is_empty(), "no stops means no targets");
}
#[test]
fn sweep_rank_strict_up_rejects_at_or_within_epsilon() {
let car = 10.0;
assert!(
sweep::rank(SweepMode::Strict, SweepDirection::Up, car, car).is_none(),
"Strict Up rejects stop == car"
);
assert!(
sweep::rank(
SweepMode::Strict,
SweepDirection::Up,
car,
car + EPSILON / 2.0
)
.is_none(),
"Strict Up rejects stop within EPSILON of car"
);
}
#[test]
fn sweep_rank_strict_up_accepts_just_past_epsilon() {
let car = 10.0;
let stop = EPSILON.mul_add(2.0, car);
let cost = sweep::rank(SweepMode::Strict, SweepDirection::Up, car, stop)
.expect("Strict Up accepts stop > car + EPSILON");
assert!((cost - (stop - car)).abs() < EPSILON);
}
#[test]
fn sweep_rank_strict_down_rejects_at_or_within_epsilon() {
let car = 10.0;
assert!(sweep::rank(SweepMode::Strict, SweepDirection::Down, car, car).is_none());
assert!(
sweep::rank(
SweepMode::Strict,
SweepDirection::Down,
car,
car - EPSILON / 2.0
)
.is_none()
);
}
#[test]
fn sweep_rank_strict_down_accepts_just_past_epsilon() {
let car = 10.0;
let stop = EPSILON.mul_add(-2.0, car);
let cost = sweep::rank(SweepMode::Strict, SweepDirection::Down, car, stop)
.expect("Strict Down accepts stop < car - EPSILON");
assert!((cost - (car - stop)).abs() < EPSILON);
}
#[test]
fn sweep_rank_lenient_up_accepts_just_below_car() {
let car = 10.0;
let stop = car - EPSILON / 2.0;
assert!(
sweep::rank(SweepMode::Lenient, SweepDirection::Up, car, stop).is_some(),
"Lenient Up accepts stop > car - EPSILON (i.e. inclusive of car position)"
);
}
#[test]
fn sweep_rank_lenient_up_rejects_well_below_car() {
let car = 10.0;
let stop = car - 1.0; assert!(sweep::rank(SweepMode::Lenient, SweepDirection::Up, car, stop).is_none());
}
#[test]
fn sweep_rank_lenient_down_accepts_just_above_car() {
let car = 10.0;
let stop = car + EPSILON / 2.0;
assert!(sweep::rank(SweepMode::Lenient, SweepDirection::Down, car, stop).is_some());
}
#[test]
fn sweep_rank_lenient_down_rejects_well_above_car() {
let car = 10.0;
let stop = car + 1.0; assert!(sweep::rank(SweepMode::Lenient, SweepDirection::Down, car, stop).is_none());
}
#[test]
fn sweep_rank_cost_is_absolute_distance_regardless_of_direction() {
let car = 10.0;
let stop_above = 15.0;
let stop_below = 5.0;
let up_cost =
sweep::rank(SweepMode::Strict, SweepDirection::Up, car, stop_above).expect("up accepts");
let down_cost = sweep::rank(SweepMode::Strict, SweepDirection::Down, car, stop_below)
.expect("down accepts");
assert!((up_cost - 5.0).abs() < EPSILON);
assert!((down_cost - 5.0).abs() < EPSILON);
}
fn manifest_with_demand(world: &mut World, stops: &[EntityId]) -> DispatchManifest {
use crate::components::Weight;
use crate::dispatch::RiderInfo;
let mut m = DispatchManifest::default();
for &s in stops {
let dummy = world.spawn();
m.waiting_at_stop.entry(s).or_default().push(RiderInfo {
id: dummy,
destination: None,
weight: Weight::from(70.0),
wait_ticks: 0,
});
}
m
}
#[test]
fn strict_demand_ahead_up_stop_at_car_position_is_not_ahead() {
let (mut world, stops) = world_with_stops(&[10.0]);
let elev = idle_elevator_at(&mut world, 10.0);
let g = group(&stops, vec![elev]);
let m = manifest_with_demand(&mut world, &stops);
assert!(!sweep::strict_demand_ahead(
SweepDirection::Up,
10.0,
&g,
&m,
&world
));
assert!(!sweep::strict_demand_ahead(
SweepDirection::Down,
10.0,
&g,
&m,
&world
));
}
#[test]
fn strict_demand_ahead_up_rejects_demand_below_car() {
let (mut world, stops) = world_with_stops(&[5.0]);
let elev = idle_elevator_at(&mut world, 10.0);
let g = group(&stops, vec![elev]);
let m = manifest_with_demand(&mut world, &stops);
assert!(!sweep::strict_demand_ahead(
SweepDirection::Up,
10.0,
&g,
&m,
&world
));
assert!(sweep::strict_demand_ahead(
SweepDirection::Down,
10.0,
&g,
&m,
&world
));
}
#[test]
fn strict_demand_ahead_up_accepts_demand_strictly_above_car() {
let (mut world, stops) = world_with_stops(&[15.0]);
let elev = idle_elevator_at(&mut world, 10.0);
let g = group(&stops, vec![elev]);
let m = manifest_with_demand(&mut world, &stops);
assert!(sweep::strict_demand_ahead(
SweepDirection::Up,
10.0,
&g,
&m,
&world
));
assert!(!sweep::strict_demand_ahead(
SweepDirection::Down,
10.0,
&g,
&m,
&world
));
}
#[allow(
clippy::too_many_arguments,
reason = "test helper threading rank context"
)]
fn rank_via_look(
look: &mut LookDispatch,
car: EntityId,
car_pos: f64,
stop: EntityId,
stop_pos: f64,
g: &ElevatorGroup,
m: &DispatchManifest,
world: &World,
) -> Option<f64> {
let ctx = RankContext {
car,
car_position: car_pos,
stop,
stop_position: stop_pos,
group: g,
manifest: m,
world,
};
look.rank(&ctx)
}
#[test]
fn look_all_demand_strictly_above_car_keeps_up_sweep() {
let (mut world, stops) = world_with_stops(&[15.0, 20.0, 25.0]);
let elev = idle_elevator_at(&mut world, 10.0);
let g = group(&stops, vec![elev]);
let m = manifest_with_demand(&mut world, &stops);
let mut look = LookDispatch::new();
look.prepare_car(elev, 10.0, &g, &m, &world);
let above = rank_via_look(&mut look, elev, 10.0, stops[0], 15.0, &g, &m, &world);
assert!(above.is_some(), "Up sweep accepts stop above car");
}
#[test]
fn look_all_demand_strictly_below_car_reverses_to_down() {
let (mut world, stops) = world_with_stops(&[0.0, 5.0]);
let elev = idle_elevator_at(&mut world, 10.0);
let g = group(&stops, vec![elev]);
let m = manifest_with_demand(&mut world, &stops);
let mut look = LookDispatch::new();
look.prepare_car(elev, 10.0, &g, &m, &world);
let below = rank_via_look(&mut look, elev, 10.0, stops[0], 0.0, &g, &m, &world);
assert!(
below.is_some(),
"after reversal, a stop below the car must rank (Down sweep accepts it)"
);
}
#[test]
fn look_repeated_stops_at_same_position_all_rank_equally() {
let (mut world, stops) = world_with_stops(&[20.0, 20.0, 20.0]);
let elev = idle_elevator_at(&mut world, 10.0);
let g = group(&stops, vec![elev]);
let m = manifest_with_demand(&mut world, &stops);
let mut look = LookDispatch::new();
look.prepare_car(elev, 10.0, &g, &m, &world);
let costs: Vec<_> = stops
.iter()
.map(|&s| rank_via_look(&mut look, elev, 10.0, s, 20.0, &g, &m, &world))
.collect();
assert!(costs.iter().all(Option::is_some));
let first = costs[0].unwrap();
for c in &costs[1..] {
assert!((c.unwrap() - first).abs() < EPSILON);
}
}