ops: add schedule conflict detection

This commit is contained in:
ApsisRS contributors 2026-09-26 17:04:56 +08:00
parent 305db01140
commit f8fd175266
6 changed files with 1000 additions and 0 deletions

6
Cargo.lock generated
View File

@ -31,6 +31,12 @@ dependencies = [
[[package]] [[package]]
name = "apsis-conflict" name = "apsis-conflict"
version = "0.1.0" version = "0.1.0"
dependencies = [
"apsis-core",
"apsis-resource",
"apsis-schedule",
"apsis-task",
]
[[package]] [[package]]
name = "apsis-constellation" name = "apsis-constellation"

View File

@ -11,3 +11,7 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-resource.workspace = true
apsis-task.workspace = true
apsis-schedule.workspace = true

View File

@ -0,0 +1,252 @@
//! The conflict type and the report that collects them.
use apsis_resource::ResourceId;
use apsis_task::TaskId;
use core::fmt;
/// The kind of problem a conflict describes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ConflictKind {
/// Two placements need more of a resource than it has.
Capacity,
/// A placement falls outside the time window of its task.
Window,
/// A placement does not agree with its task.
Requirement,
/// A placement falls outside the availability of a resource.
Availability,
/// A task that must follow another starts too early.
Precedence,
}
impl fmt::Display for ConflictKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let text = match self {
Self::Capacity => "capacity",
Self::Window => "window",
Self::Requirement => "requirement",
Self::Availability => "availability",
Self::Precedence => "precedence",
};
f.write_str(text)
}
}
/// One problem found in a schedule.
///
/// The conflict names the tasks involved and, where it applies, the resource
/// and the instant at which the problem occurs. That is enough for an
/// operator to locate the placement without re-deriving the analysis.
#[derive(Debug, Clone, PartialEq)]
pub struct Conflict {
/// The kind of problem.
pub kind: ConflictKind,
/// The task the conflict is centred on.
pub task: TaskId,
/// The other task involved, for a conflict between two placements.
pub other: Option<TaskId>,
/// The resource involved, where the conflict is about one.
pub resource: Option<ResourceId>,
/// The time at which the problem occurs, in seconds.
///
/// For a capacity conflict this is the instant at which the demand
/// exceeds the capacity. For a window conflict it is the offending edge
/// of the placement.
pub at: f64,
/// A description of the problem.
pub detail: String,
}
impl Conflict {
/// Creates a conflict.
pub fn new(kind: ConflictKind, task: TaskId, at: f64, detail: impl Into<String>) -> Self {
Self {
kind,
task,
other: None,
resource: None,
at,
detail: detail.into(),
}
}
/// Returns a copy of this conflict involving another task.
pub fn with_other(mut self, other: TaskId) -> Self {
self.other = Some(other);
self
}
/// Returns a copy of this conflict involving a resource.
pub fn with_resource(mut self, resource: ResourceId) -> Self {
self.resource = Some(resource);
self
}
/// Returns `true` when the conflict concerns a placement that should be
/// moved rather than one that should be dropped.
///
/// A capacity or availability conflict can often be repaired by moving
/// the placement, while a requirement conflict means the placement does
/// not belong in the schedule at all.
pub const fn is_movable(&self) -> bool {
matches!(
self.kind,
ConflictKind::Capacity | ConflictKind::Availability
)
}
}
impl fmt::Display for Conflict {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} conflict on {} at {}: {}",
self.kind, self.task, self.at, self.detail
)?;
if let Some(other) = self.other {
write!(f, " (with {other})")?;
}
if let Some(resource) = self.resource {
write!(f, " (on {resource})")?;
}
Ok(())
}
}
/// The conflicts found in a schedule.
///
/// The report is ordered by time and then by kind, so that reading it
/// follows the plan. The counts by kind are provided because a planner
/// usually wants to know how many of each problem there are before deciding
/// what to do.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ConflictReport {
conflicts: Vec<Conflict>,
offered_tasks: usize,
placed_tasks: usize,
}
impl ConflictReport {
/// Creates a report from a list of conflicts and the task counts.
///
/// The conflicts are sorted by time, then by kind, then by task, so that
/// the order does not depend on the order they were found.
pub fn new(mut conflicts: Vec<Conflict>, offered_tasks: usize, placed_tasks: usize) -> Self {
conflicts.sort_by(|first, second| {
first
.at
.partial_cmp(&second.at)
.unwrap_or(core::cmp::Ordering::Equal)
.then_with(|| kind_order(first.kind).cmp(&kind_order(second.kind)))
.then_with(|| first.task.cmp(&second.task))
.then_with(|| first.other.cmp(&second.other))
});
Self {
conflicts,
offered_tasks,
placed_tasks,
}
}
/// Returns the conflicts, in time order.
pub fn conflicts(&self) -> &[Conflict] {
&self.conflicts
}
/// Returns the number of conflicts.
pub fn len(&self) -> usize {
self.conflicts.len()
}
/// Returns `true` when there are no conflicts.
///
/// This is the conventional spelling of [`ConflictReport::is_clear`], and
/// the two always agree.
pub fn is_empty(&self) -> bool {
self.conflicts.is_empty()
}
/// Returns `true` when there are no conflicts.
///
/// A clear report means the schedule is feasible with respect to every
/// check this crate performs, not that it is optimal.
pub fn is_clear(&self) -> bool {
self.conflicts.is_empty()
}
/// Returns `true` when there is at least one conflict.
pub fn has_conflicts(&self) -> bool {
!self.conflicts.is_empty()
}
/// Returns the number of conflicts of one kind.
pub fn count_of(&self, kind: ConflictKind) -> usize {
self.conflicts
.iter()
.filter(|conflict| conflict.kind == kind)
.count()
}
/// Returns the conflicts of one kind, in time order.
pub fn of_kind(&self, kind: ConflictKind) -> Vec<&Conflict> {
self.conflicts
.iter()
.filter(|conflict| conflict.kind == kind)
.collect()
}
/// Returns the tasks involved in at least one conflict.
///
/// A task appears once even when it has several conflicts, which is what
/// a repair pass wants: the set of tasks to reconsider.
pub fn conflicting_tasks(&self) -> Vec<TaskId> {
let mut tasks: Vec<TaskId> = Vec::new();
for conflict in &self.conflicts {
if !tasks.contains(&conflict.task) {
tasks.push(conflict.task);
}
if let Some(other) = conflict.other {
if !tasks.contains(&other) {
tasks.push(other);
}
}
}
tasks
}
/// Returns the number of tasks that were offered to the schedule.
pub fn offered_tasks(&self) -> usize {
self.offered_tasks
}
/// Returns the number of tasks that were placed.
pub fn placed_tasks(&self) -> usize {
self.placed_tasks
}
/// Returns a one line summary of the report.
pub fn summary(&self) -> String {
format!(
"{} conflict(s) among {} placed of {} offered task(s)",
self.conflicts.len(),
self.placed_tasks,
self.offered_tasks
)
}
}
/// Returns a sort key that groups the kinds in a readable order.
///
/// The order runs from the problems that invalidate a plan to the ones that
/// merely make it expensive, so that reading the report top to bottom
/// addresses the most serious problems first.
const fn kind_order(kind: ConflictKind) -> u8 {
match kind {
ConflictKind::Requirement => 0,
ConflictKind::Window => 1,
ConflictKind::Capacity => 2,
ConflictKind::Availability => 3,
ConflictKind::Precedence => 4,
}
}

