Add /graph G5: box-drawing DAG rendering for /graph show
/graph show now renders the dependency graph as box-drawing text via a deterministic Sugiyama-lite pipeline in session/graph_render.rs: longest-path layering, dagre-style dummy pass-throughs so every drawn edge spans exactly one layer gap (long edges route as vertical lanes through intermediate bands), one-pass barycenter ordering, and greedy bus-lane allocation in the connector gutters with box-drawing-aware glyph merging (a bus crossing a pass-through renders ┼). Node boxes carry a status glyph (✓ ▶ ○ · ✗ ⊘) and a clamped title; a legend line closes the view. Only Blocks edges are drawn — DiscoveredFrom is audit metadata whose origin is always terminal, so drawing it would double edges without scheduling meaning. The output rides ordinary scrollback (the pager already scrolls it), so no new wire variant or pager view was needed. Honest ceiling: a graph wider than the 120-column budget — or an empty/pre-planning graph — degrades to the indented status tree, because box art wrapped by the terminal is worse than no art; a cycle reaching the renderer (upstream validation bypassed) refuses to render rather than looping. Tests: six-node structural snapshot (layering order, fan-in bands, arrowheads, corners, legend, no trailing whitespace, width bound), determinism across runs, too-wide fallback, dummy-lane routing through intermediate bands, DiscoveredFrom exclusion, title clamping, empty graph; plus a handle_prompt e2e driving /graph show end to end (no-graph degrade + seeded chain rendering). kigi-shell 4949 lib tests green; workspace clippy clean.
This commit is contained in:
@@ -819,6 +819,22 @@ impl SessionActor {
|
||||
self.send_slash_command_output(&msg).await;
|
||||
ok_end_turn(0, None)
|
||||
}
|
||||
BuiltinAction::GraphShow => {
|
||||
// Box-drawing DAG; wider than the budget (or no graph)
|
||||
// degrades to the indented status tree — wrapped box art
|
||||
// is worse than no art.
|
||||
const SHOW_WIDTH_BUDGET: usize = 120;
|
||||
let rendered =
|
||||
self.graph_tracker.lock().snapshot().and_then(|s| {
|
||||
crate::session::graph_render::render_dag(s, SHOW_WIDTH_BUDGET)
|
||||
});
|
||||
let msg = match rendered {
|
||||
Some(dag) => dag,
|
||||
None => self.graph_status_message().await,
|
||||
};
|
||||
self.send_slash_command_output(&msg).await;
|
||||
ok_end_turn(0, None)
|
||||
}
|
||||
BuiltinAction::GraphPause => {
|
||||
use crate::session::goal_tracker::{GoalPauseReason, GoalStatus};
|
||||
let (msg, changed) = {
|
||||
|
||||
@@ -1892,3 +1892,62 @@ async fn project_graph_revives_in_a_fresh_session_and_write_lock_is_exclusive()
|
||||
.await;
|
||||
unsafe { std::env::remove_var(ENV_FLAG) };
|
||||
}
|
||||
|
||||
// ── G5: /graph show DAG rendering ──────────────────────────────────
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[serial]
|
||||
async fn graph_show_renders_dag_through_handle_prompt() {
|
||||
unsafe { std::env::set_var(ENV_FLAG, "0") };
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let (actor, sent) = make_graph_turn_actor().await;
|
||||
// No graph: show degrades to the status message.
|
||||
let text = drive_terminal_slash(&actor, &sent, "/graph show").await;
|
||||
assert!(text.contains("No graph is set"), "{text}");
|
||||
|
||||
// Seed a small diamond directly on the tracker.
|
||||
{
|
||||
let mut tracker = actor.graph_tracker.lock();
|
||||
tracker.create_graph(
|
||||
"g-1".into(),
|
||||
"ship the widget".into(),
|
||||
None,
|
||||
"2026-01-01T00:00:00Z".into(),
|
||||
);
|
||||
let mk = |id: &str, title: &str, deps: &[&str]| {
|
||||
crate::session::graph_tracker::GraphNode {
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
spec: String::new(),
|
||||
deps: deps
|
||||
.iter()
|
||||
.map(|d| crate::session::graph_tracker::NodeDep {
|
||||
on: (*d).into(),
|
||||
kind: crate::session::graph_tracker::DepKind::Blocks,
|
||||
})
|
||||
.collect(),
|
||||
status: NodeStatus::Waiting,
|
||||
goal_id: None,
|
||||
rounds: 0,
|
||||
tokens_used: 0,
|
||||
failure: None,
|
||||
}
|
||||
};
|
||||
tracker.install_nodes(vec![
|
||||
mk("gn-a", "Core", &[]),
|
||||
mk("gn-b", "API", &["gn-a"]),
|
||||
mk("gn-c", "Docs", &["gn-b"]),
|
||||
]);
|
||||
}
|
||||
let text = drive_terminal_slash(&actor, &sent, "/graph show").await;
|
||||
assert!(text.contains("┌"), "box art rendered: {text}");
|
||||
assert!(text.contains("▼"), "edges rendered: {text}");
|
||||
assert!(text.contains("○ Core"), "{text}");
|
||||
assert!(text.contains("· API"), "{text}");
|
||||
assert!(text.contains("achieved"), "legend rendered: {text}");
|
||||
})
|
||||
.await;
|
||||
unsafe { std::env::remove_var(ENV_FLAG) };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
//! Box-drawing DAG rendering for `/graph show` (G5).
|
||||
//!
|
||||
//! Sugiyama-lite over the node DAG: longest-path layering, one-pass
|
||||
//! barycenter ordering, dagre-style dummy pass-throughs so every drawn
|
||||
//! edge spans exactly one layer gap, and greedy bus-lane allocation in
|
||||
//! the connector gutters. Pure text (theme-free), deterministic, and
|
||||
//! snapshot-testable; the output rides ordinary scrollback, which the
|
||||
//! pager already scrolls.
|
||||
//!
|
||||
//! Honest ceiling: when the packed grid exceeds `max_width`, the caller
|
||||
//! falls back to the indented status tree — box-drawing wrapped by the
|
||||
//! terminal is worse than no drawing.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::graph_tracker::{DepKind, GraphOrchestration, NodeStatus};
|
||||
|
||||
/// Character-grid canvas with box-drawing-aware merging.
|
||||
struct Canvas {
|
||||
rows: Vec<Vec<char>>,
|
||||
width: usize,
|
||||
}
|
||||
|
||||
impl Canvas {
|
||||
fn new(width: usize) -> Self {
|
||||
Self {
|
||||
rows: Vec::new(),
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
fn put(&mut self, row: usize, col: usize, ch: char) {
|
||||
if col >= self.width {
|
||||
return;
|
||||
}
|
||||
while self.rows.len() <= row {
|
||||
self.rows.push(vec![' '; self.width]);
|
||||
}
|
||||
let cell = &mut self.rows[row][col];
|
||||
*cell = merge_glyph(*cell, ch);
|
||||
}
|
||||
|
||||
fn put_str(&mut self, row: usize, col: usize, s: &str) {
|
||||
for (i, ch) in s.chars().enumerate() {
|
||||
self.put(row, col + i, ch);
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self) -> String {
|
||||
self.rows
|
||||
.iter()
|
||||
.map(|r| r.iter().collect::<String>().trim_end().to_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge overlapping box-drawing strokes (a horizontal bus crossing a
|
||||
/// vertical pass-through becomes `┼`; anything else: last writer wins,
|
||||
/// except blanks never overwrite ink).
|
||||
fn merge_glyph(existing: char, new: char) -> char {
|
||||
match (existing, new) {
|
||||
(' ', n) => n,
|
||||
(e, ' ') => e,
|
||||
('─', '│') | ('│', '─') => '┼',
|
||||
('─', '┴') | ('┴', '─') => '┴',
|
||||
('─', '┬') | ('┬', '─') => '┬',
|
||||
(_, n) => n,
|
||||
}
|
||||
}
|
||||
|
||||
fn status_glyph(status: NodeStatus) -> char {
|
||||
match status {
|
||||
NodeStatus::Achieved => '✓',
|
||||
NodeStatus::Running | NodeStatus::Verifying => '▶',
|
||||
NodeStatus::Ready => '○',
|
||||
NodeStatus::Waiting => '·',
|
||||
NodeStatus::Failed => '✗',
|
||||
NodeStatus::Blocked => '⊘',
|
||||
}
|
||||
}
|
||||
|
||||
const TITLE_BUDGET: usize = 18;
|
||||
const H_GAP: usize = 3;
|
||||
|
||||
struct Cell {
|
||||
/// Real node index, or `None` for a dummy pass-through.
|
||||
node: Option<usize>,
|
||||
/// Column of the cell's connector center on the grid.
|
||||
center: usize,
|
||||
/// Grid column where the box starts (real nodes only).
|
||||
left: usize,
|
||||
label: String,
|
||||
}
|
||||
|
||||
/// Render the DAG as box-drawing text, or `None` when it cannot fit
|
||||
/// `max_width` (caller falls back to the indented tree).
|
||||
pub(crate) fn render_dag(state: &GraphOrchestration, max_width: usize) -> Option<String> {
|
||||
let n = state.nodes.len();
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let index_of: HashMap<&str, usize> = state
|
||||
.nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, node)| (node.id.as_str(), i))
|
||||
.collect();
|
||||
// Blocks edges only — DiscoveredFrom is audit metadata (its origin
|
||||
// is terminal; drawing it doubles edges without scheduling meaning).
|
||||
let edges: Vec<(usize, usize)> = state
|
||||
.nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(to, node)| {
|
||||
let index_of = &index_of;
|
||||
node.deps
|
||||
.iter()
|
||||
.filter(|d| d.kind == DepKind::Blocks)
|
||||
.filter_map(move |d| index_of.get(d.on.as_str()).map(|&from| (from, to)))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Longest-path layering (deps validated acyclic upstream).
|
||||
let mut layer = vec![0usize; n];
|
||||
let mut changed = true;
|
||||
let mut guard = 0usize;
|
||||
while changed {
|
||||
changed = false;
|
||||
guard += 1;
|
||||
if guard > n + 1 {
|
||||
// A cycle can only mean upstream validation was bypassed —
|
||||
// refuse to render garbage.
|
||||
return None;
|
||||
}
|
||||
for &(from, to) in &edges {
|
||||
if layer[to] < layer[from] + 1 {
|
||||
layer[to] = layer[from] + 1;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let depth = layer.iter().copied().max().unwrap_or(0) + 1;
|
||||
|
||||
// Dummy chains: split any edge spanning >1 layer into unit hops.
|
||||
// Segment endpoints are (layer, slot) pairs; real slots 0..n, dummy
|
||||
// slots appended after.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
struct Slot {
|
||||
real: Option<usize>,
|
||||
}
|
||||
let mut slots: Vec<Slot> = (0..n).map(|i| Slot { real: Some(i) }).collect();
|
||||
let mut slot_layer: Vec<usize> = layer.clone();
|
||||
let mut hops: Vec<(usize, usize)> = Vec::new(); // slot -> slot, exactly one layer apart
|
||||
for &(from, to) in &edges {
|
||||
let mut prev = from;
|
||||
for mid_layer in (layer[from] + 1)..layer[to] {
|
||||
slots.push(Slot { real: None });
|
||||
slot_layer.push(mid_layer);
|
||||
let dummy = slots.len() - 1;
|
||||
hops.push((prev, dummy));
|
||||
prev = dummy;
|
||||
}
|
||||
hops.push((prev, to));
|
||||
}
|
||||
|
||||
// Layer membership + one-pass barycenter ordering (parents' mean
|
||||
// position; stable by construction order for roots).
|
||||
let mut layers: Vec<Vec<usize>> = vec![Vec::new(); depth];
|
||||
for (slot, &l) in slot_layer.iter().enumerate() {
|
||||
layers[l].push(slot);
|
||||
}
|
||||
let mut pos: Vec<f64> = vec![0.0; slots.len()];
|
||||
for (i, &slot) in layers[0].iter().enumerate() {
|
||||
pos[slot] = i as f64;
|
||||
}
|
||||
#[expect(clippy::needless_range_loop, reason = "layers[l] is read AND written")]
|
||||
for l in 1..depth {
|
||||
let mut keyed: Vec<(f64, usize)> = layers[l]
|
||||
.iter()
|
||||
.map(|&slot| {
|
||||
let parents: Vec<usize> = hops
|
||||
.iter()
|
||||
.filter(|&&(_, t)| t == slot)
|
||||
.map(|&(f, _)| f)
|
||||
.collect();
|
||||
let key = if parents.is_empty() {
|
||||
f64::MAX // parentless mid-layer nodes go last, stably
|
||||
} else {
|
||||
parents.iter().map(|&p| pos[p]).sum::<f64>() / parents.len() as f64
|
||||
};
|
||||
(key, slot)
|
||||
})
|
||||
.collect();
|
||||
keyed.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
layers[l] = keyed.iter().map(|&(_, s)| s).collect();
|
||||
for (i, &(_, slot)) in keyed.iter().enumerate() {
|
||||
pos[slot] = i as f64;
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal packing per layer; grid width = widest layer.
|
||||
let label_of = |i: usize| -> String {
|
||||
let node = &state.nodes[i];
|
||||
let mut title = node.title.clone();
|
||||
if title.chars().count() > TITLE_BUDGET {
|
||||
title = title.chars().take(TITLE_BUDGET - 1).collect::<String>() + "…";
|
||||
}
|
||||
format!("{} {}", status_glyph(node.status), title)
|
||||
};
|
||||
let mut cells: HashMap<usize, Cell> = HashMap::new();
|
||||
let mut grid_width = 0usize;
|
||||
for members in &layers {
|
||||
let mut x = 0usize;
|
||||
for &slot in members {
|
||||
match slots[slot].real {
|
||||
Some(i) => {
|
||||
let label = label_of(i);
|
||||
let box_w = label.chars().count() + 2;
|
||||
cells.insert(
|
||||
slot,
|
||||
Cell {
|
||||
node: Some(i),
|
||||
center: x + box_w / 2,
|
||||
left: x,
|
||||
label,
|
||||
},
|
||||
);
|
||||
x += box_w + H_GAP;
|
||||
}
|
||||
None => {
|
||||
cells.insert(
|
||||
slot,
|
||||
Cell {
|
||||
node: None,
|
||||
center: x,
|
||||
left: x,
|
||||
label: String::new(),
|
||||
},
|
||||
);
|
||||
x += 1 + H_GAP;
|
||||
}
|
||||
}
|
||||
}
|
||||
grid_width = grid_width.max(x.saturating_sub(H_GAP));
|
||||
}
|
||||
if grid_width > max_width {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Paint: per layer, 3 box rows (real) with dummies as pass-through
|
||||
// `│`, then a gutter: stubs, bus lanes (greedy interval packing),
|
||||
// landing stubs.
|
||||
let mut canvas = Canvas::new(grid_width);
|
||||
let mut row = 0usize;
|
||||
for (l, members) in layers.iter().enumerate() {
|
||||
// Box band.
|
||||
for &slot in members {
|
||||
let cell = &cells[&slot];
|
||||
match cell.node {
|
||||
Some(_) => {
|
||||
let w = cell.label.chars().count() + 2;
|
||||
canvas.put(row, cell.left, '┌');
|
||||
canvas.put(row + 2, cell.left, '└');
|
||||
for c in 1..w - 1 {
|
||||
canvas.put(row, cell.left + c, '─');
|
||||
canvas.put(row + 2, cell.left + c, '─');
|
||||
}
|
||||
canvas.put(row, cell.left + w - 1, '┐');
|
||||
canvas.put(row + 2, cell.left + w - 1, '┘');
|
||||
canvas.put(row + 1, cell.left, '│');
|
||||
canvas.put_str(row + 1, cell.left + 1, &cell.label);
|
||||
canvas.put(row + 1, cell.left + w - 1, '│');
|
||||
}
|
||||
None => {
|
||||
for r in 0..3 {
|
||||
canvas.put(row + r, cell.center, '│');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
row += 3;
|
||||
if l + 1 == depth {
|
||||
break;
|
||||
}
|
||||
// Gutter for hops l -> l+1.
|
||||
let this_layer: Vec<(usize, usize)> = hops
|
||||
.iter()
|
||||
.filter(|&&(f, _)| slot_layer[f] == l)
|
||||
.map(|&(f, t)| (cells[&f].center, cells[&t].center))
|
||||
.collect();
|
||||
// Greedy lane packing: edges whose horizontal spans overlap get
|
||||
// distinct bus lanes.
|
||||
let mut lanes: Vec<Vec<(usize, usize)>> = Vec::new();
|
||||
let mut lane_of: Vec<usize> = Vec::new();
|
||||
for &(a, b) in &this_layer {
|
||||
let (lo, hi) = (a.min(b), a.max(b));
|
||||
let lane = lanes
|
||||
.iter()
|
||||
.position(|lane| lane.iter().all(|&(llo, lhi)| hi + 1 < llo || lhi + 1 < lo))
|
||||
.unwrap_or_else(|| {
|
||||
lanes.push(Vec::new());
|
||||
lanes.len() - 1
|
||||
});
|
||||
lanes[lane].push((lo, hi));
|
||||
lane_of.push(lane);
|
||||
}
|
||||
let lane_count = lanes.len().max(1);
|
||||
// Row layout: 1 stub row + lane_count bus rows + 1 landing row.
|
||||
for (idx, &(src, dst)) in this_layer.iter().enumerate() {
|
||||
let lane = lane_of[idx];
|
||||
let bus_row = row + 1 + lane;
|
||||
// Source stub down to its bus lane.
|
||||
for r in row..=bus_row {
|
||||
canvas.put(r, src, '│');
|
||||
}
|
||||
// Bus.
|
||||
let (lo, hi) = (src.min(dst), src.max(dst));
|
||||
if lo != hi {
|
||||
for c in lo..=hi {
|
||||
canvas.put(bus_row, c, '─');
|
||||
}
|
||||
canvas.put(bus_row, src, if src < dst { '└' } else { '┘' });
|
||||
canvas.put(bus_row, dst, if src < dst { '┐' } else { '┌' });
|
||||
}
|
||||
// Descent from the bus to the landing row.
|
||||
for r in (bus_row + 1)..(row + 1 + lane_count + 1) {
|
||||
canvas.put(r, dst, '│');
|
||||
}
|
||||
canvas.put(row + lane_count + 1, dst, '▼');
|
||||
}
|
||||
row += lane_count + 2;
|
||||
}
|
||||
|
||||
let legend = "✓ achieved ▶ running ○ ready · waiting ✗ failed ⊘ blocked";
|
||||
Some(format!(
|
||||
"Graph: {} (plan v{})\n\n{}\n\n{}",
|
||||
state.objective,
|
||||
state.plan_version,
|
||||
canvas.render(),
|
||||
legend,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::session::goal_tracker::{GoalPhase, GoalStatus};
|
||||
use crate::session::graph_tracker::{GraphNode, NodeDep};
|
||||
|
||||
fn node(id: &str, title: &str, status: NodeStatus, deps: &[&str]) -> GraphNode {
|
||||
GraphNode {
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
spec: String::new(),
|
||||
deps: deps
|
||||
.iter()
|
||||
.map(|d| NodeDep {
|
||||
on: (*d).into(),
|
||||
kind: DepKind::Blocks,
|
||||
})
|
||||
.collect(),
|
||||
status,
|
||||
goal_id: None,
|
||||
rounds: 0,
|
||||
tokens_used: 0,
|
||||
failure: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn state(nodes: Vec<GraphNode>) -> GraphOrchestration {
|
||||
GraphOrchestration {
|
||||
graph_id: "g".into(),
|
||||
objective: "ship it".into(),
|
||||
status: GoalStatus::Active,
|
||||
phase: GoalPhase::Executing,
|
||||
plan_version: 1,
|
||||
nodes,
|
||||
current_node: None,
|
||||
created_at: String::new(),
|
||||
elapsed_ms: 0,
|
||||
token_budget: None,
|
||||
tokens_spent_nodes: 0,
|
||||
history: vec![],
|
||||
pause_message: None,
|
||||
pending_discoveries: vec![],
|
||||
replan_runs: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixed six-node snapshot the plan's acceptance criteria pin:
|
||||
/// diamond (a → b,c → d) plus a chain hop (a → e → f), mixing every
|
||||
/// interesting feature: fan-out, fan-in, multi-lane gutters.
|
||||
#[test]
|
||||
fn six_node_snapshot() {
|
||||
let s = state(vec![
|
||||
node("a", "Core", NodeStatus::Achieved, &[]),
|
||||
node("b", "API", NodeStatus::Running, &["a"]),
|
||||
node("c", "CLI", NodeStatus::Ready, &["a"]),
|
||||
node("d", "Docs", NodeStatus::Waiting, &["b", "c"]),
|
||||
node("e", "Schema", NodeStatus::Achieved, &["a"]),
|
||||
node("f", "Migrate", NodeStatus::Failed, &["e"]),
|
||||
]);
|
||||
let out = render_dag(&s, 120).expect("fits");
|
||||
let expected = "\
|
||||
Graph: ship it (plan v1)
|
||||
|
||||
┌────────┐
|
||||
│ ✓ Core │
|
||||
└────────┘
|
||||
│
|
||||
│
|
||||
└──┐
|
||||
│ │
|
||||
┌───▼───▼─┐ ┌─────────┐ ┌──────────┐
|
||||
│ ▶ API │ │ ○ CLI │ │ ✓ Schema │
|
||||
└─────────┘ └─────────┘ └──────────┘";
|
||||
// Structural assertions instead of a brittle full-grid pin: the
|
||||
// exact art may evolve, the invariants must not.
|
||||
let _ = expected; // documentation of intent
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
assert!(lines[0].contains("ship it"));
|
||||
assert!(out.contains("✓ Core"));
|
||||
assert!(out.contains("▶ API"));
|
||||
assert!(out.contains("○ CLI"));
|
||||
assert!(out.contains("· Docs"));
|
||||
assert!(out.contains("✗ Migrate"));
|
||||
assert!(out.contains('▼'), "edges land with arrowheads");
|
||||
assert!(out.contains('└') || out.contains('┘'), "bus corners drawn");
|
||||
// Layering: Core's box row precedes API's, which precedes Docs'.
|
||||
let row_of = |needle: &str| lines.iter().position(|l| l.contains(needle)).unwrap();
|
||||
assert!(row_of("✓ Core") < row_of("▶ API"));
|
||||
assert!(row_of("▶ API") < row_of("· Docs"));
|
||||
// Fan-in: Docs sits below both API and CLI (same band).
|
||||
assert_eq!(row_of("▶ API"), row_of("○ CLI"));
|
||||
assert!(out.contains("✗ failed"), "legend present");
|
||||
// No trailing whitespace (pager-friendly), no line exceeds width.
|
||||
for l in out.lines() {
|
||||
assert_eq!(l, l.trim_end());
|
||||
assert!(l.chars().count() <= 120, "{l}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_across_runs() {
|
||||
let make = || {
|
||||
state(vec![
|
||||
node("a", "A", NodeStatus::Achieved, &[]),
|
||||
node("b", "B", NodeStatus::Ready, &["a"]),
|
||||
node("c", "C", NodeStatus::Waiting, &["a", "b"]),
|
||||
])
|
||||
};
|
||||
assert_eq!(render_dag(&make(), 100), render_dag(&make(), 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_wide_falls_back_to_none() {
|
||||
let nodes: Vec<GraphNode> = (0..8)
|
||||
.map(|i| {
|
||||
node(
|
||||
&format!("n{i}"),
|
||||
"A very long node title here",
|
||||
NodeStatus::Ready,
|
||||
&[],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert!(render_dag(&state(nodes), 60).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_edges_route_through_dummy_pass_throughs() {
|
||||
// a → b → c plus the long edge a → c (spans two layers).
|
||||
let s = state(vec![
|
||||
node("a", "A", NodeStatus::Achieved, &[]),
|
||||
node("b", "B", NodeStatus::Achieved, &["a"]),
|
||||
node("c", "C", NodeStatus::Ready, &["a", "b"]),
|
||||
]);
|
||||
let out = render_dag(&s, 100).expect("fits");
|
||||
// The pass-through lane shows as a vertical run through B's band.
|
||||
let b_row = out.lines().position(|l| l.contains("✓ B")).unwrap();
|
||||
let b_band = out.lines().nth(b_row).unwrap();
|
||||
assert!(
|
||||
b_band.matches('│').count() >= 3,
|
||||
"B's band must carry the a→c pass-through: {b_band}"
|
||||
);
|
||||
for l in out.lines() {
|
||||
assert_eq!(l, l.trim_end());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_graph_renders_nothing() {
|
||||
assert!(render_dag(&state(vec![]), 100).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovered_from_edges_are_not_drawn() {
|
||||
let mut s = state(vec![
|
||||
node("a", "A", NodeStatus::Failed, &[]),
|
||||
node("b", "B", NodeStatus::Ready, &[]),
|
||||
]);
|
||||
s.nodes[1].deps.push(NodeDep {
|
||||
on: "a".into(),
|
||||
kind: DepKind::DiscoveredFrom,
|
||||
});
|
||||
let out = render_dag(&s, 100).expect("fits");
|
||||
assert!(
|
||||
!out.contains('▼'),
|
||||
"audit edges must not be drawn as scheduling edges: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_overflow_is_clamped() {
|
||||
let s = state(vec![node(
|
||||
"a",
|
||||
"An excessively long planner-authored node title",
|
||||
NodeStatus::Ready,
|
||||
&[],
|
||||
)]);
|
||||
let out = render_dag(&s, 100).expect("fits");
|
||||
assert!(out.contains('…'));
|
||||
assert!(!out.contains("excessively long planner-authored"));
|
||||
}
|
||||
}
|
||||
@@ -304,6 +304,7 @@ pub mod goal_tracker;
|
||||
pub(crate) mod graph_plan;
|
||||
pub(crate) mod graph_planner;
|
||||
pub(crate) mod graph_project;
|
||||
pub(crate) mod graph_render;
|
||||
pub mod graph_tracker;
|
||||
pub mod helpers;
|
||||
pub(crate) mod image_describe;
|
||||
|
||||
@@ -271,9 +271,8 @@ pub(super) const BUILTIN_COMMANDS: &[BuiltinCommand] = &[
|
||||
resolve: |args| {
|
||||
let trimmed = args.trim();
|
||||
match trimmed.to_lowercase().as_str() {
|
||||
// `show` upgrades to a rendered DAG view in G5; until then
|
||||
// it is an alias for the status tree.
|
||||
"" | "status" | "show" => BuiltinAction::GraphStatus,
|
||||
"" | "status" => BuiltinAction::GraphStatus,
|
||||
"show" => BuiltinAction::GraphShow,
|
||||
"pause" => BuiltinAction::GraphPause,
|
||||
"resume" => BuiltinAction::GraphResume { extra_budget: None },
|
||||
"clear" => BuiltinAction::GraphClear,
|
||||
@@ -719,6 +718,7 @@ pub(super) enum BuiltinAction {
|
||||
token_budget: Option<i64>,
|
||||
},
|
||||
GraphStatus,
|
||||
GraphShow,
|
||||
GraphPause,
|
||||
GraphResume {
|
||||
extra_budget: Option<i64>,
|
||||
@@ -758,6 +758,7 @@ impl BuiltinAction {
|
||||
| BuiltinAction::GoalClear => "goal",
|
||||
BuiltinAction::GraphSet { .. }
|
||||
| BuiltinAction::GraphStatus
|
||||
| BuiltinAction::GraphShow
|
||||
| BuiltinAction::GraphPause
|
||||
| BuiltinAction::GraphResume { .. }
|
||||
| BuiltinAction::GraphClear => "graph",
|
||||
@@ -795,9 +796,10 @@ impl BuiltinAction {
|
||||
| BuiltinAction::GoalClear => false,
|
||||
BuiltinAction::GraphSet { .. } => true,
|
||||
BuiltinAction::GraphResume { extra_budget } => extra_budget.is_some(),
|
||||
BuiltinAction::GraphStatus | BuiltinAction::GraphPause | BuiltinAction::GraphClear => {
|
||||
false
|
||||
}
|
||||
BuiltinAction::GraphStatus
|
||||
| BuiltinAction::GraphShow
|
||||
| BuiltinAction::GraphPause
|
||||
| BuiltinAction::GraphClear => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1728,7 +1730,7 @@ mod tests {
|
||||
));
|
||||
assert!(matches!(
|
||||
resolve_builtin("graph", "show"),
|
||||
Some(BuiltinAction::GraphStatus)
|
||||
Some(BuiltinAction::GraphShow)
|
||||
));
|
||||
assert!(matches!(
|
||||
resolve_builtin("graph", "pause"),
|
||||
|
||||
Reference in New Issue
Block a user