OXYGENHUB

Coverage / crates/hub/src/render/graph.rs 100.0%

75 of 75 lines run, 22 of 22 functions · run #512 on main · run missed

1
//! The history graph's lane layout is `oxy_core::lanes`, shared with `oxy
2
//! log`; this module only adapts hex hashes from views to it.
3
4
pub use oxy_core::lanes::{width, Row};
5
6
/// `changes` newest first as `(hash hex, deps hex)`.
716×
pub fn layout(changes: &[(String, Vec<String>)]) -> Vec<Row> {
892×
    let parse = |h: &str| oxy_core::hash::ObjectRef::from_hex(h).unwrap_or(oxy_core::hash::ObjectRef([0; 32]));
916×
    let input: Vec<(oxy_core::hash::ChangeHash, Vec<oxy_core::hash::ChangeHash>)> =
1055×
        changes.iter().map(|(h, d)| (parse(h), d.iter().map(|x| parse(x)).collect())).collect();
1116×
    oxy_core::lanes::layout(&input)
1216×
}
13
14
/// A card on the swimlane board (hub spec §6.7): one change, placed by its
15
/// lane (row) and its age (column, oldest on the left).
16
#[derive(Clone, Debug, PartialEq)]
17
pub struct Card {
18
    pub hash: String,
19
    pub short: String,
20
    pub title: String,
21
    pub x: usize,
22
    pub y: usize,
23
    pub head: bool,
24
}
25
26
/// The swimlane board: cards and the dependency edges between them.
27
#[derive(Clone, Debug, PartialEq)]
28
pub struct Board {
29
    pub cards: Vec<Card>,
30
    /// SVG path data, from a change to each change of the board it depends on.
31
    pub edges: Vec<String>,
32
    pub width: usize,
33
    pub height: usize,
34
    pub lanes: usize,
35
}
36
37
pub const CARD_W: usize = 150;
38
pub const CARD_H: usize = 34;
39
const GAP_X: usize = 40;
40
const GAP_Y: usize = 22;
41
42
/// Lay `changes` (newest first, as for [`layout`]) out as swimlanes: each
43
/// lane a row, time running left to right. Independent work sits in
44
/// separate lanes; there are no merge nodes, only dependency edges.
455×
pub fn board(changes: &[crate::repos::view::ChangeView]) -> Board {
4615×
    let input: Vec<(String, Vec<String>)> = changes.iter().map(|c| (c.hash.clone(), c.deps.clone())).collect();
475×
    let rows = layout(&input);
485×
    let n = changes.len();
495×
    let pos: std::collections::HashMap<&str, (usize, usize)> = changes
505×
        .iter()
515×
        .zip(&rows)
525×
        .enumerate()
5315×
        .map(|(i, (c, r))| (c.hash.as_str(), ((n - 1 - i) * (CARD_W + GAP_X) + 10, r.lane * (CARD_H + GAP_Y) + 10)))
545×
        .collect();
555×
    let cards = changes
565×
        .iter()
5715×
        .map(|c| {
5815×
            let (x, y) = pos[c.hash.as_str()];
5915×
            let mut title: String = c.title.chars().take(22).collect();
6015×
            if c.title.chars().count() > 22 {
611×
                title.push('…');
6214×
            }
6315×
            Card { hash: c.hash.clone(), short: c.short.clone(), title, x, y, head: c.head }
6415×
        })
655×
        .collect();
665×
    let mut edges = Vec::new();
6715×
    for c in changes {
6815×
        let (cx, cy) = pos[c.hash.as_str()];
6915×
        for d in &c.deps {
708×
            if let Some(&(dx, dy)) = pos.get(d.as_str()) {
718×
                // From the dependency's right edge to the change's left edge.
728×
                let (x1, y1, x2, y2) = (dx + CARD_W, dy + CARD_H / 2, cx, cy + CARD_H / 2);
738×
                let mid = (x1 + x2) / 2;
748×
                edges.push(format!("M{x1} {y1} C{mid} {y1} {mid} {y2} {x2} {y2}"));
758×
            }
76
        }
77
    }
785×
    let lanes = width(&rows).max(1);
795×
    Board { cards, edges, width: n.max(1) * (CARD_W + GAP_X) + 20, height: lanes * (CARD_H + GAP_Y) + 20, lanes }
805×
}
81
82
#[cfg(test)]
83
mod board_tests {
84
    use super::*;
85
    use crate::repos::view::{AuthorView, ChangeView, SigView};
86
874×
    fn change(hash: char, title: &str, deps: &[char]) -> ChangeView {
889×
        let h = |c: char| std::iter::repeat_n(c, 64).collect::<String>();
89
        ChangeView {
904×
            hash: h(hash),
914×
            short: h(hash)[..8].to_string(),
924×
            title: title.into(),
934×
            body: String::new(),
944×
            author: AuthorView { name: String::new(), email: String::new(), key: String::new(), unknown: true, account: None },
95
            when_ms: 0,
964×
            deps: deps.iter().map(|d| h(*d)).collect(),
97
            head: false,
98
            resolution: false,
994×
            signature: SigView::Unsigned,
1004×
            via: None,
1014×
            labels: vec![],
102
        }
1034×
    }
104
105
    #[test]
1061×
    fn independent_work_gets_its_own_lane() {
107
        // Newest first: c depends on a; b is independent of both.
1081×
        let changes = vec![change('c', "c on a", &['a']), change('b', "b alone", &[]), change('a', "base", &[])];
1091×
        let b = board(&changes);
1101×
        assert_eq!(b.cards.len(), 3);
11117×
        let card = |h: char| b.cards.iter().find(|c| c.hash.starts_with(h)).unwrap().clone();
1121×
        assert!(card('a').x < card('b').x && card('b').x < card('c').x, "oldest on the left");
1131×
        assert_eq!(card('a').y, card('c').y, "a chain stays in one lane");
1141×
        assert_ne!(card('b').y, card('a').y, "independent work in its own lane");
1151×
        assert_eq!(b.edges.len(), 1, "one dependency, no merge nodes");
1161×
        assert!(b.lanes >= 2);
1171×
        let long = board(&[change('d', "a title far longer than the card can hold", &[])]);
1181×
        assert!(long.cards[0].title.ends_with('…') && long.cards[0].title.chars().count() == 23);
1191×
    }
120
}