View File

@ -0,0 +1,271 @@
//! The conflict detection itself.
use crate::conflict::{Conflict, ConflictKind, ConflictReport};
use apsis_resource::{ResourceId, ResourceSet};
use apsis_schedule::Schedule;
use apsis_task::{Task, TaskId};
/// A precedence constraint: one task must finish before another starts.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Precedence {
/// The task that must finish first.
pub before: TaskId,
/// The task that must start after.
pub after: TaskId,
}
impl Precedence {
/// Creates a precedence constraint.
pub const fn new(before: TaskId, after: TaskId) -> Self {
Self { before, after }
}
}
/// Returns the placements of a task, which is at most one.
pub fn placements_of(schedule: &Schedule, task: TaskId) -> Option<&apsis_schedule::Placement> {
schedule.placement_of(task)
}
/// Returns every conflict in a schedule.
///
/// The checks are performed in full: the report contains every problem found,
/// not just the first. That is deliberate, because a planner that is handed a
/// plan usually wants to know how bad it is before deciding whether to repair
/// or rebuild.
///
/// The checks are:
///
/// - every placement's task exists in the list, and the durations agree;
/// - every placement falls inside its task's window;
/// - no resource exceeds its capacity at any instant;
/// - every placement falls inside the availability windows of each resource
/// it requires.
///
/// The resource sweep examines the boundaries of the placements, because the
/// total demand on a resource is constant between them, so the check is exact
/// and takes time proportional to the number of placements rather than to the
/// length of the plan.
pub fn detect(schedule: &Schedule, tasks: &[Task], resources: &ResourceSet) -> ConflictReport {
let mut conflicts = Vec::new();
// The requirement and window checks need only the placement and its
// task.
for placement in schedule.placements() {
let task = tasks.iter().find(|task| task.id == placement.task);
let Some(task) = task else {
conflicts.push(
Conflict::new(
ConflictKind::Requirement,
placement.task,
placement.start,
"the task is not in the task list",
)
.with_resource(ResourceId::new(0)),
);
continue;
};
if (placement.duration - task.duration).abs() > 1e-12 {
conflicts.push(Conflict::new(
ConflictKind::Requirement,
placement.task,
placement.start,
format!(
"placed for {} s but the task lasts {} s",
placement.duration, task.duration
),
));
}
if placement.start < task.earliest_start {
conflicts.push(Conflict::new(
ConflictKind::Window,
placement.task,
placement.start,
format!(
"starts at {}, before the window opens at {}",
placement.start, task.earliest_start
),
));
}
if placement.end() > task.latest_end {
conflicts.push(Conflict::new(
ConflictKind::Window,
placement.task,
placement.end(),
format!(
"ends at {}, after the window closes at {}",
placement.end(),
task.latest_end
),
));
}
}
// The availability check: a placement must fall inside a window of every
// resource it requires.
for placement in schedule.placements() {
let Some(task) = tasks.iter().find(|task| task.id == placement.task) else {
continue;
};
for requirement in &task.requirements {
let Some(resource) = resources.get(requirement.resource) else {
conflicts.push(
Conflict::new(
ConflictKind::Requirement,
placement.task,
placement.start,
format!("{} does not exist", requirement.resource),
)
.with_resource(requirement.resource),
);
continue;
};
// A demand greater than the capacity can never be met, which is a
// requirement problem rather than a placement problem.
if requirement.demand > resource.capacity() + 1e-9 {
conflicts.push(
Conflict::new(
ConflictKind::Requirement,
placement.task,
placement.start,
format!(
"demand {} exceeds the capacity {} of {}",
requirement.demand,
resource.capacity(),
resource.id
),
)
.with_resource(requirement.resource),
);
continue;
}
if !resource.is_available_over(placement.start, placement.duration) {
conflicts.push(
Conflict::new(
ConflictKind::Availability,
placement.task,
placement.start,
format!(
"{} is not available from {} for {} s",
resource.id, placement.start, placement.duration
),
)
.with_resource(requirement.resource),
);
}
}
}
// The capacity check, one resource at a time.
for resource in resources.resources() {
// Each event is a time, a change in demand, and the task responsible,
// so that a conflict can name the pair.
let mut events: Vec<(f64, f64, TaskId)> = Vec::new();
for placement in schedule.placements() {
let Some(task) = tasks.iter().find(|task| task.id == placement.task) else {
continue;
};
if let Some(demand) = task.demand_for(resource.id) {
events.push((placement.start, demand, placement.task));
events.push((placement.end(), -demand, placement.task));
}
}
if events.is_empty() {
continue;
}
// A release sorts before an acquisition at the same instant, so two
// placements that touch are not counted as overlapping.
events.sort_by(|first, second| {
first
.0
.partial_cmp(&second.0)
.unwrap_or(core::cmp::Ordering::Equal)
.then_with(|| {
first
.1
.partial_cmp(&second.1)
.unwrap_or(core::cmp::Ordering::Equal)
})
.then_with(|| first.2.cmp(&second.2))
});
let mut current = 0.0;
let mut holders: Vec<TaskId> = Vec::new();
for (time, change, task) in events {
if change > 0.0 {
holders.push(task);
} else {
holders.retain(|holder| *holder != task);
}
current += change;
if current > resource.capacity() + 1e-9 {
// Report the pair that first pushes the resource over, so
// that the conflict names two tasks rather than only the one
// that arrived last.
let mut involved = holders.clone();
involved.sort();
let first = involved.first().copied();
let second = involved.get(1).copied();
let detail = format!(
"demand {} exceeds the capacity {} of {}",
current,
resource.capacity(),
resource.id
);
let mut conflict =
Conflict::new(ConflictKind::Capacity, first.unwrap_or(task), time, detail)
.with_resource(resource.id);
if let Some(second) = second {
conflict = conflict.with_other(second);
}
conflicts.push(conflict);
// Report each resource once, at the first instant it is over
// committed. A second report at a later instant would add
// nothing a repair pass can use.
break;
}
}
}
ConflictReport::new(conflicts, tasks.len(), schedule.len())
}
/// Returns the precedence conflicts among a set of constraints.
///
/// A precedence conflict occurs when the task that must come second starts
/// before the one that must come first has finished. A constraint whose
/// tasks are not both placed is not a conflict, because nothing has been
/// promised about them.
///
/// The constraints are checked as given, so they must be acyclic. A cycle is
/// a contradiction in the caller's own model and is not detected here.
pub fn detect_precedence(schedule: &Schedule, constraints: &[Precedence]) -> Vec<Conflict> {
let mut conflicts = Vec::new();
for constraint in constraints {
let (Some(before), Some(after)) = (
schedule.placement_of(constraint.before),
schedule.placement_of(constraint.after),
) else {
continue;
};
if after.start < before.end() {
conflicts.push(
Conflict::new(
ConflictKind::Precedence,
constraint.after,
after.start,
format!(
"{} must follow {} but starts at {} before it ends at {}",
constraint.after,
constraint.before,
after.start,
before.end()
),
)
.with_other(constraint.before),
);
}
}
conflicts
}

