use color_eyre::{eyre::eyre, Result};
use std::collections::HashSet;
use std::process::Command;
use std::sync::Arc;
use crate::columns::JobColumn;
use crate::filter::JobFilter;
use crate::job::{array_base_id, Job};
use crate::job_rows::{build_rows, filter_rows, JobRow};
use crate::notify::{detect_transitions, JobTransition};
use crate::scheduler::{Scheduler, SlurmScheduler};
use crate::update_content::{
ContentTick, ContentUpdater, LogFollowRequest, LogFollowUpdate, TIMEOUT_ERROR,
};
use crate::user_options::UserOptions;
#[derive(Debug, Clone)]
pub enum JobListAction {
Next,
Previous,
Select(usize),
SelectSortCategory(JobColumn),
NextSortCategory,
ReverseSortDirection,
UpdateSqueueCommand(String),
ToggleGroup,
SetFilter(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum RowKey {
Job(String),
Group(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum UpdateStatus {
Pending,
Success,
Error(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct UpdateOutcome {
pub status: UpdateStatus,
pub transitions: Vec<JobTransition>,
pub log_follow: Option<LogFollowUpdate>,
}
pub struct JobList {
pub jobs: Vec<Job>,
selected: usize,
job_details: String,
log_tail: String,
sort_category: JobColumn,
reverse: bool,
content_updater: ContentUpdater,
pub squeue_command: String,
group_job_arrays: bool,
expanded_groups: HashSet<String>,
filter: Option<JobFilter>,
}
impl JobList {
pub fn new() -> JobList {
Self::with_scheduler(Arc::new(SlurmScheduler))
}
pub fn with_scheduler(scheduler: Arc<dyn Scheduler>) -> JobList {
JobList {
jobs: Vec::new(),
selected: 0,
job_details: String::new(),
log_tail: String::new(),
sort_category: JobColumn::Id,
reverse: false,
content_updater: ContentUpdater::with_scheduler(scheduler),
squeue_command: match get_user() {
Some(user) => format!("squeue -u {}", user),
None => "squeue".to_string(),
},
group_job_arrays: true,
expanded_groups: HashSet::new(),
filter: None,
}
}
}
impl Default for JobList {
fn default() -> Self {
Self::new()
}
}
fn get_user() -> Option<String> {
if let Ok(user) = std::env::var("USER") {
let user = user.trim();
if !user.is_empty() {
return Some(user.to_string());
}
}
if let Ok(output) = Command::new("whoami").output() {
if output.status.success() {
let user = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !user.is_empty() {
return Some(user);
}
}
}
None
}
impl JobList {
pub fn rows(&self) -> Vec<JobRow> {
let rows = build_rows(&self.jobs, self.group_job_arrays, &self.expanded_groups);
match &self.filter {
Some(filter) => filter_rows(rows, &self.jobs, |job| filter.matches(job)),
None => rows,
}
}
pub fn filter_text(&self) -> Option<&str> {
self.filter.as_ref().map(|filter| filter.text())
}
pub fn filter_match_count(&self) -> usize {
match &self.filter {
Some(filter) => self.jobs.iter().filter(|job| filter.matches(job)).count(),
None => self.jobs.len(),
}
}
pub fn get_job(&self) -> Option<&Job> {
match self.rows().get(self.selected)? {
JobRow::Single { job_index } | JobRow::Task { job_index, .. } => {
self.jobs.get(*job_index)
}
JobRow::Group { task_indices, .. } => {
task_indices.first().and_then(|&index| self.jobs.get(index))
}
}
}
pub fn selected_group(&self) -> Option<(String, usize, &Job)> {
match self.rows().get(self.selected)? {
JobRow::Group {
base_id,
task_indices,
..
} => {
let first = self.jobs.get(*task_indices.first()?)?;
Some((base_id.clone(), task_indices.len(), first))
}
_ => None,
}
}
pub fn selected_row_key(&self) -> Option<RowKey> {
match self.rows().get(self.selected)? {
JobRow::Single { job_index } | JobRow::Task { job_index, .. } => {
Some(RowKey::Job(self.jobs.get(*job_index)?.id.clone()))
}
JobRow::Group { base_id, .. } => Some(RowKey::Group(base_id.clone())),
}
}
pub fn get_job_details(&self) -> &str {
&self.job_details
}
pub fn get_log_tail(&self) -> &str {
&self.log_tail
}
pub fn get_index(&self) -> usize {
self.selected
}
pub fn get_sort_category(&self) -> &JobColumn {
&self.sort_category
}
pub fn is_reverse(&self) -> bool {
self.reverse
}
pub fn len(&self) -> usize {
self.rows().len()
}
pub fn is_empty(&self) -> bool {
self.jobs.is_empty()
}
}
impl JobList {
pub fn set_index(&mut self, index: usize) -> Result<()> {
let row_count = self.len();
if row_count == 0 {
self.selected = 0;
self.job_details = "No job selected".to_string();
self.log_tail = "No job selected".to_string();
return Ok(());
}
if index >= row_count {
return Err(eyre!("Index out of bounds"));
}
self.selected = index;
Ok(())
}
fn set_loading_text(&mut self) {
self.job_details = "loading...".to_string();
self.log_tail = "loading...".to_string();
}
pub fn select_job_by_id(&mut self, id: String) -> Result<()> {
let index = self.rows().iter().position(|row| match row {
JobRow::Single { job_index } | JobRow::Task { job_index, .. } => {
self.jobs[*job_index].id == id
}
JobRow::Group {
task_indices,
expanded,
..
} => !expanded && task_indices.iter().any(|&i| self.jobs[i].id == id),
});
match index {
Some(index) => {
self.set_index(index)?;
Ok(())
}
None => Err(eyre!("Job with id {} not found", id)),
}
}
pub fn select_row_by_key(&mut self, key: &RowKey) -> Result<()> {
match key {
RowKey::Job(id) => self.select_job_by_id(id.clone()),
RowKey::Group(base) => {
let index = self.rows().iter().position(
|row| matches!(row, JobRow::Group { base_id, .. } if base_id == base),
);
match index {
Some(index) => self.set_index(index),
None => Err(eyre!("Job array group {} not found", base)),
}
}
}
}
pub fn handle_joblist_action(&mut self, action: JobListAction, columns: &[JobColumn]) {
match action {
JobListAction::Next => self.next(),
JobListAction::Previous => self.previous(),
JobListAction::Select(index) => {
if index < self.len() {
if index == self.selected {
self.toggle_selected_group();
} else {
self.set_index(index).unwrap();
self.set_loading_text();
}
}
}
JobListAction::ToggleGroup => {
self.toggle_selected_group();
}
JobListAction::NextSortCategory => {
self.set_sort_category(self.sort_category.next_in(columns));
}
JobListAction::ReverseSortDirection => {
self.negate_reverse();
}
JobListAction::SelectSortCategory(category) => {
if category != self.sort_category {
self.set_sort_category(category);
} else {
self.negate_reverse();
}
}
JobListAction::UpdateSqueueCommand(command) => {
self.squeue_command = command;
}
JobListAction::SetFilter(text) => {
self.set_filter(&text, columns);
}
}
}
pub fn set_filter(&mut self, text: &str, columns: &[JobColumn]) {
let key = self.selected_row_key();
self.filter = JobFilter::new(text, columns);
match key {
Some(key) => self.reselect_row(&key),
None => self.set_index(0).unwrap(),
}
}
pub fn set_sort_category(&mut self, category: JobColumn) {
self.sort_category = category;
self.sort_raw();
self.set_index(0).unwrap();
}
pub fn toggle_selected_group(&mut self) {
match self.rows().get(self.selected) {
Some(JobRow::Group { base_id, .. }) => {
let base = base_id.clone();
if !self.expanded_groups.remove(&base) {
self.expanded_groups.insert(base);
}
}
Some(JobRow::Task { job_index, .. }) => {
if let Some(base) = array_base_id(&self.jobs[*job_index].id) {
let base = base.to_string();
self.expanded_groups.remove(&base);
self.reselect_row(&RowKey::Group(base));
}
}
_ => {}
}
}
pub fn set_group_job_arrays(&mut self, group_job_arrays: bool) {
if self.group_job_arrays == group_job_arrays {
return;
}
self.group_job_arrays = group_job_arrays;
if self.selected >= self.len() {
self.set_index(0).unwrap();
}
}
pub fn negate_reverse(&mut self) {
self.reverse = !self.reverse;
self.sort_raw();
self.set_index(0).unwrap();
}
}
impl JobList {
pub fn update_jobs(
&mut self,
user_options: &UserOptions,
log_request: Option<LogFollowRequest>,
) -> UpdateOutcome {
self.set_group_job_arrays(user_options.group_job_arrays);
let job: Option<Job> = self.get_job().cloned();
let selected_key = self.selected_row_key();
let command = self.squeue_command.clone();
let mut transitions = Vec::new();
let mut log_follow = None;
let status =
match self
.content_updater
.tick(job.clone(), command, user_options.clone(), log_request)
{
ContentTick::New(content) => {
let content = *content;
transitions = detect_transitions(&self.jobs, &content.job_list);
self.jobs = content.job_list;
self.job_details = content.details_text;
self.log_tail = content.log_text;
log_follow = content.log_follow;
match content.error {
Some(error) => UpdateStatus::Error(error),
None => UpdateStatus::Success,
}
}
ContentTick::Pending => UpdateStatus::Pending,
ContentTick::TimedOut => UpdateStatus::Error(TIMEOUT_ERROR.to_string()),
};
self.sort_raw();
if let Some(key) = selected_key {
self.reselect_row(&key);
}
UpdateOutcome {
status,
transitions,
log_follow,
}
}
fn reselect_job(&mut self, id: String) {
if self.select_job_by_id(id).is_err() {
self.set_index(0).unwrap();
}
}
fn reselect_row(&mut self, key: &RowKey) {
match key {
RowKey::Job(id) => self.reselect_job(id.clone()),
RowKey::Group(_) => {
if self.select_row_by_key(key).is_err() {
self.set_index(0).unwrap();
}
}
}
}
pub fn next(&mut self) {
let row_count = self.len();
if row_count == 0 {
return;
}
let new_index = (self.selected + 1) % row_count;
self.set_index(new_index).unwrap();
self.set_loading_text();
}
pub fn previous(&mut self) {
let job_count = self.len();
if job_count == 0 {
return;
}
let new_index = (self.selected + job_count - 1) % job_count;
self.set_index(new_index).unwrap();
self.set_loading_text();
}
fn sort_raw(&mut self) {
if self.jobs.is_empty() {
return;
}
match self.sort_category {
JobColumn::Id => {
self.jobs.sort_by(|a, b| compare_job_ids(&b.id, &a.id));
}
JobColumn::Name => {
self.jobs.sort_by(|a, b| {
a.name
.cmp(&b.name)
.then_with(|| compare_job_ids(&a.id, &b.id))
});
}
JobColumn::Status => {
self.jobs.sort_by(|a, b| {
a.status
.priority()
.cmp(&b.status.priority())
.then_with(|| compare_job_ids(&a.id, &b.id))
});
}
JobColumn::Time => {
self.jobs.sort_by(|a, b| {
a.time
.cmp(&b.time)
.then_with(|| compare_job_ids(&a.id, &b.id))
});
}
JobColumn::Partition => {
self.jobs.sort_by(|a, b| {
a.partition
.cmp(&b.partition)
.then_with(|| compare_job_ids(&a.id, &b.id))
});
}
JobColumn::Nodes => {
self.jobs.sort_by(|a, b| {
b.nodes
.cmp(&a.nodes)
.then_with(|| compare_job_ids(&a.id, &b.id))
});
}
JobColumn::Priority => {
self.jobs.sort_by(|a, b| {
b.priority
.cmp(&a.priority)
.then_with(|| compare_job_ids(&a.id, &b.id))
});
}
JobColumn::Cpus => {
self.jobs.sort_by(|a, b| {
b.cpus
.cmp(&a.cpus)
.then_with(|| compare_job_ids(&a.id, &b.id))
});
}
JobColumn::Reason => {
self.jobs.sort_by(|a, b| {
a.reason
.as_deref()
.unwrap_or("")
.cmp(b.reason.as_deref().unwrap_or(""))
.then_with(|| compare_job_ids(&a.id, &b.id))
});
}
JobColumn::Account => {
self.jobs.sort_by(|a, b| {
a.account
.cmp(&b.account)
.then_with(|| compare_job_ids(&a.id, &b.id))
});
}
JobColumn::Qos => {
self.jobs.sort_by(|a, b| {
a.qos
.cmp(&b.qos)
.then_with(|| compare_job_ids(&a.id, &b.id))
});
}
JobColumn::NodeList => {
self.jobs.sort_by(|a, b| {
a.nodelist
.cmp(&b.nodelist)
.then_with(|| compare_job_ids(&a.id, &b.id))
});
}
}
if self.reverse {
self.jobs.reverse();
}
}
pub fn sort(&mut self) {
if self.jobs.is_empty() {
return;
}
let key = self.selected_row_key();
self.sort_raw();
if let Some(key) = key {
self.reselect_row(&key);
}
}
}
fn compare_job_ids(a: &str, b: &str) -> std::cmp::Ordering {
use std::cmp::Ordering;
match (parse_job_id(a), parse_job_id(b)) {
(Some(key_a), Some(key_b)) => key_a.cmp(&key_b).then_with(|| a.cmp(b)),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => a.cmp(b),
}
}
fn parse_job_id(id: &str) -> Option<(u64, Option<u64>)> {
let (job_part, task_part) = match id.split_once('_') {
Some((job, task)) => (job, Some(task)),
None => (id, None),
};
let job: u64 = job_part.parse().ok()?;
let task: Option<u64> = task_part.and_then(|task| task.parse().ok());
Some((job, task))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::job::JobStatus;
fn create_job_list() -> JobList {
let mut job_list = JobList::new();
job_list.jobs.push(Job::new(
"1",
"job1",
JobStatus::Running,
"00:00:00",
"partition1",
1,
"workdir1",
"command1",
None,
));
job_list.jobs.push(Job::new(
"2",
"job2",
JobStatus::Pending,
"00:00:00",
"partition1",
2,
"workdir2",
"command2",
None,
));
job_list.jobs.push(Job::new(
"3",
"job3",
JobStatus::Completing,
"00:00:00",
"partition1",
3,
"workdir3",
"command3",
None,
));
job_list
}
#[test]
fn test_set_index() {
let mut job_list = create_job_list();
assert!(job_list.set_index(1).is_ok());
assert_eq!(job_list.selected, 1);
assert!(job_list.set_index(3).is_err());
let mut job_list = JobList::new();
assert!(job_list.set_index(0).is_ok());
assert_eq!(job_list.selected, 0);
assert_eq!(job_list.job_details, "No job selected");
assert_eq!(job_list.log_tail, "No job selected");
}
#[test]
fn test_select_job_by_id() {
let mut job_list = create_job_list();
assert!(job_list.select_job_by_id("2".to_string()).is_ok());
assert_eq!(job_list.selected, 1);
assert!(job_list.select_job_by_id("4".to_string()).is_err());
}
#[test]
fn test_next() {
let mut job_list = create_job_list();
assert_eq!(job_list.len(), 3);
job_list.next();
assert_eq!(job_list.selected, 1);
assert_eq!(job_list.job_details, "loading...");
assert_eq!(job_list.log_tail, "loading...");
job_list.selected = 2;
job_list.next();
assert_eq!(job_list.selected, 0);
}
#[test]
fn test_previous() {
let mut job_list = create_job_list();
assert_eq!(job_list.len(), 3);
job_list.previous();
assert_eq!(job_list.selected, 2);
assert_eq!(job_list.job_details, "loading...");
assert_eq!(job_list.log_tail, "loading...");
job_list.previous();
assert_eq!(job_list.selected, 1);
}
#[test]
fn test_reverse() {
let mut job_list = create_job_list();
job_list.selected = 1;
assert!(!job_list.reverse);
job_list.negate_reverse();
assert!(job_list.reverse);
assert_eq!(job_list.selected, 0);
job_list.negate_reverse();
assert!(!job_list.reverse);
}
#[test]
fn test_sort() {
let mut job_list = create_job_list();
let job = job_list.get_job().unwrap().clone();
job_list.sort();
assert_eq!(job_list.jobs[0].id, "3");
assert_eq!(job_list.jobs[1].id, "2");
assert_eq!(job_list.jobs[2].id, "1");
assert_eq!(job_list.get_job().unwrap().id, job.id);
job_list.reverse = true;
job_list.sort();
assert_eq!(job_list.jobs[0].id, "1");
assert_eq!(job_list.jobs[1].id, "2");
assert_eq!(job_list.jobs[2].id, "3");
assert_eq!(job_list.get_job().unwrap().id, job.id);
}
fn create_job(id: &str, status: JobStatus, time: &str, nodes: u32) -> Job {
Job::new(
id,
"job",
status,
time,
"partition1",
nodes,
"workdir",
"command",
None,
)
}
fn job_ids(job_list: &JobList) -> Vec<&str> {
job_list.jobs.iter().map(|job| job.id.as_str()).collect()
}
#[test]
fn test_compare_job_ids() {
use std::cmp::Ordering;
assert_eq!(compare_job_ids("9", "10"), Ordering::Less);
assert_eq!(compare_job_ids("10", "9"), Ordering::Greater);
assert_eq!(compare_job_ids("10", "10"), Ordering::Equal);
assert_eq!(compare_job_ids("123_4", "123_10"), Ordering::Less);
assert_eq!(compare_job_ids("123_4", "124_1"), Ordering::Less);
assert_eq!(compare_job_ids("123", "123_1"), Ordering::Less);
assert_eq!(compare_job_ids("abc", "999999"), Ordering::Greater);
assert_eq!(compare_job_ids("abc", "abd"), Ordering::Less);
}
#[test]
fn test_sort_by_id_numeric() {
let mut job_list = JobList::new();
for id in ["9", "10", "123_10", "2", "123_4", "abc"] {
job_list
.jobs
.push(create_job(id, JobStatus::Running, "00:00:00", 1));
}
job_list.sort_raw();
assert_eq!(
job_ids(&job_list),
["abc", "123_10", "123_4", "10", "9", "2"]
);
job_list.reverse = true;
job_list.sort_raw();
assert_eq!(
job_ids(&job_list),
["2", "9", "10", "123_4", "123_10", "abc"]
);
}
#[test]
fn test_sort_by_status() {
let mut job_list = create_job_list();
job_list.set_sort_category(JobColumn::Status);
assert_eq!(job_ids(&job_list), ["2", "1", "3"]);
assert_eq!(job_list.selected, 0);
}
#[test]
fn test_sort_by_nodes() {
let mut job_list = JobList::new();
job_list
.jobs
.push(create_job("9", JobStatus::Running, "00:00:00", 2));
job_list
.jobs
.push(create_job("10", JobStatus::Running, "00:00:00", 4));
job_list
.jobs
.push(create_job("11", JobStatus::Running, "00:00:00", 2));
job_list.set_sort_category(JobColumn::Nodes);
assert_eq!(job_ids(&job_list), ["10", "9", "11"]);
}
#[test]
fn test_sort_by_priority() {
let mut job_list = JobList::new();
for (id, priority) in [("9", 900u64), ("10", 4294901760), ("11", 900)] {
let mut job = create_job(id, JobStatus::Running, "00:00:00", 1);
job.priority = priority;
job_list.jobs.push(job);
}
job_list.set_sort_category(JobColumn::Priority);
assert_eq!(job_ids(&job_list), ["10", "9", "11"]);
job_list.negate_reverse();
assert_eq!(job_ids(&job_list), ["11", "9", "10"]);
}
#[test]
fn test_next_sort_category_cycles_displayed_columns() {
let mut job_list = create_job_list();
job_list.handle_joblist_action(JobListAction::NextSortCategory, &JobColumn::defaults());
assert_eq!(*job_list.get_sort_category(), JobColumn::Name);
let columns = vec![JobColumn::Id, JobColumn::Priority];
job_list.handle_joblist_action(JobListAction::NextSortCategory, &columns);
assert_eq!(*job_list.get_sort_category(), JobColumn::Id);
job_list.handle_joblist_action(JobListAction::NextSortCategory, &columns);
assert_eq!(*job_list.get_sort_category(), JobColumn::Priority);
job_list.handle_joblist_action(JobListAction::NextSortCategory, &columns);
assert_eq!(*job_list.get_sort_category(), JobColumn::Id);
}
#[test]
fn test_sort_by_time() {
let mut job_list = JobList::new();
job_list
.jobs
.push(create_job("9", JobStatus::Running, "00:30:00", 1));
job_list
.jobs
.push(create_job("10", JobStatus::Running, "00:05:00", 1));
job_list
.jobs
.push(create_job("11", JobStatus::Running, "00:05:00", 1));
job_list.set_sort_category(JobColumn::Time);
assert_eq!(job_ids(&job_list), ["10", "11", "9"]);
}
#[test]
fn test_reselect_job_resets_on_id_miss() {
let mut job_list = create_job_list();
job_list.select_job_by_id("2".to_string()).unwrap();
assert_eq!(job_list.selected, 1);
job_list.jobs.remove(1);
assert!(job_list.selected < job_list.len());
job_list.reselect_job("2".to_string());
assert_eq!(job_list.selected, 0);
job_list.reselect_job("3".to_string());
assert_eq!(job_list.get_job().unwrap().id, "3");
}
fn create_array_job_list() -> JobList {
let mut job_list = JobList::new();
for id in ["100_1", "100_2", "300"] {
job_list
.jobs
.push(create_job(id, JobStatus::Running, "00:00:00", 1));
}
job_list
}
#[test]
fn test_navigation_over_collapsed_group_rows() {
let mut job_list = create_array_job_list();
assert_eq!(job_list.len(), 2);
assert_eq!(job_list.get_job().unwrap().id, "100_1");
job_list.next();
assert_eq!(job_list.get_job().unwrap().id, "300");
job_list.next(); assert_eq!(job_list.get_job().unwrap().id, "100_1");
job_list.previous();
assert_eq!(job_list.get_job().unwrap().id, "300");
}
#[test]
fn test_toggle_group_expands_and_collapses() {
let mut job_list = create_array_job_list();
job_list.toggle_selected_group();
assert_eq!(job_list.len(), 4); assert!(matches!(
job_list.rows()[0],
JobRow::Group { expanded: true, .. }
));
assert_eq!(job_list.get_index(), 0);
job_list.next();
assert_eq!(job_list.get_job().unwrap().id, "100_1");
job_list.next();
assert_eq!(job_list.get_job().unwrap().id, "100_2");
job_list.toggle_selected_group();
assert_eq!(job_list.len(), 2);
assert_eq!(job_list.get_index(), 0);
assert!(matches!(
job_list.rows()[0],
JobRow::Group {
expanded: false,
..
}
));
job_list.next();
assert_eq!(job_list.get_job().unwrap().id, "300");
job_list.toggle_selected_group();
assert_eq!(job_list.len(), 2);
assert_eq!(job_list.get_job().unwrap().id, "300");
}
#[test]
fn test_select_reclick_toggles_group_row() {
let mut job_list = create_array_job_list();
let columns = JobColumn::defaults();
job_list.handle_joblist_action(JobListAction::Select(0), &columns);
assert_eq!(job_list.len(), 4);
job_list.handle_joblist_action(JobListAction::Select(0), &columns);
assert_eq!(job_list.len(), 2);
job_list.handle_joblist_action(JobListAction::Select(1), &columns);
assert_eq!(job_list.get_job().unwrap().id, "300");
assert_eq!(job_list.len(), 2);
job_list.handle_joblist_action(JobListAction::Select(0), &columns);
job_list.handle_joblist_action(JobListAction::ToggleGroup, &columns);
assert_eq!(job_list.len(), 4);
}
#[test]
fn test_select_job_by_id_finds_collapsed_task_and_expanded_task() {
let mut job_list = create_array_job_list();
job_list.select_job_by_id("100_2".to_string()).unwrap();
assert_eq!(job_list.get_index(), 0);
assert!(job_list.selected_group().is_some());
job_list.toggle_selected_group();
job_list.select_job_by_id("100_2".to_string()).unwrap();
assert_eq!(job_list.get_job().unwrap().id, "100_2");
assert!(job_list.selected_group().is_none());
}
#[test]
fn test_selected_group_reports_base_id_and_task_count() {
let mut job_list = create_array_job_list();
let (base_id, task_count, first) = job_list.selected_group().unwrap();
assert_eq!(base_id, "100");
assert_eq!(task_count, 2);
assert_eq!(first.id, "100_1");
job_list.next();
assert!(job_list.selected_group().is_none());
}
#[test]
fn test_expansion_and_selection_persist_across_refresh() {
let mut job_list = create_array_job_list();
job_list.toggle_selected_group();
job_list.select_job_by_id("100_2".to_string()).unwrap();
let key = job_list.selected_row_key().unwrap();
job_list.jobs = ["100_1", "100_2", "100_3", "300"]
.iter()
.map(|id| create_job(id, JobStatus::Running, "00:00:00", 1))
.collect();
job_list.sort_raw();
job_list.reselect_row(&key);
assert_eq!(job_list.len(), 5);
assert_eq!(job_list.get_job().unwrap().id, "100_2");
assert!(job_list.selected_group().is_none());
job_list.toggle_selected_group(); let key = job_list.selected_row_key().unwrap();
assert_eq!(key, RowKey::Group("100".to_string()));
job_list.jobs = ["100_1", "100_2", "300"]
.iter()
.map(|id| create_job(id, JobStatus::Running, "00:00:00", 1))
.collect();
job_list.sort_raw();
job_list.reselect_row(&key);
assert!(job_list.selected_group().is_some());
job_list.jobs = vec![create_job("300", JobStatus::Running, "00:00:00", 1)];
job_list.reselect_row(&key);
assert_eq!(job_list.get_index(), 0);
assert_eq!(job_list.get_job().unwrap().id, "300");
}
#[test]
fn test_group_job_arrays_disabled_shows_flat_rows() {
let mut job_list = create_array_job_list();
assert_eq!(job_list.len(), 2);
job_list.set_group_job_arrays(false);
assert_eq!(job_list.len(), 3);
assert!(job_list
.rows()
.iter()
.all(|row| matches!(row, JobRow::Single { .. })));
job_list.set_index(2).unwrap();
job_list.set_group_job_arrays(true);
assert_eq!(job_list.get_index(), 0);
}
#[test]
fn test_filter_narrows_rows_and_clear_restores_them() {
let mut job_list = create_job_list();
let columns = JobColumn::defaults();
job_list.set_filter("job2", &columns);
assert_eq!(job_list.len(), 1);
assert_eq!(job_list.get_job().unwrap().id, "2");
assert_eq!(job_list.filter_text(), Some("job2"));
assert_eq!(job_list.filter_match_count(), 1);
assert_eq!(job_list.jobs.len(), 3);
job_list.set_filter("", &columns);
assert_eq!(job_list.len(), 3);
assert_eq!(job_list.filter_text(), None);
assert_eq!(job_list.filter_match_count(), 3);
}
#[test]
fn test_filter_keeps_the_selected_job_if_it_still_matches() {
let mut job_list = create_job_list();
let columns = JobColumn::defaults();
job_list.select_job_by_id("2".to_string()).unwrap();
job_list.set_filter("job", &columns);
assert_eq!(job_list.get_job().unwrap().id, "2");
job_list.set_filter("job3", &columns);
assert_eq!(job_list.get_index(), 0);
assert_eq!(job_list.get_job().unwrap().id, "3");
job_list.set_filter("", &columns);
assert_eq!(job_list.get_job().unwrap().id, "3");
}
#[test]
fn test_filter_with_no_matches_yields_empty_rows_without_panics() {
let mut job_list = create_job_list();
let columns = JobColumn::defaults();
job_list.set_filter("no such job", &columns);
assert_eq!(job_list.len(), 0);
assert_eq!(job_list.filter_match_count(), 0);
assert_eq!(job_list.get_index(), 0);
assert!(job_list.get_job().is_none());
assert!(!job_list.is_empty());
job_list.next();
job_list.previous();
assert!(job_list.set_index(0).is_ok());
job_list.handle_joblist_action(JobListAction::Select(0), &columns);
job_list.set_filter("", &columns);
assert_eq!(job_list.len(), 3);
}
#[test]
fn test_filter_narrows_array_groups_to_matching_tasks() {
let mut job_list = create_array_job_list();
job_list.jobs[1].status = JobStatus::Pending;
let columns = JobColumn::defaults();
job_list.set_filter("PD", &columns);
let rows = job_list.rows();
assert_eq!(rows.len(), 1);
match &rows[0] {
JobRow::Group { task_indices, .. } => assert_eq!(task_indices.len(), 1),
other => panic!("expected a group row, got {:?}", other),
}
assert_eq!(job_list.get_job().unwrap().id, "100_2");
job_list.toggle_selected_group();
assert_eq!(job_list.len(), 2);
let key = job_list.selected_row_key().unwrap();
job_list.sort_raw();
job_list.reselect_row(&key);
assert_eq!(job_list.len(), 2);
}
#[test]
fn test_get_user_has_no_trailing_whitespace() {
if let Some(user) = get_user() {
assert_eq!(user, user.trim());
assert!(!user.is_empty());
}
}
}
#[cfg(test)]
mod proptests {
use super::*;
use crate::columns::ALL_COLUMNS;
use crate::job::JobStatus;
use proptest::prelude::*;
#[derive(Debug, Clone)]
enum Action {
Next,
Previous,
Select(usize),
NextSortCategory,
ReverseSortDirection,
SelectSortCategory(usize),
ToggleGroup,
SetGrouping(bool),
SetFilter(String),
Refresh(Vec<Job>),
}
fn job_id() -> impl Strategy<Value = String> {
prop_oneof![
"[0-9]{1,4}",
"10[0-2]_[0-9]",
"[0-9]{1,3}_[0-9]{1,2}",
"10[0-2]_\\[[0-9]-[1-9][0-9]\\]",
"[a-z]{1,4}",
]
}
fn status() -> impl Strategy<Value = JobStatus> {
prop_oneof![
Just(JobStatus::Unknown),
Just(JobStatus::Running),
Just(JobStatus::Pending),
Just(JobStatus::Completing),
Just(JobStatus::Completed),
Just(JobStatus::Timeout),
Just(JobStatus::Cancelled),
Just(JobStatus::Failed),
]
}
fn job() -> impl Strategy<Value = Job> {
(
job_id(),
status(),
0..5u32,
"[0-9]-[0-9]{2}:[0-9]{2}:[0-9]{2}",
any::<u16>(),
0..64u32,
)
.prop_map(|(id, status, nodes, time, priority, cpus)| {
let mut job = Job::new(
&id,
"job",
status,
&time,
"part",
nodes,
"/w",
"/w/run.sh",
None,
);
job.priority = priority as u64;
job.cpus = cpus;
job
})
}
fn jobs() -> impl Strategy<Value = Vec<Job>> {
prop::collection::vec(job(), 0..30)
}
fn filter_input() -> impl Strategy<Value = String> {
prop_oneof![
Just(String::new()),
"[a-z0-9]{1,4}",
"[0-9a-z.*+\\[\\](|]{1,6}",
]
}
fn action() -> impl Strategy<Value = Action> {
prop_oneof![
Just(Action::Next),
Just(Action::Previous),
(0..40usize).prop_map(Action::Select),
Just(Action::NextSortCategory),
Just(Action::ReverseSortDirection),
(0..ALL_COLUMNS.len()).prop_map(Action::SelectSortCategory),
Just(Action::ToggleGroup),
any::<bool>().prop_map(Action::SetGrouping),
filter_input().prop_map(Action::SetFilter),
jobs().prop_map(Action::Refresh),
]
}
fn apply(job_list: &mut JobList, action: Action, columns: &[JobColumn]) {
match action {
Action::Next => job_list.handle_joblist_action(JobListAction::Next, columns),
Action::Previous => job_list.handle_joblist_action(JobListAction::Previous, columns),
Action::Select(index) => {
job_list.handle_joblist_action(JobListAction::Select(index), columns)
}
Action::NextSortCategory => {
job_list.handle_joblist_action(JobListAction::NextSortCategory, columns)
}
Action::ReverseSortDirection => {
job_list.handle_joblist_action(JobListAction::ReverseSortDirection, columns)
}
Action::SelectSortCategory(index) => job_list.handle_joblist_action(
JobListAction::SelectSortCategory(ALL_COLUMNS[index]),
columns,
),
Action::ToggleGroup => {
job_list.handle_joblist_action(JobListAction::ToggleGroup, columns)
}
Action::SetGrouping(grouping) => job_list.set_group_job_arrays(grouping),
Action::SetFilter(text) => {
job_list.handle_joblist_action(JobListAction::SetFilter(text), columns)
}
Action::Refresh(new_jobs) => {
let key = job_list.selected_row_key();
job_list.jobs = new_jobs;
job_list.sort_raw();
if let Some(key) = key {
job_list.reselect_row(&key);
}
}
}
}
fn check_invariants(job_list: &JobList) -> Result<(), TestCaseError> {
let rows = job_list.rows();
if rows.is_empty() {
prop_assert_eq!(job_list.get_index(), 0, "empty list must select index 0");
prop_assert!(job_list.get_job().is_none());
} else {
prop_assert!(
job_list.get_index() < rows.len(),
"selected index {} out of bounds of {} visible rows",
job_list.get_index(),
rows.len()
);
prop_assert!(
job_list.get_job().is_some(),
"an in-bounds selection must yield a job"
);
}
let _ = job_list.selected_row_key();
let _ = job_list.selected_group();
Ok(())
}
proptest! {
#![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })]
#[test]
fn selection_stays_in_bounds_under_any_action_sequence(
initial in jobs(),
actions in prop::collection::vec(action(), 0..50),
) {
let columns = JobColumn::defaults();
let mut job_list = JobList::new();
job_list.jobs = initial;
job_list.sort_raw();
check_invariants(&job_list)?;
for action in actions {
apply(&mut job_list, action, &columns);
check_invariants(&job_list)?;
}
}
}
}