ops: add tasks with resource requirements and windows
This commit is contained in:
parent
72640083af
commit
b3808d9a2a
|
|
@ -292,6 +292,10 @@ version = "0.1.0"
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "apsis-task"
|
name = "apsis-task"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"apsis-core",
|
||||||
|
"apsis-resource",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "apsis-time"
|
name = "apsis-time"
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,5 @@ publish = false
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
apsis-core.workspace = true
|
||||||
|
apsis-resource.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
//! Errors reported while building or querying a task.
|
||||||
|
|
||||||
|
use core::fmt;
|
||||||
|
|
||||||
|
/// The category of a task error.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum TaskErrorKind {
|
||||||
|
/// The duration is not finite, or is negative.
|
||||||
|
InvalidDuration,
|
||||||
|
/// A time window is not finite or ends before it starts.
|
||||||
|
InvalidWindow,
|
||||||
|
/// The window is shorter than the task duration.
|
||||||
|
WindowTooShort,
|
||||||
|
/// A resource demand is not positive and finite.
|
||||||
|
InvalidDemand,
|
||||||
|
/// The same resource is required more than once.
|
||||||
|
DuplicateRequirement,
|
||||||
|
/// A priority or value is not finite.
|
||||||
|
InvalidWeight,
|
||||||
|
/// No task has the requested identifier.
|
||||||
|
NoSuchTask,
|
||||||
|
/// A task with the identifier already exists.
|
||||||
|
DuplicateTask,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for TaskErrorKind {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let text = match self {
|
||||||
|
Self::InvalidDuration => "the duration is not usable",
|
||||||
|
Self::InvalidWindow => "the time window is not usable",
|
||||||
|
Self::WindowTooShort => "the window is shorter than the task",
|
||||||
|
Self::InvalidDemand => "a resource demand is not usable",
|
||||||
|
Self::DuplicateRequirement => "a resource is required more than once",
|
||||||
|
Self::InvalidWeight => "a priority or value is not finite",
|
||||||
|
Self::NoSuchTask => "no such task",
|
||||||
|
Self::DuplicateTask => "a task with that identifier exists",
|
||||||
|
};
|
||||||
|
f.write_str(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A failure while building or querying a task.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TaskError {
|
||||||
|
kind: TaskErrorKind,
|
||||||
|
detail: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskError {
|
||||||
|
/// Creates an error with the given category.
|
||||||
|
pub fn new(kind: TaskErrorKind) -> Self {
|
||||||
|
Self { kind, detail: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attaches a description of the values that caused the failure.
|
||||||
|
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
|
||||||
|
self.detail = Some(detail.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the category of this failure.
|
||||||
|
pub fn kind(&self) -> TaskErrorKind {
|
||||||
|
self.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the detail text attached to this error, if any.
|
||||||
|
pub fn detail(&self) -> Option<&str> {
|
||||||
|
self.detail.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for TaskError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match &self.detail {
|
||||||
|
Some(detail) => write!(f, "{}: {detail}", self.kind),
|
||||||
|
None => write!(f, "{}", self.kind),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::error::Error for TaskError {}
|
||||||
|
|
@ -1 +1,45 @@
|
||||||
//! Task modeling for satellite operations.
|
//! Task modeling for satellite operations.
|
||||||
|
//!
|
||||||
|
//! A task is a unit of work that a scheduler places on a set of resources. It
|
||||||
|
//! carries everything a scheduler needs to decide whether and where to run
|
||||||
|
//! it: how long it takes, when it may run, which resources it needs and in
|
||||||
|
//! what amount, and what it is worth.
|
||||||
|
//!
|
||||||
|
//! A task requires all of its resources at the same time, for its whole
|
||||||
|
//! duration. That is the model an observation has: the satellite and the
|
||||||
|
//! ground station must both be available while the data is taken. A task that
|
||||||
|
//! needs resources in sequence is expressed as two tasks with a
|
||||||
|
//! precedence constraint, which the scheduling layer provides.
|
||||||
|
//!
|
||||||
|
//! Tasks are immutable once built. The state a scheduler assigns to one is
|
||||||
|
//! kept separately, in the schedule, so that the same task can be considered
|
||||||
|
//! for several placements without being modified.
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! use apsis_resource::ResourceId;
|
||||||
|
//! use apsis_task::{Requirement, Task, TaskId};
|
||||||
|
//!
|
||||||
|
//! // An observation of sixty seconds that needs a satellite and a station.
|
||||||
|
//! let task = Task::new(TaskId::new(0), 60.0)
|
||||||
|
//! .with_window(100.0, 1_000.0)
|
||||||
|
//! .with_priority(5.0)
|
||||||
|
//! .try_require(ResourceId::new(0), 1.0)
|
||||||
|
//! .expect("the satellite is not yet required")
|
||||||
|
//! .try_require(ResourceId::new(1), 1.0)
|
||||||
|
//! .expect("the station is not yet required");
|
||||||
|
//!
|
||||||
|
//! assert_eq!(task.duration, 60.0);
|
||||||
|
//! assert_eq!(task.requirements.len(), 2);
|
||||||
|
//! assert!(task.is_valid());
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(missing_docs)]
|
||||||
|
|
||||||
|
mod error;
|
||||||
|
mod task;
|
||||||
|
|
||||||
|
pub use error::{TaskError, TaskErrorKind};
|
||||||
|
pub use task::{Requirement, Task, TaskId, TaskSet, TaskState, task_order};
|
||||||
|
|
||||||
|
pub use apsis_resource::ResourceId;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,505 @@
|
||||||
|
//! Tasks, their requirements, and the set that holds them.
|
||||||
|
|
||||||
|
use crate::error::{TaskError, TaskErrorKind};
|
||||||
|
use apsis_resource::ResourceId;
|
||||||
|
|
||||||
|
/// An identifier for a task.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
pub struct TaskId(pub usize);
|
||||||
|
|
||||||
|
impl TaskId {
|
||||||
|
/// Creates an identifier.
|
||||||
|
pub const fn new(index: usize) -> Self {
|
||||||
|
Self(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the index.
|
||||||
|
pub const fn index(self) -> usize {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::fmt::Display for TaskId {
|
||||||
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||||
|
write!(f, "task {}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A demand that a task makes on one resource.
|
||||||
|
///
|
||||||
|
/// The demand is the amount of the resource's capacity the task consumes
|
||||||
|
/// while it runs. A demand that exceeds the resource's capacity makes the
|
||||||
|
/// task impossible to schedule there, which a scheduler discovers by
|
||||||
|
/// comparing the two.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Requirement {
|
||||||
|
/// The resource required.
|
||||||
|
pub resource: ResourceId,
|
||||||
|
/// The amount of the resource consumed.
|
||||||
|
pub demand: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Requirement {
|
||||||
|
/// Creates a requirement.
|
||||||
|
pub const fn new(resource: ResourceId, demand: f64) -> Self {
|
||||||
|
Self { resource, demand }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when the demand is positive and finite.
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
self.demand.is_finite() && self.demand > 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A unit of work to be scheduled.
|
||||||
|
///
|
||||||
|
/// A task is immutable after construction. The builder methods take `self` by
|
||||||
|
/// value and return a new task, so a caller can share a base task and derive
|
||||||
|
/// variants from it without the variants interfering.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Task {
|
||||||
|
/// The identifier of this task.
|
||||||
|
pub id: TaskId,
|
||||||
|
/// The duration of the task, in seconds.
|
||||||
|
pub duration: f64,
|
||||||
|
/// The earliest time at which the task may start, in seconds.
|
||||||
|
///
|
||||||
|
/// This is the start of the earliest window in which the task may run.
|
||||||
|
/// A task with no window has no restriction, which is what the
|
||||||
|
/// constructors give.
|
||||||
|
pub earliest_start: f64,
|
||||||
|
/// The latest time at which the task may end, in seconds.
|
||||||
|
///
|
||||||
|
/// A task with no window has no restriction.
|
||||||
|
pub latest_end: f64,
|
||||||
|
/// The resources the task requires, in the order they were added.
|
||||||
|
pub requirements: Vec<Requirement>,
|
||||||
|
/// The priority of the task.
|
||||||
|
///
|
||||||
|
/// A larger value means the task is more important. The scale is the
|
||||||
|
/// caller's. A scheduler that maximises weighted value uses this number,
|
||||||
|
/// and one that maximises the count of scheduled tasks ignores it.
|
||||||
|
pub priority: f64,
|
||||||
|
/// Whether the task must be scheduled.
|
||||||
|
///
|
||||||
|
/// A mandatory task is scheduled even at the cost of other tasks. A
|
||||||
|
/// scheduler may still fail to place one when no placement exists, and
|
||||||
|
/// reports that rather than dropping it silently.
|
||||||
|
pub mandatory: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Task {
|
||||||
|
/// Creates a task with the given duration and no requirements.
|
||||||
|
///
|
||||||
|
/// The task may start at any finite time, because it has no window. It
|
||||||
|
/// has a priority of zero and is not mandatory.
|
||||||
|
pub fn new(id: TaskId, duration: f64) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
duration,
|
||||||
|
earliest_start: f64::NEG_INFINITY,
|
||||||
|
latest_end: f64::INFINITY,
|
||||||
|
requirements: Vec::new(),
|
||||||
|
priority: 0.0,
|
||||||
|
mandatory: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy of this task with a time window.
|
||||||
|
///
|
||||||
|
/// The window is the interval in which the whole task must fit, so the
|
||||||
|
/// task may start no earlier than the window start and must end no later
|
||||||
|
/// than the window end.
|
||||||
|
pub fn with_window(mut self, start: f64, end: f64) -> Self {
|
||||||
|
self.earliest_start = start;
|
||||||
|
self.latest_end = end;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy of this task with a priority.
|
||||||
|
pub fn with_priority(mut self, priority: f64) -> Self {
|
||||||
|
self.priority = priority;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy of this task marked mandatory or not.
|
||||||
|
pub fn with_mandatory(mut self, mandatory: bool) -> Self {
|
||||||
|
self.mandatory = mandatory;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy of this task with an additional resource requirement.
|
||||||
|
///
|
||||||
|
/// Adding the same resource twice is allowed at this point, because a
|
||||||
|
/// builder chain cannot report an error. [`Task::validate`] rejects it,
|
||||||
|
/// and [`Task::try_require`] reports it immediately.
|
||||||
|
pub fn require(mut self, resource: ResourceId, demand: f64) -> Self {
|
||||||
|
self.requirements.push(Requirement::new(resource, demand));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy of this task with an additional resource requirement,
|
||||||
|
/// reporting a duplicate or an invalid demand immediately.
|
||||||
|
pub fn try_require(mut self, resource: ResourceId, demand: f64) -> Result<Self, TaskError> {
|
||||||
|
let requirement = Requirement::new(resource, demand);
|
||||||
|
if !requirement.is_valid() {
|
||||||
|
return Err(TaskError::new(TaskErrorKind::InvalidDemand)
|
||||||
|
.with_detail(format!("{resource} with demand {demand}")));
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.requirements
|
||||||
|
.iter()
|
||||||
|
.any(|existing| existing.resource == resource)
|
||||||
|
{
|
||||||
|
return Err(TaskError::new(TaskErrorKind::DuplicateRequirement)
|
||||||
|
.with_detail(format!("{resource}")));
|
||||||
|
}
|
||||||
|
self.requirements.push(requirement);
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the demand the task makes on a resource.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the task does not require the resource.
|
||||||
|
pub fn demand_for(&self, resource: ResourceId) -> Option<f64> {
|
||||||
|
self.requirements
|
||||||
|
.iter()
|
||||||
|
.find(|requirement| requirement.resource == resource)
|
||||||
|
.map(|requirement| requirement.demand)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when the task requires a resource.
|
||||||
|
pub fn requires(&self, resource: ResourceId) -> bool {
|
||||||
|
self.demand_for(resource).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of resources the task requires.
|
||||||
|
pub fn requirement_count(&self) -> usize {
|
||||||
|
self.requirements.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the identifiers of the required resources.
|
||||||
|
pub fn resources(&self) -> Vec<ResourceId> {
|
||||||
|
self.requirements
|
||||||
|
.iter()
|
||||||
|
.map(|requirement| requirement.resource)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the length of the window in which the task must fit, in
|
||||||
|
/// seconds.
|
||||||
|
///
|
||||||
|
/// Returns infinity when the task has no restriction at either end, and
|
||||||
|
/// zero or a negative value when the window cannot hold the task at all.
|
||||||
|
pub fn window_length(&self) -> f64 {
|
||||||
|
self.latest_end - self.earliest_start
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when the window cannot hold the task.
|
||||||
|
pub fn window_is_too_short(&self) -> bool {
|
||||||
|
self.duration > self.window_length()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the earliest time the task could end, given its window.
|
||||||
|
///
|
||||||
|
/// Returns infinity when the task has no upper bound, and negative
|
||||||
|
/// infinity when it has no lower bound.
|
||||||
|
pub fn earliest_possible_end(&self) -> f64 {
|
||||||
|
self.earliest_start + self.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when the task fits its window.
|
||||||
|
pub fn fits_window(&self) -> bool {
|
||||||
|
!self.window_is_too_short()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when the task uses any resource more than once, or
|
||||||
|
/// demands a non-positive amount.
|
||||||
|
pub fn has_duplicate_requirements(&self) -> bool {
|
||||||
|
self.requirements
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.any(|(index, requirement)| {
|
||||||
|
self.requirements[..index]
|
||||||
|
.iter()
|
||||||
|
.any(|earlier| earlier.resource == requirement.resource)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates the task, returning the reason when it is not usable.
|
||||||
|
///
|
||||||
|
/// A task is usable when its duration is finite and positive, its window
|
||||||
|
/// is finite at the ends it states and holds the duration, every demand
|
||||||
|
/// is positive and finite and names a distinct resource, and the weights
|
||||||
|
/// are finite.
|
||||||
|
pub fn validate(&self) -> Result<(), TaskError> {
|
||||||
|
if !self.duration.is_finite() || self.duration <= 0.0 {
|
||||||
|
return Err(TaskError::new(TaskErrorKind::InvalidDuration)
|
||||||
|
.with_detail(format!("duration {}", self.duration)));
|
||||||
|
}
|
||||||
|
if self.earliest_start.is_nan() || self.latest_end.is_nan() {
|
||||||
|
return Err(
|
||||||
|
TaskError::new(TaskErrorKind::InvalidWindow).with_detail(format!(
|
||||||
|
"window [{}, {}]",
|
||||||
|
self.earliest_start, self.latest_end
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if self.latest_end < self.earliest_start {
|
||||||
|
return Err(
|
||||||
|
TaskError::new(TaskErrorKind::InvalidWindow).with_detail(format!(
|
||||||
|
"window [{}, {}] runs backwards",
|
||||||
|
self.earliest_start, self.latest_end
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if self.window_is_too_short() {
|
||||||
|
return Err(
|
||||||
|
TaskError::new(TaskErrorKind::WindowTooShort).with_detail(format!(
|
||||||
|
"duration {} does not fit the window [{}, {}]",
|
||||||
|
self.duration, self.earliest_start, self.latest_end
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for requirement in &self.requirements {
|
||||||
|
if !requirement.is_valid() {
|
||||||
|
return Err(
|
||||||
|
TaskError::new(TaskErrorKind::InvalidDemand).with_detail(format!(
|
||||||
|
"{} with demand {}",
|
||||||
|
requirement.resource, requirement.demand
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.has_duplicate_requirements() {
|
||||||
|
return Err(TaskError::new(TaskErrorKind::DuplicateRequirement));
|
||||||
|
}
|
||||||
|
if !self.priority.is_finite() {
|
||||||
|
return Err(TaskError::new(TaskErrorKind::InvalidWeight)
|
||||||
|
.with_detail(format!("priority {}", self.priority)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when the task is usable.
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
self.validate().is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the value of the task, which is its priority weighted by its
|
||||||
|
/// duration.
|
||||||
|
///
|
||||||
|
/// This is the quantity a scheduler maximises when it is asked to make
|
||||||
|
/// the most of the available time: a long task of high priority is worth
|
||||||
|
/// more than a short one of the same priority.
|
||||||
|
pub fn weighted_value(&self) -> f64 {
|
||||||
|
self.priority * self.duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::fmt::Display for Task {
|
||||||
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{} of {} s requiring {} resource(s)",
|
||||||
|
self.id,
|
||||||
|
self.duration,
|
||||||
|
self.requirements.len()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The state a scheduler has assigned to a task.
|
||||||
|
///
|
||||||
|
/// The state is kept apart from the task so that the same task can be
|
||||||
|
/// considered for several placements without being modified. A task that has
|
||||||
|
/// been placed records when and where, so that a schedule can be examined
|
||||||
|
/// without a second lookup.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum TaskState {
|
||||||
|
/// The task has not been placed and is still being considered.
|
||||||
|
Pending,
|
||||||
|
/// The task has been placed at a time.
|
||||||
|
Scheduled {
|
||||||
|
/// The start time of the placement, in seconds.
|
||||||
|
start: f64,
|
||||||
|
},
|
||||||
|
/// The task was deliberately left out.
|
||||||
|
///
|
||||||
|
/// A scheduler reports this rather than silently dropping a task, so that
|
||||||
|
/// an operator can see what was given up and why.
|
||||||
|
Deferred,
|
||||||
|
/// The task cannot be scheduled at all.
|
||||||
|
///
|
||||||
|
/// A task is infeasible when its window is too short, when it demands
|
||||||
|
/// more of a resource than the resource has, or when a resource it needs
|
||||||
|
/// does not exist.
|
||||||
|
Infeasible {
|
||||||
|
/// A description of why the task cannot be scheduled.
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskState {
|
||||||
|
/// Returns `true` when the task has been placed.
|
||||||
|
pub const fn is_scheduled(&self) -> bool {
|
||||||
|
matches!(self, Self::Scheduled { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the start time of the placement, if there is one.
|
||||||
|
pub const fn start(&self) -> Option<f64> {
|
||||||
|
match self {
|
||||||
|
Self::Scheduled { start } => Some(*start),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the end time of the placement, if there is one.
|
||||||
|
pub fn end(&self, duration: f64) -> Option<f64> {
|
||||||
|
self.start().map(|start| start + duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a short name for the state.
|
||||||
|
pub const fn name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Pending => "pending",
|
||||||
|
Self::Scheduled { .. } => "scheduled",
|
||||||
|
Self::Deferred => "deferred",
|
||||||
|
Self::Infeasible { .. } => "infeasible",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A collection of tasks, indexed by identifier.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
|
pub struct TaskSet {
|
||||||
|
tasks: Vec<Task>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskSet {
|
||||||
|
/// Creates an empty set.
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self { tasks: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a set from a list of tasks.
|
||||||
|
///
|
||||||
|
/// Returns an error when two tasks share an identifier or when any task
|
||||||
|
/// is not usable.
|
||||||
|
pub fn from_tasks(tasks: Vec<Task>) -> Result<Self, TaskError> {
|
||||||
|
let mut set = Self::new();
|
||||||
|
for task in tasks {
|
||||||
|
set.insert(task)?;
|
||||||
|
}
|
||||||
|
Ok(set)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts a task.
|
||||||
|
///
|
||||||
|
/// Returns an error when the identifier is already present or when the
|
||||||
|
/// task is not usable.
|
||||||
|
pub fn insert(&mut self, task: Task) -> Result<(), TaskError> {
|
||||||
|
task.validate()?;
|
||||||
|
if self.get(task.id).is_some() {
|
||||||
|
return Err(
|
||||||
|
TaskError::new(TaskErrorKind::DuplicateTask).with_detail(format!("{}", task.id))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.tasks.push(task);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the task with the given identifier.
|
||||||
|
pub fn get(&self, id: TaskId) -> Option<&Task> {
|
||||||
|
self.tasks.iter().find(|task| task.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a task.
|
||||||
|
///
|
||||||
|
/// Returns an error when no task has the identifier.
|
||||||
|
pub fn remove(&mut self, id: TaskId) -> Result<Task, TaskError> {
|
||||||
|
let index = self
|
||||||
|
.tasks
|
||||||
|
.iter()
|
||||||
|
.position(|task| task.id == id)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
TaskError::new(TaskErrorKind::NoSuchTask).with_detail(format!("{id}"))
|
||||||
|
})?;
|
||||||
|
Ok(self.tasks.remove(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the tasks in the order they were added.
|
||||||
|
pub fn tasks(&self) -> &[Task] {
|
||||||
|
&self.tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of tasks.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.tasks.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when there are no tasks.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.tasks.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the total duration of every task, in seconds.
|
||||||
|
///
|
||||||
|
/// This is the load the tasks would place on the schedule if each used
|
||||||
|
/// one unit of resource for its whole duration.
|
||||||
|
pub fn total_duration(&self) -> f64 {
|
||||||
|
self.tasks.iter().map(|task| task.duration).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the total weighted value of every task.
|
||||||
|
pub fn total_weighted_value(&self) -> f64 {
|
||||||
|
self.tasks.iter().map(Task::weighted_value).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the identifiers of the tasks that require a resource.
|
||||||
|
pub fn tasks_requiring(&self, resource: ResourceId) -> Vec<TaskId> {
|
||||||
|
self.tasks
|
||||||
|
.iter()
|
||||||
|
.filter(|task| task.requires(resource))
|
||||||
|
.map(|task| task.id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the identifiers of the mandatory tasks.
|
||||||
|
pub fn mandatory(&self) -> Vec<TaskId> {
|
||||||
|
self.tasks
|
||||||
|
.iter()
|
||||||
|
.filter(|task| task.mandatory)
|
||||||
|
.map(|task| task.id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a task ordering suitable for a greedy scheduler.
|
||||||
|
///
|
||||||
|
/// The order is by decreasing weighted value, which is an efficient
|
||||||
|
/// heuristic: placing the tasks that contribute most first leaves the least
|
||||||
|
/// valuable ones to fill the gaps. Ties are broken by the earlier deadline
|
||||||
|
/// and then by the identifier, so that the order is deterministic for a given
|
||||||
|
/// set.
|
||||||
|
///
|
||||||
|
/// This is not guaranteed to find the best possible schedule. Scheduling
|
||||||
|
/// with resource constraints is intractable in general, so a heuristic is the
|
||||||
|
/// practical choice and the caller may supply its own order instead.
|
||||||
|
pub fn task_order(tasks: &[Task]) -> Vec<TaskId> {
|
||||||
|
let mut ordered: Vec<&Task> = tasks.iter().collect();
|
||||||
|
ordered.sort_by(|first, second| {
|
||||||
|
second
|
||||||
|
.weighted_value()
|
||||||
|
.partial_cmp(&first.weighted_value())
|
||||||
|
.unwrap_or(core::cmp::Ordering::Equal)
|
||||||
|
.then_with(|| {
|
||||||
|
first
|
||||||
|
.latest_end
|
||||||
|
.partial_cmp(&second.latest_end)
|
||||||
|
.unwrap_or(core::cmp::Ordering::Equal)
|
||||||
|
})
|
||||||
|
.then_with(|| first.id.cmp(&second.id))
|
||||||
|
});
|
||||||
|
ordered.into_iter().map(|task| task.id).collect()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,376 @@
|
||||||
|
//! Tests for tasks and the set that holds them.
|
||||||
|
|
||||||
|
use apsis_resource::ResourceId;
|
||||||
|
use apsis_task::{Requirement, Task, TaskErrorKind, TaskId, TaskSet, TaskState, task_order};
|
||||||
|
|
||||||
|
/// A sixty second task with no window and no requirements.
|
||||||
|
fn simple() -> Task {
|
||||||
|
Task::new(TaskId::new(0), 60.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A task requiring two resources for sixty seconds, in a window from 100 to
|
||||||
|
/// 1 000 seconds.
|
||||||
|
fn observation() -> Task {
|
||||||
|
simple()
|
||||||
|
.with_window(100.0, 1_000.0)
|
||||||
|
.with_priority(5.0)
|
||||||
|
.try_require(ResourceId::new(0), 1.0)
|
||||||
|
.expect("the resource is new")
|
||||||
|
.try_require(ResourceId::new(1), 1.0)
|
||||||
|
.expect("the resource is new")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_task_has_the_given_duration_and_nothing_else() {
|
||||||
|
let task = simple();
|
||||||
|
assert_eq!(task.duration, 60.0);
|
||||||
|
assert_eq!(task.priority, 0.0);
|
||||||
|
assert!(!task.mandatory);
|
||||||
|
assert!(task.requirements.is_empty());
|
||||||
|
assert_eq!(task.requirement_count(), 0);
|
||||||
|
// A task with no window is unbounded at both ends.
|
||||||
|
assert!(task.earliest_start.is_infinite() && task.earliest_start < 0.0);
|
||||||
|
assert!(task.latest_end.is_infinite() && task.latest_end > 0.0);
|
||||||
|
assert!(task.window_is_too_short() == false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_window_restricts_the_task() {
|
||||||
|
let task = observation();
|
||||||
|
assert_eq!(task.earliest_start, 100.0);
|
||||||
|
assert_eq!(task.latest_end, 1_000.0);
|
||||||
|
assert_eq!(task.window_length(), 900.0);
|
||||||
|
assert!(task.fits_window());
|
||||||
|
assert_eq!(task.earliest_possible_end(), 160.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_window_shorter_than_the_task_is_detected() {
|
||||||
|
let task = simple().with_window(0.0, 30.0);
|
||||||
|
assert!(task.window_is_too_short());
|
||||||
|
assert!(!task.fits_window());
|
||||||
|
assert_eq!(
|
||||||
|
task.validate().expect_err("the window is too short").kind(),
|
||||||
|
TaskErrorKind::WindowTooShort
|
||||||
|
);
|
||||||
|
assert!(!task.is_valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_window_exactly_the_length_of_the_task_is_accepted() {
|
||||||
|
// The task must fit entirely inside the window, and a window of exactly
|
||||||
|
// the duration holds it.
|
||||||
|
let task = simple().with_window(0.0, 60.0);
|
||||||
|
assert!(!task.window_is_too_short());
|
||||||
|
assert!(task.is_valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requiring_a_resource_records_the_demand() {
|
||||||
|
let task = observation();
|
||||||
|
assert_eq!(task.requirement_count(), 2);
|
||||||
|
assert_eq!(task.demand_for(ResourceId::new(0)), Some(1.0));
|
||||||
|
assert_eq!(task.demand_for(ResourceId::new(1)), Some(1.0));
|
||||||
|
assert_eq!(task.demand_for(ResourceId::new(9)), None);
|
||||||
|
assert!(task.requires(ResourceId::new(0)));
|
||||||
|
assert!(!task.requires(ResourceId::new(9)));
|
||||||
|
assert_eq!(
|
||||||
|
task.resources(),
|
||||||
|
vec![ResourceId::new(0), ResourceId::new(1)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_duplicate_requirement_is_reported_immediately() {
|
||||||
|
let task = simple()
|
||||||
|
.try_require(ResourceId::new(0), 1.0)
|
||||||
|
.expect("the resource is new");
|
||||||
|
let error = task
|
||||||
|
.try_require(ResourceId::new(0), 2.0)
|
||||||
|
.expect_err("the resource is already required");
|
||||||
|
assert_eq!(error.kind(), TaskErrorKind::DuplicateRequirement);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_duplicate_requirement_built_by_chaining_is_rejected_on_validation() {
|
||||||
|
// The plain builder cannot report an error, so the duplicate survives
|
||||||
|
// until validation.
|
||||||
|
let task = simple()
|
||||||
|
.require(ResourceId::new(0), 1.0)
|
||||||
|
.require(ResourceId::new(0), 1.0);
|
||||||
|
assert!(task.has_duplicate_requirements());
|
||||||
|
assert_eq!(
|
||||||
|
task.validate().expect_err("duplicate").kind(),
|
||||||
|
TaskErrorKind::DuplicateRequirement
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_invalid_demand_is_rejected() {
|
||||||
|
for demand in [0.0, -1.0, f64::NAN, f64::INFINITY] {
|
||||||
|
let error = simple()
|
||||||
|
.try_require(ResourceId::new(0), demand)
|
||||||
|
.expect_err("the demand is not usable");
|
||||||
|
assert_eq!(
|
||||||
|
error.kind(),
|
||||||
|
TaskErrorKind::InvalidDemand,
|
||||||
|
"demand {demand}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_invalid_duration_is_rejected() {
|
||||||
|
for duration in [0.0, -1.0, f64::NAN, f64::INFINITY] {
|
||||||
|
let task = Task::new(TaskId::new(0), duration);
|
||||||
|
assert!(!task.is_valid(), "duration {duration}");
|
||||||
|
assert_eq!(
|
||||||
|
task.validate().expect_err("invalid duration").kind(),
|
||||||
|
TaskErrorKind::InvalidDuration
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reversed_window_is_rejected() {
|
||||||
|
let task = simple().with_window(100.0, 50.0);
|
||||||
|
assert_eq!(
|
||||||
|
task.validate().expect_err("reversed").kind(),
|
||||||
|
TaskErrorKind::InvalidWindow
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_finite_window_is_rejected() {
|
||||||
|
assert_eq!(
|
||||||
|
simple()
|
||||||
|
.with_window(f64::NAN, 100.0)
|
||||||
|
.validate()
|
||||||
|
.expect_err("nan")
|
||||||
|
.kind(),
|
||||||
|
TaskErrorKind::InvalidWindow
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
simple()
|
||||||
|
.with_window(0.0, f64::NAN)
|
||||||
|
.validate()
|
||||||
|
.expect_err("nan")
|
||||||
|
.kind(),
|
||||||
|
TaskErrorKind::InvalidWindow
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_finite_priority_is_rejected() {
|
||||||
|
let task = simple().with_priority(f64::NAN);
|
||||||
|
assert_eq!(
|
||||||
|
task.validate().expect_err("nan").kind(),
|
||||||
|
TaskErrorKind::InvalidWeight
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_weighted_value_combines_priority_and_duration() {
|
||||||
|
let task = simple().with_priority(5.0);
|
||||||
|
assert_eq!(task.weighted_value(), 300.0);
|
||||||
|
// A task with no priority is worth nothing.
|
||||||
|
assert_eq!(simple().weighted_value(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_mandatory_flag_is_recorded() {
|
||||||
|
assert!(!simple().mandatory);
|
||||||
|
assert!(simple().with_mandatory(true).mandatory);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_builder_does_not_modify_the_original() {
|
||||||
|
// A builder chain returns a new value, so a base task can be shared.
|
||||||
|
let base = simple();
|
||||||
|
let first = base.clone().with_priority(1.0);
|
||||||
|
let second = base.clone().with_priority(2.0);
|
||||||
|
assert_eq!(base.priority, 0.0);
|
||||||
|
assert_eq!(first.priority, 1.0);
|
||||||
|
assert_eq!(second.priority, 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_task_displays_its_shape() {
|
||||||
|
let text = observation().to_string();
|
||||||
|
assert!(text.contains("task 0"), "{text}");
|
||||||
|
assert!(text.contains("60 s"), "{text}");
|
||||||
|
assert!(text.contains("2 resource"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn task_states_report_their_placement() {
|
||||||
|
let pending = TaskState::Pending;
|
||||||
|
assert!(!pending.is_scheduled());
|
||||||
|
assert_eq!(pending.start(), None);
|
||||||
|
assert_eq!(pending.name(), "pending");
|
||||||
|
|
||||||
|
let scheduled = TaskState::Scheduled { start: 100.0 };
|
||||||
|
assert!(scheduled.is_scheduled());
|
||||||
|
assert_eq!(scheduled.start(), Some(100.0));
|
||||||
|
assert_eq!(scheduled.end(60.0), Some(160.0));
|
||||||
|
assert_eq!(scheduled.name(), "scheduled");
|
||||||
|
|
||||||
|
let deferred = TaskState::Deferred;
|
||||||
|
assert!(!deferred.is_scheduled());
|
||||||
|
assert_eq!(deferred.name(), "deferred");
|
||||||
|
|
||||||
|
let infeasible = TaskState::Infeasible {
|
||||||
|
reason: "window too short".to_string(),
|
||||||
|
};
|
||||||
|
assert!(!infeasible.is_scheduled());
|
||||||
|
assert_eq!(infeasible.name(), "infeasible");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_set_holds_tasks_by_identifier() {
|
||||||
|
let mut set = TaskSet::new();
|
||||||
|
assert!(set.is_empty());
|
||||||
|
set.insert(simple()).expect("the identifier is new");
|
||||||
|
set.insert(Task::new(TaskId::new(1), 30.0))
|
||||||
|
.expect("the identifier is new");
|
||||||
|
assert_eq!(set.len(), 2);
|
||||||
|
assert_eq!(set.total_duration(), 90.0);
|
||||||
|
|
||||||
|
assert!(set.get(TaskId::new(0)).is_some());
|
||||||
|
assert!(set.get(TaskId::new(9)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_duplicate_task_identifier_is_rejected() {
|
||||||
|
let mut set = TaskSet::new();
|
||||||
|
set.insert(simple()).expect("the identifier is new");
|
||||||
|
let error = set
|
||||||
|
.insert(Task::new(TaskId::new(0), 30.0))
|
||||||
|
.expect_err("the identifier is taken");
|
||||||
|
assert_eq!(error.kind(), TaskErrorKind::DuplicateTask);
|
||||||
|
assert_eq!(set.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_invalid_task_is_rejected_by_the_set() {
|
||||||
|
let mut set = TaskSet::new();
|
||||||
|
let error = set
|
||||||
|
.insert(Task::new(TaskId::new(0), -1.0))
|
||||||
|
.expect_err("the duration is invalid");
|
||||||
|
assert_eq!(error.kind(), TaskErrorKind::InvalidDuration);
|
||||||
|
assert!(set.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_set_can_be_built_from_a_list() {
|
||||||
|
let set = TaskSet::from_tasks(vec![simple(), Task::new(TaskId::new(1), 30.0)])
|
||||||
|
.expect("the tasks are usable");
|
||||||
|
assert_eq!(set.len(), 2);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
TaskSet::from_tasks(vec![simple(), simple()])
|
||||||
|
.expect_err("the identifiers collide")
|
||||||
|
.kind(),
|
||||||
|
TaskErrorKind::DuplicateTask
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_task_can_be_removed() {
|
||||||
|
let mut set = TaskSet::new();
|
||||||
|
set.insert(simple()).expect("new");
|
||||||
|
let removed = set.remove(TaskId::new(0)).expect("the task exists");
|
||||||
|
assert_eq!(removed.id, TaskId::new(0));
|
||||||
|
assert!(set.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
set.remove(TaskId::new(0)).expect_err("gone").kind(),
|
||||||
|
TaskErrorKind::NoSuchTask
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_set_reports_queries() {
|
||||||
|
let set = TaskSet::from_tasks(vec![
|
||||||
|
observation(),
|
||||||
|
Task::new(TaskId::new(1), 30.0).with_mandatory(true),
|
||||||
|
Task::new(TaskId::new(2), 10.0)
|
||||||
|
.with_priority(2.0)
|
||||||
|
.try_require(ResourceId::new(0), 0.5)
|
||||||
|
.expect("the resource is new"),
|
||||||
|
])
|
||||||
|
.expect("the tasks are usable");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
set.tasks_requiring(ResourceId::new(0)),
|
||||||
|
vec![TaskId::new(0), TaskId::new(2)]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
set.tasks_requiring(ResourceId::new(9)),
|
||||||
|
Vec::<TaskId>::new()
|
||||||
|
);
|
||||||
|
assert_eq!(set.mandatory(), vec![TaskId::new(1)]);
|
||||||
|
// The weighted value is the priority times the duration.
|
||||||
|
assert_eq!(set.total_weighted_value(), 300.0 + 0.0 + 20.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_task_order_places_the_most_valuable_first() {
|
||||||
|
let set = TaskSet::from_tasks(vec![
|
||||||
|
Task::new(TaskId::new(0), 10.0).with_priority(1.0),
|
||||||
|
Task::new(TaskId::new(1), 100.0).with_priority(5.0),
|
||||||
|
Task::new(TaskId::new(2), 50.0).with_priority(2.0),
|
||||||
|
])
|
||||||
|
.expect("the tasks are usable");
|
||||||
|
|
||||||
|
let order = task_order(set.tasks());
|
||||||
|
// The weighted values are 10, 500, and 100, so the order is 1, 2, 0.
|
||||||
|
assert_eq!(order, vec![TaskId::new(1), TaskId::new(2), TaskId::new(0)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_task_order_breaks_ties_by_deadline_then_identifier() {
|
||||||
|
let set = TaskSet::from_tasks(vec![
|
||||||
|
// The same value and no deadline.
|
||||||
|
Task::new(TaskId::new(0), 10.0).with_priority(1.0),
|
||||||
|
Task::new(TaskId::new(1), 10.0).with_priority(1.0),
|
||||||
|
// The same value but an earlier deadline.
|
||||||
|
Task::new(TaskId::new(2), 10.0)
|
||||||
|
.with_priority(1.0)
|
||||||
|
.with_window(0.0, 100.0),
|
||||||
|
Task::new(TaskId::new(3), 10.0).with_priority(1.0),
|
||||||
|
])
|
||||||
|
.expect("the tasks are usable");
|
||||||
|
|
||||||
|
let order = task_order(set.tasks());
|
||||||
|
// The task with the deadline comes first, then the others by identifier.
|
||||||
|
assert_eq!(
|
||||||
|
order,
|
||||||
|
vec![
|
||||||
|
TaskId::new(2),
|
||||||
|
TaskId::new(0),
|
||||||
|
TaskId::new(1),
|
||||||
|
TaskId::new(3)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_task_order_of_an_empty_set_is_empty() {
|
||||||
|
assert!(task_order(&[]).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_requirement_reports_validity() {
|
||||||
|
assert!(Requirement::new(ResourceId::new(0), 1.0).is_valid());
|
||||||
|
assert!(!Requirement::new(ResourceId::new(0), 0.0).is_valid());
|
||||||
|
assert!(!Requirement::new(ResourceId::new(0), f64::NAN).is_valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn task_identifiers_are_ordered() {
|
||||||
|
let mut ids = [TaskId::new(2), TaskId::new(0), TaskId::new(1)];
|
||||||
|
ids.sort();
|
||||||
|
assert_eq!(ids, [TaskId::new(0), TaskId::new(1), TaskId::new(2)]);
|
||||||
|
assert_eq!(TaskId::new(3).to_string(), "task 3");
|
||||||
|
assert_eq!(TaskId::new(3).index(), 3);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue