elevator-core 15.26.0

Engine-agnostic elevator simulation library with pluggable dispatch strategies
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
use std::collections::HashSet;

use crate::components::AccessControl;
use crate::error::RejectionReason;
use crate::events::Event;
use crate::stop::StopId;

use super::helpers;

/// Rider rejected when elevator has `restricted_stops` containing the destination.
#[test]
fn rider_rejected_by_elevator_restriction() {
    let mut config = helpers::default_config();
    // Restrict elevator from serving Floor 3 (StopId(2)).
    config.elevators[0].restricted_stops = vec![StopId(2)];

    let mut sim =
        crate::sim::Simulation::new(&config, helpers::scan()).expect("config should be valid");

    // Rider wants to go from Ground to Floor 3 (restricted).
    sim.spawn_rider(StopId(0), StopId(2), 70.0)
        .expect("spawn should succeed");

    let mut all_events = Vec::new();
    for _ in 0..500 {
        sim.step();
        all_events.extend(sim.drain_events());
    }

    let rejected = all_events.iter().any(|e| {
        matches!(
            e,
            Event::RiderRejected {
                reason: RejectionReason::AccessDenied,
                ..
            }
        )
    });
    assert!(rejected, "rider should be rejected with AccessDenied");

    // Rider should NOT have arrived since the only elevator is restricted.
    assert!(
        !helpers::all_riders_arrived(&sim),
        "rider should not arrive when the only elevator is restricted"
    );
}

/// Rider rejected when their `AccessControl` does not include the destination.
#[test]
fn rider_rejected_by_rider_access_control() {
    let config = helpers::default_config();
    let mut sim =
        crate::sim::Simulation::new(&config, helpers::scan()).expect("config should be valid");

    // Spawn rider to Floor 3.
    let rider = sim
        .spawn_rider(StopId(0), StopId(2), 70.0)
        .expect("spawn should succeed");

    // Set rider access to only allow Ground and Floor 2 — NOT Floor 3.
    let stop0 = sim.stop_entity(StopId(0)).expect("stop 0 exists");
    let stop1 = sim.stop_entity(StopId(1)).expect("stop 1 exists");
    sim.set_rider_access(rider.entity(), HashSet::from([stop0, stop1]))
        .expect("set_rider_access should succeed");

    let mut all_events = Vec::new();
    for _ in 0..500 {
        sim.step();
        all_events.extend(sim.drain_events());
    }

    let rejected = all_events.iter().any(|e| {
        matches!(
            e,
            Event::RiderRejected {
                reason: RejectionReason::AccessDenied,
                ..
            }
        )
    });
    assert!(
        rejected,
        "rider should be rejected with AccessDenied due to rider access control"
    );
}

/// Rider boards normally when no access restrictions exist.
#[test]
fn rider_boards_without_restrictions() {
    let config = helpers::default_config();
    let mut sim =
        crate::sim::Simulation::new(&config, helpers::scan()).expect("config should be valid");

    sim.spawn_rider(StopId(0), StopId(2), 70.0)
        .expect("spawn should succeed");

    for _ in 0..2000 {
        sim.step();
        if helpers::all_riders_arrived(&sim) {
            break;
        }
    }

    assert!(
        helpers::all_riders_arrived(&sim),
        "rider should arrive when no restrictions"
    );
}

/// Rider with `AccessControl` listing the destination boards normally.
#[test]
fn rider_boards_when_destination_in_allowed_stops() {
    let config = helpers::default_config();
    let mut sim =
        crate::sim::Simulation::new(&config, helpers::scan()).expect("config should be valid");

    let rider = sim
        .spawn_rider(StopId(0), StopId(2), 70.0)
        .expect("spawn should succeed");

    // Allow all three stops.
    let stop0 = sim.stop_entity(StopId(0)).expect("stop 0 exists");
    let stop1 = sim.stop_entity(StopId(1)).expect("stop 1 exists");
    let stop2 = sim.stop_entity(StopId(2)).expect("stop 2 exists");
    sim.set_rider_access(rider.entity(), HashSet::from([stop0, stop1, stop2]))
        .expect("set_rider_access should succeed");

    for _ in 0..2000 {
        sim.step();
        if helpers::all_riders_arrived(&sim) {
            break;
        }
    }

    assert!(
        helpers::all_riders_arrived(&sim),
        "rider should arrive when destination is in allowed_stops"
    );
}

