use crate::components::{ElevatorPhase, Orientation, RiderPhase, Route, RouteLeg, TransportMode};
use crate::config::{
BuildingConfig, ElevatorConfig, GroupConfig, LineConfig, PassengerSpawnConfig, SimConfig,
SimulationParams,
};
use crate::dispatch::scan::ScanDispatch;
use crate::error::SimError;
use crate::events::Event as SimEvent;
use crate::ids::GroupId;
use crate::sim::Simulation;
use crate::stop::{StopConfig, StopId};
fn two_group_config() -> SimConfig {
SimConfig {
building: BuildingConfig {
name: "Transfer Tower".into(),
stops: vec![
StopConfig {
id: StopId(0),
name: "Ground".into(),
position: 0.0,
},
StopConfig {
id: StopId(1),
name: "Transfer".into(),
position: 10.0,
},
StopConfig {
id: StopId(2),
name: "Top".into(),
position: 20.0,
},
],
lines: Some(vec![
LineConfig {
id: 1,
name: "Low".into(),
serves: vec![StopId(0), StopId(1)],
elevators: vec![ElevatorConfig {
id: 1,
name: "L1".into(),
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(0),
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,
}],
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![ElevatorConfig {
id: 2,
name: "H1".into(),
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(1),
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,
}],
orientation: Orientation::Vertical,
position: None,
min_position: None,
max_position: None,
max_cars: None,
},
]),
groups: Some(vec![
GroupConfig {
id: 0,
name: "Low Rise".into(),
lines: vec![1],
dispatch: crate::dispatch::BuiltinStrategy::Scan,
reposition: None,
},
GroupConfig {
id: 1,
name: "High Rise".into(),
lines: vec![2],
dispatch: crate::dispatch::BuiltinStrategy::Scan,
reposition: None,
},
]),
},
elevators: vec![],
simulation: SimulationParams {
ticks_per_second: 60.0,
},
passenger_spawning: PassengerSpawnConfig {
mean_interval_ticks: 120,
weight_range: (50.0, 100.0),
},
}
}
fn overlapping_groups_config() -> SimConfig {
SimConfig {
building: BuildingConfig {
name: "Overlap Tower".into(),
stops: vec![
StopConfig {
id: StopId(0),
name: "Bottom".into(),
position: 0.0,
},
StopConfig {
id: StopId(1),
name: "Top".into(),
position: 10.0,
},
],
lines: Some(vec![
LineConfig {
id: 1,
name: "Express A".into(),
serves: vec![StopId(0), StopId(1)],
elevators: vec![ElevatorConfig {
id: 1,
name: "A1".into(),
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(0),
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,
}],
orientation: Orientation::Vertical,
position: None,
min_position: None,
max_position: None,
max_cars: None,
},
LineConfig {
id: 2,
name: "Express B".into(),
serves: vec![StopId(0), StopId(1)],
elevators: vec![ElevatorConfig {
id: 2,
name: "B1".into(),
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(0),
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,
}],
orientation: Orientation::Vertical,
position: None,
min_position: None,
max_position: None,
max_cars: None,
},
]),
groups: Some(vec![
GroupConfig {
id: 0,
name: "Group A".into(),
lines: vec![1],
dispatch: crate::dispatch::BuiltinStrategy::Scan,
reposition: None,
},
GroupConfig {
id: 1,
name: "Group B".into(),
lines: vec![2],
dispatch: crate::dispatch::BuiltinStrategy::Scan,
reposition: None,
},
]),
},
elevators: vec![],
simulation: SimulationParams {
ticks_per_second: 60.0,
},
passenger_spawning: PassengerSpawnConfig {
mean_interval_ticks: 120,
weight_range: (50.0, 100.0),
},
}
}
#[test]
fn explicit_config_builds_correct_groups_and_lines() {
let config = two_group_config();
let sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
assert_eq!(sim.groups().len(), 2, "should have exactly 2 groups");
let g0 = sim.groups().iter().find(|g| g.id() == GroupId(0)).unwrap();
let g1 = sim.groups().iter().find(|g| g.id() == GroupId(1)).unwrap();
assert_eq!(g0.lines().len(), 1);
assert_eq!(g1.lines().len(), 1);
assert_eq!(g0.elevator_entities().len(), 1);
assert_eq!(g1.elevator_entities().len(), 1);
let ground_eid = sim.stop_entity(StopId(0)).unwrap();
let transfer_eid = sim.stop_entity(StopId(1)).unwrap();
let top_eid = sim.stop_entity(StopId(2)).unwrap();
assert!(g0.stop_entities().contains(&ground_eid));
assert!(g0.stop_entities().contains(&transfer_eid));
assert!(!g0.stop_entities().contains(&top_eid));
assert!(!g1.stop_entities().contains(&ground_eid));
assert!(g1.stop_entities().contains(&transfer_eid));
assert!(g1.stop_entities().contains(&top_eid));
}
#[test]
fn explicit_config_lines_have_correct_stop_coverage() {
let config = two_group_config();
let sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let ground_eid = sim.stop_entity(StopId(0)).unwrap();
let transfer_eid = sim.stop_entity(StopId(1)).unwrap();
let top_eid = sim.stop_entity(StopId(2)).unwrap();
let line_entities_g0 = sim.lines_in_group(GroupId(0));
assert_eq!(line_entities_g0.len(), 1);
let low_line = line_entities_g0[0];
let line_entities_g1 = sim.lines_in_group(GroupId(1));
assert_eq!(line_entities_g1.len(), 1);
let high_line = line_entities_g1[0];
let low_stops = sim.stops_served_by_line(low_line);
assert!(low_stops.contains(&ground_eid));
assert!(low_stops.contains(&transfer_eid));
assert!(!low_stops.contains(&top_eid));
let high_stops = sim.stops_served_by_line(high_line);
assert!(!high_stops.contains(&ground_eid));
assert!(high_stops.contains(&transfer_eid));
assert!(high_stops.contains(&top_eid));
}
#[test]
fn line_component_orientation_and_bounds_set_from_config() {
let config = two_group_config();
let sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let low_line_eid = sim.lines_in_group(GroupId(0))[0];
let line_comp = sim.world().line(low_line_eid).unwrap();
assert_eq!(line_comp.orientation(), Orientation::Vertical);
assert!((line_comp.min_position() - 0.0).abs() < 1e-9);
assert!((line_comp.max_position() - 10.0).abs() < 1e-9);
}
#[test]
fn legacy_config_auto_creates_single_group_with_all_elevators() {
use super::helpers::default_config;
let config = default_config();
let sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
assert_eq!(
sim.groups().len(),
1,
"legacy config should produce exactly 1 group"
);
let group = &sim.groups()[0];
assert_eq!(group.id(), GroupId(0));
assert_eq!(group.lines().len(), 1, "should have 1 default line");
assert_eq!(group.elevator_entities().len(), 1, "should have 1 elevator");
assert_eq!(group.stop_entities().len(), 3);
}
#[test]
fn legacy_config_line_covers_all_stops() {
use super::helpers::default_config;
let config = default_config();
let sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let line_eid = sim.lines_in_group(GroupId(0))[0];
let stops = sim.stops_served_by_line(line_eid);
let s0 = sim.stop_entity(StopId(0)).unwrap();
let s1 = sim.stop_entity(StopId(1)).unwrap();
let s2 = sim.stop_entity(StopId(2)).unwrap();
assert!(stops.contains(&s0));
assert!(stops.contains(&s1));
assert!(stops.contains(&s2));
}
#[test]
fn spawn_rider_auto_detects_group_for_same_group_stops() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let ground = sim.stop_entity(StopId(0)).unwrap();
let transfer = sim.stop_entity(StopId(1)).unwrap();
let result = sim.spawn_rider(ground, transfer, 70.0);
assert!(
result.is_ok(),
"should auto-detect group 0 for Ground→Transfer"
);
}
#[test]
fn spawn_rider_returns_no_route_when_stops_in_different_groups() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let ground = sim.stop_entity(StopId(0)).unwrap();
let top = sim.stop_entity(StopId(2)).unwrap();
let result = sim.spawn_rider(ground, top, 70.0);
assert!(
matches!(result, Err(SimError::NoRoute { .. })),
"expected NoRoute, got {result:?}"
);
}
#[test]
fn spawn_rider_returns_ambiguous_route_when_multiple_groups_serve_both_stops() {
let config = overlapping_groups_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let bottom = sim.stop_entity(StopId(0)).unwrap();
let top = sim.stop_entity(StopId(1)).unwrap();
let result = sim.spawn_rider(bottom, top, 70.0);
match result {
Err(SimError::AmbiguousRoute { groups, .. }) => {
assert_eq!(groups.len(), 2, "expected 2 ambiguous groups");
}
other => panic!("expected AmbiguousRoute, got {other:?}"),
}
}
#[test]
fn cross_group_rider_arrives_via_explicit_two_leg_route() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let ground = sim.stop_entity(StopId(0)).unwrap();
let transfer = sim.stop_entity(StopId(1)).unwrap();
let top = sim.stop_entity(StopId(2)).unwrap();
let route = Route {
legs: vec![
RouteLeg {
from: ground,
to: transfer,
via: TransportMode::Group(GroupId(0)),
},
RouteLeg {
from: transfer,
to: top,
via: TransportMode::Group(GroupId(1)),
},
],
current_leg: 0,
};
let rider = sim
.spawn_rider_with_route(ground, top, 70.0, route)
.unwrap();
for _ in 0..5000 {
sim.step();
if let Some(r) = sim.world().rider(rider)
&& r.phase == RiderPhase::Arrived
{
break;
}
}
let rider_data = sim.world().rider(rider).unwrap();
assert_eq!(
rider_data.phase,
RiderPhase::Arrived,
"rider should arrive at Top via transfer"
);
}
#[test]
fn rider_only_boards_elevator_from_matching_group() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let transfer = sim.stop_entity(StopId(1)).unwrap();
let top = sim.stop_entity(StopId(2)).unwrap();
let rider = sim.spawn_rider(transfer, top, 70.0).unwrap();
let g0_elev = sim
.groups()
.iter()
.find(|g| g.id() == GroupId(0))
.unwrap()
.elevator_entities()[0];
let g1_elev = sim
.groups()
.iter()
.find(|g| g.id() == GroupId(1))
.unwrap()
.elevator_entities()[0];
let mut boarding_elevator = None;
for _ in 0..3000 {
sim.step();
if let Some(r) = sim.world().rider(rider)
&& let RiderPhase::Boarding(eid) | RiderPhase::Riding(eid) = r.phase
{
boarding_elevator = Some(eid);
break;
}
}
let boarded = boarding_elevator.expect("rider should board within 3000 ticks");
assert_eq!(
boarded, g1_elev,
"rider going Transfer→Top should board Group 1 elevator, not Group 0"
);
assert_ne!(
boarded, g0_elev,
"rider should not board Group 0 elevator for a Group 1 route"
);
}
#[test]
fn line_pinned_rider_boards_only_specified_line_elevator() {
let config = SimConfig {
building: BuildingConfig {
name: "Twin Shaft".into(),
stops: vec![
StopConfig {
id: StopId(0),
name: "Lobby".into(),
position: 0.0,
},
StopConfig {
id: StopId(1),
name: "Sky".into(),
position: 20.0,
},
],
lines: Some(vec![
LineConfig {
id: 1,
name: "Shaft A".into(),
serves: vec![StopId(0), StopId(1)],
elevators: vec![ElevatorConfig {
id: 1,
name: "A".into(),
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(0),
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,
}],
orientation: Orientation::Vertical,
position: None,
min_position: None,
max_position: None,
max_cars: None,
},
LineConfig {
id: 2,
name: "Shaft B".into(),
serves: vec![StopId(0), StopId(1)],
elevators: vec![ElevatorConfig {
id: 2,
name: "B".into(),
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(0),
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,
}],
orientation: Orientation::Vertical,
position: None,
min_position: None,
max_position: None,
max_cars: None,
},
]),
groups: Some(vec![GroupConfig {
id: 0,
name: "All Shafts".into(),
lines: vec![1, 2],
dispatch: crate::dispatch::BuiltinStrategy::Scan,
reposition: None,
}]),
},
elevators: vec![],
simulation: SimulationParams {
ticks_per_second: 60.0,
},
passenger_spawning: PassengerSpawnConfig {
mean_interval_ticks: 120,
weight_range: (50.0, 100.0),
},
};
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let line2_eid = sim
.lines_in_group(GroupId(0))
.into_iter()
.find(|&le| sim.world().line(le).is_some_and(|l| l.name() == "Shaft B"))
.expect("Shaft B line should exist");
let elevators_on_line2 = sim.elevators_on_line(line2_eid);
assert_eq!(elevators_on_line2.len(), 1);
let line2_elevator = elevators_on_line2[0];
let line1_eid = sim
.lines_in_group(GroupId(0))
.into_iter()
.find(|&le| sim.world().line(le).is_some_and(|l| l.name() == "Shaft A"))
.expect("Shaft A line should exist");
let elevators_on_line1 = sim.elevators_on_line(line1_eid);
let line1_elevator = elevators_on_line1[0];
let lobby = sim.stop_entity(StopId(0)).unwrap();
let sky = sim.stop_entity(StopId(1)).unwrap();
let route = Route {
legs: vec![RouteLeg {
from: lobby,
to: sky,
via: TransportMode::Line(line2_eid),
}],
current_leg: 0,
};
let rider = sim.spawn_rider_with_route(lobby, sky, 70.0, route).unwrap();
let mut boarding_elevator = None;
for _ in 0..3000 {
sim.step();
if let Some(r) = sim.world().rider(rider)
&& let RiderPhase::Boarding(eid) | RiderPhase::Riding(eid) = r.phase
{
boarding_elevator = Some(eid);
break;
}
}
let boarded = boarding_elevator.expect("line-pinned rider should board within 3000 ticks");
assert_eq!(
boarded, line2_elevator,
"rider pinned to Shaft B should board Shaft B elevator"
);
assert_ne!(
boarded, line1_elevator,
"rider pinned to Shaft B should not board Shaft A elevator"
);
}
#[test]
fn add_group_and_add_line_reflect_in_query_apis() {
use super::helpers::{default_config, scan};
let config = default_config();
let mut sim = Simulation::new(&config, scan()).unwrap();
assert_eq!(sim.groups().len(), 1);
let new_group_id = sim.add_group("Express", ScanDispatch::new());
assert_eq!(sim.groups().len(), 2);
let mut line_params = crate::sim::LineParams::new("Express Shaft", new_group_id);
line_params.orientation = Orientation::Vertical;
line_params.max_position = 10.0;
let line_eid = sim.add_line(&line_params).unwrap();
let lines = sim.lines_in_group(new_group_id);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0], line_eid);
let line_comp = sim.world().line(line_eid).unwrap();
assert_eq!(line_comp.group(), new_group_id);
let elevators = sim.elevators_on_line(line_eid);
assert!(elevators.is_empty());
}
#[test]
fn add_group_returns_monotonically_increasing_id() {
use super::helpers::{default_config, scan};
let config = default_config();
let mut sim = Simulation::new(&config, scan()).unwrap();
let g1 = sim.add_group("G1", ScanDispatch::new());
let g2 = sim.add_group("G2", ScanDispatch::new());
assert_eq!(g1, GroupId(1));
assert_eq!(g2, GroupId(2));
}
#[test]
fn assign_line_to_group_moves_line_between_groups() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let low_line = sim.lines_in_group(GroupId(0))[0];
assert_eq!(
sim.world().line(low_line).unwrap().group(),
GroupId(0),
"line should start in Group 0"
);
assert_eq!(sim.lines_in_group(GroupId(0)).len(), 1);
assert_eq!(sim.lines_in_group(GroupId(1)).len(), 1);
let old_group = sim.assign_line_to_group(low_line, GroupId(1)).unwrap();
assert_eq!(old_group, GroupId(0));
assert_eq!(
sim.lines_in_group(GroupId(0)).len(),
0,
"Group 0 should be empty after reassignment"
);
assert_eq!(
sim.lines_in_group(GroupId(1)).len(),
2,
"Group 1 should now have both lines"
);
assert_eq!(
sim.world().line(low_line).unwrap().group(),
GroupId(1),
"line component group field should be updated"
);
}
#[test]
fn assign_line_to_group_updates_stop_entities_cache() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let ground = sim.stop_entity(StopId(0)).unwrap();
let top = sim.stop_entity(StopId(2)).unwrap();
let g0 = sim.groups().iter().find(|g| g.id() == GroupId(0)).unwrap();
assert!(g0.stop_entities().contains(&ground));
assert!(!g0.stop_entities().contains(&top));
let low_line = sim.lines_in_group(GroupId(0))[0];
sim.assign_line_to_group(low_line, GroupId(1)).unwrap();
let g0 = sim.groups().iter().find(|g| g.id() == GroupId(0)).unwrap();
assert!(
g0.stop_entities().is_empty(),
"Group 0 stop cache should be empty"
);
let g1 = sim.groups().iter().find(|g| g.id() == GroupId(1)).unwrap();
assert!(g1.stop_entities().contains(&ground));
assert!(g1.stop_entities().contains(&top));
}
#[test]
fn assign_line_to_nonexistent_group_returns_error() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let low_line = sim.lines_in_group(GroupId(0))[0];
let result = sim.assign_line_to_group(low_line, GroupId(99));
assert!(
matches!(result, Err(SimError::GroupNotFound(GroupId(99)))),
"expected GroupNotFound(99), got {result:?}"
);
}
#[test]
fn reachable_stops_from_includes_cross_group_stops_via_transfer() {
let config = two_group_config();
let sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let ground = sim.stop_entity(StopId(0)).unwrap();
let transfer = sim.stop_entity(StopId(1)).unwrap();
let top = sim.stop_entity(StopId(2)).unwrap();
let reachable = sim.reachable_stops_from(ground);
assert!(
reachable.contains(&transfer),
"Ground should reach Transfer (same group)"
);
assert!(
reachable.contains(&top),
"Ground should reach Top via transfer"
);
}
#[test]
fn reachable_stops_from_isolated_stop_returns_empty() {
use super::helpers::scan;
let config = SimConfig {
building: BuildingConfig {
name: "Island".into(),
stops: vec![
StopConfig {
id: StopId(0),
name: "Island".into(),
position: 0.0,
},
StopConfig {
id: StopId(1),
name: "Mainland".into(),
position: 10.0,
},
],
lines: Some(vec![LineConfig {
id: 1,
name: "Main".into(),
serves: vec![StopId(0), StopId(1)],
elevators: vec![ElevatorConfig {
id: 1,
name: "E1".into(),
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(0),
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,
}],
orientation: Orientation::Vertical,
position: None,
min_position: None,
max_position: None,
max_cars: None,
}]),
groups: Some(vec![GroupConfig {
id: 0,
name: "G0".into(),
lines: vec![1],
dispatch: crate::dispatch::BuiltinStrategy::Scan,
reposition: None,
}]),
},
elevators: vec![],
simulation: SimulationParams {
ticks_per_second: 60.0,
},
passenger_spawning: PassengerSpawnConfig {
mean_interval_ticks: 120,
weight_range: (50.0, 100.0),
},
};
let mut sim = Simulation::new(&config, scan()).unwrap();
let line = sim.lines_in_group(GroupId(0))[0];
let disconnected = sim.add_stop("Stranded".into(), 50.0, line).unwrap();
sim.remove_stop_from_line(disconnected, line).unwrap();
let reachable = sim.reachable_stops_from(disconnected);
assert!(
reachable.is_empty(),
"a stop not in any line should reach nothing, got {reachable:?}"
);
}
#[test]
fn transfer_points_returns_stops_shared_across_groups() {
let config = two_group_config();
let sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let transfer = sim.stop_entity(StopId(1)).unwrap();
let transfers = sim.transfer_points();
assert_eq!(transfers.len(), 1, "exactly one transfer point expected");
assert_eq!(transfers[0], transfer);
}
#[test]
fn transfer_points_empty_for_non_overlapping_groups() {
use super::helpers::{default_config, scan};
let config = default_config();
let sim = Simulation::new(&config, scan()).unwrap();
let transfers = sim.transfer_points();
assert!(
transfers.is_empty(),
"single-group sim should have no transfer points"
);
}
#[test]
fn shortest_route_finds_direct_path_within_group() {
let config = two_group_config();
let sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let ground = sim.stop_entity(StopId(0)).unwrap();
let transfer = sim.stop_entity(StopId(1)).unwrap();
let route = sim.shortest_route(ground, transfer);
assert!(route.is_some(), "direct route within Group 0 should exist");
let route = route.unwrap();
assert_eq!(route.legs.len(), 1);
assert_eq!(route.legs[0].from, ground);
assert_eq!(route.legs[0].to, transfer);
assert_eq!(route.legs[0].via, TransportMode::Group(GroupId(0)));
}
#[test]
fn shortest_route_spans_groups_via_transfer() {
let config = two_group_config();
let sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let ground = sim.stop_entity(StopId(0)).unwrap();
let transfer = sim.stop_entity(StopId(1)).unwrap();
let top = sim.stop_entity(StopId(2)).unwrap();
let route = sim.shortest_route(ground, top);
assert!(
route.is_some(),
"route Ground→Top should exist via transfer"
);
let route = route.unwrap();
assert_eq!(route.legs.len(), 2, "should take 2 legs via transfer");
assert_eq!(route.legs[0].from, ground);
assert_eq!(route.legs[0].to, transfer);
assert_eq!(route.legs[1].from, transfer);
assert_eq!(route.legs[1].to, top);
}
#[test]
fn shortest_route_returns_none_for_unreachable_stop() {
let config = SimConfig {
building: BuildingConfig {
name: "Disconnected".into(),
stops: vec![
StopConfig {
id: StopId(0),
name: "A".into(),
position: 0.0,
},
StopConfig {
id: StopId(1),
name: "B".into(),
position: 50.0,
},
],
lines: Some(vec![
LineConfig {
id: 1,
name: "Line A".into(),
serves: vec![StopId(0)],
elevators: vec![ElevatorConfig {
id: 1,
name: "E-A".into(),
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(0),
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,
}],
orientation: Orientation::Vertical,
position: None,
min_position: None,
max_position: None,
max_cars: None,
},
LineConfig {
id: 2,
name: "Line B".into(),
serves: vec![StopId(1)],
elevators: vec![ElevatorConfig {
id: 2,
name: "E-B".into(),
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(1),
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,
}],
orientation: Orientation::Vertical,
position: None,
min_position: None,
max_position: None,
max_cars: None,
},
]),
groups: Some(vec![
GroupConfig {
id: 0,
name: "G0".into(),
lines: vec![1],
dispatch: crate::dispatch::BuiltinStrategy::Scan,
reposition: None,
},
GroupConfig {
id: 1,
name: "G1".into(),
lines: vec![2],
dispatch: crate::dispatch::BuiltinStrategy::Scan,
reposition: None,
},
]),
},
elevators: vec![],
simulation: SimulationParams {
ticks_per_second: 60.0,
},
passenger_spawning: PassengerSpawnConfig {
mean_interval_ticks: 120,
weight_range: (50.0, 100.0),
},
};
let sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let a = sim.stop_entity(StopId(0)).unwrap();
let b = sim.stop_entity(StopId(1)).unwrap();
assert!(
sim.shortest_route(a, b).is_none(),
"no route between disconnected groups"
);
}
#[test]
fn duplicate_line_ids_fail_validation() {
let mut config = two_group_config();
if let Some(lines) = config.building.lines.as_mut() {
lines[1].id = 1;
}
let result = Simulation::new(&config, ScanDispatch::new());
assert!(
matches!(
result,
Err(SimError::InvalidConfig {
field: "building.lines",
..
})
),
"expected InvalidConfig for duplicate line IDs, got {result:?}"
);
}
#[test]
fn line_references_nonexistent_stop_fails_validation() {
let mut config = two_group_config();
if let Some(lines) = config.building.lines.as_mut() {
lines[1].serves.push(StopId(99));
}
let result = Simulation::new(&config, ScanDispatch::new());
assert!(
matches!(
result,
Err(SimError::InvalidConfig {
field: "building.lines.serves",
..
})
),
"expected InvalidConfig for non-existent stop reference, got {result:?}"
);
}
#[test]
fn group_references_nonexistent_line_fails_validation() {
let mut config = two_group_config();
if let Some(groups) = config.building.groups.as_mut() {
groups[0].lines.push(99);
}
let result = Simulation::new(&config, ScanDispatch::new());
assert!(
matches!(
result,
Err(SimError::InvalidConfig {
field: "building.groups.lines",
..
})
),
"expected InvalidConfig for non-existent line reference in group, got {result:?}"
);
}
#[test]
fn orphaned_stop_not_in_any_line_fails_validation() {
let mut config = two_group_config();
config.building.stops.push(StopConfig {
id: StopId(99),
name: "Orphan".into(),
position: 100.0,
});
let result = Simulation::new(&config, ScanDispatch::new());
assert!(
matches!(
result,
Err(SimError::InvalidConfig {
field: "building.lines",
..
})
),
"expected InvalidConfig for orphaned stop, got {result:?}"
);
}
#[test]
fn no_elevators_in_any_line_fails_validation() {
let config = SimConfig {
building: BuildingConfig {
name: "Elevator-less".into(),
stops: vec![
StopConfig {
id: StopId(0),
name: "G".into(),
position: 0.0,
},
StopConfig {
id: StopId(1),
name: "T".into(),
position: 10.0,
},
],
lines: Some(vec![LineConfig {
id: 1,
name: "Empty".into(),
serves: vec![StopId(0), StopId(1)],
elevators: vec![],
orientation: Orientation::Vertical,
position: None,
min_position: None,
max_position: None,
max_cars: None,
}]),
groups: None,
},
elevators: vec![],
simulation: SimulationParams {
ticks_per_second: 60.0,
},
passenger_spawning: PassengerSpawnConfig {
mean_interval_ticks: 120,
weight_range: (50.0, 100.0),
},
};
let result = Simulation::new(&config, ScanDispatch::new());
assert!(
matches!(
result,
Err(SimError::InvalidConfig {
field: "building.lines",
..
})
),
"expected InvalidConfig when no line has any elevator, got {result:?}"
);
}
#[test]
fn lines_serving_stop_returns_both_lines_for_transfer_stop() {
let config = two_group_config();
let sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let transfer = sim.stop_entity(StopId(1)).unwrap();
let ground = sim.stop_entity(StopId(0)).unwrap();
let lines_at_transfer = sim.lines_serving_stop(transfer);
assert_eq!(
lines_at_transfer.len(),
2,
"Transfer should be served by both lines"
);
let lines_at_ground = sim.lines_serving_stop(ground);
assert_eq!(
lines_at_ground.len(),
1,
"Ground should be served by only the low-rise line"
);
}
#[test]
fn groups_serving_stop_returns_both_groups_for_transfer_stop() {
let config = two_group_config();
let sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let transfer = sim.stop_entity(StopId(1)).unwrap();
let ground = sim.stop_entity(StopId(0)).unwrap();
let groups_at_transfer = sim.groups_serving_stop(transfer);
assert_eq!(
groups_at_transfer.len(),
2,
"Transfer stop should be in 2 groups"
);
assert!(groups_at_transfer.contains(&GroupId(0)));
assert!(groups_at_transfer.contains(&GroupId(1)));
let groups_at_ground = sim.groups_serving_stop(ground);
assert_eq!(
groups_at_ground.len(),
1,
"Ground stop should be in only Group 0"
);
assert!(groups_at_ground.contains(&GroupId(0)));
}
#[test]
fn line_for_elevator_returns_correct_line_entity() {
let config = two_group_config();
let sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let low_line = sim.lines_in_group(GroupId(0))[0];
let high_line = sim.lines_in_group(GroupId(1))[0];
let low_elevs = sim.elevators_on_line(low_line);
let high_elevs = sim.elevators_on_line(high_line);
assert_eq!(sim.line_for_elevator(low_elevs[0]), Some(low_line));
assert_eq!(sim.line_for_elevator(high_elevs[0]), Some(high_line));
}
#[test]
fn remove_line_removes_from_group_and_world() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let low_line = sim.lines_in_group(GroupId(0))[0];
assert!(sim.world().line(low_line).is_some());
sim.remove_line(low_line).unwrap();
assert_eq!(sim.lines_in_group(GroupId(0)).len(), 0);
assert!(
sim.world().line(low_line).is_none(),
"line component should be removed from world after remove_line"
);
}
#[test]
fn add_stop_to_line_appears_in_serves_and_group_cache() {
use super::helpers::{default_config, scan};
let config = default_config();
let mut sim = Simulation::new(&config, scan()).unwrap();
let line_eid = sim.lines_in_group(GroupId(0))[0];
let new_stop = sim.add_stop("Rooftop".into(), 15.0, line_eid).unwrap();
assert!(
sim.stops_served_by_line(line_eid).contains(&new_stop),
"stop should be in line's serves after add_stop"
);
}
#[test]
fn remove_stop_from_line_removes_from_serves_and_updates_group_cache() {
use super::helpers::{default_config, scan};
let config = default_config();
let mut sim = Simulation::new(&config, scan()).unwrap();
let s2 = sim.stop_entity(StopId(2)).unwrap();
let line_eid = sim.lines_in_group(GroupId(0))[0];
assert!(sim.stops_served_by_line(line_eid).contains(&s2));
sim.remove_stop_from_line(s2, line_eid).unwrap();
assert!(
!sim.stops_served_by_line(line_eid).contains(&s2),
"stop should be removed from line serves after remove_stop_from_line"
);
let group = &sim.groups()[0];
assert!(
!group.stop_entities().contains(&s2),
"group stop cache should not contain removed stop"
);
}
#[test]
fn walk_only_route_rider_arrives_directly() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let ground = sim.stop_entity(StopId(0)).unwrap();
let transfer = sim.stop_entity(StopId(1)).unwrap();
let route = Route {
legs: vec![RouteLeg {
from: ground,
to: transfer,
via: TransportMode::Walk,
}],
current_leg: 0,
};
let rider = sim
.spawn_rider_with_route(ground, transfer, 70.0, route)
.unwrap();
assert_eq!(sim.world().rider(rider).unwrap().phase, RiderPhase::Waiting);
sim.world_mut().rider_mut(rider).unwrap().phase =
RiderPhase::Exiting(crate::entity::EntityId::default());
sim.step();
let rider_data = sim.world().rider(rider).unwrap();
assert_eq!(
rider_data.phase,
RiderPhase::Arrived,
"walk-only rider should arrive after advance_transient processes the Exiting phase"
);
assert_eq!(
rider_data.current_stop,
Some(ground),
"rider's current_stop should remain at spawn stop for a single-leg walk route"
);
}
#[test]
fn walk_leg_teleports_rider_to_destination() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let ground = sim.stop_entity(StopId(0)).unwrap();
let transfer = sim.stop_entity(StopId(1)).unwrap();
let top = sim.stop_entity(StopId(2)).unwrap();
let route = Route {
legs: vec![
RouteLeg {
from: ground,
to: transfer,
via: TransportMode::Group(GroupId(0)),
},
RouteLeg {
from: transfer,
to: transfer,
via: TransportMode::Walk,
},
RouteLeg {
from: transfer,
to: top,
via: TransportMode::Group(GroupId(1)),
},
],
current_leg: 0,
};
let rider = sim
.spawn_rider_with_route(ground, top, 70.0, route)
.unwrap();
for _ in 0..5000 {
sim.step();
if let Some(r) = sim.world().rider(rider)
&& r.phase == RiderPhase::Arrived
{
break;
}
}
assert_eq!(
sim.world().rider(rider).unwrap().phase,
RiderPhase::Arrived,
"rider with walk leg in multi-leg route should eventually arrive"
);
}
#[test]
fn walk_leg_rider_does_not_board_elevator() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let ground = sim.stop_entity(StopId(0)).unwrap();
let transfer = sim.stop_entity(StopId(1)).unwrap();
let route = Route {
legs: vec![RouteLeg {
from: ground,
to: transfer,
via: TransportMode::Walk,
}],
current_leg: 0,
};
let rider = sim
.spawn_rider_with_route(ground, transfer, 70.0, route)
.unwrap();
for _ in 0..50 {
sim.step();
}
let phase = sim.world().rider(rider).unwrap().phase;
assert!(
matches!(phase, RiderPhase::Waiting | RiderPhase::Arrived),
"walk-leg rider should never board an elevator, got {phase:?}"
);
}
#[test]
fn remove_line_with_riders_aboard_ejects_riders() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let ground = sim.stop_entity(StopId(0)).unwrap();
let transfer = sim.stop_entity(StopId(1)).unwrap();
let rider = sim.spawn_rider(ground, transfer, 70.0).unwrap();
let mut is_riding = false;
for _ in 0..3000 {
sim.step();
if let Some(r) = sim.world().rider(rider)
&& matches!(r.phase, RiderPhase::Riding(_))
{
is_riding = true;
break;
}
}
assert!(is_riding, "rider should board elevator within 3000 ticks");
let low_line = sim.lines_in_group(GroupId(0))[0];
sim.remove_line(low_line).unwrap();
sim.step();
let phase = sim.world().rider(rider).unwrap().phase;
assert!(
matches!(phase, RiderPhase::Waiting | RiderPhase::Arrived),
"rider should be ejected when line is removed; got {phase:?}"
);
}
#[test]
fn remove_line_updates_group_cache() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
assert_eq!(sim.lines_in_group(GroupId(0)).len(), 1);
let low_line = sim.lines_in_group(GroupId(0))[0];
sim.remove_line(low_line).unwrap();
assert_eq!(
sim.lines_in_group(GroupId(0)).len(),
0,
"Group 0 should have no lines after remove_line"
);
assert_eq!(
sim.groups()
.iter()
.find(|g| g.id() == GroupId(0))
.unwrap()
.elevator_entities()
.len(),
0,
"Group 0 elevator cache should be empty after remove_line"
);
}
#[test]
fn remove_line_marks_topology_graph_dirty() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let ground = sim.stop_entity(StopId(0)).unwrap();
let top = sim.stop_entity(StopId(2)).unwrap();
let before = sim.reachable_stops_from(ground);
assert!(
before.contains(&top),
"ground should reach top before removal"
);
let low_line = sim.lines_in_group(GroupId(0))[0];
sim.remove_line(low_line).unwrap();
let after = sim.reachable_stops_from(ground);
assert!(
!after.contains(&top),
"ground should no longer reach top after low-rise line is removed"
);
}
#[test]
fn reassign_elevator_to_line_notifies_old_group_dispatcher_on_cross_group() {
use crate::dispatch::{DispatchDecision, DispatchManifest, DispatchStrategy, ElevatorGroup};
use crate::entity::EntityId;
use crate::world::World;
use std::sync::{Arc, Mutex};
struct TrackingDispatch {
removed: Arc<Mutex<Vec<EntityId>>>,
inner: ScanDispatch,
}
impl DispatchStrategy for TrackingDispatch {
fn decide(
&mut self,
elevator: EntityId,
position: f64,
group: &ElevatorGroup,
manifest: &DispatchManifest,
world: &World,
) -> DispatchDecision {
self.inner
.decide(elevator, position, group, manifest, world)
}
fn notify_removed(&mut self, elevator: EntityId) {
self.removed.lock().unwrap().push(elevator);
self.inner.notify_removed(elevator);
}
}
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let old_removed = Arc::new(Mutex::new(Vec::<EntityId>::new()));
sim.dispatchers_mut().insert(
GroupId(0),
Box::new(TrackingDispatch {
removed: old_removed.clone(),
inner: ScanDispatch::new(),
}),
);
let low_line = sim.lines_in_group(GroupId(0))[0];
let high_line = sim.lines_in_group(GroupId(1))[0];
let low_elevator = sim.elevators_on_line(low_line)[0];
sim.reassign_elevator_to_line(low_elevator, high_line)
.unwrap();
let saw_removal = old_removed.lock().unwrap().contains(&low_elevator);
assert!(
saw_removal,
"old group's dispatcher should receive notify_removed for cross-group reassignment"
);
}
#[test]
fn reassign_elevator_to_line_moves_elevator() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let low_line = sim.lines_in_group(GroupId(0))[0];
let high_line = sim.lines_in_group(GroupId(1))[0];
let low_elevator = sim.elevators_on_line(low_line)[0];
assert_eq!(sim.world().elevator(low_elevator).unwrap().line(), low_line);
assert_eq!(sim.elevators_on_line(low_line).len(), 1);
assert_eq!(sim.elevators_on_line(high_line).len(), 1);
sim.reassign_elevator_to_line(low_elevator, high_line)
.unwrap();
assert_eq!(
sim.world().elevator(low_elevator).unwrap().line(),
high_line,
"elevator line field should point to the new line"
);
assert_eq!(
sim.elevators_on_line(low_line).len(),
0,
"low-rise line should have no elevators after reassignment"
);
assert_eq!(
sim.elevators_on_line(high_line).len(),
2,
"high-rise line should have 2 elevators after reassignment"
);
}
#[test]
fn reassign_elevator_to_line_at_max_cars_returns_error() {
let config = SimConfig {
building: BuildingConfig {
name: "Cap Test".into(),
stops: vec![
StopConfig {
id: StopId(0),
name: "Ground".into(),
position: 0.0,
},
StopConfig {
id: StopId(1),
name: "Transfer".into(),
position: 10.0,
},
StopConfig {
id: StopId(2),
name: "Top".into(),
position: 20.0,
},
],
lines: Some(vec![
LineConfig {
id: 1,
name: "Low".into(),
serves: vec![StopId(0), StopId(1)],
elevators: vec![ElevatorConfig {
id: 1,
name: "L1".into(),
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(0),
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,
}],
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![ElevatorConfig {
id: 2,
name: "H1".into(),
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(1),
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,
}],
orientation: Orientation::Vertical,
position: None,
min_position: None,
max_position: None,
max_cars: Some(1), },
]),
groups: Some(vec![
GroupConfig {
id: 0,
name: "Low Rise".into(),
lines: vec![1],
dispatch: crate::dispatch::BuiltinStrategy::Scan,
reposition: None,
},
GroupConfig {
id: 1,
name: "High Rise".into(),
lines: vec![2],
dispatch: crate::dispatch::BuiltinStrategy::Scan,
reposition: None,
},
]),
},
elevators: vec![],
simulation: SimulationParams {
ticks_per_second: 60.0,
},
passenger_spawning: PassengerSpawnConfig {
mean_interval_ticks: 120,
weight_range: (50.0, 100.0),
},
};
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let low_line = sim.lines_in_group(GroupId(0))[0];
let high_line = sim.lines_in_group(GroupId(1))[0];
let low_elevator = sim.elevators_on_line(low_line)[0];
let result = sim.reassign_elevator_to_line(low_elevator, high_line);
assert!(
matches!(
result,
Err(SimError::InvalidConfig {
field: "line.max_cars",
..
})
),
"expected InvalidConfig(max_cars), got {result:?}"
);
}
#[test]
fn reassign_elevator_emits_elevator_reassigned_event() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let low_line = sim.lines_in_group(GroupId(0))[0];
let high_line = sim.lines_in_group(GroupId(1))[0];
let low_elevator = sim.elevators_on_line(low_line)[0];
sim.reassign_elevator_to_line(low_elevator, high_line)
.unwrap();
let events = sim.drain_events();
let reassigned = events.iter().find(|e| {
matches!(
e,
crate::events::Event::ElevatorReassigned {
elevator,
old_line,
new_line,
..
} if *elevator == low_elevator && *old_line == low_line && *new_line == high_line
)
});
assert!(
reassigned.is_some(),
"ElevatorReassigned event should be emitted with correct old/new line"
);
}
#[test]
fn max_cars_exactly_met_at_config_time_succeeds() {
let config = SimConfig {
building: BuildingConfig {
name: "Capped".into(),
stops: vec![
StopConfig {
id: StopId(0),
name: "G".into(),
position: 0.0,
},
StopConfig {
id: StopId(1),
name: "T".into(),
position: 10.0,
},
],
lines: Some(vec![LineConfig {
id: 1,
name: "Main".into(),
serves: vec![StopId(0), StopId(1)],
elevators: vec![
ElevatorConfig {
id: 1,
name: "E1".into(),
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(0),
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,
},
ElevatorConfig {
id: 2,
name: "E2".into(),
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(0),
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,
},
],
orientation: Orientation::Vertical,
position: None,
min_position: None,
max_position: None,
max_cars: Some(2),
}]),
groups: None,
},
elevators: vec![],
simulation: SimulationParams {
ticks_per_second: 60.0,
},
passenger_spawning: PassengerSpawnConfig {
mean_interval_ticks: 120,
weight_range: (50.0, 100.0),
},
};
let result = Simulation::new(&config, ScanDispatch::new());
assert!(
result.is_ok(),
"max_cars: Some(2) with exactly 2 elevators should succeed"
);
}
#[test]
fn max_cars_exceeded_at_config_time_fails_validation() {
let config = SimConfig {
building: BuildingConfig {
name: "Over Cap".into(),
stops: vec![
StopConfig {
id: StopId(0),
name: "G".into(),
position: 0.0,
},
StopConfig {
id: StopId(1),
name: "T".into(),
position: 10.0,
},
],
lines: Some(vec![LineConfig {
id: 1,
name: "Main".into(),
serves: vec![StopId(0), StopId(1)],
elevators: vec![
ElevatorConfig {
id: 1,
name: "E1".into(),
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(0),
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,
},
ElevatorConfig {
id: 2,
name: "E2".into(),
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(0),
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,
},
],
orientation: Orientation::Vertical,
position: None,
min_position: None,
max_position: None,
max_cars: Some(1),
}]),
groups: None,
},
elevators: vec![],
simulation: SimulationParams {
ticks_per_second: 60.0,
},
passenger_spawning: PassengerSpawnConfig {
mean_interval_ticks: 120,
weight_range: (50.0, 100.0),
},
};
let result = Simulation::new(&config, ScanDispatch::new());
assert!(
matches!(
result,
Err(SimError::InvalidConfig {
field: "building.lines.max_cars",
..
})
),
"expected InvalidConfig(max_cars), got {result:?}"
);
}
#[test]
fn runtime_add_elevator_to_line_at_max_cars_returns_error() {
let config = SimConfig {
building: BuildingConfig {
name: "Runtime Cap".into(),
stops: vec![
StopConfig {
id: StopId(0),
name: "G".into(),
position: 0.0,
},
StopConfig {
id: StopId(1),
name: "T".into(),
position: 10.0,
},
],
lines: Some(vec![LineConfig {
id: 1,
name: "Main".into(),
serves: vec![StopId(0), StopId(1)],
elevators: vec![ElevatorConfig {
id: 1,
name: "E1".into(),
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(0),
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,
}],
orientation: Orientation::Vertical,
position: None,
min_position: None,
max_position: None,
max_cars: Some(1),
}]),
groups: None,
},
elevators: vec![],
simulation: SimulationParams {
ticks_per_second: 60.0,
},
passenger_spawning: PassengerSpawnConfig {
mean_interval_ticks: 120,
weight_range: (50.0, 100.0),
},
};
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let line = sim.lines_in_group(GroupId(0))[0];
let result = sim.add_elevator(&crate::sim::ElevatorParams::default(), line, 0.0);
assert!(
matches!(
result,
Err(SimError::InvalidConfig {
field: "line.max_cars",
..
})
),
"expected InvalidConfig(max_cars) when adding elevator to full line, got {result:?}"
);
}
fn three_group_config() -> SimConfig {
SimConfig {
building: BuildingConfig {
name: "Three-Group Tower".into(),
stops: vec![
StopConfig {
id: StopId(0),
name: "A".into(),
position: 0.0,
},
StopConfig {
id: StopId(1),
name: "B".into(),
position: 10.0,
},
StopConfig {
id: StopId(2),
name: "C".into(),
position: 20.0,
},
StopConfig {
id: StopId(3),
name: "D".into(),
position: 30.0,
},
],
lines: Some(vec![
LineConfig {
id: 1,
name: "AB".into(),
serves: vec![StopId(0), StopId(1)],
elevators: vec![ElevatorConfig {
id: 1,
name: "E1".into(),
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(0),
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,
}],
orientation: Orientation::Vertical,
position: None,
min_position: None,
max_position: None,
max_cars: None,
},
LineConfig {
id: 2,
name: "BC".into(),
serves: vec![StopId(1), StopId(2)],
elevators: vec![ElevatorConfig {
id: 2,
name: "E2".into(),
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(1),
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,
}],
orientation: Orientation::Vertical,
position: None,
min_position: None,
max_position: None,
max_cars: None,
},
LineConfig {
id: 3,
name: "CD".into(),
serves: vec![StopId(2), StopId(3)],
elevators: vec![ElevatorConfig {
id: 3,
name: "E3".into(),
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(2),
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,
}],
orientation: Orientation::Vertical,
position: None,
min_position: None,
max_position: None,
max_cars: None,
},
]),
groups: Some(vec![
GroupConfig {
id: 0,
name: "Group AB".into(),
lines: vec![1],
dispatch: crate::dispatch::BuiltinStrategy::Scan,
reposition: None,
},
GroupConfig {
id: 1,
name: "Group BC".into(),
lines: vec![2],
dispatch: crate::dispatch::BuiltinStrategy::Scan,
reposition: None,
},
GroupConfig {
id: 2,
name: "Group CD".into(),
lines: vec![3],
dispatch: crate::dispatch::BuiltinStrategy::Scan,
reposition: None,
},
]),
},
elevators: vec![],
simulation: SimulationParams {
ticks_per_second: 60.0,
},
passenger_spawning: PassengerSpawnConfig {
mean_interval_ticks: 120,
weight_range: (50.0, 100.0),
},
}
}
#[test]
fn three_group_rider_navigates_all_legs() {
let config = three_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let a = sim.stop_entity(StopId(0)).unwrap();
let b = sim.stop_entity(StopId(1)).unwrap();
let c = sim.stop_entity(StopId(2)).unwrap();
let d = sim.stop_entity(StopId(3)).unwrap();
let route = Route {
legs: vec![
RouteLeg {
from: a,
to: b,
via: TransportMode::Group(GroupId(0)),
},
RouteLeg {
from: b,
to: c,
via: TransportMode::Group(GroupId(1)),
},
RouteLeg {
from: c,
to: d,
via: TransportMode::Group(GroupId(2)),
},
],
current_leg: 0,
};
let rider = sim.spawn_rider_with_route(a, d, 70.0, route).unwrap();
for _ in 0..10_000 {
sim.step();
if sim
.world()
.rider(rider)
.is_some_and(|r| r.phase == RiderPhase::Arrived)
{
break;
}
}
assert_eq!(
sim.world().rider(rider).unwrap().phase,
RiderPhase::Arrived,
"rider should arrive at D via all three groups"
);
}
#[test]
fn shortest_route_across_three_groups_has_three_legs() {
let config = three_group_config();
let sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let a = sim.stop_entity(StopId(0)).unwrap();
let d = sim.stop_entity(StopId(3)).unwrap();
let route = sim.shortest_route(a, d);
assert!(route.is_some(), "route A→D should exist via 3 groups");
let route = route.unwrap();
assert_eq!(
route.legs.len(),
3,
"3-group route should have exactly 3 legs"
);
assert_eq!(route.legs[0].from, a);
assert_eq!(route.legs[2].to, d);
}
#[test]
fn reachable_stops_from_traverses_full_three_group_graph() {
let config = three_group_config();
let sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let a = sim.stop_entity(StopId(0)).unwrap();
let b = sim.stop_entity(StopId(1)).unwrap();
let c = sim.stop_entity(StopId(2)).unwrap();
let d = sim.stop_entity(StopId(3)).unwrap();
let reachable = sim.reachable_stops_from(a);
assert!(reachable.contains(&b), "A should reach B");
assert!(reachable.contains(&c), "A should reach C via transfer at B");
assert!(
reachable.contains(&d),
"A should reach D via transfers at B and C"
);
}
#[test]
fn despawn_waiting_rider_removes_from_world() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let ground = sim.stop_entity(StopId(0)).unwrap();
let transfer = sim.stop_entity(StopId(1)).unwrap();
let rider = sim.spawn_rider(ground, transfer, 70.0).unwrap();
sim.despawn_rider(rider).unwrap();
assert!(
!sim.world().is_alive(rider),
"despawned waiting rider should no longer be alive"
);
assert!(
sim.world().rider(rider).is_none(),
"despawned waiting rider should have no rider component"
);
}
#[test]
fn despawn_riding_rider_removes_from_elevator_riders_list() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let ground = sim.stop_entity(StopId(0)).unwrap();
let transfer = sim.stop_entity(StopId(1)).unwrap();
let rider = sim.spawn_rider(ground, transfer, 70.0).unwrap();
let mut elevator_id = None;
for _ in 0..3000 {
sim.step();
if let Some(r) = sim.world().rider(rider)
&& let RiderPhase::Riding(e) = r.phase
{
elevator_id = Some(e);
break;
}
}
let elev = elevator_id.expect("rider should board within 3000 ticks");
let load_before = sim.world().elevator(elev).unwrap().current_load;
assert!(
load_before > 0.0,
"elevator should have load while carrying rider"
);
sim.despawn_rider(rider).unwrap();
assert!(
!sim.world().is_alive(rider),
"despawned riding rider should not be alive"
);
let car = sim.world().elevator(elev).unwrap();
assert!(
!car.riders.contains(&rider),
"elevator riders list should not contain despawned rider"
);
assert!(
car.current_load < load_before,
"elevator load should decrease after rider despawn"
);
}
#[test]
fn despawn_nonexistent_entity_does_not_panic() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let fake_id = crate::entity::EntityId::default();
sim.world_mut().despawn(fake_id);
}
#[test]
fn spawn_rider_in_group_succeeds_when_group_serves_stops() {
let config = overlapping_groups_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let bottom = sim.stop_entity(StopId(0)).unwrap();
let top = sim.stop_entity(StopId(1)).unwrap();
let result = sim.spawn_rider_in_group(bottom, top, 70.0, GroupId(0));
assert!(
result.is_ok(),
"spawn_rider_in_group should succeed for a valid group"
);
let rider = result.unwrap();
let r = sim.world().rider(rider).unwrap();
assert_eq!(r.phase, RiderPhase::Waiting);
}
#[test]
fn spawn_rider_in_nonexistent_group_returns_group_not_found() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let ground = sim.stop_entity(StopId(0)).unwrap();
let transfer = sim.stop_entity(StopId(1)).unwrap();
let result = sim.spawn_rider_in_group(ground, transfer, 70.0, GroupId(99));
assert!(
matches!(result, Err(SimError::GroupNotFound(GroupId(99)))),
"expected GroupNotFound(99), got {result:?}"
);
}
#[test]
fn snapshot_roundtrip_preserves_multi_group_topology() {
let config = two_group_config();
let sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let snap = sim.snapshot();
let restored = snap.restore(None);
assert_eq!(
restored.groups().len(),
2,
"restored sim should have 2 groups"
);
let g0 = restored
.groups()
.iter()
.find(|g| g.id() == GroupId(0))
.expect("GroupId(0) should exist after restore");
let g1 = restored
.groups()
.iter()
.find(|g| g.id() == GroupId(1))
.expect("GroupId(1) should exist after restore");
assert_eq!(g0.lines().len(), 1, "Group 0 should have 1 line");
assert_eq!(g1.lines().len(), 1, "Group 1 should have 1 line");
assert_eq!(g0.elevator_entities().len(), 1);
assert_eq!(g1.elevator_entities().len(), 1);
}
#[test]
fn snapshot_roundtrip_elevator_line_reference_is_valid() {
let config = two_group_config();
let sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let orig_g0_elev = sim.elevators_on_line(sim.lines_in_group(GroupId(0))[0])[0];
let orig_g1_elev = sim.elevators_on_line(sim.lines_in_group(GroupId(1))[0])[0];
let orig_g0_line = sim.world().elevator(orig_g0_elev).unwrap().line();
let orig_g1_line = sim.world().elevator(orig_g1_elev).unwrap().line();
let snap = sim.snapshot();
let restored = snap.restore(None);
let r_g0_elev = restored.elevators_on_line(restored.lines_in_group(GroupId(0))[0])[0];
let r_g1_elev = restored.elevators_on_line(restored.lines_in_group(GroupId(1))[0])[0];
let r_g0_line = restored.world().elevator(r_g0_elev).unwrap().line();
let r_g1_line = restored.world().elevator(r_g1_elev).unwrap().line();
assert!(
restored.world().line(r_g0_line).is_some(),
"restored Group 0 elevator's line should exist in world"
);
assert!(
restored.world().line(r_g1_line).is_some(),
"restored Group 1 elevator's line should exist in world"
);
assert_ne!(
r_g0_line, r_g1_line,
"restored elevators should reference different lines"
);
assert_ne!(orig_g0_line, orig_g1_line);
}
#[test]
fn snapshot_roundtrip_transport_mode_group_serde_alias() {
let route = Route {
legs: vec![RouteLeg {
from: crate::entity::EntityId::default(),
to: crate::entity::EntityId::default(),
via: TransportMode::Group(GroupId(5)),
}],
current_leg: 0,
};
let ron_str = ron::to_string(&route).expect("route should serialize to RON");
assert!(
ron_str.contains("Group"),
"serialized route should contain 'Group', got: {ron_str}"
);
let restored: Route = ron::from_str(&ron_str).expect("route should deserialize from RON");
assert_eq!(
restored.legs[0].via,
TransportMode::Group(GroupId(5)),
"round-tripped route leg should have Group(5)"
);
}
#[test]
fn passing_floor_event_moving_up_is_true_when_ascending() {
use crate::events::Event;
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let ground = sim.stop_entity(StopId(0)).unwrap();
let transfer = sim.stop_entity(StopId(1)).unwrap();
sim.spawn_rider(ground, transfer, 70.0).unwrap();
drop(sim);
let config3 = three_group_config();
let mut sim3 = Simulation::new(&config3, ScanDispatch::new()).unwrap();
let a = sim3.stop_entity(StopId(0)).unwrap();
let b = sim3.stop_entity(StopId(1)).unwrap();
sim3.spawn_rider(a, b, 70.0).unwrap();
let mut passing_up: Vec<bool> = Vec::new();
for _ in 0..2000 {
sim3.step();
let events = sim3.drain_events();
for e in events {
if let Event::PassingFloor { moving_up, .. } = e {
passing_up.push(moving_up);
}
}
if !passing_up.is_empty() {
break;
}
}
for up in &passing_up {
assert!(
up,
"PassingFloor while ascending should have moving_up = true"
);
}
}
#[test]
fn passing_floor_event_moving_up_is_false_when_descending() {
use crate::events::Event;
let config = three_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let a = sim.stop_entity(StopId(0)).unwrap();
let b = sim.stop_entity(StopId(1)).unwrap();
sim.spawn_rider(b, a, 70.0).unwrap();
let mut passing_events: Vec<bool> = Vec::new();
for _ in 0..3000 {
sim.step();
let events = sim.drain_events();
for e in events {
if let Event::PassingFloor { moving_up, .. } = e {
passing_events.push(moving_up);
}
}
}
for up in &passing_events {
assert!(
!up,
"PassingFloor while descending should have moving_up = false"
);
}
}
#[test]
fn orphan_line_not_referenced_by_any_group_fails_validation() {
let config = SimConfig {
building: BuildingConfig {
name: "Orphan Line".into(),
stops: vec![
StopConfig {
id: StopId(0),
name: "G".into(),
position: 0.0,
},
StopConfig {
id: StopId(1),
name: "T".into(),
position: 10.0,
},
],
lines: Some(vec![
LineConfig {
id: 1,
name: "Main".into(),
serves: vec![StopId(0), StopId(1)],
elevators: vec![ElevatorConfig {
id: 1,
name: "E1".into(),
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(0),
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,
}],
orientation: Orientation::Vertical,
position: None,
min_position: None,
max_position: None,
max_cars: None,
},
LineConfig {
id: 2,
name: "Orphan".into(),
serves: vec![StopId(0), StopId(1)],
elevators: vec![ElevatorConfig {
id: 2,
name: "E2".into(),
max_speed: 2.0,
acceleration: 1.5,
deceleration: 2.0,
weight_capacity: 800.0,
starting_stop: StopId(0),
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,
}],
orientation: Orientation::Vertical,
position: None,
min_position: None,
max_position: None,
max_cars: None,
},
]),
groups: Some(vec![GroupConfig {
id: 0,
name: "G0".into(),
lines: vec![1],
dispatch: crate::dispatch::BuiltinStrategy::Scan,
reposition: None,
}]),
},
elevators: vec![],
simulation: SimulationParams {
ticks_per_second: 60.0,
},
passenger_spawning: PassengerSpawnConfig {
mean_interval_ticks: 120,
weight_range: (50.0, 100.0),
},
};
let result = Simulation::new(&config, ScanDispatch::new());
assert!(
matches!(
result,
Err(SimError::InvalidConfig {
field: "building.lines",
..
})
),
"expected InvalidConfig for orphan line, got {result:?}"
);
}
#[test]
fn dispatch_ignores_waiting_rider_targeting_another_group() {
let config = overlapping_groups_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let bottom = sim.stop_entity(StopId(0)).unwrap();
let top = sim.stop_entity(StopId(1)).unwrap();
let group_a_elevator = sim
.groups()
.iter()
.find(|g| g.id() == GroupId(0))
.unwrap()
.elevator_entities()[0];
let route = Route {
legs: vec![RouteLeg {
from: bottom,
to: top,
via: TransportMode::Group(GroupId(1)),
}],
current_leg: 0,
};
let rider = sim
.spawn_rider_with_route(bottom, top, 70.0, route)
.unwrap();
assert_eq!(sim.world().rider(rider).unwrap().phase, RiderPhase::Waiting);
let mut saw_door_opening = false;
for _ in 0..100 {
sim.step();
let phase = sim.world().elevator(group_a_elevator).unwrap().phase;
if matches!(
phase,
ElevatorPhase::DoorOpening | ElevatorPhase::Loading | ElevatorPhase::DoorClosing
) {
saw_door_opening = true;
break;
}
}
assert!(
!saw_door_opening,
"Group A elevator must not open/close doors when only a Group B rider is waiting"
);
}
#[test]
fn car_with_opposite_indicator_eventually_boards_waiting_rider() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let ground = sim.stop_entity(StopId(0)).unwrap();
let transfer = sim.stop_entity(StopId(1)).unwrap();
let g0_elev = sim
.groups()
.iter()
.find(|g| g.id() == GroupId(0))
.unwrap()
.elevator_entities()[0];
let route = Route {
legs: vec![RouteLeg {
from: transfer,
to: ground,
via: TransportMode::Group(GroupId(0)),
}],
current_leg: 0,
};
let rider = sim
.spawn_rider_with_route(transfer, ground, 70.0, route)
.unwrap();
let mut events: Vec<SimEvent> = Vec::new();
let mut boarded = false;
for _ in 0..3000 {
sim.step();
events.extend(sim.drain_events());
if let Some(r) = sim.world().rider(rider)
&& matches!(
r.phase,
RiderPhase::Boarding(_) | RiderPhase::Riding(_) | RiderPhase::Arrived
)
{
boarded = true;
break;
}
}
assert!(
boarded,
"rider going the opposite direction of the car's indicator should still board \
(car entity {g0_elev:?})"
);
let mut door_closes_before_board = 0;
for e in &events {
match e {
SimEvent::DoorClosed { elevator, .. } if *elevator == g0_elev => {
door_closes_before_board += 1;
}
SimEvent::RiderBoarded { rider: r, .. } if *r == rider => break,
_ => {}
}
}
assert_eq!(
door_closes_before_board, 0,
"direction-filtered rider should board during the car's first Loading \
session — a non-zero count means doors cycled shut with the rider \
still waiting (Bug A)"
);
}
#[test]
fn dispatch_arrive_in_place_sets_target_and_pops_queue() {
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let ground = sim.stop_entity(StopId(0)).unwrap();
let elev = sim
.groups()
.iter()
.find(|g| g.id() == GroupId(0))
.unwrap()
.elevator_entities()[0];
sim.push_destination(elev, ground).unwrap();
assert_eq!(sim.destination_queue(elev).unwrap().len(), 1);
let transfer = sim.stop_entity(StopId(1)).unwrap();
let route = Route {
legs: vec![RouteLeg {
from: ground,
to: transfer,
via: TransportMode::Group(GroupId(0)),
}],
current_leg: 0,
};
let _rider = sim
.spawn_rider_with_route(ground, transfer, 70.0, route)
.unwrap();
sim.step();
let car = sim.world().elevator(elev).unwrap();
assert_eq!(
car.target_stop(),
Some(ground),
"arrive-in-place dispatch must set target_stop to the assigned stop"
);
assert!(
!sim.destination_queue(elev).unwrap().contains(&ground),
"arrive-in-place dispatch must pop the matching queue front"
);
}
#[test]
fn advance_queue_arrive_in_place_resets_direction_indicators() {
use crate::components::ServiceMode;
let config = two_group_config();
let mut sim = Simulation::new(&config, ScanDispatch::new()).unwrap();
let ground = sim.stop_entity(StopId(0)).unwrap();
let elev = sim
.groups()
.iter()
.find(|g| g.id() == GroupId(0))
.unwrap()
.elevator_entities()[0];
sim.set_service_mode(elev, ServiceMode::Independent)
.unwrap();
{
let car = sim.world_mut().elevator_mut(elev).unwrap();
car.going_up = false;
car.going_down = true;
}
sim.push_destination_front(elev, ground).unwrap();
sim.step();
let car = sim.world().elevator(elev).unwrap();
assert!(
car.going_up() && car.going_down(),
"advance_queue arrive-in-place must re-light both indicator lamps \
(got going_up={}, going_down={})",
car.going_up(),
car.going_down()
);
assert_eq!(
car.target_stop(),
Some(ground),
"advance_queue arrive-in-place must set target_stop, mirroring \
dispatch.rs's arrive-in-place semantics"
);
}