diff --git a/justfile b/justfile index 001e3c5..8af4c07 100644 --- a/justfile +++ b/justfile @@ -14,13 +14,13 @@ lint: cd {{tauri_path}} && cargo clippy test: - cd {{tauri_path}} && cargo test --lib --release + cd {{tauri_path}} && cargo test --lib -- --test-threads=1 test-integration: - cd {{tauri_path}} && cargo test --test integration --release + cd {{tauri_path}} && cargo test --test integration -- --test-threads=1 --nocapture test-all: - cd {{tauri_path}} && cargo test --release -- --nocapture + cd {{tauri_path}} && cargo test --release bench: cd {{tauri_path}} && cargo bench diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index e05b79b..640ca96 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -7,4 +7,6 @@ /gen/schemas # Ignore exported txt/doc files and the log file -rota.* \ No newline at end of file +rota.* + +mutants.* \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5ff1737..cbbb103 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -65,7 +65,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" dependencies = [ "android_log-sys", - "env_filter", + "env_filter 0.1.4", "log", ] @@ -84,12 +84,56 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -637,6 +681,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "combine" version = "4.6.7" @@ -742,7 +792,7 @@ dependencies = [ "ciborium", "clap", "criterion-plot", - "itertools 0.13.0", + "itertools", "num-traits", "oorandom", "page_size", @@ -762,7 +812,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed943f81ea2faa8dcecbbfa50164acf95d555afec96a27871663b300e387b2e4" dependencies = [ "cast", - "itertools 0.13.0", + "itertools", ] [[package]] @@ -852,6 +902,22 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "ctor" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + [[package]] name = "darling" version = "0.21.3" @@ -1030,6 +1096,21 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dtor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "dunce" version = "1.0.5" @@ -1105,6 +1186,29 @@ dependencies = [ "regex", ] +[[package]] +name = "env_filter" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +dependencies = [ + "anstream", + "anstyle", + "env_filter 1.0.0", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -2033,6 +2137,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -2042,15 +2152,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.15" @@ -2080,6 +2181,30 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jiff" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c867c356cc096b33f4981825ab281ecba3db0acefe60329f044c1789d94c6543" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7946b4325269738f270bb55b3c19ab5c5040525f83fd625259422a9d25d9be5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "jni" version = "0.21.1" @@ -2668,6 +2793,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" @@ -3009,6 +3140,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -3448,8 +3594,9 @@ dependencies = [ "anyhow", "chrono", "criterion", + "ctor 0.6.3", "docx-rs", - "itertools 0.14.0", + "env_logger", "log", "rand 0.9.2", "rayon", @@ -4340,7 +4487,7 @@ dependencies = [ "anyhow", "brotli", "cargo_metadata", - "ctor", + "ctor 0.2.9", "dunce", "glob", "html5ever", @@ -4868,6 +5015,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.19.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 60a6db1..3250d60 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,7 +23,6 @@ tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = "0.4.42" -itertools = "0.14.0" rstest = "0.26.1" tauri-plugin-log = "2" log = "0.4.29" @@ -35,6 +34,8 @@ thiserror = "2.0.18" [dev-dependencies] criterion = { version = "0.8.1", features = ["html_reports"] } +env_logger = "0.11" +ctor = "0.6.3" [[bench]] name = "rayon" diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 471f747..36d3b7a 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -1,13 +1,16 @@ +use std::collections::HashMap; + use anyhow::Context; use chrono::Month; use serde::{Deserialize, Serialize}; use crate::{ resident::{Resident, ResidentDTO, ResidentId}, + schedule::ShiftType, slot::Day, }; -const MONTH: u8 = 2; +const MONTH: u8 = 4; const YEAR: i32 = 2026; #[derive(Debug, Clone)] @@ -87,6 +90,21 @@ impl UserConfig { day.is_weekend(self.month.number_from_month(), self.year) || self.holidays.contains(&(day.0)) } + + pub fn get_initial_supply(&self) -> HashMap { + let mut supply = HashMap::new(); + let total_days = self.total_days; + + for d in 1..=total_days { + if Day(d).is_open_shift() { + *supply.entry(ShiftType::OpenFirst).or_insert(0) += 1; + *supply.entry(ShiftType::OpenSecond).or_insert(0) += 1; + } else { + *supply.entry(ShiftType::Closed).or_insert(0) += 1; + } + } + supply + } } impl Default for UserConfig { @@ -153,3 +171,18 @@ impl TryFrom for UserConfig { }) } } + +#[cfg(test)] +mod tests { + use crate::{config::UserConfig, fixtures::complex_config, schedule::ShiftType}; + use rstest::rstest; + + #[rstest] + fn test_get_initial_supply(complex_config: UserConfig) { + let supply = complex_config.get_initial_supply(); + + assert_eq!(15, *supply.get(&ShiftType::OpenFirst).unwrap()); + assert_eq!(15, *supply.get(&ShiftType::OpenSecond).unwrap()); + assert_eq!(15, *supply.get(&ShiftType::Closed).unwrap()); + } +} diff --git a/src-tauri/src/errors.rs b/src-tauri/src/errors.rs index 782e2b9..cb64e94 100644 --- a/src-tauri/src/errors.rs +++ b/src-tauri/src/errors.rs @@ -1,5 +1,6 @@ use std::io; +use log::Level; use serde::Serialize; use thiserror::Error; @@ -18,6 +19,16 @@ pub enum SearchError { NoSolutionFound, } +impl SearchError { + pub fn log_level(&self) -> Level { + match self { + SearchError::SolutionFound | SearchError::ScheduleFull => Level::Info, + SearchError::NoSolutionFound => Level::Warn, + SearchError::Timeout | SearchError::Config(_) => Level::Error, + } + } +} + impl From for SearchError { fn from(err: anyhow::Error) -> Self { SearchError::Config(err.to_string()) diff --git a/src-tauri/src/export.rs b/src-tauri/src/export.rs index d83cf07..38c60e7 100644 --- a/src-tauri/src/export.rs +++ b/src-tauri/src/export.rs @@ -194,12 +194,12 @@ mod tests { #[fixture] fn config() -> UserConfig { UserConfig::default().with_residents(vec![ - Resident::new(1, "Στέφανος"), - Resident::new(2, "Ιορδάνης"), - Resident::new(3, "Μαρία"), - Resident::new(4, "Βεατρίκη"), - Resident::new(5, "Τάκης"), - Resident::new(6, "Μάκης"), + Resident::new(1, "R1"), + Resident::new(2, "R2"), + Resident::new(3, "R3"), + Resident::new(4, "R4"), + Resident::new(5, "R5"), + Resident::new(6, "R6"), ]) } diff --git a/src-tauri/src/fixtures.rs b/src-tauri/src/fixtures.rs new file mode 100644 index 0000000..e27d79d --- /dev/null +++ b/src-tauri/src/fixtures.rs @@ -0,0 +1,177 @@ +use crate::{ + config::{ToxicPair, UserConfig}, + resident::Resident, + schedule::ShiftType, + slot::{Day, ShiftPosition, Slot}, +}; + +use rstest::fixture; + +#[fixture] +pub fn minimal_config() -> UserConfig { + UserConfig::default().with_residents(vec![ + Resident::new(1, "R1"), + Resident::new(2, "R2"), + Resident::new(3, "R3"), + Resident::new(4, "R4"), + Resident::new(5, "R5"), + ]) +} + +#[fixture] +pub fn maximal_config() -> UserConfig { + UserConfig::default() + .with_holidays(vec![2, 3, 10, 11, 12, 25]) + .with_residents(vec![ + Resident::new(1, "R1").with_max_shifts(3), + Resident::new(2, "R2").with_max_shifts(4), + Resident::new(3, "R3").with_reduced_load(), + Resident::new(4, "R4").with_allowed_types(vec![ShiftType::Closed]), + Resident::new(5, "R5") + .with_allowed_types(vec![ShiftType::OpenFirst, ShiftType::OpenSecond]), + Resident::new(6, "R6").with_negative_shifts(vec![Day(5), Day(15), Day(25)]), + Resident::new(7, "R7"), + Resident::new(8, "R8"), + Resident::new(9, "R9"), + Resident::new(10, "R10"), + ]) + .with_toxic_pairs(vec![ + ToxicPair::new(1, 2), + ToxicPair::new(3, 4), + ToxicPair::new(7, 8), + ]) +} + +#[fixture] +pub fn manual_shifts_heavy_config() -> UserConfig { + UserConfig::default().with_residents(vec![ + Resident::new(1, "R1").with_manual_shifts(vec![ + Slot::new(Day(1), ShiftPosition::First), + Slot::new(Day(3), ShiftPosition::First), + Slot::new(Day(5), ShiftPosition::Second), + ]), + Resident::new(2, "R2").with_manual_shifts(vec![ + Slot::new(Day(2), ShiftPosition::First), + Slot::new(Day(4), ShiftPosition::First), + ]), + Resident::new(3, "R3"), + Resident::new(4, "R4"), + Resident::new(5, "R5"), + Resident::new(6, "R6"), + ]) +} + +#[fixture] +pub fn complex_config() -> UserConfig { + UserConfig::default() + .with_holidays(vec![5, 12, 19, 26]) + .with_residents(vec![ + Resident::new(1, "R1") + .with_max_shifts(3) + .with_negative_shifts(vec![Day(1), Day(2), Day(3)]), + Resident::new(2, "R2") + .with_max_shifts(3) + .with_negative_shifts(vec![Day(4), Day(5), Day(6)]), + Resident::new(3, "R3") + .with_max_shifts(3) + .with_negative_shifts(vec![Day(7), Day(8), Day(9)]), + Resident::new(4, "R4").with_allowed_types(vec![ShiftType::Closed]), + Resident::new(5, "R5") + .with_allowed_types(vec![ShiftType::OpenFirst, ShiftType::OpenSecond]), + Resident::new(6, "R6"), + Resident::new(7, "R7"), + Resident::new(8, "R8"), + Resident::new(9, "R9"), + ]) + .with_toxic_pairs(vec![ + ToxicPair::new(1, 2), + ToxicPair::new(2, 3), + ToxicPair::new(5, 6), + ToxicPair::new(6, 7), + ]) +} + +#[fixture] +pub fn hard_config() -> UserConfig { + UserConfig::default() + .with_holidays(vec![25]) + .with_residents(vec![ + Resident::new(1, "R1") + .with_negative_shifts(vec![ + Day(2), + Day(3), + Day(4), + Day(5), + Day(6), + Day(7), + Day(8), + Day(9), + Day(13), + Day(14), + Day(15), + ]) + .with_manual_shifts(vec![ + Slot::new(Day(1), ShiftPosition::First), + Slot::new(Day(12), ShiftPosition::First), + ]) + .with_max_shifts(6), + Resident::new(2, "R2") + .with_negative_shifts(vec![ + Day(2), + Day(3), + Day(4), + Day(5), + Day(6), + Day(7), + Day(8), + Day(9), + ]) + .with_manual_shifts(vec![Slot::new(Day(1), ShiftPosition::Second)]), + Resident::new(3, "R3").with_negative_shifts(vec![ + Day(12), + Day(13), + Day(14), + Day(15), + Day(16), + ]), + Resident::new(4, "R4").with_negative_shifts(vec![Day(14), Day(15)]), + Resident::new(5, "R5") + .with_manual_shifts(vec![ + Slot::new(Day(2), ShiftPosition::First), + Slot::new(Day(4), ShiftPosition::First), + Slot::new(Day(7), ShiftPosition::First), + Slot::new(Day(9), ShiftPosition::First), + ]) + .with_negative_shifts(vec![ + Day(12), + Day(13), + Day(14), + Day(15), + Day(16), + Day(17), + Day(18), + Day(19), + Day(20), + Day(21), + Day(22), + Day(23), + Day(24), + Day(25), + ]), + Resident::new(6, "R6") + .with_allowed_types(vec![ShiftType::OpenSecond]) + .with_max_shifts(5), + Resident::new(7, "R7") + .with_max_shifts(5) + .with_negative_shifts(vec![Day(30), Day(31)]), + Resident::new(8, "R8") + .with_allowed_types(vec![ShiftType::OpenSecond]) + .with_max_shifts(5), + ]) + .with_toxic_pairs(vec![ + ToxicPair::new(3, 6), + ToxicPair::new(3, 8), + ToxicPair::new(3, 7), + ToxicPair::new(3, 1), + ]) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1faa4fb..58335dc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ use crate::{ pub mod config; pub mod errors; pub mod export; +pub mod fixtures; pub mod resident; pub mod schedule; pub mod scheduler; @@ -106,8 +107,20 @@ pub fn run() { #[cfg(test)] mod tests { + + use ctor::ctor; use rstest::rstest; + #[ctor] + fn global_setup() { + env_logger::builder() + .filter_level(log::LevelFilter::Info) + .is_test(true) + .try_init(); + } + #[rstest] - pub fn test_endpoints() {} + pub fn test_endpoints() { + // see how tauri mocks endpoint tests + } } diff --git a/src-tauri/src/schedule.rs b/src-tauri/src/schedule.rs index b750ab9..fe37b87 100644 --- a/src-tauri/src/schedule.rs +++ b/src-tauri/src/schedule.rs @@ -5,7 +5,7 @@ use crate::{ config::{ToxicPair, UserConfig}, resident::ResidentId, slot::{weekday_to_greek, Day, ShiftPosition, Slot}, - workload::{WorkloadBounds, WorkloadTracker}, + workload::WorkloadTracker, }; use serde::Serializer; @@ -32,8 +32,8 @@ impl MonthlySchedule { self.0.get(slot) } - pub fn insert(&mut self, slot: Slot, resident_id: ResidentId) { - self.0.insert(slot, resident_id); + pub fn insert(&mut self, slot: Slot, r_id: ResidentId) { + self.0.insert(slot, r_id); } pub fn remove(&mut self, slot: Slot) { @@ -44,32 +44,6 @@ impl MonthlySchedule { self.0.contains_key(slot) } - /// if any restriction is violated => we return true (leading to pruning in the backtracking algorithm) - /// 1) no same person in consecutive days - /// 2) avoid input toxic pairs - /// 3) apply fairness on total shifts split residents also take into account reduced (-1) workload - /// - /// @slot points to an occupied slot - /// @config info manually set on the GUI by the user - pub fn restrictions_violated( - &self, - slot: &Slot, - config: &UserConfig, - bounds: &WorkloadBounds, - tracker: &WorkloadTracker, - ) -> bool { - let resident_id = match self.get_resident_id(slot) { - Some(id) => id, - None => return false, - }; - - self.has_resident_in_consecutive_days(slot) - || self.has_toxic_pair(slot, config) - || tracker.is_total_workload_exceeded(bounds, resident_id) - || tracker.is_holiday_workload_exceeded(bounds, resident_id) - || tracker.is_max_shift_type_exceeded(bounds, resident_id, slot) - } - pub fn has_resident_in_consecutive_days(&self, slot: &Slot) -> bool { if slot.day == Day(1) { return false; @@ -89,9 +63,20 @@ impl MonthlySchedule { .any(|s| self.get_resident_id(s) == self.get_resident_id(slot)) } + pub fn resident_worked_on_day(&self, day: Day, res_id: ResidentId) -> bool { + if day.0 < 1 { + return false; + } + + let first = Slot::new(day, ShiftPosition::First); + let second = Slot::new(day, ShiftPosition::Second); + + self.get_resident_id(&first) == Some(&res_id) + || self.get_resident_id(&second) == Some(&res_id) + } + pub fn has_toxic_pair(&self, slot: &Slot, config: &UserConfig) -> bool { - // can only have caused a toxic pair violation if we just added a 2nd resident in an open shift - if !slot.is_open_second() { + if !slot.is_open_shift() { return false; } @@ -113,11 +98,11 @@ impl MonthlySchedule { sorted.sort_by_key(|(slot, _)| (slot.day, slot.position)); let mut output = String::from("Μηνιαίο Πρόγραμμα Εφημεριών\n"); - for (slot, res_id) in sorted { + for (slot, r_id) in sorted { let res_name = config .residents .iter() - .find(|r| &r.id == res_id) + .find(|r| &r.id == r_id) .map(|r| r.name.as_str()) .unwrap_or(""); @@ -137,7 +122,7 @@ impl MonthlySchedule { pub fn report(&self, config: &UserConfig, tracker: &WorkloadTracker) -> String { let mut output = String::new(); - output.push_str("\n--- Αναφορά ---\n"); + output.push('\n'); output.push_str(&format!( "{:<15} | {:<6} | {:<10} | {:<10} | {:<7} | {:<10}\n", "Ειδικευόμενος", "Σύνολο", "Ανοιχτή(1)", "Ανοιχτή(2)", "Κλειστή", "ΣΚ/Αργίες" @@ -148,16 +133,16 @@ impl MonthlySchedule { let mut residents: Vec<_> = config.residents.iter().collect(); residents.sort_by_key(|r| &r.name); - for res in residents { - let total = tracker.current_workload(&res.id); - let o1 = tracker.get_type_count(&res.id, ShiftType::OpenFirst); - let o2 = tracker.get_type_count(&res.id, ShiftType::OpenSecond); - let cl = tracker.get_type_count(&res.id, ShiftType::Closed); - let holiday = tracker.current_holiday_workload(&res.id); + for r in residents { + let total = tracker.current_workload(&r.id); + let o1 = tracker.get_type_count(&r.id, ShiftType::OpenFirst); + let o2 = tracker.get_type_count(&r.id, ShiftType::OpenSecond); + let cl = tracker.get_type_count(&r.id, ShiftType::Closed); + let holiday = tracker.current_holiday_workload(&r.id); output.push_str(&format!( "{:<15} | {:<6} | {:<10} | {:<10} | {:<7} | {:<10}\n", - res.name, total, o1, o2, cl, holiday + r.name, total, o1, o2, cl, holiday )); } output.push_str("-".repeat(85).as_str()); @@ -209,25 +194,19 @@ mod tests { #[fixture] fn resident() -> Resident { - Resident::new(1, "Stefanos") + Resident::new(1, "R1") } #[fixture] fn toxic_config() -> UserConfig { UserConfig::default() - .with_residents(vec![ - Resident::new(1, "Stefanos"), - Resident::new(2, "Iordanis"), - ]) + .with_residents(vec![Resident::new(1, "R1"), Resident::new(2, "R2")]) .with_toxic_pairs(vec![ToxicPair::new(1, 2)]) } #[fixture] fn config() -> UserConfig { - UserConfig::default().with_residents(vec![ - Resident::new(1, "Stefanos"), - Resident::new(2, "Iordanis"), - ]) + UserConfig::default().with_residents(vec![Resident::new(1, "R1"), Resident::new(2, "R2")]) } #[rstest] @@ -271,11 +250,11 @@ mod tests { let slot_1 = Slot::new(Day(1), ShiftPosition::First); let slot_2 = Slot::new(Day(1), ShiftPosition::Second); - let stefanos = &toxic_config.residents[0]; - let iordanis = &toxic_config.residents[1]; + let r1 = &toxic_config.residents[0]; + let r2 = &toxic_config.residents[1]; - schedule.insert(slot_1, stefanos.id); - schedule.insert(slot_2, iordanis.id); + schedule.insert(slot_1, r1.id); + schedule.insert(slot_2, r2.id); assert!(schedule.has_toxic_pair(&slot_2, &toxic_config)) } diff --git a/src-tauri/src/scheduler.rs b/src-tauri/src/scheduler.rs index 69b2303..2be90c0 100644 --- a/src-tauri/src/scheduler.rs +++ b/src-tauri/src/scheduler.rs @@ -1,7 +1,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use crate::{ - config::UserConfig, + config::{ToxicPair, UserConfig}, errors::SearchError, resident::ResidentId, schedule::MonthlySchedule, @@ -10,7 +10,6 @@ use crate::{ workload::{WorkloadBounds, WorkloadTracker}, }; -use log::info; use rand::{rngs::SmallRng, seq::SliceRandom, SeedableRng}; use rayon::{ current_thread_index, @@ -60,7 +59,7 @@ impl Scheduler { .map(Slot::from) .ok_or(SearchError::ScheduleFull)?; - let resident_ids = self.valid_residents(slot, schedule); + let resident_ids = self.valid_residents(slot, schedule, tracker); let solved_in_thread = AtomicBool::new(false); let sovled_state = resident_ids.par_iter().find_map_any(|&id| { @@ -81,7 +80,8 @@ impl Scheduler { Ok(true) => Some((local_schedule, local_tracker)), Ok(false) => None, Err(e) => { - info!("Thread Id: [{}] {}", current_thread_index().unwrap(), e); + let thread_id = current_thread_index().unwrap(); + log::log!(e.log_level(), "Thread Id: [{}] {}", thread_id, e); None } } @@ -114,18 +114,13 @@ impl Scheduler { return Err(SearchError::Timeout); } - if !slot.is_first() - && schedule.restrictions_violated(&slot.previous(), &self.config, &self.bounds, tracker) - { + if schedule.has_resident_in_consecutive_days(&slot.previous()) { return Ok(false); } - if slot.greater_than(self.config.total_days) { - if tracker.are_all_thresholds_met(&self.config, &self.bounds) { - solved_in_thread.store(true, Ordering::Relaxed); - return Ok(true); - } - return Ok(false); + if self.found_solution(slot) { + solved_in_thread.store(true, Ordering::Relaxed); + return Ok(true); } if schedule.is_slot_manually_assigned(&slot) { @@ -133,7 +128,7 @@ impl Scheduler { } let mut rng = SmallRng::from_rng(&mut rand::rng()); - let mut valid_resident_ids = self.valid_residents(slot, schedule); + let mut valid_resident_ids = self.valid_residents(slot, schedule, tracker); valid_resident_ids.shuffle(&mut rng); valid_resident_ids.sort_by_key(|res_id| { let type_count = tracker.get_type_count(res_id, slot.shift_type()); @@ -156,10 +151,19 @@ impl Scheduler { Ok(false) } + pub fn found_solution(&self, slot: Slot) -> bool { + slot.greater_than(self.config.total_days) + } + /// Return all valid residents for the current slot - pub fn valid_residents(&self, slot: Slot, schedule: &MonthlySchedule) -> Vec { - let required_type = slot.shift_type(); - let other_resident = slot + pub fn valid_residents( + &self, + slot: Slot, + schedule: &MonthlySchedule, + tracker: &WorkloadTracker, + ) -> Vec { + let is_holiday_slot = self.config.is_holiday_or_weekend_slot(slot.day.0); + let other_resident_id = slot .other_position() .and_then(|partner_slot| schedule.get_resident_id(&partner_slot)); @@ -167,9 +171,26 @@ impl Scheduler { .residents .iter() .filter(|r| { - Some(&r.id) != other_resident - && !r.negative_shifts.contains(&slot.day) - && r.allowed_types.contains(&required_type) + if let Some(other_id) = other_resident_id { + if &r.id == other_id { + return false; + } + + if self + .config + .toxic_pairs + .iter() + .any(|tp| tp.matches(&ToxicPair::from((r.id, *other_id)))) + { + return false; + } + } + + !r.negative_shifts.contains(&slot.day) + && r.allowed_types.contains(&slot.shift_type()) + && !tracker.reached_workload_limit(&self.bounds, &r.id) + && (!is_holiday_slot || !tracker.reached_holiday_limit(&self.bounds, &r.id)) + && !tracker.reached_shift_type_limit(&self.bounds, &r.id, &slot) }) .map(|r| r.id) .collect() @@ -185,7 +206,6 @@ mod tests { resident::Resident, schedule::MonthlySchedule, scheduler::Scheduler, - slot::{Day, ShiftPosition, Slot}, workload::{WorkloadBounds, WorkloadTracker}, }; @@ -197,12 +217,12 @@ mod tests { #[fixture] fn config() -> UserConfig { UserConfig::default().with_residents(vec![ - Resident::new(1, "Stefanos"), - Resident::new(2, "Iordanis"), - Resident::new(3, "Maria"), - Resident::new(4, "Veatriki"), - Resident::new(5, "Takis"), - Resident::new(6, "Akis"), + Resident::new(1, "R1"), + Resident::new(2, "R2"), + Resident::new(3, "R3"), + Resident::new(4, "R4"), + Resident::new(5, "R5"), + Resident::new(6, "R6"), ]) } @@ -227,25 +247,8 @@ mod tests { mut tracker: WorkloadTracker, scheduler: Scheduler, ) { - assert!(scheduler.run(&mut schedule, &mut tracker).is_ok()); - - for d in 1..=scheduler.config.total_days { - let day = Day(d); - if day.is_open_shift() { - let slot_first = Slot::new(day, ShiftPosition::First); - assert!(schedule.get_resident_id(&slot_first).is_some()); - let slot_second = Slot::new(day, ShiftPosition::Second); - assert!(schedule.get_resident_id(&slot_second).is_some()); - } else { - let slot_first = Slot::new(day, ShiftPosition::First); - assert!(schedule.get_resident_id(&slot_first).is_some()); - } - } - - for r in &scheduler.config.residents { - let workload = tracker.current_workload(&r.id); - let limit = *scheduler.bounds.max_workloads.get(&r.id).unwrap(); - assert!(workload <= limit); - } + let solved = scheduler.run(&mut schedule, &mut tracker); + assert!(solved.is_ok()); + assert!(solved.unwrap()); } } diff --git a/src-tauri/src/slot.rs b/src-tauri/src/slot.rs index 1869452..4a347e9 100644 --- a/src-tauri/src/slot.rs +++ b/src-tauri/src/slot.rs @@ -45,7 +45,7 @@ impl Slot { day: self.day, position: ShiftPosition::Second, }, - _ => Self { + ShiftPosition::First | ShiftPosition::Second => Self { day: self.day.next(), position: ShiftPosition::First, }, diff --git a/src-tauri/src/timer.rs b/src-tauri/src/timer.rs index 3d3df45..c7af2ab 100644 --- a/src-tauri/src/timer.rs +++ b/src-tauri/src/timer.rs @@ -1,6 +1,6 @@ use std::time::Instant; -pub const TIME_LIMIT_IN_MS: u128 = 100000; +pub const TIME_LIMIT_IN_MS: u128 = 5000; // 5 sec pub struct Timer { instant: Instant, diff --git a/src-tauri/src/workload.rs b/src-tauri/src/workload.rs index 346c5b0..4fde08a 100644 --- a/src-tauri/src/workload.rs +++ b/src-tauri/src/workload.rs @@ -2,9 +2,9 @@ use std::collections::HashMap; use crate::{ config::UserConfig, - resident::ResidentId, + resident::{Resident, ResidentId}, schedule::ShiftType, - slot::{Day, Slot}, + slot::Slot, }; #[derive(Default)] @@ -12,175 +12,91 @@ pub struct WorkloadBounds { pub max_workloads: HashMap, pub max_holiday_shifts: HashMap, pub max_by_shift_type: HashMap<(ResidentId, ShiftType), u8>, - pub min_by_shift_type: HashMap<(ResidentId, ShiftType), u8>, } impl WorkloadBounds { pub fn new_with_config(config: &UserConfig) -> Self { + let residents = &config.residents; + let total_slots = config.total_slots; + let total_holiday_slots = config.total_holiday_slots; + let mut bounds = Self::default(); - bounds.calculate_max_workloads(config); - bounds.calculate_max_holiday_shifts(config); - bounds.calculate_max_by_shift_type(config); + bounds.calculate_max_workloads(residents, total_slots); + debug_assert!(bounds.max_workloads.values().sum::() >= total_slots); + bounds.calculate_max_holiday_shifts(residents, total_holiday_slots); + debug_assert!(bounds.max_holiday_shifts.values().sum::() >= total_holiday_slots); + bounds.calculate_max_by_shift_type(residents); + debug_assert!(bounds.max_by_shift_type.values().sum::() >= total_slots); bounds } - /// get map with total amount of slots in a month for each type of shift - pub fn get_initial_supply(&self, config: &UserConfig) -> HashMap { - let mut supply = HashMap::new(); - let total_days = config.total_days; - - for d in 1..=total_days { - if Day(d).is_open_shift() { - *supply.entry(ShiftType::OpenFirst).or_insert(0) += 1; - *supply.entry(ShiftType::OpenSecond).or_insert(0) += 1; - } else { - *supply.entry(ShiftType::Closed).or_insert(0) += 1; - } - } - supply - } - - /// 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_max_workloads(&mut self, config: &UserConfig) { - let auto_computed_residents: Vec<_> = config - .residents + pub fn calculate_max_workloads(&mut self, residents: &Vec, total_slots: u8) { + let non_manual_residents: Vec<_> = residents .iter() .filter(|r| r.max_shifts.is_none()) .collect(); - // if all residents have a manually set max shifts size, just use those values for the max workload - if auto_computed_residents.is_empty() { - for r in &config.residents { + // all residents' max workload were manually inserted + if non_manual_residents.is_empty() { + for r in residents { self.max_workloads.insert(r.id, r.max_shifts.unwrap_or(0)); } return; } - // Untested scenario: Resident has manual max_shifts and also reduced workload flag - // Probably should forbid using both options from GUI + let total_manual_workload: u8 = residents.iter().map(|r| r.max_shifts.unwrap_or(0)).sum(); + let remaining_slots = total_slots - total_manual_workload; + let workload_share = remaining_slots.div_ceil(non_manual_residents.len() as u8); - let manual_max_shifts_sum: u8 = config - .residents - .iter() - .map(|r| r.max_shifts.unwrap_or(0)) - .sum(); - - let max_shifts_ceiling = ((config.total_slots - manual_max_shifts_sum) as f32 - / auto_computed_residents.len() as f32) - .ceil() as u8; - - for r in &config.residents { - let max_shifts = match r.max_shifts { - Some(shifts) => shifts, - None if r.reduced_load => max_shifts_ceiling - 1, - None => max_shifts_ceiling, + for r in residents { + let max_workload = match r.max_shifts { + Some(max_shifts) => max_shifts, + None if r.reduced_load => workload_share - 1, + None => workload_share, }; - self.max_workloads.insert(r.id, max_shifts); + self.max_workloads.insert(r.id, max_workload); } } - pub fn calculate_max_holiday_shifts(&mut self, config: &UserConfig) { - let total_slots = config.total_slots; - let total_holiday_slots = config.total_holiday_slots; - - for r in &config.residents { - let workload_limit = *self.max_workloads.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.max_holiday_shifts.insert(r.id, holiday_limit); + pub fn calculate_max_holiday_shifts( + &mut self, + residents: &Vec, + total_holiday_slots: u8, + ) { + let total_residents = residents.len(); + let holiday_share = total_holiday_slots.div_ceil(total_residents as u8); + for r in residents { + self.max_holiday_shifts.insert(r.id, holiday_share); } } - pub fn calculate_max_by_shift_type(&mut self, config: &UserConfig) { - let mut supply_by_shift_type = self.get_initial_supply(config); - let mut local_limits = HashMap::new(); - let mut local_thresholds = HashMap::new(); - - let all_shift_types = [ + pub fn calculate_max_by_shift_type(&mut self, residents: &Vec) { + let mut upper_limits = HashMap::new(); + let shift_types = [ ShiftType::OpenFirst, ShiftType::OpenSecond, ShiftType::Closed, ]; - // residents with 1 available shift types - for res in config - .residents - .iter() - .filter(|r| r.allowed_types.len() == 1) - { - let shift_type = res.allowed_types[0]; - let total_limit = *self.max_workloads.get(&res.id).unwrap_or(&0); + for r in residents { + let total_limit = *self.max_workloads.get(&r.id).unwrap_or(&0); + let n_allowed = r.allowed_types.len(); - local_limits.insert((res.id, shift_type), total_limit); - local_thresholds.insert((res.id, shift_type), total_limit.saturating_sub(2)); - - for other_type in all_shift_types { - if other_type != shift_type { - local_limits.insert((res.id, other_type), 0); - local_thresholds.insert((res.id, other_type), 0); - } - } - - if let Some(s) = supply_by_shift_type.get_mut(&shift_type) { - *s = s.saturating_sub(total_limit) - } - } - - // residents with 2 available shift types - for res in config - .residents - .iter() - .filter(|r| r.allowed_types.len() == 2) - { - let total_limit = *self.max_workloads.get(&res.id).unwrap_or(&0); - let per_type = ((total_limit as f32) / 2.0).ceil() as u8; - - let deduct_amount = (total_limit as f32 / 2.0) as u8; - - for shift_type in all_shift_types { - if res.allowed_types.contains(&shift_type) { - local_limits.insert((res.id, shift_type), per_type); - local_thresholds.insert((res.id, shift_type), per_type.saturating_sub(2)); - if let Some(s) = supply_by_shift_type.get_mut(&shift_type) { - *s = s.saturating_sub(deduct_amount); + for shift_type in shift_types { + let limit = if r.allowed_types.contains(&shift_type) { + if n_allowed == 1 { + total_limit + } else { + (total_limit as f32 / n_allowed as f32).floor() as u8 + 1 } } else { - local_limits.insert((res.id, shift_type), 0); - local_thresholds.insert((res.id, shift_type), 0); - } + 0 + }; + + upper_limits.insert((r.id, shift_type), limit); } } - - // residents with 3 available shift types - for res in config - .residents - .iter() - .filter(|r| r.allowed_types.len() == 3) - { - let total_limit = *self.max_workloads.get(&res.id).unwrap_or(&0); - let per_type = ((total_limit as f32) / 3.0).ceil() as u8; - - let deduct_amount = (total_limit as f32 / 3.0) as u8; - - for shift_type in all_shift_types { - if res.allowed_types.contains(&shift_type) { - local_limits.insert((res.id, shift_type), per_type); - local_thresholds.insert((res.id, shift_type), per_type.saturating_sub(2)); - if let Some(s) = supply_by_shift_type.get_mut(&shift_type) { - *s = s.saturating_sub(deduct_amount); - } - } else { - local_limits.insert((res.id, shift_type), 0); - local_thresholds.insert((res.id, shift_type), 0); - } - } - } - - self.max_by_shift_type = local_limits; - self.min_by_shift_type = local_thresholds; + self.max_by_shift_type = upper_limits; } } @@ -192,71 +108,47 @@ pub struct WorkloadTracker { } impl WorkloadTracker { - pub fn insert(&mut self, res_id: ResidentId, config: &UserConfig, slot: Slot) { - *self.total_counts.entry(res_id).or_insert(0) += 1; + pub fn insert(&mut self, r_id: ResidentId, config: &UserConfig, slot: Slot) { + *self.total_counts.entry(r_id).or_insert(0) += 1; *self .type_counts - .entry((res_id, slot.shift_type())) + .entry((r_id, slot.shift_type())) .or_insert(0) += 1; if config.is_holiday_or_weekend_slot(slot.day.0) { - *self.holidays.entry(res_id).or_insert(0) += 1; + *self.holidays.entry(r_id).or_insert(0) += 1; } } - pub fn remove(&mut self, resident_id: ResidentId, config: &UserConfig, slot: Slot) { - if let Some(count) = self.total_counts.get_mut(&resident_id) { + pub fn remove(&mut self, r_id: ResidentId, config: &UserConfig, slot: Slot) { + if let Some(count) = self.total_counts.get_mut(&r_id) { *count = count.saturating_sub(1); } - if let Some(count) = self.type_counts.get_mut(&(resident_id, slot.shift_type())) { + if let Some(count) = self.type_counts.get_mut(&(r_id, slot.shift_type())) { *count = count.saturating_sub(1); } if config.is_holiday_or_weekend_slot(slot.day.0) { - if let Some(count) = self.holidays.get_mut(&resident_id) { + if let Some(count) = self.holidays.get_mut(&r_id) { *count = count.saturating_sub(1); } } } - pub fn current_workload(&self, res_id: &ResidentId) -> u8 { - *self.total_counts.get(res_id).unwrap_or(&0) + pub fn current_workload(&self, r_id: &ResidentId) -> u8 { + *self.total_counts.get(r_id).unwrap_or(&0) } - pub fn current_holiday_workload(&self, resident_id: &ResidentId) -> u8 { - *self.holidays.get(resident_id).unwrap_or(&0) + pub fn current_holiday_workload(&self, r_id: &ResidentId) -> u8 { + *self.holidays.get(r_id).unwrap_or(&0) } - pub fn are_all_thresholds_met(&self, config: &UserConfig, bounds: &WorkloadBounds) -> bool { - const SHIFT_TYPES: [ShiftType; 3] = [ - ShiftType::OpenFirst, - ShiftType::OpenSecond, - ShiftType::Closed, - ]; + pub fn reached_workload_limit(&self, bounds: &WorkloadBounds, r_id: &ResidentId) -> bool { + let current_load = self.current_workload(r_id); - for r in &config.residents { - for shift_type in SHIFT_TYPES { - let current_load = self.type_counts.get(&(r.id, shift_type)).unwrap_or(&0); - if let Some(&min) = bounds.min_by_shift_type.get(&(r.id, shift_type)) { - if *current_load < min { - return false; - } - } - } - } - true - } - - pub fn is_total_workload_exceeded( - &self, - bounds: &WorkloadBounds, - resident_id: &ResidentId, - ) -> bool { - let current_load = self.current_workload(resident_id); - - if let Some(&max) = bounds.max_workloads.get(resident_id) { - if current_load > max { + if let Some(&max) = bounds.max_workloads.get(r_id) { + if current_load >= max { return true; } } @@ -264,15 +156,11 @@ impl WorkloadTracker { false } - pub fn is_holiday_workload_exceeded( - &self, - bounds: &WorkloadBounds, - resident_id: &ResidentId, - ) -> bool { - let current_load = self.current_holiday_workload(resident_id); + pub fn reached_holiday_limit(&self, bounds: &WorkloadBounds, r_id: &ResidentId) -> bool { + let current_load = self.current_holiday_workload(r_id); - if let Some(&max) = bounds.max_holiday_shifts.get(resident_id) { - if current_load > max { + if let Some(&max) = bounds.max_holiday_shifts.get(r_id) { + if current_load >= max { return true; } } @@ -280,27 +168,24 @@ impl WorkloadTracker { false } - pub fn is_max_shift_type_exceeded( + pub fn reached_shift_type_limit( &self, bounds: &WorkloadBounds, - resident_id: &ResidentId, + r_id: &ResidentId, slot: &Slot, ) -> bool { let shift_type = slot.shift_type(); - let current_load = self - .type_counts - .get(&(*resident_id, shift_type)) - .unwrap_or(&0); + let current_load = self.type_counts.get(&(*r_id, shift_type)).unwrap_or(&0); - if let Some(&max) = bounds.max_by_shift_type.get(&(*resident_id, shift_type)) { - return *current_load > max; + if let Some(&max) = bounds.max_by_shift_type.get(&(*r_id, shift_type)) { + return *current_load >= max; } false } - pub fn get_type_count(&self, res_id: &ResidentId, shift_type: ShiftType) -> u8 { - *self.type_counts.get(&(*res_id, shift_type)).unwrap_or(&0) + pub fn get_type_count(&self, r_id: &ResidentId, shift_type: ShiftType) -> u8 { + *self.type_counts.get(&(*r_id, shift_type)).unwrap_or(&0) } } @@ -308,7 +193,9 @@ impl WorkloadTracker { mod tests { use crate::{ config::UserConfig, + fixtures::{complex_config, hard_config, minimal_config}, resident::{Resident, ResidentId}, + schedule::ShiftType, slot::{Day, ShiftPosition, Slot}, workload::{WorkloadBounds, WorkloadTracker}, }; @@ -317,11 +204,11 @@ mod tests { #[fixture] fn config() -> UserConfig { UserConfig::default().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"), + Resident::new(1, "R1").with_max_shifts(2), + Resident::new(2, "R2").with_max_shifts(2), + Resident::new(3, "R3").with_reduced_load(), + Resident::new(4, "R4"), + Resident::new(5, "R5"), ]) } @@ -333,53 +220,252 @@ mod tests { #[rstest] fn test_max_workloads(config: UserConfig) { let bounds = WorkloadBounds::new_with_config(&config); - - assert_eq!(bounds.max_workloads[&ResidentId(1)], 2); - assert_eq!(bounds.max_workloads[&ResidentId(2)], 2); - assert!(bounds.max_workloads[&ResidentId(3)] > 0); + assert_eq!(2, bounds.max_workloads[&ResidentId(1)]); + assert_eq!(2, bounds.max_workloads[&ResidentId(2)]); + assert_eq!(13, bounds.max_workloads[&ResidentId(3)]); + assert_eq!(14, bounds.max_workloads[&ResidentId(4)]); + assert_eq!(14, bounds.max_workloads[&ResidentId(5)]); } #[rstest] - fn test_is_total_workload_exceeded(mut tracker: WorkloadTracker, config: UserConfig) { - let res_id = ResidentId(1); + fn test_reached_workload_limit(mut tracker: WorkloadTracker, config: UserConfig) { + let r_id = ResidentId(1); let mut bounds = WorkloadBounds::default(); - bounds.max_workloads.insert(res_id, 1); + bounds.max_workloads.insert(r_id, 1); let slot_1 = Slot::new(Day(1), ShiftPosition::First); let slot_2 = Slot::new(Day(2), ShiftPosition::First); - tracker.insert(res_id, &config, slot_1); - assert!(!tracker.is_total_workload_exceeded(&bounds, &res_id,)); + assert!(!tracker.reached_workload_limit(&bounds, &r_id,)); - tracker.insert(res_id, &config, slot_2); - assert!(tracker.is_total_workload_exceeded(&bounds, &res_id,)); + tracker.insert(r_id, &config, slot_1); + assert!(tracker.reached_workload_limit(&bounds, &r_id,)); + + tracker.insert(r_id, &config, slot_2); + assert!(tracker.reached_workload_limit(&bounds, &r_id,)); } #[rstest] - fn test_is_holiday_workload_exceeded(mut tracker: WorkloadTracker, config: UserConfig) { - let res_id = ResidentId(1); + fn test_reached_holiday_limit(mut tracker: WorkloadTracker, config: UserConfig) { + let r_id = ResidentId(1); let mut bounds = WorkloadBounds::default(); - bounds.max_holiday_shifts.insert(res_id, 1); + bounds.max_holiday_shifts.insert(r_id, 1); - let sat = Slot::new(Day(7), ShiftPosition::First); - let sun = Slot::new(Day(8), ShiftPosition::First); + let sat = Slot::new(Day(11), ShiftPosition::First); + let sun = Slot::new(Day(12), ShiftPosition::First); - tracker.insert(res_id, &config, sat); - assert!(!tracker.is_holiday_workload_exceeded(&bounds, &res_id)); + assert!(!tracker.reached_holiday_limit(&bounds, &r_id)); - tracker.insert(res_id, &config, sun); - assert!(tracker.is_holiday_workload_exceeded(&bounds, &res_id)); + tracker.insert(r_id, &config, sat); + assert!(tracker.reached_holiday_limit(&bounds, &r_id)); + + tracker.insert(r_id, &config, sun); + assert!(tracker.reached_holiday_limit(&bounds, &r_id)); } #[rstest] fn test_backtracking_accuracy(mut tracker: WorkloadTracker, config: UserConfig) { - let res_id = ResidentId(1); + let r_id = ResidentId(1); let slot = Slot::new(Day(1), ShiftPosition::First); - tracker.insert(res_id, &config, slot); - assert_eq!(tracker.current_workload(&res_id), 1); + tracker.insert(r_id, &config, slot); + assert_eq!(tracker.current_workload(&r_id), 1); - tracker.remove(res_id, &config, slot); - assert_eq!(tracker.current_workload(&res_id), 0); + tracker.remove(r_id, &config, slot); + assert_eq!(tracker.current_workload(&r_id), 0); + } + + #[rstest] + fn test_calculate_max_workloads_minimal(minimal_config: UserConfig) { + let mut bounds = WorkloadBounds::default(); + bounds.calculate_max_workloads(&minimal_config.residents, minimal_config.total_slots); + + assert_eq!(9, *bounds.max_workloads.get(&ResidentId(1)).unwrap()); + assert_eq!(9, *bounds.max_workloads.get(&ResidentId(2)).unwrap()); + assert_eq!(9, *bounds.max_workloads.get(&ResidentId(3)).unwrap()); + assert_eq!(9, *bounds.max_workloads.get(&ResidentId(4)).unwrap()); + assert_eq!(9, *bounds.max_workloads.get(&ResidentId(5)).unwrap()); + } + + #[rstest] + fn test_calculate_max_workloads_complex(complex_config: UserConfig) { + let mut bounds = WorkloadBounds::default(); + bounds.calculate_max_workloads(&complex_config.residents, complex_config.total_slots); + + assert_eq!(3, *bounds.max_workloads.get(&ResidentId(1)).unwrap()); + assert_eq!(3, *bounds.max_workloads.get(&ResidentId(2)).unwrap()); + assert_eq!(3, *bounds.max_workloads.get(&ResidentId(3)).unwrap()); + + assert_eq!(6, *bounds.max_workloads.get(&ResidentId(4)).unwrap()); + assert_eq!(6, *bounds.max_workloads.get(&ResidentId(5)).unwrap()); + assert_eq!(6, *bounds.max_workloads.get(&ResidentId(6)).unwrap()); + assert_eq!(6, *bounds.max_workloads.get(&ResidentId(7)).unwrap()); + assert_eq!(6, *bounds.max_workloads.get(&ResidentId(8)).unwrap()); + assert_eq!(6, *bounds.max_workloads.get(&ResidentId(9)).unwrap()); + } + + #[rstest] + fn test_calculate_max_workloads_hard(hard_config: UserConfig) { + let mut bounds = WorkloadBounds::default(); + bounds.calculate_max_workloads(&hard_config.residents, hard_config.total_slots); + + assert_eq!(5, *bounds.max_workloads.get(&ResidentId(6)).unwrap()); + assert_eq!(5, *bounds.max_workloads.get(&ResidentId(7)).unwrap()); + assert_eq!(5, *bounds.max_workloads.get(&ResidentId(8)).unwrap()); + + assert_eq!(6, *bounds.max_workloads.get(&ResidentId(1)).unwrap()); + assert_eq!(6, *bounds.max_workloads.get(&ResidentId(2)).unwrap()); + assert_eq!(6, *bounds.max_workloads.get(&ResidentId(3)).unwrap()); + assert_eq!(6, *bounds.max_workloads.get(&ResidentId(4)).unwrap()); + assert_eq!(6, *bounds.max_workloads.get(&ResidentId(5)).unwrap()); + } + + #[rstest] + fn test_calculate_max_holiday_shifts_complex(complex_config: UserConfig) { + let mut bounds = WorkloadBounds::default(); + bounds.calculate_max_holiday_shifts( + &complex_config.residents, + complex_config.total_holiday_slots, + ); + + for i in 1..=9 { + assert_eq!(2, *bounds.max_holiday_shifts.get(&ResidentId(i)).unwrap()); + } + } + + #[rstest] + fn test_calculate_max_holiday_shifts_minimal(minimal_config: UserConfig) { + let mut bounds = WorkloadBounds::default(); + bounds.calculate_max_holiday_shifts( + &minimal_config.residents, + minimal_config.total_holiday_slots, + ); + + for i in 1..=5 { + assert_eq!(3, *bounds.max_holiday_shifts.get(&ResidentId(i)).unwrap()); + } + } + + #[rstest] + fn test_calculate_max_holiday_shifts_hard(hard_config: UserConfig) { + let mut bounds = WorkloadBounds::default(); + bounds + .calculate_max_holiday_shifts(&hard_config.residents, hard_config.total_holiday_slots); + + for i in 1..=8 { + assert_eq!(2, *bounds.max_holiday_shifts.get(&ResidentId(i)).unwrap()); + } + } + + #[rstest] + fn test_calculate_max_by_shift_type_minimal(minimal_config: UserConfig) { + let mut bounds = WorkloadBounds::default(); + bounds.calculate_max_workloads(&minimal_config.residents, minimal_config.total_slots); + bounds.calculate_max_by_shift_type(&minimal_config.residents); + let m = bounds.max_by_shift_type; + + assert_eq!(4, *m.get(&(ResidentId(1), ShiftType::OpenFirst)).unwrap()); + assert_eq!(4, *m.get(&(ResidentId(1), ShiftType::OpenSecond)).unwrap()); + assert_eq!(4, *m.get(&(ResidentId(1), ShiftType::Closed)).unwrap()); + + assert_eq!(4, *m.get(&(ResidentId(2), ShiftType::OpenFirst)).unwrap()); + assert_eq!(4, *m.get(&(ResidentId(2), ShiftType::OpenSecond)).unwrap()); + assert_eq!(4, *m.get(&(ResidentId(2), ShiftType::Closed)).unwrap()); + + assert_eq!(4, *m.get(&(ResidentId(3), ShiftType::OpenFirst)).unwrap()); + assert_eq!(4, *m.get(&(ResidentId(3), ShiftType::OpenSecond)).unwrap()); + assert_eq!(4, *m.get(&(ResidentId(3), ShiftType::Closed)).unwrap()); + + assert_eq!(4, *m.get(&(ResidentId(4), ShiftType::OpenFirst)).unwrap()); + assert_eq!(4, *m.get(&(ResidentId(4), ShiftType::OpenSecond)).unwrap()); + assert_eq!(4, *m.get(&(ResidentId(4), ShiftType::Closed)).unwrap()); + + assert_eq!(4, *m.get(&(ResidentId(5), ShiftType::OpenFirst)).unwrap()); + assert_eq!(4, *m.get(&(ResidentId(5), ShiftType::OpenSecond)).unwrap()); + assert_eq!(4, *m.get(&(ResidentId(5), ShiftType::Closed)).unwrap()); + } + + #[rstest] + fn test_calculate_max_by_shift_type_complex(complex_config: UserConfig) { + let mut bounds = WorkloadBounds::default(); + bounds.calculate_max_workloads(&complex_config.residents, complex_config.total_slots); + bounds.calculate_max_by_shift_type(&complex_config.residents); + let m = bounds.max_by_shift_type; + + assert_eq!(2, *m.get(&(ResidentId(1), ShiftType::OpenFirst)).unwrap()); + assert_eq!(2, *m.get(&(ResidentId(1), ShiftType::OpenSecond)).unwrap()); + assert_eq!(2, *m.get(&(ResidentId(1), ShiftType::Closed)).unwrap()); + + assert_eq!(2, *m.get(&(ResidentId(2), ShiftType::OpenFirst)).unwrap()); + assert_eq!(2, *m.get(&(ResidentId(2), ShiftType::OpenSecond)).unwrap()); + assert_eq!(2, *m.get(&(ResidentId(2), ShiftType::Closed)).unwrap()); + + assert_eq!(2, *m.get(&(ResidentId(3), ShiftType::OpenFirst)).unwrap()); + assert_eq!(2, *m.get(&(ResidentId(3), ShiftType::OpenSecond)).unwrap()); + assert_eq!(2, *m.get(&(ResidentId(3), ShiftType::Closed)).unwrap()); + + assert_eq!(0, *m.get(&(ResidentId(4), ShiftType::OpenFirst)).unwrap()); + assert_eq!(0, *m.get(&(ResidentId(4), ShiftType::OpenSecond)).unwrap()); + assert_eq!(6, *m.get(&(ResidentId(4), ShiftType::Closed)).unwrap()); + + assert_eq!(4, *m.get(&(ResidentId(5), ShiftType::OpenFirst)).unwrap()); + assert_eq!(4, *m.get(&(ResidentId(5), ShiftType::OpenSecond)).unwrap()); + assert_eq!(0, *m.get(&(ResidentId(5), ShiftType::Closed)).unwrap()); + + assert_eq!(3, *m.get(&(ResidentId(6), ShiftType::OpenFirst)).unwrap()); + assert_eq!(3, *m.get(&(ResidentId(6), ShiftType::OpenSecond)).unwrap()); + assert_eq!(3, *m.get(&(ResidentId(6), ShiftType::Closed)).unwrap()); + + assert_eq!(3, *m.get(&(ResidentId(7), ShiftType::OpenFirst)).unwrap()); + assert_eq!(3, *m.get(&(ResidentId(7), ShiftType::OpenSecond)).unwrap()); + assert_eq!(3, *m.get(&(ResidentId(7), ShiftType::Closed)).unwrap()); + + assert_eq!(3, *m.get(&(ResidentId(8), ShiftType::OpenFirst)).unwrap()); + assert_eq!(3, *m.get(&(ResidentId(8), ShiftType::OpenSecond)).unwrap()); + assert_eq!(3, *m.get(&(ResidentId(8), ShiftType::Closed)).unwrap()); + + assert_eq!(3, *m.get(&(ResidentId(9), ShiftType::OpenFirst)).unwrap()); + assert_eq!(3, *m.get(&(ResidentId(9), ShiftType::OpenSecond)).unwrap()); + assert_eq!(3, *m.get(&(ResidentId(9), ShiftType::Closed)).unwrap()); + } + + #[rstest] + fn test_calculate_max_by_shift_type_hard(hard_config: UserConfig) { + let mut bounds = WorkloadBounds::default(); + bounds.calculate_max_workloads(&hard_config.residents, hard_config.total_slots); + bounds.calculate_max_by_shift_type(&hard_config.residents); + let m = bounds.max_by_shift_type; + + assert_eq!(3, *m.get(&(ResidentId(1), ShiftType::OpenFirst)).unwrap()); + assert_eq!(3, *m.get(&(ResidentId(1), ShiftType::OpenSecond)).unwrap()); + assert_eq!(3, *m.get(&(ResidentId(1), ShiftType::Closed)).unwrap()); + + assert_eq!(3, *m.get(&(ResidentId(2), ShiftType::OpenFirst)).unwrap()); + assert_eq!(3, *m.get(&(ResidentId(2), ShiftType::OpenSecond)).unwrap()); + assert_eq!(3, *m.get(&(ResidentId(2), ShiftType::Closed)).unwrap()); + + assert_eq!(3, *m.get(&(ResidentId(3), ShiftType::OpenFirst)).unwrap()); + assert_eq!(3, *m.get(&(ResidentId(3), ShiftType::OpenSecond)).unwrap()); + assert_eq!(3, *m.get(&(ResidentId(3), ShiftType::Closed)).unwrap()); + + assert_eq!(3, *m.get(&(ResidentId(4), ShiftType::OpenFirst)).unwrap()); + assert_eq!(3, *m.get(&(ResidentId(4), ShiftType::OpenSecond)).unwrap()); + assert_eq!(3, *m.get(&(ResidentId(4), ShiftType::Closed)).unwrap()); + + assert_eq!(3, *m.get(&(ResidentId(5), ShiftType::OpenFirst)).unwrap()); + assert_eq!(3, *m.get(&(ResidentId(5), ShiftType::OpenSecond)).unwrap()); + assert_eq!(3, *m.get(&(ResidentId(5), ShiftType::Closed)).unwrap()); + + assert_eq!(0, *m.get(&(ResidentId(6), ShiftType::OpenFirst)).unwrap()); + assert_eq!(5, *m.get(&(ResidentId(6), ShiftType::OpenSecond)).unwrap()); + assert_eq!(0, *m.get(&(ResidentId(6), ShiftType::Closed)).unwrap()); + + assert_eq!(2, *m.get(&(ResidentId(7), ShiftType::OpenFirst)).unwrap()); + assert_eq!(2, *m.get(&(ResidentId(7), ShiftType::OpenSecond)).unwrap()); + assert_eq!(2, *m.get(&(ResidentId(7), ShiftType::Closed)).unwrap()); + + assert_eq!(0, *m.get(&(ResidentId(8), ShiftType::OpenFirst)).unwrap()); + assert_eq!(5, *m.get(&(ResidentId(8), ShiftType::OpenSecond)).unwrap()); + assert_eq!(0, *m.get(&(ResidentId(8), ShiftType::Closed)).unwrap()); } } diff --git a/src-tauri/tests/integration.rs b/src-tauri/tests/integration.rs index afcf1d8..952d11e 100644 --- a/src-tauri/tests/integration.rs +++ b/src-tauri/tests/integration.rs @@ -2,94 +2,21 @@ mod integration_tests { use rota_lib::{ config::{ToxicPair, UserConfig}, - resident::Resident, - schedule::{MonthlySchedule, ShiftType}, + fixtures::{ + complex_config, hard_config, manual_shifts_heavy_config, maximal_config, minimal_config, + }, + schedule::MonthlySchedule, scheduler::Scheduler, slot::{Day, ShiftPosition, Slot}, workload::{WorkloadBounds, WorkloadTracker}, }; - use rstest::{fixture, rstest}; + use rstest::rstest; - #[fixture] - fn minimal_config() -> UserConfig { - UserConfig::default().with_residents(vec![ - Resident::new(1, "R1"), - Resident::new(2, "R2"), - Resident::new(3, "R3"), - Resident::new(4, "R4"), - ]) - } - - #[fixture] - fn maximal_config() -> UserConfig { - UserConfig::default() - .with_holidays(vec![2, 3, 10, 11, 12, 25]) - .with_residents(vec![ - Resident::new(1, "R1").with_max_shifts(3), - Resident::new(2, "R2").with_max_shifts(4), - Resident::new(3, "R3").with_reduced_load(), - Resident::new(4, "R4").with_allowed_types(vec![ShiftType::Closed]), - Resident::new(5, "R5") - .with_allowed_types(vec![ShiftType::OpenFirst, ShiftType::OpenSecond]), - Resident::new(6, "R6").with_negative_shifts(vec![Day(5), Day(15), Day(25)]), - Resident::new(7, "R7"), - Resident::new(8, "R8"), - Resident::new(9, "R9"), - Resident::new(10, "R10"), - ]) - .with_toxic_pairs(vec![ - ToxicPair::new(1, 2), - ToxicPair::new(3, 4), - ToxicPair::new(7, 8), - ]) - } - - #[fixture] - fn manual_shifts_heavy_config() -> UserConfig { - UserConfig::default().with_residents(vec![ - Resident::new(1, "R1").with_manual_shifts(vec![ - Slot::new(Day(1), ShiftPosition::First), - Slot::new(Day(3), ShiftPosition::First), - Slot::new(Day(5), ShiftPosition::Second), - ]), - Resident::new(2, "R2").with_manual_shifts(vec![ - Slot::new(Day(2), ShiftPosition::First), - Slot::new(Day(4), ShiftPosition::First), - ]), - Resident::new(3, "R3"), - Resident::new(4, "R4"), - Resident::new(5, "R5"), - Resident::new(6, "R6"), - ]) - } - - #[fixture] - fn complex_config() -> UserConfig { - UserConfig::default() - .with_holidays(vec![5, 12, 19, 26]) - .with_residents(vec![ - Resident::new(1, "R1") - .with_max_shifts(3) - .with_negative_shifts(vec![Day(1), Day(2), Day(3)]), - Resident::new(2, "R2") - .with_max_shifts(3) - .with_negative_shifts(vec![Day(4), Day(5), Day(6)]), - Resident::new(3, "R3") - .with_max_shifts(3) - .with_negative_shifts(vec![Day(7), Day(8), Day(9)]), - Resident::new(4, "R4").with_allowed_types(vec![ShiftType::Closed]), - Resident::new(5, "R5") - .with_allowed_types(vec![ShiftType::OpenFirst, ShiftType::OpenSecond]), - Resident::new(6, "R6"), - Resident::new(7, "R7"), - Resident::new(8, "R8"), - ]) - .with_toxic_pairs(vec![ - ToxicPair::new(1, 2), - ToxicPair::new(2, 3), - ToxicPair::new(5, 6), - ToxicPair::new(6, 7), - ]) + #[ctor::ctor] + fn global_setup() { + env_logger::builder() + .filter_level(log::LevelFilter::Info) + .init(); } #[rstest] @@ -98,8 +25,9 @@ mod integration_tests { let mut tracker = WorkloadTracker::default(); let scheduler = Scheduler::new_with_config(minimal_config.clone()); - assert!(scheduler.run(&mut schedule, &mut tracker)?); - + let solved = scheduler.run(&mut schedule, &mut tracker)?; + println!("{}", schedule.report(&minimal_config, &tracker)); + assert!(solved); validate_all_constraints(&schedule, &tracker, &minimal_config); Ok(()) @@ -111,8 +39,9 @@ mod integration_tests { let mut tracker = WorkloadTracker::default(); let scheduler = Scheduler::new_with_config(maximal_config.clone()); - assert!(scheduler.run(&mut schedule, &mut tracker)?); - + let solved = scheduler.run(&mut schedule, &mut tracker)?; + println!("{}", schedule.report(&maximal_config, &tracker)); + assert!(solved); validate_all_constraints(&schedule, &tracker, &maximal_config); Ok(()) @@ -126,8 +55,9 @@ mod integration_tests { let mut tracker = WorkloadTracker::default(); let scheduler = Scheduler::new_with_config(manual_shifts_heavy_config.clone()); - assert!(scheduler.run(&mut schedule, &mut tracker)?); - + let solved = scheduler.run(&mut schedule, &mut tracker)?; + println!("{}", schedule.report(&manual_shifts_heavy_config, &tracker)); + assert!(solved); validate_all_constraints(&schedule, &tracker, &manual_shifts_heavy_config); Ok(()) @@ -139,13 +69,28 @@ mod integration_tests { let mut tracker = WorkloadTracker::default(); let scheduler = Scheduler::new_with_config(complex_config.clone()); - assert!(scheduler.run(&mut schedule, &mut tracker)?); - + let solved = scheduler.run(&mut schedule, &mut tracker)?; + println!("{}", schedule.report(&complex_config, &tracker)); + assert!(solved); validate_all_constraints(&schedule, &tracker, &complex_config); Ok(()) } + #[rstest] + fn test_hard_config(hard_config: UserConfig) -> anyhow::Result<()> { + let mut schedule = MonthlySchedule::new(); + let mut tracker = WorkloadTracker::default(); + let scheduler = Scheduler::new_with_config(hard_config.clone()); + + let solved = scheduler.run(&mut schedule, &mut tracker)?; + println!("{}", schedule.report(&hard_config, &tracker)); + assert!(solved); + validate_all_constraints(&schedule, &tracker, &hard_config); + + Ok(()) + } + fn validate_all_constraints( schedule: &MonthlySchedule, tracker: &WorkloadTracker, @@ -162,8 +107,8 @@ mod integration_tests { .iter() .filter_map(|&p| schedule.get_resident_id(&Slot::new(Day(d - 1), p))) .collect(); - for res in current { - assert!(!previous.contains(&res)); + for r in current { + assert!(!previous.contains(&r)); } } @@ -181,20 +126,20 @@ mod integration_tests { } let bounds = WorkloadBounds::new_with_config(config); - for (slot, res_id) in &schedule.0 { - let res = config + for (slot, r_id) in &schedule.0 { + let r = config .residents .iter() - .find(|r| &r.id == res_id) + .find(|r| &r.id == r_id) .expect("Resident not found"); - assert!(res.allowed_types.contains(&slot.shift_type())); - assert!(!res.negative_shifts.contains(&slot.day)); + assert!(r.allowed_types.contains(&slot.shift_type())); + assert!(!r.negative_shifts.contains(&slot.day)); } for resident in &config.residents { let workload = tracker.current_workload(&resident.id); let max = *bounds.max_workloads.get(&resident.id).unwrap(); - assert!(workload <= max); + assert!(workload <= max, "workload: {}, max: {}", workload, max); } } }