use crate::model::anomaly::Anomaly;
use crate::model::join::AgentRef;
use crate::model::snapshot::{Counts, Node};
use crate::model::types::Status;
use crate::view::phrase;
pub const AGENT: char = '◍';
pub const WARNING: char = '⚠';
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Progress {
pub closed: usize,
pub total: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
pub status: Status,
pub glyph: char,
pub id: String,
pub title: String,
pub badges: Vec<String>,
pub progress: Option<Progress>,
pub agent: Option<String>,
pub agent_briefly: Option<String>,
pub anomalies: Option<String>,
pub shut_over: Option<Counts>,
pub notes: Vec<String>,
}
pub fn cells(
node: &Node,
root: &str,
progress: Option<Progress>,
shut_over: Option<Counts>,
) -> Row {
let mut notes = Vec::new();
notes.extend(
shut_over
.as_ref()
.filter(|_| node.status.is_closed())
.map(Counts::unfinished)
.filter(|unfinished| *unfinished > 0)
.map(phrase::unfinished_beneath),
);
notes.extend(phrase::unrecognised_status(&node.status));
Row {
status: node.status.clone(),
glyph: status_glyph(&node.status),
id: abbreviate(&node.id, root).to_string(),
title: node.title.clone(),
badges: node.badges.iter().map(|b| b.text.clone()).collect(),
progress,
agent: node.agent.as_ref().map(agent_marker),
agent_briefly: node.agent.as_ref().map(agent_briefly),
anomalies: anomaly_marker(&node.anomalies),
shut_over,
notes,
}
}
pub fn status_glyph(status: &Status) -> char {
match status {
Status::Open => '○',
Status::InProgress => '◐',
Status::Blocked => '●',
Status::Closed => '✓',
Status::Deferred => '❄',
Status::Other(_) => '?',
}
}
pub fn abbreviate<'a>(id: &'a str, root: &str) -> &'a str {
id.strip_prefix(root)
.filter(|rest| rest.starts_with('.'))
.unwrap_or(id)
}
pub fn agent_marker(agent: &AgentRef) -> String {
named(agent, agent.title.as_deref().unwrap_or(&agent.pane.id))
}
pub fn agent_briefly(agent: &AgentRef) -> String {
named(agent, &agent.pane.id)
}
fn named(agent: &AgentRef, doing: &str) -> String {
let mut said = vec![
format!("{AGENT} {doing}"),
phrase::pane_state(&agent.pane_status),
];
said.extend(phrase::join_caveat(agent.source).map(str::to_string));
said.join(" · ")
}
pub fn anomaly_marker(anomalies: &[Anomaly]) -> Option<String> {
if anomalies.is_empty() {
return None;
}
let said: Vec<String> = anomalies.iter().map(phrase::anomaly).collect();
Some(format!("{WARNING} {}", said.join(" · ")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::badges::Badged;
use crate::model::join::JoinSource;
use crate::model::types::testing::key;
use crate::model::types::PaneStatus;
use pretty_assertions::assert_eq;
const ROOT: &str = "smt-4kd3p";
fn node(id: &str, status: Status) -> Node {
Node {
id: id.into(),
title: "wallpaper timer calls dms".into(),
status,
issue_type: "task".into(),
priority: 2,
ready: false,
blocked_by: Vec::new(),
started_at: None,
closed_at: None,
badges: Vec::new(),
agent: None,
anomalies: Vec::new(),
description: String::new(),
notes: String::new(),
owner: None,
parent: None,
depends_on: Vec::new(),
blocks: Vec::new(),
}
}
fn agent(source: JoinSource) -> AgentRef {
AgentRef {
pane: key("wCM:p9"),
pane_status: PaneStatus::Working,
title: Some("shell selector".into()),
source,
}
}
fn unlabelled(source: JoinSource) -> AgentRef {
AgentRef {
title: None,
..agent(source)
}
}
#[test]
fn every_glyph_is_the_one_bd_lists_beside_that_status_in_its_own_legend() {
assert_eq!(status_glyph(&Status::Open), '○');
assert_eq!(status_glyph(&Status::InProgress), '◐');
assert_eq!(status_glyph(&Status::Blocked), '●');
assert_eq!(status_glyph(&Status::Closed), '✓');
assert_eq!(status_glyph(&Status::Deferred), '❄');
}
#[test]
fn the_glyph_is_the_beads_own_status_and_no_two_statuses_share_one() {
let statuses = [
Status::InProgress,
Status::Blocked,
Status::Open,
Status::Deferred,
Status::Closed,
Status::Other("triage".into()),
];
let mut glyphs: Vec<char> = statuses.iter().map(status_glyph).collect();
let drawn = glyphs.len();
glyphs.sort_unstable();
glyphs.dedup();
assert_eq!(glyphs.len(), drawn);
}
#[test]
fn the_glyph_does_not_move_when_an_agent_arrives() {
let mut staffed = node("smt-4kd3p.20", Status::InProgress);
staffed.agent = Some(agent(JoinSource::AgentPane));
assert_eq!(
cells(&staffed, ROOT, None, None).glyph,
cells(&node("smt-4kd3p.20", Status::InProgress), ROOT, None, None).glyph
);
}
#[test]
fn a_node_under_the_root_shows_only_what_it_adds_to_it() {
assert_eq!(abbreviate("smt-4kd3p.20", ROOT), ".20");
assert_eq!(abbreviate("smt-4kd3p.1.4", ROOT), ".1.4");
}
#[test]
fn a_node_that_does_not_descend_from_the_root_keeps_its_whole_id() {
assert_eq!(abbreviate("mdw-6qzt4.3", ROOT), "mdw-6qzt4.3");
assert_eq!(abbreviate("smt-4kd3pX.3", ROOT), "smt-4kd3pX.3");
}
#[test]
fn the_root_keeps_its_whole_id() {
assert_eq!(abbreviate(ROOT, ROOT), ROOT);
}
#[test]
fn a_live_agent_is_marked_with_what_it_is_doing_and_not_with_its_pane() {
let said = agent_marker(&agent(JoinSource::AgentPane));
assert_eq!(said, "◍ shell selector · working");
}
#[test]
fn a_pane_that_says_what_it_is_doing_never_shows_its_id() {
let said = agent_marker(&agent(JoinSource::DisplayAgent));
assert!(!said.contains("wCM:p9"), "{said}");
}
#[test]
fn a_pane_that_has_said_nothing_falls_back_to_the_id_it_cannot_lose() {
let said = agent_marker(&unlabelled(JoinSource::AgentPane));
assert_eq!(said, "◍ wCM:p9 · working");
}
#[test]
fn an_agent_the_bead_never_named_is_marked_as_inferred() {
let said = agent_marker(&agent(JoinSource::DisplayAgent));
assert!(said.contains("inferred, not confirmed"), "{said}");
}
#[test]
fn the_join_caveat_follows_the_state_rather_than_the_caption() {
let said = agent_marker(&agent(JoinSource::DisplayAgent));
assert_eq!(said, "◍ shell selector · working · inferred, not confirmed");
}
#[test]
fn a_caption_is_kept_apart_from_the_state_that_follows_it() {
let waiting = AgentRef {
pane_status: PaneStatus::Blocked,
..agent(JoinSource::AgentPane)
};
assert_eq!(
agent_marker(&waiting),
"◍ shell selector · waiting at a prompt"
);
}
#[test]
fn a_row_carries_every_anomaly_that_fired_rather_than_the_first() {
let said = anomaly_marker(&[
Anomaly::OrphanClaim { refused: None },
Anomaly::StaleClaim { days: 58 },
])
.expect("two rules fired");
assert!(said.contains("no pane"), "{said}");
assert!(said.contains("58"), "{said}");
}
#[test]
fn a_bead_with_nothing_wrong_carries_no_marker_at_all() {
let row = cells(&node("smt-4kd3p.20", Status::Open), ROOT, None, None);
assert_eq!(row.anomalies, None);
assert_eq!(row.agent, None);
assert_eq!(row.notes, Vec::<String>::new());
}
#[test]
fn a_status_outside_bds_own_set_leaves_the_word_bd_used_on_the_row() {
let odd = node("smt-4kd3p.20", Status::Other("triage".into()));
let row = cells(&odd, ROOT, None, None);
assert_eq!(row.glyph, '?');
assert!(
row.notes.iter().any(|note| note.contains("triage")),
"{row:?}"
);
}
#[test]
fn badges_are_drawn_in_the_order_they_were_configured() {
let mut badged = node("smt-4kd3p.20", Status::Blocked);
badged.badges = vec![
Badged {
key: "delivery_pr".into(),
text: "⇢ #12".into(),
},
Badged {
key: "blocked_on".into(),
text: "⏸ waiting".into(),
},
];
assert_eq!(
cells(&badged, ROOT, None, None).badges,
vec!["⇢ #12", "⏸ waiting"]
);
}
#[test]
fn a_row_says_what_the_bead_says() {
let row = cells(&node("smt-4kd3p.20", Status::Blocked), ROOT, None, None);
assert_eq!(row.status, Status::Blocked);
assert_eq!(row.glyph, '●');
assert_eq!(row.id, ".20");
assert_eq!(row.title, "wallpaper timer calls dms");
}
}