Complete backtracking, restriction violations, split model.rs into multiple files
This commit is contained in:
333
src-tauri/src/config.rs
Normal file
333
src-tauri/src/config.rs
Normal file
@@ -0,0 +1,333 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Month;
|
||||
|
||||
use crate::{
|
||||
resident::Resident,
|
||||
schedule::{MonthlySchedule, ShiftType},
|
||||
slot::{Day, ShiftPosition, Slot},
|
||||
};
|
||||
|
||||
const YEAR: i32 = 2026;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UserConfig {
|
||||
month: Month,
|
||||
year: i32,
|
||||
pub holidays: Vec<usize>,
|
||||
pub residents: Vec<Resident>,
|
||||
pub toxic_pairs: Vec<(String, String)>,
|
||||
|
||||
// calculated from inputs
|
||||
pub workload_limits: HashMap<String, u8>,
|
||||
pub holiday_limits: HashMap<String, u8>,
|
||||
}
|
||||
|
||||
impl UserConfig {
|
||||
pub fn new(month: usize) -> Self {
|
||||
Self {
|
||||
month: Month::try_from(month as u8).unwrap(),
|
||||
year: YEAR,
|
||||
holidays: vec![],
|
||||
residents: vec![],
|
||||
toxic_pairs: vec![],
|
||||
workload_limits: HashMap::new(),
|
||||
holiday_limits: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_holidays(mut self, holidays: Vec<usize>) -> Self {
|
||||
self.holidays = holidays;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_residents(mut self, residents: Vec<Resident>) -> Self {
|
||||
self.residents = residents;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn add(&mut self, resident: Resident) {
|
||||
self.residents.push(resident);
|
||||
}
|
||||
|
||||
pub fn with_toxic_pairs(mut self, toxic_pairs: Vec<(String, String)>) -> Self {
|
||||
self.toxic_pairs = toxic_pairs;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn total_days(&self) -> u8 {
|
||||
self.month.num_days(self.year).unwrap()
|
||||
}
|
||||
|
||||
pub fn total_slots(&self) -> u8 {
|
||||
(1..=self.total_days())
|
||||
.map(|d| if Day(d).is_open_shift() { 2 } else { 1 })
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub fn total_holiday_slots(&self) -> u8 {
|
||||
(1..=self.total_days())
|
||||
.filter(|&d| self.is_holiday_or_weekend_slot(d))
|
||||
.map(|d| if Day(d).is_open_shift() { 2 } else { 1 })
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub fn is_holiday_or_weekend_slot(&self, day: u8) -> bool {
|
||||
let day = Day(day);
|
||||
day.is_weekend(self.month.number_from_month(), self.year)
|
||||
|| self.holidays.contains(&(day.0 as usize))
|
||||
}
|
||||
|
||||
/// this is called after the user config params have been initialized, can be done with the builder (lite) pattern
|
||||
/// initialize a hashmap for O(1) search calls for the residents' max workload
|
||||
pub fn calculate_workload_limits(&mut self) {
|
||||
let total_slots = self.total_slots();
|
||||
|
||||
let max_shifts_sum: usize = self
|
||||
.residents
|
||||
.iter()
|
||||
.map(|r| r.max_shifts.unwrap_or(0))
|
||||
.sum();
|
||||
|
||||
let residents_without_max_shifts: Vec<_> = self
|
||||
.residents
|
||||
.iter()
|
||||
.filter(|r| r.max_shifts.is_none())
|
||||
.collect();
|
||||
|
||||
let residents_without_max_shifts_size = residents_without_max_shifts.len();
|
||||
|
||||
if residents_without_max_shifts_size == 0 {
|
||||
for r in &self.residents {
|
||||
self.workload_limits
|
||||
.insert(r.id.clone(), r.max_shifts.unwrap_or(0) as u8);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Untested scenario: Resident has manual max_shifts and also reduced workload flag
|
||||
|
||||
let total_reduced_loads: usize = residents_without_max_shifts
|
||||
.iter()
|
||||
.filter(|r| r.reduced_load)
|
||||
.count();
|
||||
let max_shifts_ceiling = (total_slots - max_shifts_sum as u8 + total_reduced_loads as u8)
|
||||
.div_ceil(residents_without_max_shifts_size as u8);
|
||||
|
||||
for r in &self.residents {
|
||||
let max_shifts = if let Some(manual_max_shifts) = r.max_shifts {
|
||||
manual_max_shifts as u8
|
||||
} else if r.reduced_load {
|
||||
max_shifts_ceiling - 1
|
||||
} else {
|
||||
max_shifts_ceiling
|
||||
};
|
||||
self.workload_limits.insert(r.id.clone(), max_shifts);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn calculate_holiday_limits(&mut self) {
|
||||
let total_slots = self.total_slots();
|
||||
let total_holiday_slots = self.total_holiday_slots();
|
||||
|
||||
for r in &self.residents {
|
||||
let workload_limit = *self.workload_limits.get(&r.id).unwrap_or(&0);
|
||||
|
||||
let share = (workload_limit as f32 / total_slots as f32) * total_holiday_slots as f32;
|
||||
let holiday_limit = share.ceil() as u8;
|
||||
|
||||
self.holiday_limits.insert(r.id.clone(), holiday_limit);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return all possible candidates for the next slot
|
||||
/// TODO: move this to another file, UserConfig should only hold the info set from GUI
|
||||
///
|
||||
/// @slot
|
||||
/// @schedule
|
||||
pub fn candidates(
|
||||
&self,
|
||||
slot: Slot,
|
||||
schedule: &MonthlySchedule,
|
||||
) -> Vec<(&Resident, &ShiftType)> {
|
||||
let mut candidates = vec![];
|
||||
let is_open = slot.is_open_shift();
|
||||
|
||||
let other_position = match slot.position {
|
||||
ShiftPosition::First => ShiftPosition::Second,
|
||||
ShiftPosition::Second => ShiftPosition::First,
|
||||
};
|
||||
let other_slot = Slot::new(slot.day, other_position);
|
||||
let already_on_duty = schedule.get_resident_id(&other_slot);
|
||||
|
||||
for resident in &self.residents {
|
||||
if let Some(on_duty_id) = &already_on_duty {
|
||||
if &&resident.id == on_duty_id {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if resident.negative_shifts.contains(&slot.day) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for shift_type in &resident.allowed_types {
|
||||
match (shift_type, is_open, slot.position) {
|
||||
(ShiftType::OpenFirst, true, ShiftPosition::First) => {
|
||||
candidates.push((resident, shift_type))
|
||||
}
|
||||
(ShiftType::OpenSecond, true, ShiftPosition::Second) => {
|
||||
candidates.push((resident, shift_type))
|
||||
}
|
||||
(ShiftType::Closed, false, _) => candidates.push((resident, shift_type)),
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
pub fn default() -> Self {
|
||||
Self {
|
||||
month: Month::try_from(2).unwrap(),
|
||||
year: YEAR,
|
||||
holidays: vec![],
|
||||
residents: vec![],
|
||||
toxic_pairs: vec![],
|
||||
workload_limits: HashMap::new(),
|
||||
holiday_limits: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Month;
|
||||
use rstest::{fixture, rstest};
|
||||
|
||||
use crate::{
|
||||
config::UserConfig,
|
||||
resident::Resident,
|
||||
schedule::{MonthlySchedule, ShiftType},
|
||||
slot::{Day, ShiftPosition, Slot},
|
||||
};
|
||||
|
||||
#[fixture]
|
||||
fn setup() -> (UserConfig, MonthlySchedule) {
|
||||
let mut config = UserConfig::default();
|
||||
let res_a = Resident::new("1", "Stefanos");
|
||||
let res_b = Resident::new("2", "Iordanis");
|
||||
|
||||
config.add(res_a);
|
||||
config.add(res_b);
|
||||
|
||||
let schedule = MonthlySchedule::new();
|
||||
(config, schedule)
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_candidates_prevents_double_booking_on_open_day(setup: (UserConfig, MonthlySchedule)) {
|
||||
let (config, mut schedule) = setup;
|
||||
|
||||
let slot_1 = Slot::new(Day(1), ShiftPosition::First);
|
||||
let slot_2 = Slot::new(Day(1), ShiftPosition::Second);
|
||||
|
||||
let stefanos = &config.residents[0];
|
||||
let iordanis = &config.residents[1];
|
||||
|
||||
schedule.insert(slot_1, stefanos);
|
||||
|
||||
let candidates = config.candidates(slot_2, &schedule);
|
||||
|
||||
let stefanos_is_candidate = candidates.iter().any(|(r, _)| r.id == stefanos.id);
|
||||
assert!(!stefanos_is_candidate);
|
||||
|
||||
let iordanis_is_candidate = candidates.iter().any(|(r, _)| r.id == iordanis.id);
|
||||
assert!(iordanis_is_candidate);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_candidates_respects_shift_type_position(setup: (UserConfig, MonthlySchedule)) {
|
||||
let (config, schedule) = setup;
|
||||
let slot_1 = Slot::new(Day(1), ShiftPosition::First);
|
||||
let candidates = config.candidates(slot_1, &schedule);
|
||||
|
||||
for (_, shift_type) in candidates {
|
||||
assert_eq!(shift_type, &ShiftType::OpenFirst);
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_set_limits_fair_distribution() {
|
||||
let mut config = UserConfig::new(1).with_residents(vec![
|
||||
Resident::new("1", "Stefanos").with_max_shifts(2),
|
||||
Resident::new("2", "Iordanis").with_max_shifts(2),
|
||||
Resident::new("3", "Maria").with_reduced_load(),
|
||||
Resident::new("4", "Veatriki"),
|
||||
Resident::new("5", "Takis"),
|
||||
]);
|
||||
|
||||
config.calculate_workload_limits();
|
||||
|
||||
assert_eq!(config.workload_limits["1"], 2);
|
||||
assert_eq!(config.workload_limits["2"], 2);
|
||||
assert_eq!(config.workload_limits["3"], 14);
|
||||
assert_eq!(config.workload_limits["4"], 15);
|
||||
assert_eq!(config.workload_limits["5"], 15);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_set_limits_complex_distribution() {
|
||||
let mut config = UserConfig {
|
||||
month: Month::January,
|
||||
year: 2026,
|
||||
holidays: vec![],
|
||||
toxic_pairs: vec![],
|
||||
workload_limits: HashMap::new(),
|
||||
residents: vec![
|
||||
Resident::new("1", "Stefanos").with_max_shifts(2),
|
||||
Resident::new("2", "Iordanis").with_max_shifts(2),
|
||||
Resident::new("3", "Maria").with_reduced_load(),
|
||||
Resident::new("4", "Veatriki"),
|
||||
Resident::new("5", "Takis"),
|
||||
],
|
||||
holiday_limits: HashMap::new(),
|
||||
};
|
||||
|
||||
config.calculate_workload_limits();
|
||||
|
||||
assert_eq!(config.workload_limits["1"], 2);
|
||||
assert_eq!(config.workload_limits["2"], 2);
|
||||
assert_eq!(config.workload_limits["3"], 14);
|
||||
assert_eq!(config.workload_limits["4"], 15);
|
||||
assert_eq!(config.workload_limits["5"], 15);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_calculate_holiday_limits() {
|
||||
let mut config = UserConfig::default();
|
||||
|
||||
let stefanos = Resident::new("1", "Stefanos");
|
||||
let iordanis = Resident::new("2", "Iordanis");
|
||||
|
||||
config.residents = vec![stefanos, iordanis];
|
||||
|
||||
config.calculate_workload_limits();
|
||||
config.calculate_holiday_limits();
|
||||
|
||||
let stefanos_limit = *config.holiday_limits.get("1").unwrap();
|
||||
let iordanis_limit = *config.holiday_limits.get("2").unwrap();
|
||||
|
||||
assert_eq!(stefanos_limit, 6);
|
||||
assert_eq!(iordanis_limit, 6);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_total_holiday_slots() {
|
||||
let config = UserConfig::default().with_holidays(vec![2, 3, 4]);
|
||||
assert_eq!(16, config.total_holiday_slots());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user