View File

@ -1 +1,58 @@
//! Resource and time conflict detection. //! Resource and time conflict detection.
//!
//! A schedule can be wrong in several ways, and this crate finds them. It
//! reports conflicts rather than refusing them, so that a plan loaded from a
//! file can be checked in full and every problem listed at once instead of
//! one per attempt.
//!
//! Five kinds of conflict are detected:
//!
//! - **Capacity**: two placements need more of a resource at the same instant
//! than the resource has.
//! - **Window**: a placement starts before its task may, or ends after it
//! must.
//! - **Requirement**: a placement's task is not known, or its duration does
//! not match the schedule.
//! - **Availability**: a placement falls outside the availability windows of
//! a resource it needs, so the resource would not actually be there.
//! - **Precedence**: a task that must follow another starts before it ends.
//!
//! The distinction between a capacity conflict and an availability conflict
//! matters in practice: the first says the plan is over committed, the second
//! says the plan asks for a resource at a time when it is switched off or out
//! of view, which is a different repair.
//!
//! ```
//! use apsis_resource::{Resource, ResourceId, ResourceSet, Window};
//! use apsis_schedule::Schedule;
//! use apsis_task::{Task, TaskId};
//! use apsis_conflict::{ConflictKind, detect};
//!
//! let mut station = Resource::new(ResourceId::new(0), 1.0);
//! station.set_windows(&[Window::new(0.0, 1_000.0)]).expect("usable");
//! let resources = ResourceSet::from_resources(vec![station]).expect("distinct");
//!
//! let task = Task::new(TaskId::new(0), 60.0)
//! .try_require(ResourceId::new(0), 1.0)
//! .expect("the resource is new");
//! let mut schedule = Schedule::new();
//! schedule.place(&task, 100.0).expect("the task fits");
//!
//! let report = detect(&schedule, &[task], &resources);
//! assert!(report.is_clear());
//! assert_eq!(report.conflicts().len(), 0);
//! let _ = ConflictKind::Capacity;
//! ```
#![forbid(unsafe_code)]
#![deny(missing_docs)]
mod conflict;
mod detect;
pub use conflict::{Conflict, ConflictKind, ConflictReport};
pub use detect::{Precedence, detect, detect_precedence, placements_of};
pub use apsis_resource::{ResourceId, ResourceSet};
pub use apsis_schedule::Schedule;
pub use apsis_task::{Task, TaskId};