/// Elevator restriction does not affect riders going to unrestricted stops.
#[test]
fn restriction_does_not_affect_unrestricted_destinations() {
    let mut config = helpers::default_config();
    // Restrict Floor 3 only.
    config.elevators[0].restricted_stops = vec![StopId(2)];

    let mut sim =
        crate::sim::Simulation::new(&config, helpers::scan()).expect("config should be valid");

    // Rider going to Floor 2 (not restricted).
    sim.spawn_rider(StopId(0), StopId(1), 70.0)
        .expect("spawn should succeed");

    for _ in 0..2000 {
        sim.step();
        if helpers::all_riders_arrived(&sim) {
            break;
        }
    }

    assert!(
        helpers::all_riders_arrived(&sim),
        "rider to unrestricted stop should arrive"
    );
}

/// Elevator restriction and rider access control work independently in the same sim.
///
/// Tests each restriction type in isolation (one rider per restriction) to avoid
/// the loading system's single-rejection-slot-per-tick behavior masking failures.
#[test]
fn both_restriction_types_work_in_same_sim() {
    let mut config = helpers::default_config();
    // Elevator restricts Floor 2.
    config.elevators[0].restricted_stops = vec![StopId(1)];

    let mut sim =
        crate::sim::Simulation::new(&config, helpers::scan()).expect("config should be valid");

    // Rider 1: going to Floor 2 (elevator-restricted). Test alone first.
    let rider1 = sim
        .spawn_rider(StopId(0), StopId(1), 70.0)
        .expect("spawn should succeed");

    let mut events_phase1 = Vec::new();
    for _ in 0..200 {
        sim.step();
        events_phase1.extend(sim.drain_events());
    }

    let rider1_rejected = events_phase1.iter().any(|e| {
        matches!(
            e,
            Event::RiderRejected {
                rider,
                reason: RejectionReason::AccessDenied,
                ..
            } if *rider == rider1.entity()
        )
    });
    assert!(
        rider1_rejected,
        "rider1 (elevator-restricted) should be rejected"
    );

    // Despawn rider1 so it doesn't monopolize the rejection slot.
    sim.despawn_rider(rider1).expect("despawn should succeed");

    // Rider 2: going to Floor 3 with access control that doesn't include Floor 3.
    let rider2 = sim
        .spawn_rider(StopId(0), StopId(2), 70.0)
        .expect("spawn should succeed");
    let stop0 = sim.stop_entity(StopId(0)).expect("stop 0 exists");
    let stop1 = sim.stop_entity(StopId(1)).expect("stop 1 exists");
    sim.set_rider_access(rider2.entity(), HashSet::from([stop0, stop1]))
        .expect("set_rider_access should succeed");

    let mut events_phase2 = Vec::new();
    for _ in 0..200 {
        sim.step();
        events_phase2.extend(sim.drain_events());
    }

    let rider2_rejected = events_phase2.iter().any(|e| {
        matches!(
            e,
            Event::RiderRejected {
                rider,
                reason: RejectionReason::AccessDenied,
                ..
            } if *rider == rider2.entity()
        )
    });
    assert!(
        rider2_rejected,
        "rider2 (access-control-restricted) should be rejected"
    );
}

