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 cd {{tauri_path}} && cargo clippy
test: test:
cd {{tauri_path}} && cargo test --lib --release cd {{tauri_path}} && cargo test --lib -- --test-threads=1
test-integration: test-integration:
cd {{tauri_path}} && cargo test --test integration --release cd {{tauri_path}} && cargo test --test integration -- --test-threads=1 --nocapture
test-all: test-all:
cd {{tauri_path}} && cargo test --release -- --nocapture cd {{tauri_path}} && cargo test --release
bench: bench:
cd {{tauri_path}} && cargo bench cd {{tauri_path}} && cargo bench

View File

@@ -8,3 +8,5 @@
# Ignore exported txt/doc files and the log file # Ignore exported txt/doc files and the log file
rota.* 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" checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
dependencies = [ dependencies = [
"android_log-sys", "android_log-sys",
"env_filter", "env_filter 0.1.4",
"log", "log",
] ]
@@ -84,12 +84,56 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" 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]] [[package]]
name = "anstyle" name = "anstyle"
version = "1.0.13" version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" 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]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.100" version = "1.0.100"
@@ -637,6 +681,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@@ -742,7 +792,7 @@ dependencies = [
"ciborium", "ciborium",
"clap", "clap",
"criterion-plot", "criterion-plot",
"itertools 0.13.0", "itertools",
"num-traits", "num-traits",
"oorandom", "oorandom",
"page_size", "page_size",
@@ -762,7 +812,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed943f81ea2faa8dcecbbfa50164acf95d555afec96a27871663b300e387b2e4" checksum = "ed943f81ea2faa8dcecbbfa50164acf95d555afec96a27871663b300e387b2e4"
dependencies = [ dependencies = [
"cast", "cast",
"itertools 0.13.0", "itertools",
] ]
[[package]] [[package]]
@@ -852,6 +902,22 @@ dependencies = [
"syn 2.0.111", "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]] [[package]]
name = "darling" name = "darling"
version = "0.21.3" version = "0.21.3"
@@ -1030,6 +1096,21 @@ dependencies = [
"dtoa", "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]] [[package]]
name = "dunce" name = "dunce"
version = "1.0.5" version = "1.0.5"
@@ -1105,6 +1186,29 @@ dependencies = [
"regex", "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]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -2033,6 +2137,12 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.13.0" version = "0.13.0"
@@ -2042,15 +2152,6 @@ dependencies = [
"either", "either",
] ]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.15" version = "1.0.15"
@@ -2080,6 +2181,30 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "jni" name = "jni"
version = "0.21.1" version = "0.21.1"
@@ -2668,6 +2793,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "oorandom" name = "oorandom"
version = "11.1.5" version = "11.1.5"
@@ -3009,6 +3140,21 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.4" version = "0.1.4"
@@ -3448,8 +3594,9 @@ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
"criterion", "criterion",
"ctor 0.6.3",
"docx-rs", "docx-rs",
"itertools 0.14.0", "env_logger",
"log", "log",
"rand 0.9.2", "rand 0.9.2",
"rayon", "rayon",
@@ -4340,7 +4487,7 @@ dependencies = [
"anyhow", "anyhow",
"brotli", "brotli",
"cargo_metadata", "cargo_metadata",
"ctor", "ctor 0.2.9",
"dunce", "dunce",
"glob", "glob",
"html5ever", "html5ever",
@@ -4868,6 +5015,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.19.0" version = "1.19.0"

View File

@@ -23,7 +23,6 @@ tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
chrono = "0.4.42" chrono = "0.4.42"
itertools = "0.14.0"
rstest = "0.26.1" rstest = "0.26.1"
tauri-plugin-log = "2" tauri-plugin-log = "2"
log = "0.4.29" log = "0.4.29"
@@ -35,6 +34,8 @@ thiserror = "2.0.18"
[dev-dependencies] [dev-dependencies]
criterion = { version = "0.8.1", features = ["html_reports"] } criterion = { version = "0.8.1", features = ["html_reports"] }
env_logger = "0.11"
ctor = "0.6.3"
[[bench]] [[bench]]
name = "rayon" name = "rayon"

View File

@@ -1,13 +1,16 @@
use std::collections::HashMap;
use anyhow::Context; use anyhow::Context;
use chrono::Month; use chrono::Month;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
resident::{Resident, ResidentDTO, ResidentId}, resident::{Resident, ResidentDTO, ResidentId},
schedule::ShiftType,
slot::Day, slot::Day,
}; };
const MONTH: u8 = 2; const MONTH: u8 = 4;
const YEAR: i32 = 2026; const YEAR: i32 = 2026;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -87,6 +90,21 @@ impl UserConfig {
day.is_weekend(self.month.number_from_month(), self.year) day.is_weekend(self.month.number_from_month(), self.year)
|| self.holidays.contains(&(day.0)) || 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 { 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 std::io;
use log::Level;
use serde::Serialize; use serde::Serialize;
use thiserror::Error; use thiserror::Error;
@@ -18,6 +19,16 @@ pub enum SearchError {
NoSolutionFound, 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 { impl From<anyhow::Error> for SearchError {
fn from(err: anyhow::Error) -> Self { fn from(err: anyhow::Error) -> Self {
SearchError::Config(err.to_string()) SearchError::Config(err.to_string())

View File

@@ -194,12 +194,12 @@ mod tests {
#[fixture] #[fixture]
fn config() -> UserConfig { fn config() -> UserConfig {
UserConfig::default().with_residents(vec![ UserConfig::default().with_residents(vec![
Resident::new(1, "Στέφανος"), Resident::new(1, "R1"),
Resident::new(2, "Ιορδάνης"), Resident::new(2, "R2"),
Resident::new(3, "Μαρία"), Resident::new(3, "R3"),
Resident::new(4, "Βεατρίκη"), Resident::new(4, "R4"),
Resident::new(5, "Τάκης"), Resident::new(5, "R5"),
Resident::new(6, "Μάκης"), 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 config;
pub mod errors; pub mod errors;
pub mod export; pub mod export;
pub mod fixtures;
pub mod resident; pub mod resident;
pub mod schedule; pub mod schedule;
pub mod scheduler; pub mod scheduler;
@@ -106,8 +107,20 @@ pub fn run() {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use ctor::ctor;
use rstest::rstest; use rstest::rstest;
#[rstest] #[ctor]
pub fn test_endpoints() {} 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}, config::{ToxicPair, UserConfig},
resident::ResidentId, resident::ResidentId,
slot::{weekday_to_greek, Day, ShiftPosition, Slot}, slot::{weekday_to_greek, Day, ShiftPosition, Slot},
workload::{WorkloadBounds, WorkloadTracker}, workload::WorkloadTracker,
}; };
use serde::Serializer; use serde::Serializer;
@@ -32,8 +32,8 @@ impl MonthlySchedule {
self.0.get(slot) self.0.get(slot)
} }
pub fn insert(&mut self, slot: Slot, resident_id: ResidentId) { pub fn insert(&mut self, slot: Slot, r_id: ResidentId) {
self.0.insert(slot, resident_id); self.0.insert(slot, r_id);
} }
pub fn remove(&mut self, slot: Slot) { pub fn remove(&mut self, slot: Slot) {
@@ -44,32 +44,6 @@ impl MonthlySchedule {
self.0.contains_key(slot) 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 { pub fn has_resident_in_consecutive_days(&self, slot: &Slot) -> bool {
if slot.day == Day(1) { if slot.day == Day(1) {
return false; return false;
@@ -89,9 +63,20 @@ impl MonthlySchedule {
.any(|s| self.get_resident_id(s) == self.get_resident_id(slot)) .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 { 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_shift() {
if !slot.is_open_second() {
return false; return false;
} }
@@ -113,11 +98,11 @@ impl MonthlySchedule {
sorted.sort_by_key(|(slot, _)| (slot.day, slot.position)); sorted.sort_by_key(|(slot, _)| (slot.day, slot.position));
let mut output = String::from("Μηνιαίο Πρόγραμμα Εφημεριών\n"); let mut output = String::from("Μηνιαίο Πρόγραμμα Εφημεριών\n");
for (slot, res_id) in sorted { for (slot, r_id) in sorted {
let res_name = config let res_name = config
.residents .residents
.iter() .iter()
.find(|r| &r.id == res_id) .find(|r| &r.id == r_id)
.map(|r| r.name.as_str()) .map(|r| r.name.as_str())
.unwrap_or(""); .unwrap_or("");
@@ -137,7 +122,7 @@ impl MonthlySchedule {
pub fn report(&self, config: &UserConfig, tracker: &WorkloadTracker) -> String { pub fn report(&self, config: &UserConfig, tracker: &WorkloadTracker) -> String {
let mut output = String::new(); let mut output = String::new();
output.push_str("\n--- Αναφορά ---\n"); output.push('\n');
output.push_str(&format!( output.push_str(&format!(
"{:<15} | {:<6} | {:<10} | {:<10} | {:<7} | {:<10}\n", "{:<15} | {:<6} | {:<10} | {:<10} | {:<7} | {:<10}\n",
"Ειδικευόμενος", "Σύνολο", "Ανοιχτή(1)", "Ανοιχτή(2)", "Κλειστή", "ΣΚ/Αργίες" "Ειδικευόμενος", "Σύνολο", "Ανοιχτή(1)", "Ανοιχτή(2)", "Κλειστή", "ΣΚ/Αργίες"
@@ -148,16 +133,16 @@ impl MonthlySchedule {
let mut residents: Vec<_> = config.residents.iter().collect(); let mut residents: Vec<_> = config.residents.iter().collect();
residents.sort_by_key(|r| &r.name); residents.sort_by_key(|r| &r.name);
for res in residents { for r in residents {
let total = tracker.current_workload(&res.id); let total = tracker.current_workload(&r.id);
let o1 = tracker.get_type_count(&res.id, ShiftType::OpenFirst); let o1 = tracker.get_type_count(&r.id, ShiftType::OpenFirst);
let o2 = tracker.get_type_count(&res.id, ShiftType::OpenSecond); let o2 = tracker.get_type_count(&r.id, ShiftType::OpenSecond);
let cl = tracker.get_type_count(&res.id, ShiftType::Closed); let cl = tracker.get_type_count(&r.id, ShiftType::Closed);
let holiday = tracker.current_holiday_workload(&res.id); let holiday = tracker.current_holiday_workload(&r.id);
output.push_str(&format!( output.push_str(&format!(
"{:<15} | {:<6} | {:<10} | {:<10} | {:<7} | {:<10}\n", "{:<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()); output.push_str("-".repeat(85).as_str());
@@ -209,25 +194,19 @@ mod tests {
#[fixture] #[fixture]
fn resident() -> Resident { fn resident() -> Resident {
Resident::new(1, "Stefanos") Resident::new(1, "R1")
} }
#[fixture] #[fixture]
fn toxic_config() -> UserConfig { fn toxic_config() -> UserConfig {
UserConfig::default() UserConfig::default()
.with_residents(vec![ .with_residents(vec![Resident::new(1, "R1"), Resident::new(2, "R2")])
Resident::new(1, "Stefanos"),
Resident::new(2, "Iordanis"),
])
.with_toxic_pairs(vec![ToxicPair::new(1, 2)]) .with_toxic_pairs(vec![ToxicPair::new(1, 2)])
} }
#[fixture] #[fixture]
fn config() -> UserConfig { fn config() -> UserConfig {
UserConfig::default().with_residents(vec![ UserConfig::default().with_residents(vec![Resident::new(1, "R1"), Resident::new(2, "R2")])
Resident::new(1, "Stefanos"),
Resident::new(2, "Iordanis"),
])
} }
#[rstest] #[rstest]
@@ -271,11 +250,11 @@ mod tests {
let slot_1 = Slot::new(Day(1), ShiftPosition::First); let slot_1 = Slot::new(Day(1), ShiftPosition::First);
let slot_2 = Slot::new(Day(1), ShiftPosition::Second); let slot_2 = Slot::new(Day(1), ShiftPosition::Second);
let stefanos = &toxic_config.residents[0]; let r1 = &toxic_config.residents[0];
let iordanis = &toxic_config.residents[1]; let r2 = &toxic_config.residents[1];
schedule.insert(slot_1, stefanos.id); schedule.insert(slot_1, r1.id);
schedule.insert(slot_2, iordanis.id); schedule.insert(slot_2, r2.id);
assert!(schedule.has_toxic_pair(&slot_2, &toxic_config)) assert!(schedule.has_toxic_pair(&slot_2, &toxic_config))
} }

View File

@@ -1,7 +1,7 @@
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use crate::{ use crate::{
config::UserConfig, config::{ToxicPair, UserConfig},
errors::SearchError, errors::SearchError,
resident::ResidentId, resident::ResidentId,
schedule::MonthlySchedule, schedule::MonthlySchedule,
@@ -10,7 +10,6 @@ use crate::{
workload::{WorkloadBounds, WorkloadTracker}, workload::{WorkloadBounds, WorkloadTracker},
}; };
use log::info;
use rand::{rngs::SmallRng, seq::SliceRandom, SeedableRng}; use rand::{rngs::SmallRng, seq::SliceRandom, SeedableRng};
use rayon::{ use rayon::{
current_thread_index, current_thread_index,
@@ -60,7 +59,7 @@ impl Scheduler {
.map(Slot::from) .map(Slot::from)
.ok_or(SearchError::ScheduleFull)?; .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 solved_in_thread = AtomicBool::new(false);
let sovled_state = resident_ids.par_iter().find_map_any(|&id| { 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(true) => Some((local_schedule, local_tracker)),
Ok(false) => None, Ok(false) => None,
Err(e) => { 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 None
} }
} }
@@ -114,26 +114,21 @@ impl Scheduler {
return Err(SearchError::Timeout); return Err(SearchError::Timeout);
} }
if !slot.is_first() if schedule.has_resident_in_consecutive_days(&slot.previous()) {
&& schedule.restrictions_violated(&slot.previous(), &self.config, &self.bounds, tracker)
{
return Ok(false); return Ok(false);
} }
if slot.greater_than(self.config.total_days) { if self.found_solution(slot) {
if tracker.are_all_thresholds_met(&self.config, &self.bounds) {
solved_in_thread.store(true, Ordering::Relaxed); solved_in_thread.store(true, Ordering::Relaxed);
return Ok(true); return Ok(true);
} }
return Ok(false);
}
if schedule.is_slot_manually_assigned(&slot) { if schedule.is_slot_manually_assigned(&slot) {
return self.search(schedule, tracker, slot.next(), solved_in_thread); return self.search(schedule, tracker, slot.next(), solved_in_thread);
} }
let mut rng = SmallRng::from_rng(&mut rand::rng()); 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.shuffle(&mut rng);
valid_resident_ids.sort_by_key(|res_id| { valid_resident_ids.sort_by_key(|res_id| {
let type_count = tracker.get_type_count(res_id, slot.shift_type()); let type_count = tracker.get_type_count(res_id, slot.shift_type());
@@ -156,10 +151,19 @@ impl Scheduler {
Ok(false) 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 /// Return all valid residents for the current slot
pub fn valid_residents(&self, slot: Slot, schedule: &MonthlySchedule) -> Vec<ResidentId> { pub fn valid_residents(
let required_type = slot.shift_type(); &self,
let other_resident = slot 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() .other_position()
.and_then(|partner_slot| schedule.get_resident_id(&partner_slot)); .and_then(|partner_slot| schedule.get_resident_id(&partner_slot));
@@ -167,9 +171,26 @@ impl Scheduler {
.residents .residents
.iter() .iter()
.filter(|r| { .filter(|r| {
Some(&r.id) != other_resident if let Some(other_id) = other_resident_id {
&& !r.negative_shifts.contains(&slot.day) if &r.id == other_id {
&& r.allowed_types.contains(&required_type) 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) .map(|r| r.id)
.collect() .collect()
@@ -185,7 +206,6 @@ mod tests {
resident::Resident, resident::Resident,
schedule::MonthlySchedule, schedule::MonthlySchedule,
scheduler::Scheduler, scheduler::Scheduler,
slot::{Day, ShiftPosition, Slot},
workload::{WorkloadBounds, WorkloadTracker}, workload::{WorkloadBounds, WorkloadTracker},
}; };
@@ -197,12 +217,12 @@ mod tests {
#[fixture] #[fixture]
fn config() -> UserConfig { fn config() -> UserConfig {
UserConfig::default().with_residents(vec![ UserConfig::default().with_residents(vec![
Resident::new(1, "Stefanos"), Resident::new(1, "R1"),
Resident::new(2, "Iordanis"), Resident::new(2, "R2"),
Resident::new(3, "Maria"), Resident::new(3, "R3"),
Resident::new(4, "Veatriki"), Resident::new(4, "R4"),
Resident::new(5, "Takis"), Resident::new(5, "R5"),
Resident::new(6, "Akis"), Resident::new(6, "R6"),
]) ])
} }
@@ -227,25 +247,8 @@ mod tests {
mut tracker: WorkloadTracker, mut tracker: WorkloadTracker,
scheduler: Scheduler, scheduler: Scheduler,
) { ) {
assert!(scheduler.run(&mut schedule, &mut tracker).is_ok()); let solved = scheduler.run(&mut schedule, &mut tracker);
assert!(solved.is_ok());
for d in 1..=scheduler.config.total_days { assert!(solved.unwrap());
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);
}
} }
} }

View File

@@ -45,7 +45,7 @@ impl Slot {
day: self.day, day: self.day,
position: ShiftPosition::Second, position: ShiftPosition::Second,
}, },
_ => Self { ShiftPosition::First | ShiftPosition::Second => Self {
day: self.day.next(), day: self.day.next(),
position: ShiftPosition::First, position: ShiftPosition::First,
}, },

View File

@@ -1,6 +1,6 @@
use std::time::Instant; 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 { pub struct Timer {
instant: Instant, instant: Instant,

View File

@@ -2,9 +2,9 @@ use std::collections::HashMap;
use crate::{ use crate::{
config::UserConfig, config::UserConfig,
resident::ResidentId, resident::{Resident, ResidentId},
schedule::ShiftType, schedule::ShiftType,
slot::{Day, Slot}, slot::Slot,
}; };
#[derive(Default)] #[derive(Default)]
@@ -12,175 +12,91 @@ pub struct WorkloadBounds {
pub max_workloads: HashMap<ResidentId, u8>, pub max_workloads: HashMap<ResidentId, u8>,
pub max_holiday_shifts: HashMap<ResidentId, u8>, pub max_holiday_shifts: HashMap<ResidentId, u8>,
pub max_by_shift_type: HashMap<(ResidentId, ShiftType), u8>, pub max_by_shift_type: HashMap<(ResidentId, ShiftType), u8>,
pub min_by_shift_type: HashMap<(ResidentId, ShiftType), u8>,
} }
impl WorkloadBounds { impl WorkloadBounds {
pub fn new_with_config(config: &UserConfig) -> Self { 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(); let mut bounds = Self::default();
bounds.calculate_max_workloads(config); bounds.calculate_max_workloads(residents, total_slots);
bounds.calculate_max_holiday_shifts(config); debug_assert!(bounds.max_workloads.values().sum::<u8>() >= total_slots);
bounds.calculate_max_by_shift_type(config); 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 bounds
} }
/// get map with total amount of slots in a month for each type of shift pub fn calculate_max_workloads(&mut self, residents: &Vec<Resident>, total_slots: u8) {
pub fn get_initial_supply(&self, config: &UserConfig) -> HashMap<ShiftType, u8> { let non_manual_residents: Vec<_> = residents
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
.iter() .iter()
.filter(|r| r.max_shifts.is_none()) .filter(|r| r.max_shifts.is_none())
.collect(); .collect();
// if all residents have a manually set max shifts size, just use those values for the max workload // all residents' max workload were manually inserted
if auto_computed_residents.is_empty() { if non_manual_residents.is_empty() {
for r in &config.residents { for r in residents {
self.max_workloads.insert(r.id, r.max_shifts.unwrap_or(0)); self.max_workloads.insert(r.id, r.max_shifts.unwrap_or(0));
} }
return; return;
} }
// Untested scenario: Resident has manual max_shifts and also reduced workload flag let total_manual_workload: u8 = residents.iter().map(|r| r.max_shifts.unwrap_or(0)).sum();
// Probably should forbid using both options from GUI 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 for r in residents {
.residents let max_workload = match r.max_shifts {
.iter() Some(max_shifts) => max_shifts,
.map(|r| r.max_shifts.unwrap_or(0)) None if r.reduced_load => workload_share - 1,
.sum(); None => workload_share,
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,
}; };
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) { pub fn calculate_max_holiday_shifts(
let total_slots = config.total_slots; &mut self,
let total_holiday_slots = config.total_holiday_slots; residents: &Vec<Resident>,
total_holiday_slots: u8,
for r in &config.residents { ) {
let workload_limit = *self.max_workloads.get(&r.id).unwrap_or(&0); let total_residents = residents.len();
let holiday_share = total_holiday_slots.div_ceil(total_residents as u8);
let share = (workload_limit as f32 / total_slots as f32) * total_holiday_slots as f32; for r in residents {
let holiday_limit = share.ceil() as u8; self.max_holiday_shifts.insert(r.id, holiday_share);
self.max_holiday_shifts.insert(r.id, holiday_limit);
} }
} }
pub fn calculate_max_by_shift_type(&mut self, config: &UserConfig) { pub fn calculate_max_by_shift_type(&mut self, residents: &Vec<Resident>) {
let mut supply_by_shift_type = self.get_initial_supply(config); let mut upper_limits = HashMap::new();
let mut local_limits = HashMap::new(); let shift_types = [
let mut local_thresholds = HashMap::new();
let all_shift_types = [
ShiftType::OpenFirst, ShiftType::OpenFirst,
ShiftType::OpenSecond, ShiftType::OpenSecond,
ShiftType::Closed, ShiftType::Closed,
]; ];
// residents with 1 available shift types for r in residents {
for res in config let total_limit = *self.max_workloads.get(&r.id).unwrap_or(&0);
.residents let n_allowed = r.allowed_types.len();
.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);
local_limits.insert((res.id, shift_type), total_limit); for shift_type in shift_types {
local_thresholds.insert((res.id, shift_type), total_limit.saturating_sub(2)); let limit = if r.allowed_types.contains(&shift_type) {
if n_allowed == 1 {
for other_type in all_shift_types { total_limit
if other_type != shift_type { } else {
local_limits.insert((res.id, other_type), 0); (total_limit as f32 / n_allowed as f32).floor() as u8 + 1
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);
} }
} else { } else {
local_limits.insert((res.id, shift_type), 0); 0
local_thresholds.insert((res.id, shift_type), 0); };
}
}
}
// residents with 3 available shift types upper_limits.insert((r.id, shift_type), limit);
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 = upper_limits;
self.max_by_shift_type = local_limits;
self.min_by_shift_type = local_thresholds;
} }
} }
@@ -192,71 +108,47 @@ pub struct WorkloadTracker {
} }
impl WorkloadTracker { impl WorkloadTracker {
pub fn insert(&mut self, res_id: ResidentId, config: &UserConfig, slot: Slot) { pub fn insert(&mut self, r_id: ResidentId, config: &UserConfig, slot: Slot) {
*self.total_counts.entry(res_id).or_insert(0) += 1; *self.total_counts.entry(r_id).or_insert(0) += 1;
*self *self
.type_counts .type_counts
.entry((res_id, slot.shift_type())) .entry((r_id, slot.shift_type()))
.or_insert(0) += 1; .or_insert(0) += 1;
if config.is_holiday_or_weekend_slot(slot.day.0) { 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) { pub fn remove(&mut self, r_id: ResidentId, config: &UserConfig, slot: Slot) {
if let Some(count) = self.total_counts.get_mut(&resident_id) { if let Some(count) = self.total_counts.get_mut(&r_id) {
*count = count.saturating_sub(1); *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); *count = count.saturating_sub(1);
} }
if config.is_holiday_or_weekend_slot(slot.day.0) { 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); *count = count.saturating_sub(1);
} }
} }
} }
pub fn current_workload(&self, res_id: &ResidentId) -> u8 { pub fn current_workload(&self, r_id: &ResidentId) -> u8 {
*self.total_counts.get(res_id).unwrap_or(&0) *self.total_counts.get(r_id).unwrap_or(&0)
} }
pub fn current_holiday_workload(&self, resident_id: &ResidentId) -> u8 { pub fn current_holiday_workload(&self, r_id: &ResidentId) -> u8 {
*self.holidays.get(resident_id).unwrap_or(&0) *self.holidays.get(r_id).unwrap_or(&0)
} }
pub fn are_all_thresholds_met(&self, config: &UserConfig, bounds: &WorkloadBounds) -> bool { pub fn reached_workload_limit(&self, bounds: &WorkloadBounds, r_id: &ResidentId) -> bool {
const SHIFT_TYPES: [ShiftType; 3] = [ let current_load = self.current_workload(r_id);
ShiftType::OpenFirst,
ShiftType::OpenSecond,
ShiftType::Closed,
];
for r in &config.residents { if let Some(&max) = bounds.max_workloads.get(r_id) {
for shift_type in SHIFT_TYPES { if current_load >= max {
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 {
return true; return true;
} }
} }
@@ -264,15 +156,11 @@ impl WorkloadTracker {
false false
} }
pub fn is_holiday_workload_exceeded( pub fn reached_holiday_limit(&self, bounds: &WorkloadBounds, r_id: &ResidentId) -> bool {
&self, let current_load = self.current_holiday_workload(r_id);
bounds: &WorkloadBounds,
resident_id: &ResidentId,
) -> bool {
let current_load = self.current_holiday_workload(resident_id);
if let Some(&max) = bounds.max_holiday_shifts.get(resident_id) { if let Some(&max) = bounds.max_holiday_shifts.get(r_id) {
if current_load > max { if current_load >= max {
return true; return true;
} }
} }
@@ -280,27 +168,24 @@ impl WorkloadTracker {
false false
} }
pub fn is_max_shift_type_exceeded( pub fn reached_shift_type_limit(
&self, &self,
bounds: &WorkloadBounds, bounds: &WorkloadBounds,
resident_id: &ResidentId, r_id: &ResidentId,
slot: &Slot, slot: &Slot,
) -> bool { ) -> bool {
let shift_type = slot.shift_type(); let shift_type = slot.shift_type();
let current_load = self let current_load = self.type_counts.get(&(*r_id, shift_type)).unwrap_or(&0);
.type_counts
.get(&(*resident_id, shift_type))
.unwrap_or(&0);
if let Some(&max) = bounds.max_by_shift_type.get(&(*resident_id, shift_type)) { if let Some(&max) = bounds.max_by_shift_type.get(&(*r_id, shift_type)) {
return *current_load > max; return *current_load >= max;
} }
false false
} }
pub fn get_type_count(&self, res_id: &ResidentId, shift_type: ShiftType) -> u8 { pub fn get_type_count(&self, r_id: &ResidentId, shift_type: ShiftType) -> u8 {
*self.type_counts.get(&(*res_id, shift_type)).unwrap_or(&0) *self.type_counts.get(&(*r_id, shift_type)).unwrap_or(&0)
} }
} }
@@ -308,7 +193,9 @@ impl WorkloadTracker {
mod tests { mod tests {
use crate::{ use crate::{
config::UserConfig, config::UserConfig,
fixtures::{complex_config, hard_config, minimal_config},
resident::{Resident, ResidentId}, resident::{Resident, ResidentId},
schedule::ShiftType,
slot::{Day, ShiftPosition, Slot}, slot::{Day, ShiftPosition, Slot},
workload::{WorkloadBounds, WorkloadTracker}, workload::{WorkloadBounds, WorkloadTracker},
}; };
@@ -317,11 +204,11 @@ mod tests {
#[fixture] #[fixture]
fn config() -> UserConfig { fn config() -> UserConfig {
UserConfig::default().with_residents(vec![ UserConfig::default().with_residents(vec![
Resident::new(1, "Stefanos").with_max_shifts(2), Resident::new(1, "R1").with_max_shifts(2),
Resident::new(2, "Iordanis").with_max_shifts(2), Resident::new(2, "R2").with_max_shifts(2),
Resident::new(3, "Maria").with_reduced_load(), Resident::new(3, "R3").with_reduced_load(),
Resident::new(4, "Veatriki"), Resident::new(4, "R4"),
Resident::new(5, "Takis"), Resident::new(5, "R5"),
]) ])
} }
@@ -333,53 +220,252 @@ mod tests {
#[rstest] #[rstest]
fn test_max_workloads(config: UserConfig) { fn test_max_workloads(config: UserConfig) {
let bounds = WorkloadBounds::new_with_config(&config); let bounds = WorkloadBounds::new_with_config(&config);
assert_eq!(2, bounds.max_workloads[&ResidentId(1)]);
assert_eq!(bounds.max_workloads[&ResidentId(1)], 2); assert_eq!(2, bounds.max_workloads[&ResidentId(2)]);
assert_eq!(bounds.max_workloads[&ResidentId(2)], 2); assert_eq!(13, bounds.max_workloads[&ResidentId(3)]);
assert!(bounds.max_workloads[&ResidentId(3)] > 0); assert_eq!(14, bounds.max_workloads[&ResidentId(4)]);
assert_eq!(14, bounds.max_workloads[&ResidentId(5)]);
} }
#[rstest] #[rstest]
fn test_is_total_workload_exceeded(mut tracker: WorkloadTracker, config: UserConfig) { fn test_reached_workload_limit(mut tracker: WorkloadTracker, config: UserConfig) {
let res_id = ResidentId(1); let r_id = ResidentId(1);
let mut bounds = WorkloadBounds::default(); 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_1 = Slot::new(Day(1), ShiftPosition::First);
let slot_2 = Slot::new(Day(2), ShiftPosition::First); let slot_2 = Slot::new(Day(2), ShiftPosition::First);
tracker.insert(res_id, &config, slot_1); assert!(!tracker.reached_workload_limit(&bounds, &r_id,));
assert!(!tracker.is_total_workload_exceeded(&bounds, &res_id,));
tracker.insert(res_id, &config, slot_2); tracker.insert(r_id, &config, slot_1);
assert!(tracker.is_total_workload_exceeded(&bounds, &res_id,)); assert!(tracker.reached_workload_limit(&bounds, &r_id,));
tracker.insert(r_id, &config, slot_2);
assert!(tracker.reached_workload_limit(&bounds, &r_id,));
} }
#[rstest] #[rstest]
fn test_is_holiday_workload_exceeded(mut tracker: WorkloadTracker, config: UserConfig) { fn test_reached_holiday_limit(mut tracker: WorkloadTracker, config: UserConfig) {
let res_id = ResidentId(1); let r_id = ResidentId(1);
let mut bounds = WorkloadBounds::default(); 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 sat = Slot::new(Day(11), ShiftPosition::First);
let sun = Slot::new(Day(8), ShiftPosition::First); let sun = Slot::new(Day(12), ShiftPosition::First);
tracker.insert(res_id, &config, sat); assert!(!tracker.reached_holiday_limit(&bounds, &r_id));
assert!(!tracker.is_holiday_workload_exceeded(&bounds, &res_id));
tracker.insert(res_id, &config, sun); tracker.insert(r_id, &config, sat);
assert!(tracker.is_holiday_workload_exceeded(&bounds, &res_id)); assert!(tracker.reached_holiday_limit(&bounds, &r_id));
tracker.insert(r_id, &config, sun);
assert!(tracker.reached_holiday_limit(&bounds, &r_id));
} }
#[rstest] #[rstest]
fn test_backtracking_accuracy(mut tracker: WorkloadTracker, config: UserConfig) { 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); let slot = Slot::new(Day(1), ShiftPosition::First);
tracker.insert(res_id, &config, slot); tracker.insert(r_id, &config, slot);
assert_eq!(tracker.current_workload(&res_id), 1); assert_eq!(tracker.current_workload(&r_id), 1);
tracker.remove(res_id, &config, slot); tracker.remove(r_id, &config, slot);
assert_eq!(tracker.current_workload(&res_id), 0); 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 { mod integration_tests {
use rota_lib::{ use rota_lib::{
config::{ToxicPair, UserConfig}, config::{ToxicPair, UserConfig},
resident::Resident, fixtures::{
schedule::{MonthlySchedule, ShiftType}, complex_config, hard_config, manual_shifts_heavy_config, maximal_config, minimal_config,
},
schedule::MonthlySchedule,
scheduler::Scheduler, scheduler::Scheduler,
slot::{Day, ShiftPosition, Slot}, slot::{Day, ShiftPosition, Slot},
workload::{WorkloadBounds, WorkloadTracker}, workload::{WorkloadBounds, WorkloadTracker},
}; };
use rstest::{fixture, rstest}; use rstest::rstest;
#[fixture] #[ctor::ctor]
fn minimal_config() -> UserConfig { fn global_setup() {
UserConfig::default().with_residents(vec![ env_logger::builder()
Resident::new(1, "R1"), .filter_level(log::LevelFilter::Info)
Resident::new(2, "R2"), .init();
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),
])
} }
#[rstest] #[rstest]
@@ -98,8 +25,9 @@ mod integration_tests {
let mut tracker = WorkloadTracker::default(); let mut tracker = WorkloadTracker::default();
let scheduler = Scheduler::new_with_config(minimal_config.clone()); 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); validate_all_constraints(&schedule, &tracker, &minimal_config);
Ok(()) Ok(())
@@ -111,8 +39,9 @@ mod integration_tests {
let mut tracker = WorkloadTracker::default(); let mut tracker = WorkloadTracker::default();
let scheduler = Scheduler::new_with_config(maximal_config.clone()); 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); validate_all_constraints(&schedule, &tracker, &maximal_config);
Ok(()) Ok(())
@@ -126,8 +55,9 @@ mod integration_tests {
let mut tracker = WorkloadTracker::default(); let mut tracker = WorkloadTracker::default();
let scheduler = Scheduler::new_with_config(manual_shifts_heavy_config.clone()); 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); validate_all_constraints(&schedule, &tracker, &manual_shifts_heavy_config);
Ok(()) Ok(())
@@ -139,13 +69,28 @@ mod integration_tests {
let mut tracker = WorkloadTracker::default(); let mut tracker = WorkloadTracker::default();
let scheduler = Scheduler::new_with_config(complex_config.clone()); 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); validate_all_constraints(&schedule, &tracker, &complex_config);
Ok(()) 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( fn validate_all_constraints(
schedule: &MonthlySchedule, schedule: &MonthlySchedule,
tracker: &WorkloadTracker, tracker: &WorkloadTracker,
@@ -162,8 +107,8 @@ mod integration_tests {
.iter() .iter()
.filter_map(|&p| schedule.get_resident_id(&Slot::new(Day(d - 1), p))) .filter_map(|&p| schedule.get_resident_id(&Slot::new(Day(d - 1), p)))
.collect(); .collect();
for res in current { for r in current {
assert!(!previous.contains(&res)); assert!(!previous.contains(&r));
} }
} }
@@ -181,20 +126,20 @@ mod integration_tests {
} }
let bounds = WorkloadBounds::new_with_config(config); let bounds = WorkloadBounds::new_with_config(config);
for (slot, res_id) in &schedule.0 { for (slot, r_id) in &schedule.0 {
let res = config let r = config
.residents .residents
.iter() .iter()
.find(|r| &r.id == res_id) .find(|r| &r.id == r_id)
.expect("Resident not found"); .expect("Resident not found");
assert!(res.allowed_types.contains(&slot.shift_type())); assert!(r.allowed_types.contains(&slot.shift_type()));
assert!(!res.negative_shifts.contains(&slot.day)); assert!(!r.negative_shifts.contains(&slot.day));
} }
for resident in &config.residents { for resident in &config.residents {
let workload = tracker.current_workload(&resident.id); let workload = tracker.current_workload(&resident.id);
let max = *bounds.max_workloads.get(&resident.id).unwrap(); let max = *bounds.max_workloads.get(&resident.id).unwrap();
assert!(workload <= max); assert!(workload <= max, "workload: {}, max: {}", workload, max);
} }
} }
} }