View File

@ -0,0 +1,410 @@
//! Tests for conflict detection.
use apsis_conflict::{Conflict, ConflictKind, Precedence, Schedule, detect, detect_precedence};
use apsis_resource::{Resource, ResourceId, ResourceSet, Window};
use apsis_task::{Task, TaskId};
/// A resource available from 0 to 1 000 seconds.
fn resource(id: usize, capacity: f64) -> Resource {
let mut resource = Resource::new(ResourceId::new(id), capacity);
resource
.set_windows(&[Window::new(0.0, 1_000.0)])
.expect("the window is usable");
resource
}
/// A task requiring one unit of each named resource.
fn task(id: usize, duration: f64, resources: &[usize]) -> Task {
let mut task = Task::new(TaskId::new(id), duration);
for resource in resources {
task = task
.try_require(ResourceId::new(*resource), 1.0)
.expect("the resource is new");
}
task
}
/// The standard resource set used by the tests.
fn resources() -> ResourceSet {
ResourceSet::from_resources(vec![resource(0, 1.0), resource(1, 1.0)])
.expect("the identifiers are distinct")
}
#[test]
fn a_sound_schedule_has_no_conflicts() {
let resources = resources();
let tasks = vec![task(0, 60.0, &[0]), task(1, 30.0, &[1])];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 0.0).expect("fits");
schedule.place(&tasks[1], 100.0).expect("fits");
let report = detect(&schedule, &tasks, &resources);
assert!(report.is_clear());
assert!(report.is_empty());
assert!(!report.has_conflicts());
assert_eq!(report.len(), 0);
assert_eq!(report.placed_tasks(), 2);
assert_eq!(report.offered_tasks(), 2);
assert!(report.conflicting_tasks().is_empty());
assert!(report.summary().contains("0 conflict"));
}
#[test]
fn a_capacity_conflict_is_found() {
let resources = resources();
let tasks = vec![task(0, 60.0, &[0]), task(1, 60.0, &[0])];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 0.0).expect("fits");
schedule.place(&tasks[1], 30.0).expect("fits");
let report = detect(&schedule, &tasks, &resources);
assert!(report.has_conflicts());
assert_eq!(report.count_of(ConflictKind::Capacity), 1);
let conflict = report.of_kind(ConflictKind::Capacity)[0];
assert_eq!(conflict.resource, Some(ResourceId::new(0)));
// The conflict names both tasks, so a repair pass knows the pair.
assert!(conflict.other.is_some());
// The instant reported is the start of the second placement.
assert_eq!(conflict.at, 30.0);
assert!(conflict.is_movable());
}
#[test]
fn back_to_back_placements_do_not_conflict() {
let resources = resources();
let tasks = vec![task(0, 60.0, &[0]), task(1, 60.0, &[0])];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 0.0).expect("fits");
schedule.place(&tasks[1], 60.0).expect("fits");
assert!(detect(&schedule, &tasks, &resources).is_clear());
}
#[test]
fn a_window_conflict_is_found_at_the_start() {
let resources = resources();
let tasks = [task(0, 60.0, &[0]).with_window(100.0, 1_000.0)];
let mut schedule = Schedule::new();
// The schedule refuses a placement before the window, so the conflict is
// constructed directly to test the detector.
schedule.place(&tasks[0], 100.0).expect("fits");
// A task list whose window has moved since the schedule was built.
let moved = vec![task(0, 60.0, &[0]).with_window(200.0, 1_000.0)];
let report = detect(&schedule, &moved, &resources);
assert_eq!(report.count_of(ConflictKind::Window), 1);
let conflict = report.of_kind(ConflictKind::Window)[0];
assert_eq!(conflict.at, 100.0);
assert!(!conflict.is_movable());
}
#[test]
fn a_window_conflict_is_found_at_the_end() {
let resources = resources();
let tasks = [task(0, 60.0, &[0]).with_window(0.0, 1_000.0)];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 100.0).expect("fits");
// A task list whose window closes earlier than the placement ends.
let shortened = vec![task(0, 60.0, &[0]).with_window(0.0, 120.0)];
let report = detect(&schedule, &shortened, &resources);
assert_eq!(report.count_of(ConflictKind::Window), 1);
let conflict = report.of_kind(ConflictKind::Window)[0];
// The instant reported is the end of the placement.
assert_eq!(conflict.at, 160.0);
}
#[test]
fn a_requirement_conflict_is_found_for_a_missing_task() {
let resources = resources();
let tasks = [task(0, 60.0, &[0])];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 0.0).expect("fits");
// The task list is empty, so the placement has no task.
let report = detect(&schedule, &[], &resources);
assert_eq!(report.count_of(ConflictKind::Requirement), 1);
assert_eq!(report.placed_tasks(), 1);
assert_eq!(report.offered_tasks(), 0);
}
#[test]
fn a_requirement_conflict_is_found_for_a_duration_mismatch() {
let resources = resources();
let original = task(0, 60.0, &[0]);
let mut schedule = Schedule::new();
schedule.place(&original, 0.0).expect("fits");
let changed = vec![task(0, 90.0, &[0])];
let report = detect(&schedule, &changed, &resources);
assert_eq!(report.count_of(ConflictKind::Requirement), 1);
}
#[test]
fn a_requirement_conflict_is_found_for_a_missing_resource() {
let resources = resources();
let tasks = vec![task(0, 60.0, &[9])];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 0.0).expect("fits");
let report = detect(&schedule, &tasks, &resources);
assert_eq!(report.count_of(ConflictKind::Requirement), 1);
let conflict = report.of_kind(ConflictKind::Requirement)[0];
assert_eq!(conflict.resource, Some(ResourceId::new(9)));
}
#[test]
fn a_requirement_conflict_is_found_for_an_impossible_demand() {
let resources = resources();
// A demand of two on a resource of capacity one can never be met.
let tasks = vec![
Task::new(TaskId::new(0), 60.0)
.try_require(ResourceId::new(0), 2.0)
.expect("the resource is new"),
];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 0.0).expect("fits");
let report = detect(&schedule, &tasks, &resources);
assert_eq!(report.count_of(ConflictKind::Requirement), 1);
let conflict = report.of_kind(ConflictKind::Requirement)[0];
assert!(conflict.detail.contains("exceeds the capacity"));
}
#[test]
fn an_availability_conflict_is_found_outside_the_windows() {
// The resource is available only from 200 to 300 seconds.
let mut limited = Resource::new(ResourceId::new(0), 1.0);
limited
.set_windows(&[Window::new(200.0, 300.0)])
.expect("the window is usable");
let resources = ResourceSet::from_resources(vec![limited]).expect("distinct");
let tasks = vec![task(0, 60.0, &[0])];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 0.0).expect("fits");
let report = detect(&schedule, &tasks, &resources);
assert_eq!(report.count_of(ConflictKind::Availability), 1);
let conflict = report.of_kind(ConflictKind::Availability)[0];
assert_eq!(conflict.resource, Some(ResourceId::new(0)));
assert!(conflict.is_movable());
}
#[test]
fn an_availability_conflict_is_found_for_a_placement_that_overruns_a_window() {
// The resource is available only from 0 to 120 seconds, so a placement
// from 100 for 60 seconds overruns it.
let mut limited = Resource::new(ResourceId::new(0), 1.0);
limited
.set_windows(&[Window::new(0.0, 120.0)])
.expect("the window is usable");
let resources = ResourceSet::from_resources(vec![limited]).expect("distinct");
let tasks = vec![task(0, 60.0, &[0])];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 100.0).expect("fits");
assert_eq!(
detect(&schedule, &tasks, &resources).count_of(ConflictKind::Availability),
1
);
}
#[test]
fn a_placement_inside_the_availability_has_no_conflict() {
let resources = resources();
let tasks = vec![task(0, 60.0, &[0])];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 200.0).expect("fits");
assert!(detect(&schedule, &tasks, &resources).is_clear());
}
#[test]
fn several_conflicts_are_reported_together() {
// The detector reports every problem, not just the first.
let resources = resources();
let tasks = [task(0, 60.0, &[0]), task(1, 60.0, &[0])];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 0.0).expect("fits");
schedule.place(&tasks[1], 30.0).expect("fits");
// A task list that also moves the window of the first task.
let changed = vec![
task(0, 60.0, &[0]).with_window(100.0, 1_000.0),
task(1, 60.0, &[0]),
];
let report = detect(&schedule, &changed, &resources);
assert!(report.count_of(ConflictKind::Capacity) >= 1);
assert!(report.count_of(ConflictKind::Window) >= 1);
assert!(report.len() >= 2);
}
#[test]
fn the_report_is_ordered_by_time() {
let resources = resources();
// The placements are made inside a wide window, then the task list is
// changed so that both placements fall before the new window opens. The
// schedule refuses such a placement, which is why the changed list is
// used for the check.
let placed = [task(0, 60.0, &[0]), task(1, 60.0, &[1])];
let mut schedule = Schedule::new();
schedule.place(&placed[0], 100.0).expect("fits");
schedule.place(&placed[1], 300.0).expect("fits");
let changed = vec![
task(0, 60.0, &[0]).with_window(500.0, 1_000.0),
task(1, 60.0, &[1]).with_window(500.0, 1_000.0),
];
let report = detect(&schedule, &changed, &resources);
assert!(report.len() >= 2);
let times: Vec<f64> = report.conflicts().iter().map(|c| c.at).collect();
let mut sorted = times.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).expect("finite"));
assert_eq!(times, sorted);
}
#[test]
fn the_report_lists_the_conflicting_tasks_once_each() {
let resources = resources();
let tasks = vec![task(0, 60.0, &[0]), task(1, 60.0, &[0])];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 0.0).expect("fits");
schedule.place(&tasks[1], 30.0).expect("fits");
let report = detect(&schedule, &tasks, &resources);
let tasks_involved = report.conflicting_tasks();
// Both tasks are involved, and each appears once.
assert!(tasks_involved.contains(&TaskId::new(0)));
assert!(tasks_involved.contains(&TaskId::new(1)));
assert_eq!(tasks_involved.len(), 2);
}
#[test]
fn the_counts_by_kind_sum_to_the_total() {
let resources = resources();
let tasks = vec![task(0, 60.0, &[0]), task(1, 60.0, &[0])];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 0.0).expect("fits");
schedule.place(&tasks[1], 30.0).expect("fits");
let report = detect(&schedule, &tasks, &resources);
let summed = report.count_of(ConflictKind::Capacity)
+ report.count_of(ConflictKind::Window)
+ report.count_of(ConflictKind::Requirement)
+ report.count_of(ConflictKind::Availability)
+ report.count_of(ConflictKind::Precedence);
assert_eq!(summed, report.len());
}
#[test]
fn a_precedence_violation_is_found() {
let tasks = [task(0, 60.0, &[0]), task(1, 60.0, &[1])];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 100.0).expect("fits");
// The second task must follow the first, but starts inside it.
schedule.place(&tasks[1], 120.0).expect("fits");
let conflicts = detect_precedence(
&schedule,
&[Precedence::new(TaskId::new(0), TaskId::new(1))],
);
assert_eq!(conflicts.len(), 1);
let conflict = &conflicts[0];
assert_eq!(conflict.kind, ConflictKind::Precedence);
assert_eq!(conflict.task, TaskId::new(1));
assert_eq!(conflict.other, Some(TaskId::new(0)));
assert_eq!(conflict.at, 120.0);
}
#[test]
fn a_satisfied_precedence_has_no_conflict() {
let tasks = [task(0, 60.0, &[0]), task(1, 60.0, &[1])];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 100.0).expect("fits");
// Starting exactly at the end of the first is allowed, because the
// placement interval is half-open.
schedule.place(&tasks[1], 160.0).expect("fits");
assert!(
detect_precedence(
&schedule,
&[Precedence::new(TaskId::new(0), TaskId::new(1))]
)
.is_empty()
);
}
#[test]
fn a_precedence_over_an_unplaced_task_is_ignored() {
let tasks = [task(0, 60.0, &[0])];
let mut schedule = Schedule::new();
schedule.place(&tasks[0], 100.0).expect("fits");
// The second task is not placed, so nothing has been promised.
assert!(
detect_precedence(
&schedule,
&[Precedence::new(TaskId::new(0), TaskId::new(1))]
)
.is_empty()
);
assert!(
detect_precedence(
&schedule,
&[Precedence::new(TaskId::new(1), TaskId::new(0))]
)
.is_empty()
);
}
#[test]
fn a_conflict_displays_its_parts() {
let conflict = Conflict::new(
ConflictKind::Capacity,
TaskId::new(0),
30.0,
"over committed",
)
.with_other(TaskId::new(1))
.with_resource(ResourceId::new(2));
let text = conflict.to_string();
assert!(text.contains("capacity"), "{text}");
assert!(text.contains("task 0"), "{text}");
assert!(text.contains("task 1"), "{text}");
assert!(text.contains("resource 2"), "{text}");
assert!(text.contains("30"), "{text}");
}
#[test]
fn the_conflict_kinds_display_their_names() {
assert_eq!(ConflictKind::Capacity.to_string(), "capacity");
assert_eq!(ConflictKind::Window.to_string(), "window");
assert_eq!(ConflictKind::Requirement.to_string(), "requirement");
assert_eq!(ConflictKind::Availability.to_string(), "availability");
assert_eq!(ConflictKind::Precedence.to_string(), "precedence");
}
#[test]
fn movability_is_reported_by_kind() {
assert!(Conflict::new(ConflictKind::Capacity, TaskId::new(0), 0.0, "").is_movable());
assert!(Conflict::new(ConflictKind::Availability, TaskId::new(0), 0.0, "").is_movable());
assert!(!Conflict::new(ConflictKind::Window, TaskId::new(0), 0.0, "").is_movable());
assert!(!Conflict::new(ConflictKind::Requirement, TaskId::new(0), 0.0, "").is_movable());
assert!(!Conflict::new(ConflictKind::Precedence, TaskId::new(0), 0.0, "").is_movable());
}
#[test]
fn the_report_can_be_built_directly() {
use apsis_conflict::ConflictReport;
let report = ConflictReport::new(
vec![Conflict::new(
ConflictKind::Window,
TaskId::new(0),
5.0,
"late",
)],
3,
2,
);
assert_eq!(report.len(), 1);
assert_eq!(report.offered_tasks(), 3);
assert_eq!(report.placed_tasks(), 2);
assert!(report.summary().contains("1 conflict"));
}