/// `RiderRejected` event carries `AccessDenied` reason with `None` context.
#[test]
fn rejection_event_has_access_denied_reason() {
    let mut config = helpers::default_config();
    config.elevators[0].restricted_stops = vec![StopId(2)];

    let mut sim =
        crate::sim::Simulation::new(&config, helpers::scan()).expect("config should be valid");

    let rider = sim
        .spawn_rider(StopId(0), StopId(2), 70.0)
        .expect("spawn should succeed");

    let mut all_events = Vec::new();
    for _ in 0..500 {
        sim.step();
        all_events.extend(sim.drain_events());
    }

    let rejection = all_events.iter().find(|e| {
        matches!(
            e,
            Event::RiderRejected {
                reason: RejectionReason::AccessDenied,
                ..
            }
        )
    });

    assert!(rejection.is_some(), "should have AccessDenied rejection");
    if let Some(Event::RiderRejected {
        rider: rid,
        reason,
        context,
        ..
    }) = rejection
    {
        assert_eq!(*rid, rider.entity());
        assert_eq!(*reason, RejectionReason::AccessDenied);
        assert!(context.is_none(), "AccessDenied should have no context");
    }
}

/// Elevator is never dispatched (no `ElevatorAssigned` event) to a restricted stop,
/// even when it's the only stop with demand. Verifies the global guard in
/// `systems::dispatch::commit_go_to_stop`.
#[test]
fn elevator_not_dispatched_to_restricted_stop() {
    let mut config = helpers::default_config();
    config.elevators[0].restricted_stops = vec![StopId(2)];

    let mut sim =
        crate::sim::Simulation::new(&config, helpers::scan()).expect("config should be valid");

    sim.spawn_rider(StopId(0), StopId(2), 70.0)
        .expect("spawn should succeed");

    let mut all_events = Vec::new();
    for _ in 0..500 {
        sim.step();
        all_events.extend(sim.drain_events());
    }

    let stop2 = sim.stop_entity(StopId(2)).expect("stop 2 exists");
    let dispatched_to_restricted = all_events
        .iter()
        .any(|e| matches!(e, Event::ElevatorAssigned { stop, .. } if *stop == stop2));
    assert!(
        !dispatched_to_restricted,
        "elevator should never be assigned to a restricted stop"
    );
}

/// `AccessControl` component round-trips through serde.
#[test]
fn access_control_serde_roundtrip() {
    let stop_id = crate::entity::EntityId::default();
    let ac = AccessControl::new(HashSet::from([stop_id]));
    let serialized = ron::to_string(&ac).expect("serialize should succeed");
    let deserialized: AccessControl =
        ron::from_str(&serialized).expect("deserialize should succeed");
    assert!(deserialized.can_access(stop_id));
}

/// `ElevatorConfig` with `restricted_stops` round-trips through RON serde.
#[test]
fn config_restricted_stops_serde_roundtrip() {
    let mut config = helpers::default_config();
    config.elevators[0].restricted_stops = vec![StopId(1), StopId(2)];

    let serialized = ron::to_string(&config).expect("serialize should succeed");
    let deserialized: crate::config::SimConfig =
        ron::from_str(&serialized).expect("deserialize should succeed");
    assert_eq!(deserialized.elevators[0].restricted_stops.len(), 2);
    assert!(
        deserialized.elevators[0]
            .restricted_stops
            .contains(&StopId(1))
    );
    assert!(
        deserialized.elevators[0]
            .restricted_stops
            .contains(&StopId(2))
    );
}

/// `AccessControl::default()` (empty `allowed_stops`) means unrestricted —
/// every `can_access` query returns true. Pre-fix an empty set
/// silently blocked all access, so `set_rider_access(rider,
/// AccessControl::default())` bricked the rider with no warning. (#289)
#[test]
fn access_control_empty_allows_any_stop() {
    use crate::entity::EntityId;

    let ac = AccessControl::default();
    assert!(ac.allowed_stops().is_empty());
    // Any stop entity should be reachable. EntityId::default() is a
    // safe stand-in here because `can_access` doesn't dereference the
    // id, just predicates on set membership.
    assert!(ac.can_access(EntityId::default()));
}

/// Non-empty `allowed_stops` is an allowlist: only listed stops pass.
#[test]
fn access_control_non_empty_is_an_allowlist() {
    use crate::entity::EntityId;

    let allowed = EntityId::default();
    let mut set = HashSet::new();
    set.insert(allowed);
    let ac = AccessControl::new(set);

    assert!(ac.can_access(allowed));
    // Negative case (a different EntityId rejected) is covered by the
    // existing `rider_rejected_by_rider_access_control` integration test.
}

