Add option to manually swap shifts after the schedule has been generated
This commit is contained in:
@@ -47,6 +47,29 @@ pub enum ExportError {
|
||||
OpenFailed(String),
|
||||
}
|
||||
|
||||
#[derive(Error, Debug, Serialize)]
|
||||
#[serde(tag = "kind", content = "details")]
|
||||
pub enum SwapError {
|
||||
#[error("no schedule has been generated yet")]
|
||||
NotGenerated,
|
||||
#[error("invalid shift position: {0}")]
|
||||
InvalidPosition(String),
|
||||
#[error("slot has no assigned resident")]
|
||||
SlotEmpty,
|
||||
#[error("both slots belong to the same resident")]
|
||||
SameResident,
|
||||
#[error("resident not found in config")]
|
||||
ResidentNotFound,
|
||||
#[error("resident cannot take the target slot: negative shift or disallowed type")]
|
||||
ResidentCannotTakeSlot,
|
||||
#[error("swap violates same-day or toxic pair constraint")]
|
||||
SameDayOrToxicPair,
|
||||
#[error("swap creates a consecutive-day constraint violation")]
|
||||
ConsecutiveDays,
|
||||
#[error("cannot swap the same slot or slots on the same day")]
|
||||
SameSlotOrDay,
|
||||
}
|
||||
|
||||
impl Serialize for ExportError {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use log::info;
|
||||
use log::{info, warn};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
config::{UserConfig, UserConfigDTO},
|
||||
errors::{ExportError, SearchError},
|
||||
errors::{ExportError, SearchError, SwapError},
|
||||
export::{Export, FileType},
|
||||
schedule::{MonthlySchedule, ResidentMetrics},
|
||||
scheduler::Scheduler,
|
||||
slot::{Day, ShiftPosition, Slot},
|
||||
workload::WorkloadTracker,
|
||||
};
|
||||
|
||||
@@ -38,12 +40,13 @@ fn generate(
|
||||
) -> Result<MonthlySchedule, SearchError> {
|
||||
let mut schedule = MonthlySchedule::new();
|
||||
let mut tracker = WorkloadTracker::default();
|
||||
let config = UserConfig::try_from(config)?;
|
||||
let config = UserConfig::try_from(config).inspect_err(|e| warn!("generate: {e}"))?;
|
||||
let scheduler = Scheduler::new_with_config(config.clone());
|
||||
|
||||
let solved = scheduler.run(&mut schedule, &mut tracker)?;
|
||||
|
||||
if !solved {
|
||||
warn!("generate: no solution found");
|
||||
return Err(SearchError::NoSolutionFound);
|
||||
}
|
||||
|
||||
@@ -79,12 +82,19 @@ fn export(state: tauri::State<'_, AppState>) -> Result<(), ExportError> {
|
||||
let schedule = state.schedule.lock().unwrap();
|
||||
let tracker = state.tracker.lock().unwrap();
|
||||
let config = state.config.lock().unwrap();
|
||||
let config = config.as_ref().ok_or(ExportError::NotGenerated)?;
|
||||
let config = config
|
||||
.as_ref()
|
||||
.ok_or(ExportError::NotGenerated)
|
||||
.inspect_err(|e| warn!("export: {e}"))?;
|
||||
|
||||
let metrics = schedule.metrics(config, &tracker);
|
||||
|
||||
schedule.export(FileType::Docx, config, &metrics)?;
|
||||
schedule.export(FileType::Txt, config, &metrics)?;
|
||||
schedule
|
||||
.export(FileType::Docx, config, &metrics)
|
||||
.inspect_err(|e| warn!("export: {e}"))?;
|
||||
schedule
|
||||
.export(FileType::Txt, config, &metrics)
|
||||
.inspect_err(|e| warn!("export: {e}"))?;
|
||||
|
||||
let log_dir = std::env::current_dir().unwrap_or(std::path::PathBuf::from("."));
|
||||
info!("Files exported at {}", log_dir.display());
|
||||
@@ -92,6 +102,94 @@ fn export(state: tauri::State<'_, AppState>) -> Result<(), ExportError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SwapOption {
|
||||
resident_name: String,
|
||||
slot_day: u8,
|
||||
slot_position: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn find_possible_swaps(
|
||||
slot_day: u8,
|
||||
slot_position: String,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Vec<SwapOption> {
|
||||
let schedule = state.schedule.lock().unwrap();
|
||||
let config = state.config.lock().unwrap();
|
||||
|
||||
if let (Some(config), Ok(position)) = (
|
||||
config.as_ref(),
|
||||
ShiftPosition::try_from(slot_position.as_str()),
|
||||
) {
|
||||
let options: Vec<SwapOption> = schedule
|
||||
.possible_swaps(Slot::new(Day(slot_day), position), config)
|
||||
.into_iter()
|
||||
.filter_map(|(slot_b, resident_b_id)| {
|
||||
let name = config
|
||||
.residents
|
||||
.iter()
|
||||
.find(|r| r.id == resident_b_id)?
|
||||
.name
|
||||
.clone();
|
||||
Some(SwapOption {
|
||||
resident_name: name,
|
||||
slot_day: slot_b.day.0,
|
||||
slot_position: slot_b.position.as_str().to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
info!(
|
||||
"Found {} swap options for slot day={} position={}",
|
||||
options.len(),
|
||||
slot_day,
|
||||
slot_position
|
||||
);
|
||||
options
|
||||
} else {
|
||||
warn!("find_possible_swaps: no config loaded or invalid position '{slot_position}'");
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn perform_swap(
|
||||
slot_a_day: u8,
|
||||
slot_a_position: String,
|
||||
slot_b_day: u8,
|
||||
slot_b_position: String,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<MonthlySchedule, SwapError> {
|
||||
let mut schedule = state.schedule.lock().unwrap();
|
||||
let mut tracker = state.tracker.lock().unwrap();
|
||||
let config = state.config.lock().unwrap();
|
||||
let config = config
|
||||
.as_ref()
|
||||
.ok_or(SwapError::NotGenerated)
|
||||
.inspect_err(|e| warn!("perform_swap: {e}"))?;
|
||||
|
||||
let pos_a = ShiftPosition::try_from(slot_a_position.as_str())
|
||||
.map_err(|_| SwapError::InvalidPosition(slot_a_position.clone()))
|
||||
.inspect_err(|e| warn!("perform_swap: {e}"))?;
|
||||
let pos_b = ShiftPosition::try_from(slot_b_position.as_str())
|
||||
.map_err(|_| SwapError::InvalidPosition(slot_b_position.clone()))
|
||||
.inspect_err(|e| warn!("perform_swap: {e}"))?;
|
||||
|
||||
let slot_a = Slot::new(Day(slot_a_day), pos_a);
|
||||
let slot_b = Slot::new(Day(slot_b_day), pos_b);
|
||||
|
||||
schedule
|
||||
.apply_swap(slot_a, slot_b, config, &mut tracker)
|
||||
.inspect_err(|e| warn!("perform_swap: {e}"))?;
|
||||
info!(
|
||||
"Swap performed: day {}/{} ↔ day {}/{}",
|
||||
slot_a_day, slot_a_position, slot_b_day, slot_b_position
|
||||
);
|
||||
|
||||
Ok(schedule.clone())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let log_dir = std::env::current_dir().unwrap_or(std::path::PathBuf::from("."));
|
||||
@@ -114,7 +212,13 @@ pub fn run() {
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![generate, export, get_metrics])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
generate,
|
||||
export,
|
||||
get_metrics,
|
||||
find_possible_swaps,
|
||||
perform_swap
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("Error while running tauri application");
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ use serde::{ser::SerializeMap, Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{
|
||||
config::UserConfig,
|
||||
config::{ToxicPair, UserConfig},
|
||||
errors::SwapError,
|
||||
resident::ResidentId,
|
||||
slot::{Day, ShiftPosition, Slot},
|
||||
workload::WorkloadTracker,
|
||||
@@ -55,7 +56,7 @@ impl MonthlySchedule {
|
||||
}
|
||||
|
||||
pub fn has_resident_in_consecutive_days(&self, slot: &Slot) -> bool {
|
||||
if slot.day == Day(1) {
|
||||
if slot.day == Day(1) || self.get_resident_id(slot).is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -73,6 +74,131 @@ impl MonthlySchedule {
|
||||
.any(|s| self.get_resident_id(s) == self.get_resident_id(slot))
|
||||
}
|
||||
|
||||
fn check_swap(&self, slot_a: Slot, slot_b: Slot, config: &UserConfig) -> Result<(), SwapError> {
|
||||
let resident_a_id = *self.get_resident_id(&slot_a).ok_or(SwapError::SlotEmpty)?;
|
||||
let resident_b_id = *self.get_resident_id(&slot_b).ok_or(SwapError::SlotEmpty)?;
|
||||
|
||||
if resident_a_id == resident_b_id {
|
||||
return Err(SwapError::SameResident);
|
||||
}
|
||||
|
||||
let resident_a = config
|
||||
.residents
|
||||
.iter()
|
||||
.find(|r| r.id == resident_a_id)
|
||||
.ok_or(SwapError::ResidentNotFound)?;
|
||||
let resident_b = config
|
||||
.residents
|
||||
.iter()
|
||||
.find(|r| r.id == resident_b_id)
|
||||
.ok_or(SwapError::ResidentNotFound)?;
|
||||
|
||||
if resident_b.negative_shifts.contains(&slot_a.day)
|
||||
|| !resident_b.allowed_types.contains(&slot_a.shift_type())
|
||||
|| resident_a.negative_shifts.contains(&slot_b.day)
|
||||
|| !resident_a.allowed_types.contains(&slot_b.shift_type())
|
||||
{
|
||||
return Err(SwapError::ResidentCannotTakeSlot);
|
||||
}
|
||||
|
||||
if let Some(partner_a) = slot_a.other_position() {
|
||||
if let Some(&other_id) = self.get_resident_id(&partner_a) {
|
||||
if other_id == resident_b_id
|
||||
|| config
|
||||
.toxic_pairs
|
||||
.iter()
|
||||
.any(|tp| tp.matches(&ToxicPair::from((resident_b_id, other_id))))
|
||||
{
|
||||
return Err(SwapError::SameDayOrToxicPair);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(partner_b) = slot_b.other_position() {
|
||||
if let Some(&other_id) = self.get_resident_id(&partner_b) {
|
||||
if other_id == resident_a_id
|
||||
|| config
|
||||
.toxic_pairs
|
||||
.iter()
|
||||
.any(|tp| tp.matches(&ToxicPair::from((resident_a_id, other_id))))
|
||||
{
|
||||
return Err(SwapError::SameDayOrToxicPair);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut temp = self.clone();
|
||||
temp.remove(slot_a);
|
||||
temp.remove(slot_b);
|
||||
temp.insert(slot_a, resident_b_id);
|
||||
temp.insert(slot_b, resident_a_id);
|
||||
|
||||
let violated = [slot_a.day, slot_a.day.next(), slot_b.day, slot_b.day.next()]
|
||||
.iter()
|
||||
.flat_map(|&day| {
|
||||
[ShiftPosition::First, ShiftPosition::Second]
|
||||
.iter()
|
||||
.map(move |&p| Slot::new(day, p))
|
||||
})
|
||||
.any(|s| temp.has_resident_in_consecutive_days(&s));
|
||||
|
||||
if violated {
|
||||
return Err(SwapError::ConsecutiveDays);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn possible_swaps(&self, slot_a: Slot, config: &UserConfig) -> Vec<(Slot, ResidentId)> {
|
||||
let Some(&resident_a_id) = self.get_resident_id(&slot_a) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let mut results: Vec<(Slot, ResidentId)> = self
|
||||
.0
|
||||
.iter()
|
||||
.filter(|(&slot_b, &resident_b_id)| {
|
||||
slot_b != slot_a
|
||||
&& slot_b.day != slot_a.day
|
||||
&& resident_b_id != resident_a_id
|
||||
&& self.check_swap(slot_a, slot_b, config).is_ok()
|
||||
})
|
||||
.map(|(&slot_b, &resident_b_id)| (slot_b, resident_b_id))
|
||||
.collect();
|
||||
|
||||
results.sort_by_key(|(slot, _)| (slot.day, slot.position));
|
||||
results
|
||||
}
|
||||
|
||||
pub fn apply_swap(
|
||||
&mut self,
|
||||
slot_a: Slot,
|
||||
slot_b: Slot,
|
||||
config: &UserConfig,
|
||||
tracker: &mut WorkloadTracker,
|
||||
) -> Result<(), SwapError> {
|
||||
if slot_a == slot_b || slot_a.day == slot_b.day {
|
||||
return Err(SwapError::SameSlotOrDay);
|
||||
}
|
||||
|
||||
self.check_swap(slot_a, slot_b, config)?;
|
||||
|
||||
let resident_a_id = *self.get_resident_id(&slot_a).unwrap();
|
||||
let resident_b_id = *self.get_resident_id(&slot_b).unwrap();
|
||||
|
||||
self.remove(slot_a);
|
||||
self.remove(slot_b);
|
||||
self.insert(slot_a, resident_b_id);
|
||||
self.insert(slot_b, resident_a_id);
|
||||
|
||||
tracker.remove(resident_a_id, config, slot_a);
|
||||
tracker.remove(resident_b_id, config, slot_b);
|
||||
tracker.insert(resident_b_id, config, slot_a);
|
||||
tracker.insert(resident_a_id, config, slot_b);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn pretty_print(&self, config: &UserConfig) -> String {
|
||||
let mut sorted: Vec<_> = self.0.iter().collect();
|
||||
sorted.sort_by_key(|(slot, _)| (slot.day, slot.position));
|
||||
@@ -178,9 +304,12 @@ mod tests {
|
||||
use rstest::rstest;
|
||||
|
||||
use crate::{
|
||||
config::{ToxicPair, UserConfig},
|
||||
errors::SwapError,
|
||||
resident::{Resident, ResidentId},
|
||||
schedule::{Day, MonthlySchedule, Slot},
|
||||
slot::ShiftPosition,
|
||||
workload::WorkloadTracker,
|
||||
};
|
||||
|
||||
#[rstest]
|
||||
@@ -225,4 +354,143 @@ mod tests {
|
||||
assert!(!schedule.has_resident_in_consecutive_days(&slot_2));
|
||||
assert!(schedule.has_resident_in_consecutive_days(&slot_3));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_apply_swap_success() {
|
||||
let config = UserConfig::default()
|
||||
.with_residents(vec![Resident::new(1, "R1"), Resident::new(2, "R2")]);
|
||||
let mut schedule = MonthlySchedule::new();
|
||||
let mut tracker = WorkloadTracker::default();
|
||||
let slot_a = Slot::new(Day(2), ShiftPosition::First);
|
||||
let slot_b = Slot::new(Day(4), ShiftPosition::First);
|
||||
let r1 = config.residents[0].id;
|
||||
let r2 = config.residents[1].id;
|
||||
schedule.insert(slot_a, r1);
|
||||
tracker.insert(r1, &config, slot_a);
|
||||
schedule.insert(slot_b, r2);
|
||||
tracker.insert(r2, &config, slot_b);
|
||||
|
||||
schedule
|
||||
.apply_swap(slot_a, slot_b, &config, &mut tracker)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(schedule.get_resident_id(&slot_a), Some(&r2));
|
||||
assert_eq!(schedule.get_resident_id(&slot_b), Some(&r1));
|
||||
assert_eq!(tracker.current_workload(&r1), 1);
|
||||
assert_eq!(tracker.current_workload(&r2), 1);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_apply_swap_same_slot_error() {
|
||||
let config = UserConfig::default()
|
||||
.with_residents(vec![Resident::new(1, "R1"), Resident::new(2, "R2")]);
|
||||
let mut schedule = MonthlySchedule::new();
|
||||
let mut tracker = WorkloadTracker::default();
|
||||
let slot_a = Slot::new(Day(2), ShiftPosition::First);
|
||||
schedule.insert(slot_a, config.residents[0].id);
|
||||
schedule.insert(
|
||||
Slot::new(Day(4), ShiftPosition::First),
|
||||
config.residents[1].id,
|
||||
);
|
||||
|
||||
let result = schedule.apply_swap(slot_a, slot_a, &config, &mut tracker);
|
||||
assert!(matches!(result, Err(SwapError::SameSlotOrDay)));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_apply_swap_negative_shift_error() {
|
||||
let config = UserConfig::default().with_residents(vec![
|
||||
Resident::new(1, "R1").with_negative_shifts(vec![Day(4)]),
|
||||
Resident::new(2, "R2"),
|
||||
]);
|
||||
let mut schedule = MonthlySchedule::new();
|
||||
let mut tracker = WorkloadTracker::default();
|
||||
let slot_a = Slot::new(Day(2), ShiftPosition::First);
|
||||
let slot_b = Slot::new(Day(4), ShiftPosition::First);
|
||||
schedule.insert(slot_a, config.residents[0].id);
|
||||
schedule.insert(slot_b, config.residents[1].id);
|
||||
|
||||
// R1 has a negative shift on Day(4) so it cannot take slot_b
|
||||
let result = schedule.apply_swap(slot_a, slot_b, &config, &mut tracker);
|
||||
assert!(matches!(result, Err(SwapError::ResidentCannotTakeSlot)));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_apply_swap_toxic_pair_error() {
|
||||
let config = UserConfig::default()
|
||||
.with_residents(vec![
|
||||
Resident::new(1, "R1"),
|
||||
Resident::new(2, "R2"),
|
||||
Resident::new(3, "R3"),
|
||||
])
|
||||
.with_toxic_pairs(vec![ToxicPair::new(2, 3)]);
|
||||
let mut schedule = MonthlySchedule::new();
|
||||
let mut tracker = WorkloadTracker::default();
|
||||
// Day(1) is open — two slots. R1 on First, R3 on Second
|
||||
let slot_1a = Slot::new(Day(1), ShiftPosition::First);
|
||||
let slot_1b = Slot::new(Day(1), ShiftPosition::Second);
|
||||
let slot_3 = Slot::new(Day(3), ShiftPosition::First);
|
||||
schedule.insert(slot_1a, config.residents[0].id); // R1
|
||||
schedule.insert(slot_1b, config.residents[2].id); // R3
|
||||
schedule.insert(slot_3, config.residents[1].id); // R2
|
||||
|
||||
// Swapping R1 (Day1/First) with R2 (Day3/First) would put R2 alongside R3 — toxic pair
|
||||
let result = schedule.apply_swap(slot_1a, slot_3, &config, &mut tracker);
|
||||
assert!(matches!(result, Err(SwapError::SameDayOrToxicPair)));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_apply_swap_consecutive_days_error() {
|
||||
let config = UserConfig::default()
|
||||
.with_residents(vec![Resident::new(1, "R1"), Resident::new(2, "R2")]);
|
||||
let mut schedule = MonthlySchedule::new();
|
||||
let mut tracker = WorkloadTracker::default();
|
||||
let slot_a = Slot::new(Day(2), ShiftPosition::First);
|
||||
let slot_b = Slot::new(Day(4), ShiftPosition::First);
|
||||
let slot_c = Slot::new(Day(3), ShiftPosition::First);
|
||||
// R2 on Day(3) and Day(4). Swapping R1 (Day2) ↔ R2 (Day4)
|
||||
// would put R2 on Day(2) consecutive with Day(3)
|
||||
schedule.insert(slot_a, config.residents[0].id); // R1
|
||||
schedule.insert(slot_b, config.residents[1].id); // R2
|
||||
schedule.insert(slot_c, config.residents[1].id); // R2 also on Day3
|
||||
|
||||
let result = schedule.apply_swap(slot_a, slot_b, &config, &mut tracker);
|
||||
assert!(matches!(result, Err(SwapError::ConsecutiveDays)));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_possible_swaps_returns_candidate() {
|
||||
let config = UserConfig::default()
|
||||
.with_residents(vec![Resident::new(1, "R1"), Resident::new(2, "R2")]);
|
||||
let mut schedule = MonthlySchedule::new();
|
||||
let slot_a = Slot::new(Day(2), ShiftPosition::First);
|
||||
let slot_b = Slot::new(Day(4), ShiftPosition::First);
|
||||
schedule.insert(slot_a, config.residents[0].id);
|
||||
schedule.insert(slot_b, config.residents[1].id);
|
||||
|
||||
let swaps = schedule.possible_swaps(slot_a, &config);
|
||||
|
||||
assert_eq!(swaps.len(), 1);
|
||||
assert_eq!(swaps[0].0, slot_b);
|
||||
assert_eq!(swaps[0].1, config.residents[1].id);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_possible_swaps_empty_slot_returns_empty() {
|
||||
let config = UserConfig::default()
|
||||
.with_residents(vec![Resident::new(1, "R1"), Resident::new(2, "R2")]);
|
||||
let mut schedule = MonthlySchedule::new();
|
||||
schedule.insert(
|
||||
Slot::new(Day(2), ShiftPosition::First),
|
||||
config.residents[0].id,
|
||||
);
|
||||
schedule.insert(
|
||||
Slot::new(Day(4), ShiftPosition::First),
|
||||
config.residents[1].id,
|
||||
);
|
||||
|
||||
assert!(schedule
|
||||
.possible_swaps(Slot::new(Day(6), ShiftPosition::First), &config)
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +192,27 @@ pub enum ShiftPosition {
|
||||
Second,
|
||||
}
|
||||
|
||||
impl ShiftPosition {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
ShiftPosition::First => "First",
|
||||
ShiftPosition::Second => "Second",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for ShiftPosition {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(s: &str) -> Result<Self, Self::Error> {
|
||||
match s {
|
||||
"First" => Ok(ShiftPosition::First),
|
||||
"Second" => Ok(ShiftPosition::Second),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::rstest;
|
||||
|
||||
@@ -126,6 +126,36 @@ mod integration_tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_swap_preserves_constraints(
|
||||
#[values(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)] month_idx: u8,
|
||||
mut minimal_config: UserConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
minimal_config.update_month(month_idx);
|
||||
let scheduler = Scheduler::new_with_config(minimal_config.clone());
|
||||
let mut schedule = MonthlySchedule::new();
|
||||
let mut tracker = WorkloadTracker::default();
|
||||
assert!(scheduler.run(&mut schedule, &mut tracker)?);
|
||||
|
||||
let slots: Vec<_> = schedule.0.keys().copied().collect();
|
||||
let mut swapped = false;
|
||||
for slot in slots {
|
||||
let candidates = schedule.possible_swaps(slot, &minimal_config);
|
||||
if let Some(&(swap_target, _)) = candidates.first() {
|
||||
schedule
|
||||
.apply_swap(slot, swap_target, &minimal_config, &mut tracker)
|
||||
.expect("apply_swap failed for a candidate returned by possible_swaps");
|
||||
swapped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(swapped, "No valid swap found in generated schedule");
|
||||
validate_all_constraints(&schedule, &tracker, &minimal_config);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_export_pipeline(minimal_config: UserConfig) -> anyhow::Result<()> {
|
||||
let scheduler = Scheduler::new_with_config(minimal_config.clone());
|
||||
|
||||
Reference in New Issue
Block a user