| use super::availability::add_extra_unavailability; |
| use super::cohorts::assign_primary_off_days; |
| use super::coverage::public_candidate_counts; |
| use super::demand::DEMAND_RULES; |
| use super::employees::{build_employee_blueprints, instantiate_employees}; |
| use super::preferences::{ |
| add_preferences, eligible_employees_by_shift, shift_soft_preference_score, |
| }; |
| use super::shifts::{build_public_shifts, prepare_shifts}; |
| use super::time_utils::find_next_monday; |
| use super::vocabulary::*; |
| use super::witness::build_hidden_witness; |
| use super::*; |
| use chrono::{Datelike, Duration, NaiveDate, Timelike}; |
| use rand::rngs::StdRng; |
| use rand::SeedableRng; |
| use solverforge::ConstraintSet; |
| use std::collections::{BTreeMap, BTreeSet}; |
|
|
| use crate::domain::Plan; |
|
|
| |
| |
|
|
| fn schedule() -> Plan { |
| generate(DemoData::Large) |
| } |
|
|
| #[test] |
| fn test_generate_large() { |
| let schedule = schedule(); |
|
|
| assert_eq!(schedule.employees.len(), 50); |
| assert_eq!(schedule.shifts.len(), 688); |
| } |
|
|
| #[test] |
| fn test_exact_workforce_composition() { |
| let schedule = schedule(); |
| let employees = &schedule.employees; |
|
|
| let doctors = employees |
| .iter() |
| .filter(|employee| employee.skills.contains(DOCTOR)) |
| .count(); |
| let nurses = employees |
| .iter() |
| .filter(|employee| employee.skills.contains(NURSE)) |
| .count(); |
| let cardiology = employees |
| .iter() |
| .filter(|employee| employee.skills.contains(CARDIOLOGY)) |
| .count(); |
| let anaesthetics = employees |
| .iter() |
| .filter(|employee| employee.skills.contains(ANAESTHETICS)) |
| .count(); |
| let radiology_day = employees |
| .iter() |
| .filter(|employee| employee.skills.contains(RADIOLOGY_DAY)) |
| .count(); |
| let radiology_nurse = employees |
| .iter() |
| .filter(|employee| employee.skills.contains(RADIOLOGY_NURSE)) |
| .count(); |
| let radiology_call = employees |
| .iter() |
| .filter(|employee| employee.skills.contains(RADIOLOGY_CALL)) |
| .count(); |
| let ambulatory_doctors = employees |
| .iter() |
| .filter(|employee| employee.skills.contains(AMBULATORY_DOCTOR)) |
| .count(); |
| let ambulatory_nurses = employees |
| .iter() |
| .filter(|employee| employee.skills.contains(AMBULATORY_NURSE)) |
| .count(); |
| let critical_doctors = employees |
| .iter() |
| .filter(|employee| employee.skills.contains(CRITICAL_DOCTOR)) |
| .count(); |
| let critical_nurses = employees |
| .iter() |
| .filter(|employee| employee.skills.contains(CRITICAL_NURSE)) |
| .count(); |
| let outpatient_doctors = employees |
| .iter() |
| .filter(|employee| employee.skills.contains(OUTPATIENT_DOCTOR)) |
| .count(); |
| let outpatient_nurses = employees |
| .iter() |
| .filter(|employee| employee.skills.contains(OUTPATIENT_NURSE)) |
| .count(); |
|
|
| assert_eq!(doctors, 22); |
| assert_eq!(nurses, 28); |
| assert_eq!(cardiology, 4); |
| assert_eq!(anaesthetics, 6); |
| assert_eq!(radiology_day, 6); |
| assert_eq!(radiology_nurse, 6); |
| assert_eq!(radiology_call, 4); |
| assert_eq!(ambulatory_doctors, 4); |
| assert_eq!(ambulatory_nurses, 6); |
| assert_eq!(critical_doctors, 6); |
| assert_eq!(critical_nurses, 8); |
| assert_eq!(outpatient_doctors, 7); |
| assert_eq!(outpatient_nurses, 9); |
| } |
|
|
| #[test] |
| fn test_exact_shift_template_counts() { |
| let schedule = schedule(); |
| let mut actual = BTreeMap::<(String, u32, String), usize>::new(); |
| for shift in &schedule.shifts { |
| *actual |
| .entry(( |
| shift.location.clone(), |
| shift.start.time().hour(), |
| shift.required_skill.clone(), |
| )) |
| .or_default() += 1; |
| } |
|
|
| let mut expected = BTreeMap::<(String, u32, String), usize>::new(); |
| let start_date = find_next_monday(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()); |
| for day in 0..DAYS_IN_SCHEDULE { |
| let date = start_date + Duration::days(day); |
| for rule in DEMAND_RULES { |
| *expected |
| .entry(( |
| rule.location.to_string(), |
| rule.start_hour, |
| rule.required_skill.to_string(), |
| )) |
| .or_default() += rule.count_for_date(date.weekday()); |
| } |
| } |
|
|
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_preferences_are_disjoint_from_unavailability() { |
| let schedule = schedule(); |
| for employee in &schedule.employees { |
| assert!(employee |
| .desired_dates |
| .iter() |
| .all(|date| !employee.unavailable_dates.contains(date))); |
| assert!(employee |
| .undesired_dates |
| .iter() |
| .all(|date| !employee.unavailable_dates.contains(date))); |
| assert!((4..=MAX_DESIRED_DATES).contains(&employee.desired_dates.len())); |
| assert!((4..=MAX_UNDESIRED_DATES).contains(&employee.undesired_dates.len())); |
| assert!(employee |
| .desired_dates |
| .iter() |
| .all(|date| !employee.undesired_dates.contains(date))); |
| } |
| } |
|
|
| #[test] |
| fn test_preference_surface_has_one_move_signal() { |
| let schedule = schedule(); |
| let witness = build_hidden_witness(&schedule.employees, &schedule.shifts); |
| let candidate_lists = eligible_employees_by_shift(&schedule.employees, &schedule.shifts); |
| let signal_shifts = schedule |
| .shifts |
| .iter() |
| .enumerate() |
| .filter(|(shift_index, shift)| { |
| let holder = witness.assignments[*shift_index]; |
| let holder_score = shift_soft_preference_score(&schedule.employees[holder], shift); |
| candidate_lists[*shift_index] |
| .iter() |
| .copied() |
| .filter(|&candidate| candidate != holder) |
| .any(|candidate| { |
| let candidate_score = |
| shift_soft_preference_score(&schedule.employees[candidate], shift); |
| candidate_score - holder_score >= 2 |
| }) |
| }) |
| .count(); |
|
|
| assert!( |
| signal_shifts > 0, |
| "there should still be some one-move soft improvements" |
| ); |
| } |
|
|
| #[test] |
| fn test_public_candidate_redundancy() { |
| let schedule = schedule(); |
| let counts = public_candidate_counts(&schedule.employees, &schedule.shifts); |
| assert!(counts.iter().all(|&count| count >= 2)); |
| assert!(counts.iter().filter(|&&count| count >= 3).count() * 4 >= counts.len()); |
| } |
|
|
| #[test] |
| fn test_candidate_width_is_not_generic_role_wide() { |
| let schedule = schedule(); |
| let mut counts = public_candidate_counts(&schedule.employees, &schedule.shifts); |
| counts.sort_unstable(); |
| let median = counts[counts.len() / 2]; |
| let p90 = counts[counts.len() * 9 / 10]; |
|
|
| assert!(median <= 7, "median candidate count should stay narrow"); |
| assert!( |
| p90 <= 9, |
| "90th percentile candidate count should stay bounded" |
| ); |
| } |
|
|
| #[test] |
| fn test_hidden_witness_is_hard_feasible() { |
| let mut rng = StdRng::seed_from_u64(0); |
| let start_date = find_next_monday(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()); |
| let mut blueprints = build_employee_blueprints(&mut rng); |
| assign_primary_off_days(&mut blueprints); |
| let mut employees = instantiate_employees(&blueprints, start_date); |
| let mut shifts = build_public_shifts(start_date); |
| prepare_shifts(&mut shifts); |
|
|
| let witness = build_hidden_witness(&employees, &shifts); |
| add_extra_unavailability(&mut employees, &shifts, &witness.employee_touched_dates); |
| add_preferences(&mut employees, start_date, &blueprints, &shifts, &witness); |
|
|
| for (shift, employee_idx) in shifts.iter_mut().zip(witness.assignments.iter()) { |
| shift.employee_idx = Some(*employee_idx); |
| } |
|
|
| let witness_schedule = Plan::new(employees, shifts); |
| let score = crate::constraints::create_constraints().evaluate_all(&witness_schedule); |
| assert_eq!(score.hard_score(), solverforge::HardSoftDecimalScore::ZERO); |
| } |
|
|
| #[test] |
| fn test_employees_have_skills() { |
| let schedule = schedule(); |
|
|
| for employee in &schedule.employees { |
| assert!( |
| !employee.skills.is_empty(), |
| "Employee {} has no skills", |
| employee.name |
| ); |
| } |
| } |
|
|
| #[test] |
| fn test_demo_data_from_str() { |
| assert_eq!("LARGE".parse::<DemoData>(), Ok(DemoData::Large)); |
| assert_eq!("large".parse::<DemoData>(), Ok(DemoData::Large)); |
| assert!("invalid".parse::<DemoData>().is_err()); |
| } |
|
|
| #[test] |
| fn test_medical_domain() { |
| let schedule = schedule(); |
|
|
| let all_skills: BTreeSet<_> = schedule |
| .employees |
| .iter() |
| .flat_map(|employee| employee.skills.iter()) |
| .map(|skill| skill.as_str()) |
| .collect(); |
|
|
| assert!(all_skills.contains(DOCTOR) || all_skills.contains(NURSE)); |
| let locations: BTreeSet<_> = schedule |
| .shifts |
| .iter() |
| .map(|shift| shift.location.as_str()) |
| .collect(); |
| assert!(locations.contains("Ambulatory care") || locations.contains("Critical care")); |
| } |
|
|
| #[test] |
| fn test_empty_schedule_has_score() { |
| let schedule = crate::domain::Plan::new(vec![], vec![]); |
| let score = crate::constraints::create_constraints().evaluate_all(&schedule); |
| assert_eq!(score.to_string(), "0hard/0soft"); |
| } |
|
|