solverforge-hospital / tests /constraints.rs
github-actions[bot]
chore: sync uc-hospital Space
7596726
use chrono::{NaiveDate, NaiveDateTime};
use solverforge::prelude::*;
use solverforge_hospital::constraints::create_constraints;
use solverforge_hospital::domain::{Employee, Plan, Shift};
// These tests are intentionally tiny and direct: each one isolates one rule so
// a beginner can see how the domain model turns into score changes.
const SCORE_SCALE: i64 = 100_000;
const UNASSIGNED_SHIFT_HARD_UNITS: i64 = 1;
const REQUIRED_SKILL_HARD_UNITS: i64 = 10;
const STRUCTURAL_FIXED_HARD_UNITS: i64 = 20;
const STRUCTURAL_MINUTE_HARD_UNITS: i64 = 20;
// Short helper that keeps the test data readable.
fn dt(day: u32, hour: u32) -> NaiveDateTime {
NaiveDate::from_ymd_opt(2024, 1, day)
.unwrap()
.and_hms_opt(hour, 0, 0)
.unwrap()
}
// Builds a hard score using the same scaling constants as the production constraints.
fn hard_units(units: i64) -> HardSoftDecimalScore {
HardSoftDecimalScore::of_hard_scaled(units * SCORE_SCALE)
}
// Builds a minute-weighted hard score for overlap/rest style rules.
fn structural_hard_minutes(minutes: i64) -> HardSoftDecimalScore {
HardSoftDecimalScore::of_hard_scaled(minutes * STRUCTURAL_MINUTE_HARD_UNITS * SCORE_SCALE)
}
#[test]
fn self_joins_count_each_pair_once() {
let employees = vec![Employee::new(0, "A").with_skill("Doctor")];
let mut shifts = vec![
Shift::new("1", dt(1, 8), dt(1, 16), "Ward", "Doctor"),
Shift::new("2", dt(1, 12), dt(1, 20), "Ward", "Doctor"),
];
shifts[0].employee_idx = Some(0);
shifts[1].employee_idx = Some(0);
let schedule = Plan::new(employees, shifts);
let constraints = create_constraints();
let analyses = constraints.evaluate_detailed(&schedule);
let overlap = analyses
.into_iter()
.find(|analysis| analysis.constraint_ref.name == "Overlapping shift")
.unwrap();
assert_eq!(overlap.matches.len(), 1);
}
#[test]
fn unassigned_shift_is_a_hard_violation() {
let schedule = Plan::new(
vec![],
vec![Shift::new("1", dt(1, 8), dt(1, 16), "Ward", "Doctor")],
);
let score = create_constraints().evaluate_all(&schedule);
assert_eq!(score.hard_score(), hard_units(-UNASSIGNED_SHIFT_HARD_UNITS));
}
#[test]
fn overnight_shift_penalizes_unavailable_end_date() {
let mut employee = Employee::new(0, "Night nurse")
.with_skill("Nurse")
.with_unavailable_date(NaiveDate::from_ymd_opt(2024, 1, 2).unwrap());
employee.finalize();
assert_eq!(employee.unavailable_days.len(), 1);
let mut shift = Shift::new("night-1", dt(1, 22), dt(2, 6), "Ward", "Nurse");
shift.employee_idx = Some(0);
assert_eq!(
{
let date = NaiveDate::from_ymd_opt(2024, 1, 2).unwrap();
let day_start = date.and_hms_opt(0, 0, 0).unwrap();
let day_end = date
.succ_opt()
.unwrap_or(date)
.and_hms_opt(0, 0, 0)
.unwrap();
let start = shift.start.max(day_start);
let end = shift.end.min(day_end);
if start < end {
(end - start).num_minutes()
} else {
0
}
},
360
);
let schedule = Plan::new(vec![employee], vec![shift]);
let constraints = create_constraints();
let analyses = constraints.evaluate_detailed(&schedule);
let unavailable = analyses
.into_iter()
.find(|analysis| analysis.constraint_ref.name == "Unavailable employee")
.unwrap();
assert_eq!(
unavailable.score.hard_score(),
structural_hard_minutes(-360)
);
}
#[test]
fn overnight_shift_rewards_desired_end_date() {
let mut employee = Employee::new(0, "Night nurse")
.with_skill("Nurse")
.with_desired_date(NaiveDate::from_ymd_opt(2024, 1, 2).unwrap());
employee.finalize();
assert_eq!(employee.desired_days.len(), 1);
let mut shift = Shift::new("night-1", dt(1, 22), dt(2, 6), "Ward", "Nurse");
shift.employee_idx = Some(0);
assert!({
let date = NaiveDate::from_ymd_opt(2024, 1, 2).unwrap();
let day_start = date.and_hms_opt(0, 0, 0).unwrap();
let day_end = date
.succ_opt()
.unwrap_or(date)
.and_hms_opt(0, 0, 0)
.unwrap();
let start = shift.start.max(day_start);
let end = shift.end.min(day_end);
start < end && (end - start).num_minutes() > 0
});
let schedule = Plan::new(vec![employee], vec![shift]);
let constraints = create_constraints();
let analyses = constraints.evaluate_detailed(&schedule);
let desired = analyses
.into_iter()
.find(|analysis| analysis.constraint_ref.name == "Desired day for employee")
.unwrap();
assert_eq!(desired.score.soft_score(), HardSoftDecimalScore::of_soft(1));
}
#[test]
fn employee_joined_analysis_reports_required_skill_match() {
let employees = vec![Employee::new(0, "Taylor").with_skill("Nurse")];
let mut shift = Shift::new("1", dt(1, 8), dt(1, 16), "Ward", "Doctor");
shift.employee_idx = Some(0);
let schedule = Plan::new(employees, vec![shift]);
let constraints = create_constraints();
let analyses = constraints.evaluate_detailed(&schedule);
let required_skill = analyses
.into_iter()
.find(|analysis| analysis.constraint_ref.name == "Required skill")
.unwrap();
assert_eq!(required_skill.matches.len(), 1);
assert_eq!(
required_skill.score.hard_score(),
hard_units(-REQUIRED_SKILL_HARD_UNITS)
);
}
#[test]
fn overnight_and_next_day_shift_violate_one_shift_per_day() {
let employees = vec![Employee::new(0, "A").with_skill("Doctor")];
let mut overnight = Shift::new("night", dt(1, 22), dt(2, 6), "Ward", "Doctor");
overnight.employee_idx = Some(0);
let mut evening = Shift::new("late", dt(2, 18), dt(2, 22), "Ward", "Doctor");
evening.employee_idx = Some(0);
let schedule = Plan::new(employees, vec![overnight, evening]);
let constraints = create_constraints();
let analyses = constraints.evaluate_detailed(&schedule);
let one_per_day = analyses
.into_iter()
.find(|analysis| analysis.constraint_ref.name == "One shift per day")
.unwrap();
assert_eq!(one_per_day.matches.len(), 1);
assert_eq!(
one_per_day.score.hard_score(),
hard_units(-STRUCTURAL_FIXED_HARD_UNITS)
);
}
#[test]
fn required_skill_is_worse_than_unassigned() {
let unassigned = create_constraints().evaluate_all(&Plan::new(
vec![],
vec![Shift::new("missing", dt(1, 8), dt(1, 16), "Ward", "Doctor")],
));
let employees = vec![Employee::new(0, "Taylor").with_skill("Nurse")];
let mut shift = Shift::new("wrong-skill", dt(1, 8), dt(1, 16), "Ward", "Doctor");
shift.employee_idx = Some(0);
let wrong_skill = create_constraints().evaluate_all(&Plan::new(employees, vec![shift]));
assert!(wrong_skill < unassigned);
assert_eq!(
unassigned.hard_score(),
hard_units(-UNASSIGNED_SHIFT_HARD_UNITS)
);
assert_eq!(
wrong_skill.hard_score(),
hard_units(-REQUIRED_SKILL_HARD_UNITS)
);
}
#[test]
fn one_shift_per_day_does_not_cross_employee_boundaries() {
let employees = vec![
Employee::new(0, "A").with_skill("Doctor"),
Employee::new(1, "B").with_skill("Doctor"),
];
let mut overnight = Shift::new("night", dt(1, 22), dt(2, 6), "Ward", "Doctor");
overnight.employee_idx = Some(0);
let mut evening = Shift::new("late", dt(2, 18), dt(2, 22), "Ward", "Doctor");
evening.employee_idx = Some(1);
let schedule = Plan::new(employees, vec![overnight, evening]);
let constraints = create_constraints();
let analyses = constraints.evaluate_detailed(&schedule);
let one_per_day = analyses
.into_iter()
.find(|analysis| analysis.constraint_ref.name == "One shift per day")
.unwrap();
assert!(one_per_day.matches.is_empty());
assert_eq!(one_per_day.score, HardSoftDecimalScore::ZERO);
}