use chrono::{DateTime, Utc};
use ratatui::text::Span;
use crate::model::snapshot::{Counts, TrackerState};
use crate::view::fitted::Fitted;
use crate::view::lines::{ProjectLine, Unread};
use crate::view::palette;
use crate::view::phrase;
use crate::view::row::WARNING;
use crate::view::{Freshness, Mark};
use super::{beside, done, structure};
pub(super) fn project_line(
project: &ProjectLine,
prefix: &str,
how_fresh: Option<Freshness>,
now: DateTime<Utc>,
) -> Fitted {
let identity = vec![
Span::raw(prefix.to_string()),
Span::raw(project.project.clone()),
];
Fitted::new(
identity,
freshness(how_fresh, now),
summary(&project.counts),
)
.title_or_nothing()
}
fn freshness(how_fresh: Option<Freshness>, now: DateTime<Utc>) -> Vec<Span<'static>> {
let Some(how_fresh) = how_fresh else {
return Vec::new();
};
let mark = match how_fresh.mark {
Mark::Refused | Mark::Unanswered => palette::ATTENTION,
Mark::Collecting | Mark::Read => palette::QUIET,
};
let mut said = vec![Span::styled(phrase::mark(how_fresh, now), mark)];
said.extend(
phrase::last_read(how_fresh, now)
.map(|age| {
[" ".to_string(), age].map(|said| Span::styled(said, palette::QUIET))
})
.into_iter()
.flatten(),
);
said
}
pub(super) fn unread_line(unread: &Unread, prefix: &str, id_width: usize) -> Fitted {
let identity = vec![
structure(prefix),
Span::styled(WARNING.to_string(), palette::ATTENTION),
Span::raw(format!(" {:id_width$}", unread.root)),
];
let why = match &unread.tracker {
TrackerState::Unreachable(failure) => phrase::tracker_failure(failure),
TrackerState::RootNotFound => phrase::root_not_found().to_string(),
TrackerState::Ok => phrase::root_unread().to_string(),
};
Fitted::new(
identity,
vec![Span::styled(why, palette::ATTENTION)],
Vec::new(),
)
}
fn summary(counts: &Counts) -> Vec<Span<'static>> {
let mut said = Vec::new();
if counts.total > 0 {
beside(&mut said, Span::raw(done(counts.closed, counts.total)));
}
if counts.live_agents > 0 {
let agent = if counts.live_agents == 1 {
"agent"
} else {
"agents"
};
beside(
&mut said,
Span::styled(format!("{} {agent}", counts.live_agents), palette::AGENT),
);
}
if counts.anomalies > 0 {
beside(
&mut said,
Span::styled(
format!("{WARNING} {}", counts.anomalies),
palette::ATTENTION,
),
);
}
said
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use ratatui::style::Color;
use crate::app::{Awaited, Wanted};
use crate::model::anomaly::Anomaly;
use std::sync::Arc;
use crate::model::snapshot::{Node, ProviderState, Snapshot, TrackerFailure, Tree};
use crate::model::types::Status;
use crate::view::draw::tone::status_style;
use crate::view::draw::{fitted, tests::*};
use crate::view::forest::flatten;
use crate::view::row;
fn line(project: &ProjectLine, prefix: &str) -> Fitted {
project_line(project, prefix, None, drawn_at())
}
fn line_that_is(project: &ProjectLine, how_fresh: Freshness) -> Fitted {
project_line(project, OPEN, Some(how_fresh), drawn_at())
}
fn half_a_minute_old(mark: Mark) -> Freshness {
Freshness {
mark,
read_at: Some(read_at()),
}
}
fn two_projects() -> Snapshot {
let harbour = Tree {
counts: counts(0, 1, 0, 0),
beads: vec![node("qua-1", "moor the barge", Status::InProgress)],
children: vec![Vec::new()],
..tree("harbour", "qua-1", "moor the barge", counts(0, 0, 0, 0))
};
let mut snapshot = snapshot(
vec![grove(1), harbour],
Vec::new(),
ProviderState::Answering,
);
snapshot.read_at.insert("harbour".to_string(), read_at());
snapshot
}
fn project_row<'a>(frame: &'a [String], project: &str) -> &'a str {
frame
.iter()
.find(|row| row.contains(project))
.unwrap_or_else(|| panic!("{project} is on the frame: {frame:?}"))
}
fn unread(root: &str, tracker: TrackerState) -> Unread {
Unread {
root: root.into(),
tracker,
}
}
#[test]
fn a_project_line_says_which_project_it_is_and_how_much_of_it_is_done() {
let counts = counts(8, 21, 3, 3);
assert_eq!(
Painted::of(line(&project("summit-works", counts), OPEN), 40, 1).rows(),
vec!["▾ summit-works 8/21 3 agents ⚠ 3"]
);
}
#[test]
fn a_project_with_no_live_agent_and_nothing_wrong_says_only_how_much_is_done() {
let counts = counts(2, 7, 0, 0);
assert_eq!(
Painted::of(line(&project("meadow", counts), SHUT), 30, 1).rows(),
vec!["▸ meadow 2/7"]
);
}
#[test]
fn one_agent_is_not_described_in_the_plural() {
let counts = counts(2, 7, 1, 0);
let drawn = Painted::of(line(&project("meadow", counts), SHUT), 40, 1).rows();
assert!(drawn[0].ends_with("2/7 1 agent"), "{drawn:?}");
}
#[test]
fn a_project_whose_only_claim_has_no_pane_wears_the_warning_and_claims_no_agent() {
let claimed = Node {
status: Status::InProgress,
anomalies: vec![Anomaly::OrphanClaim { refused: None }],
..node("orb-4.1", "seat the guy wires", Status::InProgress)
};
let counts = Counts::over(&[claimed]);
let drawn = Painted::of(line(&project("orbital", counts), OPEN), 40, 1).rows();
says(&drawn[0], &format!("{WARNING} 1"));
does_not_say(&drawn[0], "agent");
}
#[test]
fn a_width_too_narrow_for_anything_else_keeps_as_much_of_the_project_as_it_can() {
let counts = counts(8, 21, 3, 3);
assert_eq!(
Painted::of(line(&project("summit-works", counts), OPEN), 10, 1).rows(),
vec!["▾ summit-…"]
);
}
#[test]
fn a_cut_is_counted_in_columns_and_never_lands_inside_a_glyph() {
let name = "→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→";
let drawn = Painted::of(line(&project(name, counts(0, 1, 0, 0)), OPEN), 20, 1).rows();
assert_eq!(drawn[0].chars().count(), 20);
assert!(!drawn[0].contains('\u{fffd}'), "{drawn:?}");
}
#[test]
fn a_project_says_how_long_ago_it_was_read_beside_its_own_name() {
let line = line_that_is(
&project("summit-works", counts(8, 21, 3, 3)),
half_a_minute_old(Mark::Read),
);
assert_eq!(
Painted::of(line, 60, 1).rows(),
vec!["▾ summit-works ✓ 30s ago 8/21 3 agents ⚠ 3"]
);
}
#[test]
fn the_cell_says_a_mark_and_an_age_in_every_state_and_nothing_after_it_moves() {
let said = |mark| {
Painted::of(
line_that_is(
&project("summit-works", counts(8, 21, 0, 0)),
half_a_minute_old(mark),
),
60,
1,
)
.rows()
.remove(0)
};
assert_eq!(
[
said(Mark::Collecting),
said(Mark::Read),
said(Mark::Refused)
],
[
"▾ summit-works ⠴ 30s ago 8/21",
"▾ summit-works ✓ 30s ago 8/21",
"▾ summit-works ⚠ 30s ago 8/21",
]
);
}
#[test]
fn a_project_nothing_has_read_yet_shows_the_mark_over_no_age() {
let starting = Freshness {
mark: Mark::Collecting,
read_at: None,
};
assert_eq!(
Painted::of(
line_that_is(&project("summit-works", counts(0, 0, 0, 0)), starting),
60,
1
)
.rows(),
vec!["▾ summit-works ⠴ "]
);
}
#[test]
fn a_project_no_collection_names_keeps_its_age_while_another_is_read() {
let forest = opened(&two_projects());
let frame = frame_collecting(
&forest,
&[reading(Wanted::Project("summit-works".to_string()))],
74,
12,
)
.rows();
says(&frame[0], "⠴ 30s ago");
says(project_row(&frame, "harbour"), "✓ 30s ago");
}
#[test]
fn a_collection_over_everything_marks_every_project() {
let forest = opened(&two_projects());
let frame = frame_collecting(&forest, &[reading(Wanted::Everything)], 74, 12).rows();
says(&frame[0], "⠴ 30s ago");
says(project_row(&frame, "harbour"), "⠴ 30s ago");
}
#[test]
fn a_project_with_a_root_that_would_not_read_wears_the_refused_mark() {
let mut snapshot = two_projects();
snapshot.trees.push(Arc::new(Tree::tracker_unreachable(
"harbour",
"qua-9",
TrackerFailure::Auth,
)));
snapshot.collected.clone_from(&snapshot.trees);
let frame = frame_of(&opened(&snapshot), 74, 12).rows();
says(project_row(&frame, "harbour"), "⚠ 30s ago");
says(&frame[0], "✓ 30s ago");
}
#[test]
fn a_narrow_project_line_gives_up_the_whole_cell_before_its_counts() {
let with_a_cell = |width| {
Painted::of(
line_that_is(
&project("summit-works", counts(8, 21, 3, 3)),
half_a_minute_old(Mark::Read),
),
width,
1,
)
.rows()
};
assert_eq!(
with_a_cell(46),
vec!["▾ summit-works ✓ 30s ago 8/21 3 agents ⚠ 3"]
);
for narrow in [45, 40] {
assert_eq!(
with_a_cell(narrow),
Painted::of(
line(&project("summit-works", counts(8, 21, 3, 3)), OPEN),
narrow,
1
)
.rows(),
"at {narrow} columns the cell costs the line nothing"
);
}
}
#[test]
fn a_cell_with_no_room_for_it_is_dropped_whole_rather_than_cut_or_halved() {
assert_eq!(
Painted::of(
line_that_is(
&project("summit-works", counts(8, 21, 3, 3)),
half_a_minute_old(Mark::Read),
),
45,
1
)
.rows(),
vec!["▾ summit-works 8/21 3 agents ⚠ 3"]
);
}
#[test]
fn how_fresh_a_project_is_is_drawn_dim_so_the_counts_keep_the_eye() {
let dim = |mark, said: &str| {
let painted = Painted::of(
line_that_is(
&project("summit-works", counts(8, 21, 0, 0)),
half_a_minute_old(mark),
),
60,
1,
)
.row(0);
assert!(
painted
.iter()
.any(|run| run.said.contains(said) && run.style.fg == Some(Color::DarkGray)),
"{said:?} is not dim: {painted:?}"
);
};
dim(Mark::Read, "✓ 30s ago");
dim(Mark::Collecting, "⠴ 30s ago");
}
#[test]
fn a_mark_saying_a_root_refused_wears_the_colour_that_asks_to_be_looked_at() {
let painted = Painted::of(
line_that_is(
&project("summit-works", counts(8, 21, 0, 0)),
half_a_minute_old(Mark::Refused),
),
60,
1,
)
.row(0);
assert!(
painted
.iter()
.any(|run| run.said.contains(WARNING) && run.style.fg == palette::ATTENTION.fg),
"{painted:?}"
);
assert!(
painted
.iter()
.any(|run| run.said.contains("30s ago") && run.style.fg == Some(Color::DarkGray)),
"{painted:?}"
);
}
#[test]
fn a_tracker_that_has_stopped_answering_is_drawn_as_stopped_rather_than_as_starting() {
let forest = opened(&two_projects());
let asked_at = drawn_at() - PATIENCE;
let frame = frame_collecting(
&forest,
&[Awaited {
wanted: Wanted::Everything,
asked_at,
patience: PATIENCE,
}],
74,
12,
)
.rows();
says(&frame[0], "⠿ 30s ago");
says(project_row(&frame, "harbour"), "⠿ 30s ago");
}
#[test]
fn a_project_queued_behind_a_stopped_tracker_is_drawn_apart_from_a_quiet_one() {
let forest = opened(&two_projects());
let stopped = drawn_at() - PATIENCE;
let frame = frame_collecting(
&forest,
&[
reading_since(Wanted::Project("summit-works".to_string()), stopped),
reading_since(Wanted::Project("harbour".to_string()), stopped),
],
74,
12,
)
.rows();
says(project_row(&frame, "harbour"), "⠿ 30s ago");
says(&frame[0], "⠿ 30s ago");
}
#[test]
fn a_project_nothing_has_needed_to_read_rests_beside_one_that_has_stopped() {
let forest = opened(&two_projects());
let frame = frame_collecting(
&forest,
&[reading_since(
Wanted::Project("summit-works".to_string()),
drawn_at() - PATIENCE,
)],
74,
12,
)
.rows();
says(&frame[0], "⠿ 30s ago");
says(project_row(&frame, "harbour"), "✓ 30s ago");
}
#[test]
fn a_project_two_outstanding_reads_name_is_drawn_against_the_older_of_them() {
let forest = opened(&two_projects());
let frame = frame_collecting(
&forest,
&[
reading_since(
Wanted::Project("harbour".to_string()),
drawn_at() - PATIENCE,
),
reading_since(Wanted::Everything, drawn_at()),
],
74,
12,
)
.rows();
says(project_row(&frame, "harbour"), "⠿ 30s ago");
says(&frame[0], "⠴ 30s ago");
}
fn reading_since(wanted: Wanted, asked_at: chrono::DateTime<Utc>) -> Awaited {
Awaited {
wanted,
asked_at,
patience: PATIENCE,
}
}
#[test]
fn a_mark_saying_a_tracker_stopped_answering_wears_the_colour_that_asks_to_be_looked_at() {
let painted = Painted::of(
line_that_is(
&project("summit-works", counts(8, 21, 0, 0)),
half_a_minute_old(Mark::Unanswered),
),
60,
1,
)
.row(0);
assert!(
painted
.iter()
.any(|run| run.said.contains('⠿') && run.style.fg == palette::ATTENTION.fg),
"{painted:?}"
);
assert!(
painted
.iter()
.any(|run| run.said.contains("30s ago") && run.style.fg == Some(Color::DarkGray)),
"{painted:?}"
);
}
#[test]
fn a_root_is_drawn_with_its_own_status_glyph_like_any_other_bead() {
let forest = flatten(snapshot(
vec![grove(2)],
Vec::new(),
ProviderState::Answering,
));
let root = &forest.lines()[1];
let painted = Painted::of(fitted(root, 12, &at_rest()), 60, 1).row(0);
let drawn = Painted::of(fitted(root, 12, &at_rest()), 60, 1).rows();
assert!(
drawn[0].contains(&format!(
"{} smt-4kd3p",
row::status_glyph(&Status::InProgress)
)),
"{drawn:?}"
);
assert!(
painted.iter().any(
|run| run.said.contains(row::status_glyph(&Status::InProgress))
&& run.style.fg == status_style(&Status::InProgress).fg
),
"{painted:?}"
);
}
#[test]
fn an_unread_root_is_named_where_its_row_would_have_been_with_the_reason() {
let unread = unread(
"smt-4kd3p",
TrackerState::Unreachable(TrackerFailure::Unavailable),
);
let drawn = Painted::of(unread_line(&unread, LAST, 9), 60, 1).rows();
says(&drawn[0], "smt-4kd3p");
says(&drawn[0], "the tracker did not answer");
}
#[test]
fn a_root_the_tracker_does_not_hold_says_so_beside_its_id() {
let unread = unread("smt-4kd3p", TrackerState::RootNotFound);
let drawn = Painted::of(unread_line(&unread, LAST, 9), 90, 1).rows();
says(&drawn[0], "smt-4kd3p");
says(&drawn[0], "no such bead in this tracker");
does_not_say(&drawn[0], "bdi cannot read");
}
#[test]
fn an_unread_root_never_shows_a_count_it_could_not_read() {
let unread = unread("smt-4kd3p", TrackerState::Unreachable(TrackerFailure::Auth));
let drawn = Painted::of(unread_line(&unread, LAST, 9), 120, 1).rows();
does_not_say(&drawn[0], "0/0");
}
#[test]
fn a_root_with_no_row_and_no_reason_still_says_it_is_there() {
let drawn = Painted::of(
unread_line(&unread("smt-4kd3p", TrackerState::Ok), LAST, 9),
90,
1,
)
.rows();
says(&drawn[0], "smt-4kd3p");
says(&drawn[0], "this root drew no rows, and nothing said why");
}
#[test]
fn a_narrow_unread_root_keeps_the_root_over_the_reason() {
let unread = unread("smt-4kd3p", TrackerState::Unreachable(TrackerFailure::Auth));
let drawn = Painted::of(unread_line(&unread, LAST, 9), 24, 1).rows();
says(&drawn[0], "smt-4kd3p");
assert_eq!(drawn[0].chars().count(), 24);
}
}