From 9e571188c8a688fec4898039e0dcb641b21d684a Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Sat, 26 Sep 2026 16:59:48 +0800 Subject: [PATCH] ops: add tasks with resource requirements and windows --- Cargo.lock | 4 + crates/ops/apsis-task/Cargo.toml | 2 + crates/ops/apsis-task/src/error.rs | 82 +++++ crates/ops/apsis-task/src/lib.rs | 44 +++ crates/ops/apsis-task/src/task.rs | 505 ++++++++++++++++++++++++++++ crates/ops/apsis-task/tests/task.rs | 376 +++++++++++++++++++++ 6 files changed, 1013 insertions(+) create mode 100644 crates/ops/apsis-task/src/error.rs create mode 100644 crates/ops/apsis-task/src/task.rs create mode 100644 crates/ops/apsis-task/tests/task.rs diff --git a/Cargo.lock b/Cargo.lock index 74e75c5..79ecfef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -292,6 +292,10 @@ version = "0.1.0" [[package]] name = "apsis-task" version = "0.1.0" +dependencies = [ + "apsis-core", + "apsis-resource", +] [[package]] name = "apsis-time" diff --git a/crates/ops/apsis-task/Cargo.toml b/crates/ops/apsis-task/Cargo.toml index fa3b3fb..1144c21 100644 --- a/crates/ops/apsis-task/Cargo.toml +++ b/crates/ops/apsis-task/Cargo.toml @@ -11,3 +11,5 @@ publish = false workspace = true [dependencies] +apsis-core.workspace = true +apsis-resource.workspace = true diff --git a/crates/ops/apsis-task/src/error.rs b/crates/ops/apsis-task/src/error.rs new file mode 100644 index 0000000..ce7014b --- /dev/null +++ b/crates/ops/apsis-task/src/error.rs @@ -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, +} + +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) -> 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 {} diff --git a/crates/ops/apsis-task/src/lib.rs b/crates/ops/apsis-task/src/lib.rs index 785074f..a2518c1 100644 --- a/crates/ops/apsis-task/src/lib.rs +++ b/crates/ops/apsis-task/src/lib.rs @@ -1 +1,45 @@ //! 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; diff --git a/crates/ops/apsis-task/src/task.rs b/crates/ops/apsis-task/src/task.rs new file mode 100644 index 0000000..a10af80 --- /dev/null +++ b/crates/ops/apsis-task/src/task.rs @@ -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, + /// 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 { + 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 { + 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 { + 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 { + 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 { + 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, +} + +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) -> Result { + 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 { + 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 { + 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 { + 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 { + 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() +} diff --git a/crates/ops/apsis-task/tests/task.rs b/crates/ops/apsis-task/tests/task.rs new file mode 100644 index 0000000..c1bc01d --- /dev/null +++ b/crates/ops/apsis-task/tests/task.rs @@ -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::::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); +}