Remove min by shift type boundaries, improve logging, add more tests, add logs in tests, move fixtures in separate file, move restrictions_violated logic into valid_residents of next slot

This commit is contained in:
2026-02-21 23:46:26 +02:00
parent c291328bfa
commit a41d1cd469
15 changed files with 856 additions and 453 deletions

View File

@@ -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

View File

@@ -8,3 +8,5 @@
# Ignore exported txt/doc files and the log file
rota.*
mutants.*

181
src-tauri/Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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<ShiftType, u8> {
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<UserConfigDTO> 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());
}
}

View File

@@ -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<anyhow::Error> for SearchError {
fn from(err: anyhow::Error) -> Self {
SearchError::Config(err.to_string())

View File

@@ -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"),
])
}

177
src-tauri/src/fixtures.rs Normal file
View File

@@ -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),
])
}

View File

@@ -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;
#[rstest]
pub fn test_endpoints() {}
#[ctor]
fn global_setup() {
env_logger::builder()
.filter_level(log::LevelFilter::Info)
.is_test(true)
.try_init();
}
#[rstest]
pub fn test_endpoints() {
// see how tauri mocks endpoint tests
}
}

View File

@@ -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))
}

View File

@@ -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,26 +114,21 @@ 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) {
if self.found_solution(slot) {
solved_in_thread.store(true, Ordering::Relaxed);
return Ok(true);
}
return Ok(false);
}
if schedule.is_slot_manually_assigned(&slot) {
return self.search(schedule, tracker, slot.next(), solved_in_thread);
}
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<ResidentId> {
let required_type = slot.shift_type();
let other_resident = slot
pub fn valid_residents(
&self,
slot: Slot,
schedule: &MonthlySchedule,
tracker: &WorkloadTracker,
) -> Vec<ResidentId> {
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());
}
}

View File

@@ -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,
},

View File

@@ -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,

View File

@@ -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<ResidentId, u8>,
pub max_holiday_shifts: HashMap<ResidentId, u8>,
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::<u8>() >= total_slots);
bounds.calculate_max_holiday_shifts(residents, total_holiday_slots);
debug_assert!(bounds.max_holiday_shifts.values().sum::<u8>() >= total_holiday_slots);
bounds.calculate_max_by_shift_type(residents);
debug_assert!(bounds.max_by_shift_type.values().sum::<u8>() >= 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<ShiftType, u8> {
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<Resident>, 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<Resident>,
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<Resident>) {
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
};
// 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);
upper_limits.insert((r.id, shift_type), limit);
}
}
}
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());
}
}

View File

@@ -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);
}
}
}