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:
4
src-tauri/.gitignore
vendored
4
src-tauri/.gitignore
vendored
@@ -7,4 +7,6 @@
|
||||
/gen/schemas
|
||||
|
||||
# Ignore exported txt/doc files and the log file
|
||||
rota.*
|
||||
rota.*
|
||||
|
||||
mutants.*
|
||||
181
src-tauri/Cargo.lock
generated
181
src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
177
src-tauri/src/fixtures.rs
Normal 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),
|
||||
])
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use crate::{
|
||||
pub mod config;
|
||||
pub mod errors;
|
||||
pub mod export;
|
||||
pub mod fixtures;
|
||||
pub mod resident;
|
||||
pub mod schedule;
|
||||
pub mod scheduler;
|
||||
@@ -106,8 +107,20 @@ pub fn run() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use ctor::ctor;
|
||||
use rstest::rstest;
|
||||
|
||||
#[ctor]
|
||||
fn global_setup() {
|
||||
env_logger::builder()
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.is_test(true)
|
||||
.try_init();
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
pub fn test_endpoints() {}
|
||||
pub fn test_endpoints() {
|
||||
// see how tauri mocks endpoint tests
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use crate::{
|
||||
config::UserConfig,
|
||||
config::{ToxicPair, UserConfig},
|
||||
errors::SearchError,
|
||||
resident::ResidentId,
|
||||
schedule::MonthlySchedule,
|
||||
@@ -10,7 +10,6 @@ use crate::{
|
||||
workload::{WorkloadBounds, WorkloadTracker},
|
||||
};
|
||||
|
||||
use log::info;
|
||||
use rand::{rngs::SmallRng, seq::SliceRandom, SeedableRng};
|
||||
use rayon::{
|
||||
current_thread_index,
|
||||
@@ -60,7 +59,7 @@ impl Scheduler {
|
||||
.map(Slot::from)
|
||||
.ok_or(SearchError::ScheduleFull)?;
|
||||
|
||||
let resident_ids = self.valid_residents(slot, schedule);
|
||||
let resident_ids = self.valid_residents(slot, schedule, tracker);
|
||||
let solved_in_thread = AtomicBool::new(false);
|
||||
|
||||
let sovled_state = resident_ids.par_iter().find_map_any(|&id| {
|
||||
@@ -81,7 +80,8 @@ impl Scheduler {
|
||||
Ok(true) => Some((local_schedule, local_tracker)),
|
||||
Ok(false) => None,
|
||||
Err(e) => {
|
||||
info!("Thread Id: [{}] {}", current_thread_index().unwrap(), e);
|
||||
let thread_id = current_thread_index().unwrap();
|
||||
log::log!(e.log_level(), "Thread Id: [{}] {}", thread_id, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -114,18 +114,13 @@ impl Scheduler {
|
||||
return Err(SearchError::Timeout);
|
||||
}
|
||||
|
||||
if !slot.is_first()
|
||||
&& schedule.restrictions_violated(&slot.previous(), &self.config, &self.bounds, tracker)
|
||||
{
|
||||
if schedule.has_resident_in_consecutive_days(&slot.previous()) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if slot.greater_than(self.config.total_days) {
|
||||
if tracker.are_all_thresholds_met(&self.config, &self.bounds) {
|
||||
solved_in_thread.store(true, Ordering::Relaxed);
|
||||
return Ok(true);
|
||||
}
|
||||
return Ok(false);
|
||||
if self.found_solution(slot) {
|
||||
solved_in_thread.store(true, Ordering::Relaxed);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if schedule.is_slot_manually_assigned(&slot) {
|
||||
@@ -133,7 +128,7 @@ impl Scheduler {
|
||||
}
|
||||
|
||||
let mut rng = SmallRng::from_rng(&mut rand::rng());
|
||||
let mut valid_resident_ids = self.valid_residents(slot, schedule);
|
||||
let mut valid_resident_ids = self.valid_residents(slot, schedule, tracker);
|
||||
valid_resident_ids.shuffle(&mut rng);
|
||||
valid_resident_ids.sort_by_key(|res_id| {
|
||||
let type_count = tracker.get_type_count(res_id, slot.shift_type());
|
||||
@@ -156,10 +151,19 @@ impl Scheduler {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub fn found_solution(&self, slot: Slot) -> bool {
|
||||
slot.greater_than(self.config.total_days)
|
||||
}
|
||||
|
||||
/// Return all valid residents for the current slot
|
||||
pub fn valid_residents(&self, slot: Slot, schedule: &MonthlySchedule) -> Vec<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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
upper_limits.insert((r.id, shift_type), limit);
|
||||
}
|
||||
}
|
||||
|
||||
// residents with 3 available shift types
|
||||
for res in config
|
||||
.residents
|
||||
.iter()
|
||||
.filter(|r| r.allowed_types.len() == 3)
|
||||
{
|
||||
let total_limit = *self.max_workloads.get(&res.id).unwrap_or(&0);
|
||||
let per_type = ((total_limit as f32) / 3.0).ceil() as u8;
|
||||
|
||||
let deduct_amount = (total_limit as f32 / 3.0) as u8;
|
||||
|
||||
for shift_type in all_shift_types {
|
||||
if res.allowed_types.contains(&shift_type) {
|
||||
local_limits.insert((res.id, shift_type), per_type);
|
||||
local_thresholds.insert((res.id, shift_type), per_type.saturating_sub(2));
|
||||
if let Some(s) = supply_by_shift_type.get_mut(&shift_type) {
|
||||
*s = s.saturating_sub(deduct_amount);
|
||||
}
|
||||
} else {
|
||||
local_limits.insert((res.id, shift_type), 0);
|
||||
local_thresholds.insert((res.id, shift_type), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.max_by_shift_type = local_limits;
|
||||
self.min_by_shift_type = local_thresholds;
|
||||
self.max_by_shift_type = upper_limits;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,71 +108,47 @@ pub struct WorkloadTracker {
|
||||
}
|
||||
|
||||
impl WorkloadTracker {
|
||||
pub fn insert(&mut self, res_id: ResidentId, config: &UserConfig, slot: Slot) {
|
||||
*self.total_counts.entry(res_id).or_insert(0) += 1;
|
||||
pub fn insert(&mut self, r_id: ResidentId, config: &UserConfig, slot: Slot) {
|
||||
*self.total_counts.entry(r_id).or_insert(0) += 1;
|
||||
*self
|
||||
.type_counts
|
||||
.entry((res_id, slot.shift_type()))
|
||||
.entry((r_id, slot.shift_type()))
|
||||
.or_insert(0) += 1;
|
||||
|
||||
if config.is_holiday_or_weekend_slot(slot.day.0) {
|
||||
*self.holidays.entry(res_id).or_insert(0) += 1;
|
||||
*self.holidays.entry(r_id).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, resident_id: ResidentId, config: &UserConfig, slot: Slot) {
|
||||
if let Some(count) = self.total_counts.get_mut(&resident_id) {
|
||||
pub fn remove(&mut self, r_id: ResidentId, config: &UserConfig, slot: Slot) {
|
||||
if let Some(count) = self.total_counts.get_mut(&r_id) {
|
||||
*count = count.saturating_sub(1);
|
||||
}
|
||||
|
||||
if let Some(count) = self.type_counts.get_mut(&(resident_id, slot.shift_type())) {
|
||||
if let Some(count) = self.type_counts.get_mut(&(r_id, slot.shift_type())) {
|
||||
*count = count.saturating_sub(1);
|
||||
}
|
||||
|
||||
if config.is_holiday_or_weekend_slot(slot.day.0) {
|
||||
if let Some(count) = self.holidays.get_mut(&resident_id) {
|
||||
if let Some(count) = self.holidays.get_mut(&r_id) {
|
||||
*count = count.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_workload(&self, res_id: &ResidentId) -> u8 {
|
||||
*self.total_counts.get(res_id).unwrap_or(&0)
|
||||
pub fn current_workload(&self, r_id: &ResidentId) -> u8 {
|
||||
*self.total_counts.get(r_id).unwrap_or(&0)
|
||||
}
|
||||
|
||||
pub fn current_holiday_workload(&self, resident_id: &ResidentId) -> u8 {
|
||||
*self.holidays.get(resident_id).unwrap_or(&0)
|
||||
pub fn current_holiday_workload(&self, r_id: &ResidentId) -> u8 {
|
||||
*self.holidays.get(r_id).unwrap_or(&0)
|
||||
}
|
||||
|
||||
pub fn are_all_thresholds_met(&self, config: &UserConfig, bounds: &WorkloadBounds) -> bool {
|
||||
const SHIFT_TYPES: [ShiftType; 3] = [
|
||||
ShiftType::OpenFirst,
|
||||
ShiftType::OpenSecond,
|
||||
ShiftType::Closed,
|
||||
];
|
||||
pub fn reached_workload_limit(&self, bounds: &WorkloadBounds, r_id: &ResidentId) -> bool {
|
||||
let current_load = self.current_workload(r_id);
|
||||
|
||||
for r in &config.residents {
|
||||
for shift_type in SHIFT_TYPES {
|
||||
let current_load = self.type_counts.get(&(r.id, shift_type)).unwrap_or(&0);
|
||||
if let Some(&min) = bounds.min_by_shift_type.get(&(r.id, shift_type)) {
|
||||
if *current_load < min {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn is_total_workload_exceeded(
|
||||
&self,
|
||||
bounds: &WorkloadBounds,
|
||||
resident_id: &ResidentId,
|
||||
) -> bool {
|
||||
let current_load = self.current_workload(resident_id);
|
||||
|
||||
if let Some(&max) = bounds.max_workloads.get(resident_id) {
|
||||
if current_load > max {
|
||||
if let Some(&max) = bounds.max_workloads.get(r_id) {
|
||||
if current_load >= max {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -264,15 +156,11 @@ impl WorkloadTracker {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn is_holiday_workload_exceeded(
|
||||
&self,
|
||||
bounds: &WorkloadBounds,
|
||||
resident_id: &ResidentId,
|
||||
) -> bool {
|
||||
let current_load = self.current_holiday_workload(resident_id);
|
||||
pub fn reached_holiday_limit(&self, bounds: &WorkloadBounds, r_id: &ResidentId) -> bool {
|
||||
let current_load = self.current_holiday_workload(r_id);
|
||||
|
||||
if let Some(&max) = bounds.max_holiday_shifts.get(resident_id) {
|
||||
if current_load > max {
|
||||
if let Some(&max) = bounds.max_holiday_shifts.get(r_id) {
|
||||
if current_load >= max {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -280,27 +168,24 @@ impl WorkloadTracker {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn is_max_shift_type_exceeded(
|
||||
pub fn reached_shift_type_limit(
|
||||
&self,
|
||||
bounds: &WorkloadBounds,
|
||||
resident_id: &ResidentId,
|
||||
r_id: &ResidentId,
|
||||
slot: &Slot,
|
||||
) -> bool {
|
||||
let shift_type = slot.shift_type();
|
||||
let current_load = self
|
||||
.type_counts
|
||||
.get(&(*resident_id, shift_type))
|
||||
.unwrap_or(&0);
|
||||
let current_load = self.type_counts.get(&(*r_id, shift_type)).unwrap_or(&0);
|
||||
|
||||
if let Some(&max) = bounds.max_by_shift_type.get(&(*resident_id, shift_type)) {
|
||||
return *current_load > max;
|
||||
if let Some(&max) = bounds.max_by_shift_type.get(&(*r_id, shift_type)) {
|
||||
return *current_load >= max;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub fn get_type_count(&self, res_id: &ResidentId, shift_type: ShiftType) -> u8 {
|
||||
*self.type_counts.get(&(*res_id, shift_type)).unwrap_or(&0)
|
||||
pub fn get_type_count(&self, r_id: &ResidentId, shift_type: ShiftType) -> u8 {
|
||||
*self.type_counts.get(&(*r_id, shift_type)).unwrap_or(&0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,7 +193,9 @@ impl WorkloadTracker {
|
||||
mod tests {
|
||||
use crate::{
|
||||
config::UserConfig,
|
||||
fixtures::{complex_config, hard_config, minimal_config},
|
||||
resident::{Resident, ResidentId},
|
||||
schedule::ShiftType,
|
||||
slot::{Day, ShiftPosition, Slot},
|
||||
workload::{WorkloadBounds, WorkloadTracker},
|
||||
};
|
||||
@@ -317,11 +204,11 @@ mod tests {
|
||||
#[fixture]
|
||||
fn config() -> UserConfig {
|
||||
UserConfig::default().with_residents(vec![
|
||||
Resident::new(1, "Stefanos").with_max_shifts(2),
|
||||
Resident::new(2, "Iordanis").with_max_shifts(2),
|
||||
Resident::new(3, "Maria").with_reduced_load(),
|
||||
Resident::new(4, "Veatriki"),
|
||||
Resident::new(5, "Takis"),
|
||||
Resident::new(1, "R1").with_max_shifts(2),
|
||||
Resident::new(2, "R2").with_max_shifts(2),
|
||||
Resident::new(3, "R3").with_reduced_load(),
|
||||
Resident::new(4, "R4"),
|
||||
Resident::new(5, "R5"),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -333,53 +220,252 @@ mod tests {
|
||||
#[rstest]
|
||||
fn test_max_workloads(config: UserConfig) {
|
||||
let bounds = WorkloadBounds::new_with_config(&config);
|
||||
|
||||
assert_eq!(bounds.max_workloads[&ResidentId(1)], 2);
|
||||
assert_eq!(bounds.max_workloads[&ResidentId(2)], 2);
|
||||
assert!(bounds.max_workloads[&ResidentId(3)] > 0);
|
||||
assert_eq!(2, bounds.max_workloads[&ResidentId(1)]);
|
||||
assert_eq!(2, bounds.max_workloads[&ResidentId(2)]);
|
||||
assert_eq!(13, bounds.max_workloads[&ResidentId(3)]);
|
||||
assert_eq!(14, bounds.max_workloads[&ResidentId(4)]);
|
||||
assert_eq!(14, bounds.max_workloads[&ResidentId(5)]);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_is_total_workload_exceeded(mut tracker: WorkloadTracker, config: UserConfig) {
|
||||
let res_id = ResidentId(1);
|
||||
fn test_reached_workload_limit(mut tracker: WorkloadTracker, config: UserConfig) {
|
||||
let r_id = ResidentId(1);
|
||||
let mut bounds = WorkloadBounds::default();
|
||||
bounds.max_workloads.insert(res_id, 1);
|
||||
bounds.max_workloads.insert(r_id, 1);
|
||||
|
||||
let slot_1 = Slot::new(Day(1), ShiftPosition::First);
|
||||
let slot_2 = Slot::new(Day(2), ShiftPosition::First);
|
||||
|
||||
tracker.insert(res_id, &config, slot_1);
|
||||
assert!(!tracker.is_total_workload_exceeded(&bounds, &res_id,));
|
||||
assert!(!tracker.reached_workload_limit(&bounds, &r_id,));
|
||||
|
||||
tracker.insert(res_id, &config, slot_2);
|
||||
assert!(tracker.is_total_workload_exceeded(&bounds, &res_id,));
|
||||
tracker.insert(r_id, &config, slot_1);
|
||||
assert!(tracker.reached_workload_limit(&bounds, &r_id,));
|
||||
|
||||
tracker.insert(r_id, &config, slot_2);
|
||||
assert!(tracker.reached_workload_limit(&bounds, &r_id,));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_is_holiday_workload_exceeded(mut tracker: WorkloadTracker, config: UserConfig) {
|
||||
let res_id = ResidentId(1);
|
||||
fn test_reached_holiday_limit(mut tracker: WorkloadTracker, config: UserConfig) {
|
||||
let r_id = ResidentId(1);
|
||||
let mut bounds = WorkloadBounds::default();
|
||||
bounds.max_holiday_shifts.insert(res_id, 1);
|
||||
bounds.max_holiday_shifts.insert(r_id, 1);
|
||||
|
||||
let sat = Slot::new(Day(7), ShiftPosition::First);
|
||||
let sun = Slot::new(Day(8), ShiftPosition::First);
|
||||
let sat = Slot::new(Day(11), ShiftPosition::First);
|
||||
let sun = Slot::new(Day(12), ShiftPosition::First);
|
||||
|
||||
tracker.insert(res_id, &config, sat);
|
||||
assert!(!tracker.is_holiday_workload_exceeded(&bounds, &res_id));
|
||||
assert!(!tracker.reached_holiday_limit(&bounds, &r_id));
|
||||
|
||||
tracker.insert(res_id, &config, sun);
|
||||
assert!(tracker.is_holiday_workload_exceeded(&bounds, &res_id));
|
||||
tracker.insert(r_id, &config, sat);
|
||||
assert!(tracker.reached_holiday_limit(&bounds, &r_id));
|
||||
|
||||
tracker.insert(r_id, &config, sun);
|
||||
assert!(tracker.reached_holiday_limit(&bounds, &r_id));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_backtracking_accuracy(mut tracker: WorkloadTracker, config: UserConfig) {
|
||||
let res_id = ResidentId(1);
|
||||
let r_id = ResidentId(1);
|
||||
let slot = Slot::new(Day(1), ShiftPosition::First);
|
||||
|
||||
tracker.insert(res_id, &config, slot);
|
||||
assert_eq!(tracker.current_workload(&res_id), 1);
|
||||
tracker.insert(r_id, &config, slot);
|
||||
assert_eq!(tracker.current_workload(&r_id), 1);
|
||||
|
||||
tracker.remove(res_id, &config, slot);
|
||||
assert_eq!(tracker.current_workload(&res_id), 0);
|
||||
tracker.remove(r_id, &config, slot);
|
||||
assert_eq!(tracker.current_workload(&r_id), 0);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_calculate_max_workloads_minimal(minimal_config: UserConfig) {
|
||||
let mut bounds = WorkloadBounds::default();
|
||||
bounds.calculate_max_workloads(&minimal_config.residents, minimal_config.total_slots);
|
||||
|
||||
assert_eq!(9, *bounds.max_workloads.get(&ResidentId(1)).unwrap());
|
||||
assert_eq!(9, *bounds.max_workloads.get(&ResidentId(2)).unwrap());
|
||||
assert_eq!(9, *bounds.max_workloads.get(&ResidentId(3)).unwrap());
|
||||
assert_eq!(9, *bounds.max_workloads.get(&ResidentId(4)).unwrap());
|
||||
assert_eq!(9, *bounds.max_workloads.get(&ResidentId(5)).unwrap());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_calculate_max_workloads_complex(complex_config: UserConfig) {
|
||||
let mut bounds = WorkloadBounds::default();
|
||||
bounds.calculate_max_workloads(&complex_config.residents, complex_config.total_slots);
|
||||
|
||||
assert_eq!(3, *bounds.max_workloads.get(&ResidentId(1)).unwrap());
|
||||
assert_eq!(3, *bounds.max_workloads.get(&ResidentId(2)).unwrap());
|
||||
assert_eq!(3, *bounds.max_workloads.get(&ResidentId(3)).unwrap());
|
||||
|
||||
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(4)).unwrap());
|
||||
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(5)).unwrap());
|
||||
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(6)).unwrap());
|
||||
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(7)).unwrap());
|
||||
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(8)).unwrap());
|
||||
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(9)).unwrap());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_calculate_max_workloads_hard(hard_config: UserConfig) {
|
||||
let mut bounds = WorkloadBounds::default();
|
||||
bounds.calculate_max_workloads(&hard_config.residents, hard_config.total_slots);
|
||||
|
||||
assert_eq!(5, *bounds.max_workloads.get(&ResidentId(6)).unwrap());
|
||||
assert_eq!(5, *bounds.max_workloads.get(&ResidentId(7)).unwrap());
|
||||
assert_eq!(5, *bounds.max_workloads.get(&ResidentId(8)).unwrap());
|
||||
|
||||
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(1)).unwrap());
|
||||
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(2)).unwrap());
|
||||
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(3)).unwrap());
|
||||
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(4)).unwrap());
|
||||
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(5)).unwrap());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_calculate_max_holiday_shifts_complex(complex_config: UserConfig) {
|
||||
let mut bounds = WorkloadBounds::default();
|
||||
bounds.calculate_max_holiday_shifts(
|
||||
&complex_config.residents,
|
||||
complex_config.total_holiday_slots,
|
||||
);
|
||||
|
||||
for i in 1..=9 {
|
||||
assert_eq!(2, *bounds.max_holiday_shifts.get(&ResidentId(i)).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_calculate_max_holiday_shifts_minimal(minimal_config: UserConfig) {
|
||||
let mut bounds = WorkloadBounds::default();
|
||||
bounds.calculate_max_holiday_shifts(
|
||||
&minimal_config.residents,
|
||||
minimal_config.total_holiday_slots,
|
||||
);
|
||||
|
||||
for i in 1..=5 {
|
||||
assert_eq!(3, *bounds.max_holiday_shifts.get(&ResidentId(i)).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_calculate_max_holiday_shifts_hard(hard_config: UserConfig) {
|
||||
let mut bounds = WorkloadBounds::default();
|
||||
bounds
|
||||
.calculate_max_holiday_shifts(&hard_config.residents, hard_config.total_holiday_slots);
|
||||
|
||||
for i in 1..=8 {
|
||||
assert_eq!(2, *bounds.max_holiday_shifts.get(&ResidentId(i)).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_calculate_max_by_shift_type_minimal(minimal_config: UserConfig) {
|
||||
let mut bounds = WorkloadBounds::default();
|
||||
bounds.calculate_max_workloads(&minimal_config.residents, minimal_config.total_slots);
|
||||
bounds.calculate_max_by_shift_type(&minimal_config.residents);
|
||||
let m = bounds.max_by_shift_type;
|
||||
|
||||
assert_eq!(4, *m.get(&(ResidentId(1), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(4, *m.get(&(ResidentId(1), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(4, *m.get(&(ResidentId(1), ShiftType::Closed)).unwrap());
|
||||
|
||||
assert_eq!(4, *m.get(&(ResidentId(2), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(4, *m.get(&(ResidentId(2), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(4, *m.get(&(ResidentId(2), ShiftType::Closed)).unwrap());
|
||||
|
||||
assert_eq!(4, *m.get(&(ResidentId(3), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(4, *m.get(&(ResidentId(3), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(4, *m.get(&(ResidentId(3), ShiftType::Closed)).unwrap());
|
||||
|
||||
assert_eq!(4, *m.get(&(ResidentId(4), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(4, *m.get(&(ResidentId(4), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(4, *m.get(&(ResidentId(4), ShiftType::Closed)).unwrap());
|
||||
|
||||
assert_eq!(4, *m.get(&(ResidentId(5), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(4, *m.get(&(ResidentId(5), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(4, *m.get(&(ResidentId(5), ShiftType::Closed)).unwrap());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_calculate_max_by_shift_type_complex(complex_config: UserConfig) {
|
||||
let mut bounds = WorkloadBounds::default();
|
||||
bounds.calculate_max_workloads(&complex_config.residents, complex_config.total_slots);
|
||||
bounds.calculate_max_by_shift_type(&complex_config.residents);
|
||||
let m = bounds.max_by_shift_type;
|
||||
|
||||
assert_eq!(2, *m.get(&(ResidentId(1), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(2, *m.get(&(ResidentId(1), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(2, *m.get(&(ResidentId(1), ShiftType::Closed)).unwrap());
|
||||
|
||||
assert_eq!(2, *m.get(&(ResidentId(2), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(2, *m.get(&(ResidentId(2), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(2, *m.get(&(ResidentId(2), ShiftType::Closed)).unwrap());
|
||||
|
||||
assert_eq!(2, *m.get(&(ResidentId(3), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(2, *m.get(&(ResidentId(3), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(2, *m.get(&(ResidentId(3), ShiftType::Closed)).unwrap());
|
||||
|
||||
assert_eq!(0, *m.get(&(ResidentId(4), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(0, *m.get(&(ResidentId(4), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(6, *m.get(&(ResidentId(4), ShiftType::Closed)).unwrap());
|
||||
|
||||
assert_eq!(4, *m.get(&(ResidentId(5), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(4, *m.get(&(ResidentId(5), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(0, *m.get(&(ResidentId(5), ShiftType::Closed)).unwrap());
|
||||
|
||||
assert_eq!(3, *m.get(&(ResidentId(6), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(3, *m.get(&(ResidentId(6), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(3, *m.get(&(ResidentId(6), ShiftType::Closed)).unwrap());
|
||||
|
||||
assert_eq!(3, *m.get(&(ResidentId(7), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(3, *m.get(&(ResidentId(7), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(3, *m.get(&(ResidentId(7), ShiftType::Closed)).unwrap());
|
||||
|
||||
assert_eq!(3, *m.get(&(ResidentId(8), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(3, *m.get(&(ResidentId(8), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(3, *m.get(&(ResidentId(8), ShiftType::Closed)).unwrap());
|
||||
|
||||
assert_eq!(3, *m.get(&(ResidentId(9), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(3, *m.get(&(ResidentId(9), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(3, *m.get(&(ResidentId(9), ShiftType::Closed)).unwrap());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_calculate_max_by_shift_type_hard(hard_config: UserConfig) {
|
||||
let mut bounds = WorkloadBounds::default();
|
||||
bounds.calculate_max_workloads(&hard_config.residents, hard_config.total_slots);
|
||||
bounds.calculate_max_by_shift_type(&hard_config.residents);
|
||||
let m = bounds.max_by_shift_type;
|
||||
|
||||
assert_eq!(3, *m.get(&(ResidentId(1), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(3, *m.get(&(ResidentId(1), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(3, *m.get(&(ResidentId(1), ShiftType::Closed)).unwrap());
|
||||
|
||||
assert_eq!(3, *m.get(&(ResidentId(2), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(3, *m.get(&(ResidentId(2), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(3, *m.get(&(ResidentId(2), ShiftType::Closed)).unwrap());
|
||||
|
||||
assert_eq!(3, *m.get(&(ResidentId(3), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(3, *m.get(&(ResidentId(3), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(3, *m.get(&(ResidentId(3), ShiftType::Closed)).unwrap());
|
||||
|
||||
assert_eq!(3, *m.get(&(ResidentId(4), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(3, *m.get(&(ResidentId(4), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(3, *m.get(&(ResidentId(4), ShiftType::Closed)).unwrap());
|
||||
|
||||
assert_eq!(3, *m.get(&(ResidentId(5), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(3, *m.get(&(ResidentId(5), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(3, *m.get(&(ResidentId(5), ShiftType::Closed)).unwrap());
|
||||
|
||||
assert_eq!(0, *m.get(&(ResidentId(6), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(5, *m.get(&(ResidentId(6), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(0, *m.get(&(ResidentId(6), ShiftType::Closed)).unwrap());
|
||||
|
||||
assert_eq!(2, *m.get(&(ResidentId(7), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(2, *m.get(&(ResidentId(7), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(2, *m.get(&(ResidentId(7), ShiftType::Closed)).unwrap());
|
||||
|
||||
assert_eq!(0, *m.get(&(ResidentId(8), ShiftType::OpenFirst)).unwrap());
|
||||
assert_eq!(5, *m.get(&(ResidentId(8), ShiftType::OpenSecond)).unwrap());
|
||||
assert_eq!(0, *m.get(&(ResidentId(8), ShiftType::Closed)).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user