// ── Simulation::run_until_quiet ────────────────────────────────────

/// Returns 0 immediately when the sim has no riders — a fresh sim
/// or one whose riders have all reached a terminal phase is already
/// "quiet" and shouldn't burn ticks waiting.
#[test]
fn run_until_quiet_returns_zero_for_empty_sim() {
    use crate::tests::helpers;

    let mut sim = crate::sim::Simulation::new(&helpers::default_config(), helpers::scan()).unwrap();
    let ticks = sim
        .run_until_quiet(1_000)
        .expect("empty sim is already quiet");
    assert_eq!(ticks, 0);
    assert_eq!(sim.current_tick(), 0);
}

/// Delivers a normal rider and reports how many ticks it took —
/// less than the budget, and positive.
#[test]
fn run_until_quiet_delivers_rider_within_budget() {
    use crate::stop::StopId;
    use crate::tests::helpers;

    let mut sim = crate::sim::Simulation::new(&helpers::default_config(), helpers::scan()).unwrap();
    sim.spawn_rider(StopId(0), StopId(2), 70.0).unwrap();

    let ticks = sim.run_until_quiet(5_000).expect("rider must deliver");
    assert!(ticks > 0, "delivery took zero ticks?");
    assert!(ticks <= 5_000);
    assert_eq!(sim.metrics().total_delivered(), 1);
}

/// Returns `Err(max_ticks)` when the budget is exhausted before every
/// rider reaches a terminal phase — guards against silent infinite
/// loops when a dispatch stalls. The sim is left in its advanced
/// state so the caller can snapshot it for diagnosis.
#[test]
fn run_until_quiet_returns_err_on_budget_exhaustion() {
    use crate::stop::StopId;
    use crate::tests::helpers;

    let mut sim = crate::sim::Simulation::new(&helpers::default_config(), helpers::scan()).unwrap();
    sim.spawn_rider(StopId(0), StopId(2), 70.0).unwrap();

    // Two ticks isn't enough to cover a three-stop delivery.
    let err = sim.run_until_quiet(2).unwrap_err();
    assert_eq!(err, 2, "error should carry the exhausted budget");
    assert_eq!(
        sim.current_tick(),
        2,
        "sim state must reflect the steps taken"
    );
}

// ── Simulation::set_dispatch identity resolution ──────────────────

/// `set_dispatch` now consults the strategy's own `builtin_id()` to
/// fill in the snapshot identity, so a caller passing a mismatched
/// `id` argument (or a stale config value) can't silently record
/// the wrong type. The strategy's runtime type always wins for
/// built-ins — mirror of the fix #414 applied to `set_reposition`.
#[test]
fn set_dispatch_prefers_strategy_builtin_id_over_arg() {
    use crate::dispatch::BuiltinStrategy;
    use crate::dispatch::look::LookDispatch;
    use crate::ids::GroupId;
    use crate::sim::Simulation;
    use crate::tests::helpers;

    let mut sim = Simulation::new(&helpers::default_config(), helpers::scan()).unwrap();
    // Caller claims Destination while actually passing LookDispatch.
    // Pre-fix this silently recorded Destination as the snapshot id
    // AND flipped the group's HallCallMode to Destination — the DCS
    // path would then try to service a non-DCS strategy.
    sim.set_dispatch(
        GroupId(0),
        Box::new(LookDispatch::new()),
        BuiltinStrategy::Destination,
    );
    assert_eq!(
        sim.strategy_id(GroupId(0)),
        Some(&BuiltinStrategy::Look),
        "strategy.builtin_id() must override the caller-supplied id \
         when the strategy identifies as a known built-in",
    );
    // The HallCallMode sync keys off the resolved id — with the fix
    // it stays Classic because Look (not Destination) is the real id.
    assert_eq!(
        sim.groups()[0].hall_call_mode(),
        crate::dispatch::HallCallMode::Classic,
        "HallCallMode must follow the resolved built-in, not the \
         stale caller-supplied id",
    );
}