use crate::mermaid::{IrEdge, IrEndpoint, IrNode, MermaidDiagramIr};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffStatus {
Added,
Removed,
Changed,
Unchanged,
}
#[derive(Debug, Clone)]
pub struct DiffNode {
pub id: String,
pub status: DiffStatus,
pub node_idx: usize,
pub old_node_idx: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct DiffEdge {
pub from_id: String,
pub to_id: String,
pub status: DiffStatus,
pub edge_idx: usize,
pub old_edge_idx: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct DiagramDiff {
pub nodes: Vec<DiffNode>,
pub edges: Vec<DiffEdge>,
pub new_ir: MermaidDiagramIr,
pub old_ir: MermaidDiagramIr,
pub added_nodes: usize,
pub removed_nodes: usize,
pub changed_nodes: usize,
pub added_edges: usize,
pub removed_edges: usize,
pub changed_edges: usize,
}
impl DiagramDiff {
#[must_use]
pub fn is_empty(&self) -> bool {
self.added_nodes == 0
&& self.removed_nodes == 0
&& self.changed_nodes == 0
&& self.added_edges == 0
&& self.removed_edges == 0
&& self.changed_edges == 0
}
}
fn endpoint_node_id(ep: &IrEndpoint, ir: &MermaidDiagramIr) -> String {
match ep {
IrEndpoint::Node(nid) => ir
.nodes
.get(nid.0)
.map_or_else(|| format!("?{}", nid.0), |n| n.id.clone()),
IrEndpoint::Port(pid) => ir
.ports
.get(pid.0)
.and_then(|p| ir.nodes.get(p.node.0))
.map_or_else(|| format!("?port{}", pid.0), |n| n.id.clone()),
}
}
#[must_use]
pub fn diff_diagrams(old: &MermaidDiagramIr, new: &MermaidDiagramIr) -> DiagramDiff {
use std::collections::{HashMap, HashSet};
let old_node_map: HashMap<&str, usize> = old
.nodes
.iter()
.enumerate()
.map(|(i, n)| (n.id.as_str(), i))
.collect();
let new_node_map: HashMap<&str, usize> = new
.nodes
.iter()
.enumerate()
.map(|(i, n)| (n.id.as_str(), i))
.collect();
let mut diff_nodes = Vec::new();
let mut added_nodes = 0usize;
let mut removed_nodes = 0usize;
let mut changed_nodes = 0usize;
for (new_idx, new_node) in new.nodes.iter().enumerate() {
if let Some(&old_idx) = old_node_map.get(new_node.id.as_str()) {
let old_node = &old.nodes[old_idx];
let changed = node_attrs_differ(old_node, old, new_node, new);
let status = if changed {
changed_nodes += 1;
DiffStatus::Changed
} else {
DiffStatus::Unchanged
};
diff_nodes.push(DiffNode {
id: new_node.id.clone(),
status,
node_idx: new_idx,
old_node_idx: Some(old_idx),
});
} else {
added_nodes += 1;
diff_nodes.push(DiffNode {
id: new_node.id.clone(),
status: DiffStatus::Added,
node_idx: new_idx,
old_node_idx: None,
});
}
}
for (old_idx, old_node) in old.nodes.iter().enumerate() {
if !new_node_map.contains_key(old_node.id.as_str()) {
removed_nodes += 1;
diff_nodes.push(DiffNode {
id: old_node.id.clone(),
status: DiffStatus::Removed,
node_idx: old_idx,
old_node_idx: Some(old_idx),
});
}
}
type EdgeKey = (String, String);
let old_edge_map: HashMap<EdgeKey, Vec<usize>> = {
let mut m: HashMap<EdgeKey, Vec<usize>> = HashMap::new();
for (i, edge) in old.edges.iter().enumerate() {
let key = (
endpoint_node_id(&edge.from, old),
endpoint_node_id(&edge.to, old),
);
m.entry(key).or_default().push(i);
}
m
};
let mut diff_edges = Vec::new();
let mut added_edges = 0usize;
let mut removed_edges = 0usize;
let mut changed_edges = 0usize;
let mut matched_old_edges: HashSet<usize> = HashSet::new();
for (new_idx, new_edge) in new.edges.iter().enumerate() {
let key = (
endpoint_node_id(&new_edge.from, new),
endpoint_node_id(&new_edge.to, new),
);
let old_match = old_edge_map.get(&key).and_then(|indices| {
indices
.iter()
.copied()
.find(|i| !matched_old_edges.contains(i))
});
if let Some(old_idx) = old_match {
matched_old_edges.insert(old_idx);
let old_edge = &old.edges[old_idx];
let changed = edge_attrs_differ(old_edge, old, new_edge, new);
let status = if changed {
changed_edges += 1;
DiffStatus::Changed
} else {
DiffStatus::Unchanged
};
diff_edges.push(DiffEdge {
from_id: key.0,
to_id: key.1,
status,
edge_idx: new_idx,
old_edge_idx: Some(old_idx),
});
} else {
added_edges += 1;
diff_edges.push(DiffEdge {
from_id: key.0,
to_id: key.1,
status: DiffStatus::Added,
edge_idx: new_idx,
old_edge_idx: None,
});
}
}
for (old_idx, old_edge) in old.edges.iter().enumerate() {
if !matched_old_edges.contains(&old_idx) {
removed_edges += 1;
diff_edges.push(DiffEdge {
from_id: endpoint_node_id(&old_edge.from, old),
to_id: endpoint_node_id(&old_edge.to, old),
status: DiffStatus::Removed,
edge_idx: old_idx,
old_edge_idx: Some(old_idx),
});
}
}
DiagramDiff {
nodes: diff_nodes,
edges: diff_edges,
new_ir: new.clone(),
old_ir: old.clone(),
added_nodes,
removed_nodes,
changed_nodes,
added_edges,
removed_edges,
changed_edges,
}
}
fn node_attrs_differ(
old: &IrNode,
old_ir: &MermaidDiagramIr,
new: &IrNode,
new_ir: &MermaidDiagramIr,
) -> bool {
if old.shape != new.shape {
return true;
}
let old_label = old
.label
.and_then(|lid| old_ir.labels.get(lid.0))
.map(|l| l.text.as_str());
let new_label = new
.label
.and_then(|lid| new_ir.labels.get(lid.0))
.map(|l| l.text.as_str());
if old_label != new_label {
return true;
}
if old.classes != new.classes {
return true;
}
if old.members != new.members {
return true;
}
false
}
fn edge_attrs_differ(
old: &IrEdge,
old_ir: &MermaidDiagramIr,
new: &IrEdge,
new_ir: &MermaidDiagramIr,
) -> bool {
if old.arrow != new.arrow {
return true;
}
let old_label = old
.label
.and_then(|lid| old_ir.labels.get(lid.0))
.map(|l| l.text.as_str());
let new_label = new
.label
.and_then(|lid| new_ir.labels.get(lid.0))
.map(|l| l.text.as_str());
old_label != new_label
}
use crate::mermaid::MermaidConfig;
use crate::mermaid_layout::DiagramLayout;
use crate::mermaid_render::{Viewport, render_diagram};
use ftui_core::geometry::Rect;
use ftui_core::text_width::display_width;
use ftui_render::buffer::Buffer;
use ftui_render::cell::{Cell, PackedRgba};
pub struct DiffColors;
impl DiffColors {
pub const ADDED: PackedRgba = PackedRgba::rgb(46, 204, 113);
pub const REMOVED: PackedRgba = PackedRgba::rgb(231, 76, 60);
pub const CHANGED: PackedRgba = PackedRgba::rgb(241, 196, 15);
pub const UNCHANGED: PackedRgba = PackedRgba::rgb(100, 100, 100);
}
pub fn render_diff(
diff: &DiagramDiff,
new_layout: &DiagramLayout,
config: &MermaidConfig,
area: Rect,
buf: &mut Buffer,
) {
if area.is_empty() {
return;
}
let has_removed = diff.removed_nodes > 0 || diff.removed_edges > 0;
let legend_rows = if has_removed {
2u16.min(area.height.saturating_sub(4))
} else {
0
};
let diagram_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(legend_rows),
};
render_diagram(new_layout, &diff.new_ir, config, diagram_area, buf);
let vp = Viewport::fit(&new_layout.bounding_box, diagram_area);
let node_by_idx: std::collections::HashMap<usize, usize> = new_layout
.nodes
.iter()
.enumerate()
.map(|(i, n)| (n.node_idx, i))
.collect();
let edge_by_idx: std::collections::HashMap<usize, usize> = new_layout
.edges
.iter()
.enumerate()
.map(|(i, e)| (e.edge_idx, i))
.collect();
for dn in &diff.nodes {
let (color, marker) = match dn.status {
DiffStatus::Added => (DiffColors::ADDED, Some('+')),
DiffStatus::Changed => (DiffColors::CHANGED, Some('~')),
DiffStatus::Unchanged => (DiffColors::UNCHANGED, None),
DiffStatus::Removed => continue, };
if let Some(&layout_idx) = node_by_idx.get(&dn.node_idx) {
let node_box = &new_layout.nodes[layout_idx];
let cell_rect = vp.to_cell_rect(&node_box.rect);
recolor_rect_border(cell_rect, color, buf);
if dn.status == DiffStatus::Unchanged {
dim_rect_interior(cell_rect, color, buf);
}
if let Some(m) = marker {
let mx = cell_rect.x + cell_rect.width.saturating_sub(1);
let my = cell_rect.y;
buf.set_fast(mx, my, Cell::from_char(m).with_fg(color));
}
}
}
for de in &diff.edges {
let color = match de.status {
DiffStatus::Added => DiffColors::ADDED,
DiffStatus::Changed => DiffColors::CHANGED,
DiffStatus::Unchanged => DiffColors::UNCHANGED,
DiffStatus::Removed => continue, };
if let Some(&layout_idx) = edge_by_idx.get(&de.edge_idx) {
for wp in &new_layout.edges[layout_idx].waypoints {
let (cx, cy) = vp.to_cell(wp.x, wp.y);
if let Some(c) = buf.get(cx, cy) {
buf.set_fast(cx, cy, c.with_fg(color));
}
}
}
}
if has_removed && legend_rows > 0 {
render_removed_legend(diff, area, legend_rows, buf);
}
}
fn recolor_rect_border(rect: Rect, color: PackedRgba, buf: &mut Buffer) {
if rect.width == 0 || rect.height == 0 {
return;
}
let x0 = rect.x;
let y0 = rect.y;
let x1 = x0 + rect.width.saturating_sub(1);
let y1 = y0 + rect.height.saturating_sub(1);
for col in x0..=x1 {
if let Some(c) = buf.get(col, y0) {
buf.set_fast(col, y0, c.with_fg(color));
}
if let Some(c) = buf.get(col, y1) {
buf.set_fast(col, y1, c.with_fg(color));
}
}
for row in y0..=y1 {
if let Some(c) = buf.get(x0, row) {
buf.set_fast(x0, row, c.with_fg(color));
}
if let Some(c) = buf.get(x1, row) {
buf.set_fast(x1, row, c.with_fg(color));
}
}
}
fn dim_rect_interior(rect: Rect, color: PackedRgba, buf: &mut Buffer) {
if rect.width < 3 || rect.height < 3 {
return;
}
for row in (rect.y + 1)..(rect.y + rect.height.saturating_sub(1)) {
for col in (rect.x + 1)..(rect.x + rect.width.saturating_sub(1)) {
if let Some(c) = buf.get(col, row)
&& c.content.as_char().unwrap_or(' ') != ' '
{
buf.set_fast(col, row, c.with_fg(color));
}
}
}
}
fn render_removed_legend(diff: &DiagramDiff, area: Rect, rows: u16, buf: &mut Buffer) {
let legend_y = area.y + area.height.saturating_sub(rows);
let max_w = area.width as usize;
let removed_names: Vec<&str> = diff
.nodes
.iter()
.filter(|n| n.status == DiffStatus::Removed)
.map(|n| n.id.as_str())
.collect();
let mut parts = Vec::new();
if !removed_names.is_empty() {
let names = removed_names.join(", ");
parts.push(format!("-nodes: {names}"));
}
if diff.removed_edges > 0 {
parts.push(format!("-edges: {}", diff.removed_edges));
}
let legend_text = parts.join(" | ");
let display_text = if display_width(&legend_text) > max_w {
let mut result = String::new();
let mut w = 0;
for ch in legend_text.chars() {
let cw = display_width(&ch.to_string());
if w + cw + 1 > max_w {
result.push('…');
break;
}
result.push(ch);
w += cw;
}
result
} else {
legend_text
};
let cell = Cell::from_char(' ').with_fg(DiffColors::REMOVED);
let mut col = 0u16;
for ch in display_text.chars() {
let x = area.x + col;
if x >= area.x + area.width {
break;
}
buf.set_fast(x, legend_y, cell.with_char(ch));
let cw = display_width(&ch.to_string());
col += cw as u16;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mermaid::{
DiagramType, GraphDirection, IrEndpoint, IrLabel, IrLabelId, IrNodeId, MermaidDiagramMeta,
MermaidGuardReport, MermaidInitParse, MermaidSupportLevel, MermaidThemeOverrides,
NodeShape, Position, Span,
};
fn make_test_span() -> Span {
Span {
start: Position {
line: 0,
col: 0,
byte: 0,
},
end: Position {
line: 0,
col: 0,
byte: 0,
},
}
}
fn make_test_ir(node_ids: &[&str], edges: &[(usize, usize)]) -> MermaidDiagramIr {
let labels: Vec<IrLabel> = node_ids
.iter()
.map(|id| IrLabel {
text: id.to_string(),
span: make_test_span(),
})
.collect();
let nodes: Vec<IrNode> = node_ids
.iter()
.enumerate()
.map(|(i, id)| IrNode {
id: id.to_string(),
label: Some(IrLabelId(i)),
shape: NodeShape::Rect,
classes: vec![],
style_ref: None,
span_primary: make_test_span(),
span_all: vec![],
implicit: false,
members: vec![],
annotation: None,
})
.collect();
let ir_edges: Vec<IrEdge> = edges
.iter()
.map(|(from, to)| IrEdge {
from: IrEndpoint::Node(IrNodeId(*from)),
to: IrEndpoint::Node(IrNodeId(*to)),
arrow: "-->".to_string(),
label: None,
style_ref: None,
span: make_test_span(),
})
.collect();
MermaidDiagramIr {
diagram_type: DiagramType::Graph,
direction: GraphDirection::TD,
nodes,
edges: ir_edges,
ports: vec![],
clusters: vec![],
labels,
pie_entries: vec![],
pie_title: None,
pie_show_data: false,
style_refs: vec![],
links: vec![],
meta: MermaidDiagramMeta {
diagram_type: DiagramType::Graph,
direction: GraphDirection::TD,
support_level: MermaidSupportLevel::Supported,
init: MermaidInitParse::default(),
theme_overrides: MermaidThemeOverrides::default(),
guard: MermaidGuardReport::default(),
},
constraints: vec![],
quadrant_points: Vec::new(),
quadrant_title: None,
quadrant_x_axis: None,
quadrant_y_axis: None,
quadrant_labels: [None, None, None, None],
packet_fields: Vec::new(),
packet_title: None,
packet_bits_per_row: 32,
sequence_participants: Vec::new(),
sequence_controls: Vec::new(),
sequence_notes: Vec::new(),
sequence_activations: Vec::new(),
sequence_autonumber: false,
gantt_title: None,
gantt_sections: Vec::new(),
gantt_tasks: Vec::new(),
}
}
#[test]
fn diff_identical_diagrams_is_empty() {
let ir = make_test_ir(&["A", "B", "C"], &[(0, 1), (1, 2)]);
let diff = diff_diagrams(&ir, &ir);
assert!(
diff.is_empty(),
"identical diagrams should produce empty diff"
);
assert_eq!(diff.added_nodes, 0);
assert_eq!(diff.removed_nodes, 0);
assert_eq!(diff.changed_nodes, 0);
assert_eq!(diff.added_edges, 0);
assert_eq!(diff.removed_edges, 0);
assert_eq!(diff.changed_edges, 0);
assert_eq!(diff.nodes.len(), 3);
assert_eq!(diff.edges.len(), 2);
for dn in &diff.nodes {
assert_eq!(dn.status, DiffStatus::Unchanged);
}
for de in &diff.edges {
assert_eq!(de.status, DiffStatus::Unchanged);
}
}
#[test]
fn diff_detects_added_node() {
let old = make_test_ir(&["A", "B"], &[(0, 1)]);
let new = make_test_ir(&["A", "B", "C"], &[(0, 1), (1, 2)]);
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.added_nodes, 1);
assert_eq!(diff.removed_nodes, 0);
assert_eq!(diff.changed_nodes, 0);
let added = diff
.nodes
.iter()
.find(|n| n.status == DiffStatus::Added)
.unwrap();
assert_eq!(added.id, "C");
}
#[test]
fn diff_detects_removed_node() {
let old = make_test_ir(&["A", "B", "C"], &[(0, 1), (1, 2)]);
let new = make_test_ir(&["A", "B"], &[(0, 1)]);
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.added_nodes, 0);
assert_eq!(diff.removed_nodes, 1);
let removed = diff
.nodes
.iter()
.find(|n| n.status == DiffStatus::Removed)
.unwrap();
assert_eq!(removed.id, "C");
}
#[test]
fn diff_detects_changed_node_shape() {
let old = make_test_ir(&["A", "B"], &[(0, 1)]);
let mut new = make_test_ir(&["A", "B"], &[(0, 1)]);
new.nodes[1].shape = NodeShape::Diamond;
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.changed_nodes, 1);
let changed = diff
.nodes
.iter()
.find(|n| n.status == DiffStatus::Changed)
.unwrap();
assert_eq!(changed.id, "B");
}
#[test]
fn diff_detects_changed_node_label() {
let old = make_test_ir(&["A", "B"], &[(0, 1)]);
let mut new = make_test_ir(&["A", "B"], &[(0, 1)]);
new.labels[1].text = "New Label".to_string();
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.changed_nodes, 1);
let changed = diff
.nodes
.iter()
.find(|n| n.status == DiffStatus::Changed)
.unwrap();
assert_eq!(changed.id, "B");
}
#[test]
fn diff_detects_added_edge() {
let old = make_test_ir(&["A", "B", "C"], &[(0, 1)]);
let new = make_test_ir(&["A", "B", "C"], &[(0, 1), (1, 2)]);
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.added_edges, 1);
assert_eq!(diff.removed_edges, 0);
let added = diff
.edges
.iter()
.find(|e| e.status == DiffStatus::Added)
.unwrap();
assert_eq!(added.from_id, "B");
assert_eq!(added.to_id, "C");
}
#[test]
fn diff_detects_removed_edge() {
let old = make_test_ir(&["A", "B", "C"], &[(0, 1), (1, 2)]);
let new = make_test_ir(&["A", "B", "C"], &[(0, 1)]);
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.removed_edges, 1);
let removed = diff
.edges
.iter()
.find(|e| e.status == DiffStatus::Removed)
.unwrap();
assert_eq!(removed.from_id, "B");
assert_eq!(removed.to_id, "C");
}
#[test]
fn diff_detects_changed_edge_arrow() {
let old = make_test_ir(&["A", "B"], &[(0, 1)]);
let mut new = make_test_ir(&["A", "B"], &[(0, 1)]);
new.edges[0].arrow = "-.->".to_string();
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.changed_edges, 1);
let changed = diff
.edges
.iter()
.find(|e| e.status == DiffStatus::Changed)
.unwrap();
assert_eq!(changed.from_id, "A");
assert_eq!(changed.to_id, "B");
}
#[test]
fn diff_complex_scenario() {
let old = make_test_ir(&["A", "B", "C"], &[(0, 1), (1, 2)]);
let mut new = make_test_ir(&["A", "B", "D", "E"], &[(0, 1), (1, 2), (1, 3)]);
new.nodes[1].shape = NodeShape::Rounded;
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.added_nodes, 2, "D and E added");
assert_eq!(diff.removed_nodes, 1, "C removed");
assert_eq!(diff.changed_nodes, 1, "B changed shape");
assert_eq!(diff.added_edges, 2);
assert_eq!(diff.removed_edges, 1);
}
#[test]
fn diff_empty_diagrams() {
let old = make_test_ir(&[], &[]);
let new = make_test_ir(&[], &[]);
let diff = diff_diagrams(&old, &new);
assert!(diff.is_empty());
assert_eq!(diff.nodes.len(), 0);
assert_eq!(diff.edges.len(), 0);
}
#[test]
fn diff_from_empty_to_populated() {
let old = make_test_ir(&[], &[]);
let new = make_test_ir(&["A", "B"], &[(0, 1)]);
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.added_nodes, 2);
assert_eq!(diff.added_edges, 1);
assert_eq!(diff.removed_nodes, 0);
}
#[test]
fn diff_from_populated_to_empty() {
let old = make_test_ir(&["A", "B"], &[(0, 1)]);
let new = make_test_ir(&[], &[]);
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.removed_nodes, 2);
assert_eq!(diff.removed_edges, 1);
assert_eq!(diff.added_nodes, 0);
}
#[test]
fn diff_node_members_change() {
let old = make_test_ir(&["A"], &[]);
let mut new = make_test_ir(&["A"], &[]);
new.nodes[0].members = vec!["field1".to_string(), "method()".to_string()];
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.changed_nodes, 1);
}
#[test]
fn diff_preserves_node_indices() {
let old = make_test_ir(&["A", "B"], &[(0, 1)]);
let new = make_test_ir(&["A", "B", "C"], &[(0, 1), (1, 2)]);
let diff = diff_diagrams(&old, &new);
let a = diff.nodes.iter().find(|n| n.id == "A").unwrap();
assert_eq!(a.node_idx, 0);
assert_eq!(a.old_node_idx, Some(0));
let c = diff.nodes.iter().find(|n| n.id == "C").unwrap();
assert_eq!(c.node_idx, 2);
assert_eq!(c.old_node_idx, None);
}
use super::{DiffColors, render_diff};
use crate::mermaid::MermaidConfig;
use crate::mermaid_layout::layout_diagram as mermaid_layout_diagram;
use ftui_core::geometry::Rect;
use ftui_render::buffer::Buffer;
fn make_test_buffer(w: u16, h: u16) -> Buffer {
Buffer::new(w, h)
}
#[test]
fn render_diff_empty_diff_produces_output() {
let ir = make_test_ir(&["A", "B"], &[(0, 1)]);
let diff = diff_diagrams(&ir, &ir);
let config = MermaidConfig::default();
let layout = mermaid_layout_diagram(&ir, &config);
let area = Rect {
x: 0,
y: 0,
width: 40,
height: 20,
};
let mut buf = make_test_buffer(40, 20);
render_diff(&diff, &layout, &config, area, &mut buf);
let has_content = (0..40).any(|x| {
(0..20).any(|y| {
buf.get(x, y)
.and_then(|c| c.content.as_char())
.unwrap_or(' ')
!= ' '
})
});
assert!(has_content, "render_diff should produce visible output");
}
#[test]
fn render_diff_added_nodes_get_green_border() {
let old = make_test_ir(&["A"], &[]);
let new = make_test_ir(&["A", "B"], &[(0, 1)]);
let diff = diff_diagrams(&old, &new);
let config = MermaidConfig::default();
let layout = mermaid_layout_diagram(&new, &config);
let area = Rect {
x: 0,
y: 0,
width: 60,
height: 30,
};
let mut buf = make_test_buffer(60, 30);
render_diff(&diff, &layout, &config, area, &mut buf);
let has_green = (0..60)
.any(|x| (0..30).any(|y| buf.get(x, y).is_some_and(|c| c.fg == DiffColors::ADDED)));
assert!(has_green, "added node should have green-colored cells");
}
#[test]
fn render_diff_unchanged_nodes_are_dimmed() {
let ir = make_test_ir(&["A", "B"], &[(0, 1)]);
let diff = diff_diagrams(&ir, &ir);
let config = MermaidConfig::default();
let layout = mermaid_layout_diagram(&ir, &config);
let area = Rect {
x: 0,
y: 0,
width: 60,
height: 30,
};
let mut buf = make_test_buffer(60, 30);
render_diff(&diff, &layout, &config, area, &mut buf);
let has_dim = (0..60)
.any(|x| (0..30).any(|y| buf.get(x, y).is_some_and(|c| c.fg == DiffColors::UNCHANGED)));
assert!(has_dim, "unchanged nodes should have dimmed cells");
}
#[test]
fn render_diff_changed_node_has_yellow() {
let old = make_test_ir(&["A", "B"], &[(0, 1)]);
let mut new = make_test_ir(&["A", "B"], &[(0, 1)]);
new.nodes[1].shape = NodeShape::Diamond;
let diff = diff_diagrams(&old, &new);
let config = MermaidConfig::default();
let layout = mermaid_layout_diagram(&new, &config);
let area = Rect {
x: 0,
y: 0,
width: 60,
height: 30,
};
let mut buf = make_test_buffer(60, 30);
render_diff(&diff, &layout, &config, area, &mut buf);
let has_yellow = (0..60)
.any(|x| (0..30).any(|y| buf.get(x, y).is_some_and(|c| c.fg == DiffColors::CHANGED)));
assert!(has_yellow, "changed node should have yellow cells");
}
#[test]
fn render_diff_removed_legend_shown() {
let old = make_test_ir(&["A", "B", "C"], &[(0, 1), (1, 2)]);
let new = make_test_ir(&["A", "B"], &[(0, 1)]);
let diff = diff_diagrams(&old, &new);
let config = MermaidConfig::default();
let layout = mermaid_layout_diagram(&new, &config);
let area = Rect {
x: 0,
y: 0,
width: 60,
height: 30,
};
let mut buf = make_test_buffer(60, 30);
render_diff(&diff, &layout, &config, area, &mut buf);
let has_red_bottom = (0..60)
.any(|x| (28..30).any(|y| buf.get(x, y).is_some_and(|c| c.fg == DiffColors::REMOVED)));
assert!(
has_red_bottom,
"removed nodes should show red legend at bottom"
);
}
#[test]
fn render_diff_zero_area_does_not_panic() {
let ir = make_test_ir(&["A"], &[]);
let diff = diff_diagrams(&ir, &ir);
let config = MermaidConfig::default();
let layout = mermaid_layout_diagram(&ir, &config);
let area = Rect {
x: 0,
y: 0,
width: 0,
height: 0,
};
let mut buf = make_test_buffer(1, 1);
render_diff(&diff, &layout, &config, area, &mut buf);
}
#[test]
fn diff_detects_changed_edge_label() {
let mut old = make_test_ir(&["A", "B"], &[(0, 1)]);
old.labels.push(IrLabel {
text: "old label".to_string(),
span: make_test_span(),
});
old.edges[0].label = Some(IrLabelId(old.labels.len() - 1));
let mut new = make_test_ir(&["A", "B"], &[(0, 1)]);
new.labels.push(IrLabel {
text: "new label".to_string(),
span: make_test_span(),
});
new.edges[0].label = Some(IrLabelId(new.labels.len() - 1));
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.changed_edges, 1);
let changed = diff
.edges
.iter()
.find(|e| e.status == DiffStatus::Changed)
.unwrap();
assert_eq!(changed.from_id, "A");
assert_eq!(changed.to_id, "B");
assert!(changed.old_edge_idx.is_some());
}
#[test]
fn diff_edge_label_added_counts_as_change() {
let old = make_test_ir(&["A", "B"], &[(0, 1)]);
let mut new = make_test_ir(&["A", "B"], &[(0, 1)]);
new.labels.push(IrLabel {
text: "label".to_string(),
span: make_test_span(),
});
new.edges[0].label = Some(IrLabelId(new.labels.len() - 1));
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.changed_edges, 1);
}
#[test]
fn diff_node_classes_change() {
let old = make_test_ir(&["A"], &[]);
let mut new = make_test_ir(&["A"], &[]);
new.nodes[0].classes = vec!["highlighted".to_string()];
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.changed_nodes, 1);
}
#[test]
fn diff_duplicate_edges_matched_independently() {
let old = make_test_ir(&["A", "B"], &[(0, 1), (0, 1)]);
let new = make_test_ir(&["A", "B"], &[(0, 1), (0, 1)]);
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.added_edges, 0);
assert_eq!(diff.removed_edges, 0);
assert_eq!(diff.edges.len(), 2);
for de in &diff.edges {
assert_eq!(de.status, DiffStatus::Unchanged);
}
}
#[test]
fn diff_duplicate_edge_added_one() {
let old = make_test_ir(&["A", "B"], &[(0, 1)]);
let new = make_test_ir(&["A", "B"], &[(0, 1), (0, 1)]);
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.added_edges, 1);
assert_eq!(diff.removed_edges, 0);
let unchanged_count = diff
.edges
.iter()
.filter(|e| e.status == DiffStatus::Unchanged)
.count();
let added_count = diff
.edges
.iter()
.filter(|e| e.status == DiffStatus::Added)
.count();
assert_eq!(unchanged_count, 1);
assert_eq!(added_count, 1);
}
#[test]
fn diff_self_loop_edge() {
let old = make_test_ir(&["A"], &[]);
let new = make_test_ir(&["A"], &[(0, 0)]); let diff = diff_diagrams(&old, &new);
assert_eq!(diff.added_edges, 1);
let added = diff
.edges
.iter()
.find(|e| e.status == DiffStatus::Added)
.unwrap();
assert_eq!(added.from_id, "A");
assert_eq!(added.to_id, "A");
}
#[test]
fn diff_self_loop_unchanged() {
let ir = make_test_ir(&["A"], &[(0, 0)]);
let diff = diff_diagrams(&ir, &ir);
assert!(diff.is_empty());
assert_eq!(diff.edges.len(), 1);
assert_eq!(diff.edges[0].status, DiffStatus::Unchanged);
}
#[test]
fn diff_removed_node_has_old_node_idx() {
let old = make_test_ir(&["A", "B", "C"], &[]);
let new = make_test_ir(&["A"], &[]);
let diff = diff_diagrams(&old, &new);
let removed_b = diff.nodes.iter().find(|n| n.id == "B").unwrap();
assert_eq!(removed_b.status, DiffStatus::Removed);
assert_eq!(removed_b.old_node_idx, Some(1));
let removed_c = diff.nodes.iter().find(|n| n.id == "C").unwrap();
assert_eq!(removed_c.status, DiffStatus::Removed);
assert_eq!(removed_c.old_node_idx, Some(2));
}
#[test]
fn diff_removed_edge_has_old_edge_idx() {
let old = make_test_ir(&["A", "B", "C"], &[(0, 1), (1, 2)]);
let new = make_test_ir(&["A", "B", "C"], &[(0, 1)]);
let diff = diff_diagrams(&old, &new);
let removed = diff
.edges
.iter()
.find(|e| e.status == DiffStatus::Removed)
.unwrap();
assert_eq!(removed.old_edge_idx, Some(1));
}
#[test]
fn diff_node_reordering_stable() {
let old = make_test_ir(&["A", "B", "C"], &[(0, 1), (1, 2)]);
let new = make_test_ir(&["C", "A", "B"], &[(1, 2), (2, 0)]);
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.added_nodes, 0);
assert_eq!(diff.removed_nodes, 0);
assert_eq!(diff.added_edges, 0);
assert_eq!(diff.removed_edges, 0);
}
#[test]
fn diff_single_node_unchanged() {
let ir = make_test_ir(&["X"], &[]);
let diff = diff_diagrams(&ir, &ir);
assert!(diff.is_empty());
assert_eq!(diff.nodes.len(), 1);
assert_eq!(diff.nodes[0].id, "X");
assert_eq!(diff.nodes[0].status, DiffStatus::Unchanged);
}
#[test]
fn diff_is_empty_false_with_only_added_edges() {
let ir1 = make_test_ir(&["A", "B"], &[]);
let ir2 = make_test_ir(&["A", "B"], &[(0, 1)]);
let diff = diff_diagrams(&ir1, &ir2);
assert!(!diff.is_empty());
assert_eq!(diff.added_edges, 1);
assert_eq!(diff.added_nodes, 0);
}
#[test]
fn diff_is_empty_false_with_only_changed_nodes() {
let old = make_test_ir(&["A"], &[]);
let mut new = make_test_ir(&["A"], &[]);
new.nodes[0].shape = NodeShape::Circle;
let diff = diff_diagrams(&old, &new);
assert!(!diff.is_empty());
assert_eq!(diff.changed_nodes, 1);
}
#[test]
fn diff_status_equality() {
assert_eq!(DiffStatus::Added, DiffStatus::Added);
assert_eq!(DiffStatus::Removed, DiffStatus::Removed);
assert_eq!(DiffStatus::Changed, DiffStatus::Changed);
assert_eq!(DiffStatus::Unchanged, DiffStatus::Unchanged);
assert_ne!(DiffStatus::Added, DiffStatus::Removed);
assert_ne!(DiffStatus::Changed, DiffStatus::Unchanged);
}
#[test]
fn diff_colors_are_distinct() {
assert_ne!(DiffColors::ADDED, DiffColors::REMOVED);
assert_ne!(DiffColors::ADDED, DiffColors::CHANGED);
assert_ne!(DiffColors::ADDED, DiffColors::UNCHANGED);
assert_ne!(DiffColors::REMOVED, DiffColors::CHANGED);
assert_ne!(DiffColors::REMOVED, DiffColors::UNCHANGED);
assert_ne!(DiffColors::CHANGED, DiffColors::UNCHANGED);
}
#[test]
fn diff_colors_rgb_values() {
assert_eq!(DiffColors::ADDED, PackedRgba::rgb(46, 204, 113));
assert_eq!(DiffColors::REMOVED, PackedRgba::rgb(231, 76, 60));
assert_eq!(DiffColors::CHANGED, PackedRgba::rgb(241, 196, 15));
assert_eq!(DiffColors::UNCHANGED, PackedRgba::rgb(100, 100, 100));
}
#[test]
fn recolor_rect_border_zero_size_noop() {
let mut buf = make_test_buffer(10, 10);
let rect = Rect {
x: 0,
y: 0,
width: 0,
height: 0,
};
recolor_rect_border(rect, DiffColors::ADDED, &mut buf);
}
#[test]
fn recolor_rect_border_single_cell() {
let mut buf = make_test_buffer(10, 10);
buf.set_fast(5, 5, Cell::from_char('X'));
let rect = Rect {
x: 5,
y: 5,
width: 1,
height: 1,
};
recolor_rect_border(rect, DiffColors::ADDED, &mut buf);
let cell = buf.get(5, 5).unwrap();
assert_eq!(cell.fg, DiffColors::ADDED);
}
#[test]
fn dim_rect_interior_too_small_noop() {
let mut buf = make_test_buffer(10, 10);
let rect = Rect {
x: 0,
y: 0,
width: 2,
height: 2,
};
dim_rect_interior(rect, DiffColors::UNCHANGED, &mut buf);
}
#[test]
fn dim_rect_interior_dims_non_space_content() {
let mut buf = make_test_buffer(10, 10);
buf.set_fast(2, 2, Cell::from_char('A'));
buf.set_fast(3, 2, Cell::from_char('B'));
let rect = Rect {
x: 1,
y: 1,
width: 4,
height: 4,
};
dim_rect_interior(rect, DiffColors::UNCHANGED, &mut buf);
let cell_a = buf.get(2, 2).unwrap();
assert_eq!(cell_a.fg, DiffColors::UNCHANGED);
let cell_b = buf.get(3, 2).unwrap();
assert_eq!(cell_b.fg, DiffColors::UNCHANGED);
}
#[test]
fn render_diff_changed_edge_has_color() {
let old = make_test_ir(&["A", "B"], &[(0, 1)]);
let mut new = make_test_ir(&["A", "B"], &[(0, 1)]);
new.edges[0].arrow = "-.->".to_string();
let diff = diff_diagrams(&old, &new);
let config = MermaidConfig::default();
let layout = mermaid_layout_diagram(&new, &config);
let area = Rect {
x: 0,
y: 0,
width: 60,
height: 30,
};
let mut buf = make_test_buffer(60, 30);
render_diff(&diff, &layout, &config, area, &mut buf);
let has_changed_color = (0..60)
.any(|x| (0..30).any(|y| buf.get(x, y).is_some_and(|c| c.fg == DiffColors::CHANGED)));
assert!(
has_changed_color,
"changed edge should have yellow-colored cells"
);
}
#[test]
fn render_diff_small_area_does_not_panic() {
let old = make_test_ir(&["A", "B", "C"], &[(0, 1), (1, 2)]);
let new = make_test_ir(&["A", "B"], &[(0, 1)]);
let diff = diff_diagrams(&old, &new);
let config = MermaidConfig::default();
let layout = mermaid_layout_diagram(&new, &config);
let area = Rect {
x: 0,
y: 0,
width: 5,
height: 5,
};
let mut buf = make_test_buffer(5, 5);
render_diff(&diff, &layout, &config, area, &mut buf);
}
#[test]
fn diff_edge_with_same_label_unchanged() {
let mut old = make_test_ir(&["A", "B"], &[(0, 1)]);
old.labels.push(IrLabel {
text: "same".to_string(),
span: make_test_span(),
});
old.edges[0].label = Some(IrLabelId(old.labels.len() - 1));
let mut new = make_test_ir(&["A", "B"], &[(0, 1)]);
new.labels.push(IrLabel {
text: "same".to_string(),
span: make_test_span(),
});
new.edges[0].label = Some(IrLabelId(new.labels.len() - 1));
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.changed_edges, 0);
assert!(diff.is_empty());
}
#[test]
fn diff_many_nodes_only_one_changed() {
let old = make_test_ir(
&["A", "B", "C", "D", "E"],
&[(0, 1), (1, 2), (2, 3), (3, 4)],
);
let mut new = make_test_ir(
&["A", "B", "C", "D", "E"],
&[(0, 1), (1, 2), (2, 3), (3, 4)],
);
new.nodes[2].shape = NodeShape::Hexagon;
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.changed_nodes, 1);
assert_eq!(diff.added_nodes, 0);
assert_eq!(diff.removed_nodes, 0);
assert_eq!(diff.changed_edges, 0);
let changed = diff
.nodes
.iter()
.find(|n| n.status == DiffStatus::Changed)
.unwrap();
assert_eq!(changed.id, "C");
}
#[test]
fn diff_total_node_count_consistent() {
let old = make_test_ir(&["A", "B", "C"], &[(0, 1)]);
let new = make_test_ir(&["B", "C", "D", "E"], &[(0, 1)]);
let diff = diff_diagrams(&old, &new);
let new_count = new.nodes.len();
let removed = diff.removed_nodes;
assert_eq!(diff.nodes.len(), new_count + removed);
let added = diff
.nodes
.iter()
.filter(|n| n.status == DiffStatus::Added)
.count();
let unchanged = diff
.nodes
.iter()
.filter(|n| n.status == DiffStatus::Unchanged)
.count();
let changed = diff
.nodes
.iter()
.filter(|n| n.status == DiffStatus::Changed)
.count();
let removed_count = diff
.nodes
.iter()
.filter(|n| n.status == DiffStatus::Removed)
.count();
assert_eq!(
added + unchanged + changed + removed_count,
diff.nodes.len()
);
assert_eq!(added, diff.added_nodes);
assert_eq!(removed_count, diff.removed_nodes);
assert_eq!(changed, diff.changed_nodes);
}
#[test]
fn endpoint_node_id_out_of_bounds_node() {
let ir = make_test_ir(&["A"], &[]);
let result = endpoint_node_id(&IrEndpoint::Node(IrNodeId(99)), &ir);
assert_eq!(result, "?99");
}
#[test]
fn endpoint_node_id_port_fallback() {
use crate::mermaid::{IrPort, IrPortId, IrPortSideHint};
let mut ir = make_test_ir(&["A"], &[]);
ir.ports.push(IrPort {
node: IrNodeId(0),
name: "p1".to_string(),
side_hint: IrPortSideHint::Auto,
span: make_test_span(),
});
let result = endpoint_node_id(&IrEndpoint::Port(IrPortId(0)), &ir);
assert_eq!(result, "A");
}
#[test]
fn endpoint_node_id_port_out_of_bounds() {
let ir = make_test_ir(&["A"], &[]);
let result = endpoint_node_id(&IrEndpoint::Port(crate::mermaid::IrPortId(99)), &ir);
assert_eq!(result, "?port99");
}
#[test]
fn node_no_label_both_unchanged() {
let mut old = make_test_ir(&["A"], &[]);
old.nodes[0].label = None;
let mut new = make_test_ir(&["A"], &[]);
new.nodes[0].label = None;
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.changed_nodes, 0);
assert_eq!(diff.nodes[0].status, DiffStatus::Unchanged);
}
#[test]
fn node_label_added_counts_as_change() {
let mut old = make_test_ir(&["A"], &[]);
old.nodes[0].label = None;
let new = make_test_ir(&["A"], &[]); let diff = diff_diagrams(&old, &new);
assert_eq!(diff.changed_nodes, 1);
}
#[test]
fn node_label_removed_counts_as_change() {
let old = make_test_ir(&["A"], &[]);
let mut new = make_test_ir(&["A"], &[]);
new.nodes[0].label = None;
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.changed_nodes, 1);
}
#[test]
fn edge_label_removed_counts_as_change() {
let mut old = make_test_ir(&["A", "B"], &[(0, 1)]);
old.labels.push(IrLabel {
text: "edge label".to_string(),
span: make_test_span(),
});
old.edges[0].label = Some(IrLabelId(old.labels.len() - 1));
let new = make_test_ir(&["A", "B"], &[(0, 1)]); let diff = diff_diagrams(&old, &new);
assert_eq!(diff.changed_edges, 1);
}
#[test]
fn diff_status_clone_copy() {
let s = DiffStatus::Added;
let s2 = s; let s3 = s; assert_eq!(s2, s3);
assert_eq!(s, DiffStatus::Added);
}
#[test]
fn diff_status_all_variants_debug() {
for status in [
DiffStatus::Added,
DiffStatus::Removed,
DiffStatus::Changed,
DiffStatus::Unchanged,
] {
let dbg = format!("{:?}", status);
assert!(!dbg.is_empty());
}
}
#[test]
fn diff_node_clone() {
let dn = DiffNode {
id: "test".to_string(),
status: DiffStatus::Added,
node_idx: 0,
old_node_idx: None,
};
let cloned = dn.clone();
assert_eq!(cloned.id, "test");
assert_eq!(cloned.status, DiffStatus::Added);
assert_eq!(cloned.node_idx, 0);
assert!(cloned.old_node_idx.is_none());
}
#[test]
fn diff_edge_clone() {
let de = DiffEdge {
from_id: "A".to_string(),
to_id: "B".to_string(),
status: DiffStatus::Removed,
edge_idx: 5,
old_edge_idx: Some(3),
};
let cloned = de.clone();
assert_eq!(cloned.from_id, "A");
assert_eq!(cloned.to_id, "B");
assert_eq!(cloned.status, DiffStatus::Removed);
assert_eq!(cloned.edge_idx, 5);
assert_eq!(cloned.old_edge_idx, Some(3));
}
#[test]
fn diagram_diff_clone() {
let ir = make_test_ir(&["A"], &[]);
let diff = diff_diagrams(&ir, &ir);
let cloned = diff.clone();
assert_eq!(cloned.nodes.len(), diff.nodes.len());
assert_eq!(cloned.is_empty(), diff.is_empty());
}
#[test]
fn render_diff_only_edge_change_no_node_change() {
let old = make_test_ir(&["A", "B"], &[(0, 1)]);
let mut new = make_test_ir(&["A", "B"], &[(0, 1)]);
new.edges[0].arrow = "-->|label|".to_string();
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.changed_nodes, 0);
assert_eq!(diff.changed_edges, 1);
let config = MermaidConfig::default();
let layout = mermaid_layout_diagram(&new, &config);
let area = Rect {
x: 0,
y: 0,
width: 60,
height: 30,
};
let mut buf = make_test_buffer(60, 30);
render_diff(&diff, &layout, &config, area, &mut buf);
}
#[test]
fn render_diff_offset_area() {
let ir = make_test_ir(&["A", "B"], &[(0, 1)]);
let diff = diff_diagrams(&ir, &ir);
let config = MermaidConfig::default();
let layout = mermaid_layout_diagram(&ir, &config);
let area = Rect {
x: 10,
y: 5,
width: 50,
height: 25,
};
let mut buf = make_test_buffer(70, 40);
render_diff(&diff, &layout, &config, area, &mut buf);
}
#[test]
fn render_diff_many_removed_nodes_legend() {
let old = make_test_ir(
&[
"A",
"B",
"LongNodeName1",
"LongNodeName2",
"LongNodeName3",
"LongNodeName4",
],
&[(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)],
);
let new = make_test_ir(&["A", "B"], &[(0, 1)]);
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.removed_nodes, 4);
let config = MermaidConfig::default();
let layout = mermaid_layout_diagram(&new, &config);
let area = Rect {
x: 0,
y: 0,
width: 30,
height: 20,
};
let mut buf = make_test_buffer(30, 20);
render_diff(&diff, &layout, &config, area, &mut buf);
}
#[test]
fn render_diff_removed_edges_only() {
let old = make_test_ir(&["A", "B", "C"], &[(0, 1), (1, 2), (0, 2)]);
let new = make_test_ir(&["A", "B", "C"], &[(0, 1)]);
let diff = diff_diagrams(&old, &new);
assert_eq!(diff.removed_edges, 2);
assert_eq!(diff.removed_nodes, 0);
let config = MermaidConfig::default();
let layout = mermaid_layout_diagram(&new, &config);
let area = Rect {
x: 0,
y: 0,
width: 60,
height: 30,
};
let mut buf = make_test_buffer(60, 30);
render_diff(&diff, &layout, &config, area, &mut buf);
let has_red = (0..60)
.any(|x| (0..30).any(|y| buf.get(x, y).is_some_and(|c| c.fg == DiffColors::REMOVED)));
assert!(has_red, "removed edges should show red legend");
}
#[test]
fn dim_rect_interior_skips_spaces() {
let mut buf = make_test_buffer(10, 10);
let rect = Rect {
x: 0,
y: 0,
width: 5,
height: 5,
};
let before_fg = buf.get(2, 2).unwrap().fg;
dim_rect_interior(rect, DiffColors::UNCHANGED, &mut buf);
let after_fg = buf.get(2, 2).unwrap().fg;
assert_eq!(before_fg, after_fg);
}
#[test]
fn recolor_rect_border_3x3() {
let mut buf = make_test_buffer(10, 10);
for y in 2..5 {
for x in 2..5 {
buf.set_fast(x, y, Cell::from_char('X'));
}
}
let rect = Rect {
x: 2,
y: 2,
width: 3,
height: 3,
};
recolor_rect_border(rect, DiffColors::CHANGED, &mut buf);
assert_eq!(buf.get(2, 2).unwrap().fg, DiffColors::CHANGED);
assert_eq!(buf.get(3, 2).unwrap().fg, DiffColors::CHANGED);
assert_eq!(buf.get(4, 2).unwrap().fg, DiffColors::CHANGED);
assert_eq!(buf.get(2, 4).unwrap().fg, DiffColors::CHANGED);
assert_eq!(buf.get(4, 4).unwrap().fg, DiffColors::CHANGED);
assert_eq!(buf.get(2, 3).unwrap().fg, DiffColors::CHANGED);
assert_eq!(buf.get(4, 3).unwrap().fg, DiffColors::CHANGED);
let interior = buf.get(3, 3).unwrap();
assert_ne!(interior.fg, DiffColors::CHANGED);
}
#[test]
fn is_empty_false_for_removed_nodes_only() {
let old = make_test_ir(&["A"], &[]);
let new = make_test_ir(&[], &[]);
let diff = diff_diagrams(&old, &new);
assert!(!diff.is_empty());
}
#[test]
fn is_empty_false_for_removed_edges_only() {
let old = make_test_ir(&["A", "B"], &[(0, 1)]);
let new = make_test_ir(&["A", "B"], &[]);
let diff = diff_diagrams(&old, &new);
assert!(!diff.is_empty());
assert_eq!(diff.removed_edges, 1);
assert_eq!(diff.added_nodes, 0);
assert_eq!(diff.removed_nodes, 0);
assert_eq!(diff.changed_nodes, 0);
}
#[test]
fn is_empty_false_for_changed_edges_only() {
let old = make_test_ir(&["A", "B"], &[(0, 1)]);
let mut new = make_test_ir(&["A", "B"], &[(0, 1)]);
new.edges[0].arrow = "-.->".to_string();
let diff = diff_diagrams(&old, &new);
assert!(!diff.is_empty());
assert_eq!(diff.changed_edges, 1);
assert_eq!(diff.added_nodes, 0);
}
#[test]
fn diff_total_edge_count_consistent() {
let old = make_test_ir(&["A", "B", "C"], &[(0, 1), (1, 2)]);
let new = make_test_ir(&["A", "B", "D"], &[(0, 1), (1, 2)]);
let diff = diff_diagrams(&old, &new);
let added = diff
.edges
.iter()
.filter(|e| e.status == DiffStatus::Added)
.count();
let removed = diff
.edges
.iter()
.filter(|e| e.status == DiffStatus::Removed)
.count();
let changed = diff
.edges
.iter()
.filter(|e| e.status == DiffStatus::Changed)
.count();
let unchanged = diff
.edges
.iter()
.filter(|e| e.status == DiffStatus::Unchanged)
.count();
assert_eq!(added + removed + changed + unchanged, diff.edges.len());
assert_eq!(added, diff.added_edges);
assert_eq!(removed, diff.removed_edges);
assert_eq!(changed, diff.changed_edges);
}
#[test]
fn diff_symmetry_added_removed_swap() {
let a = make_test_ir(&["X", "Y"], &[(0, 1)]);
let b = make_test_ir(&["X", "Z"], &[(0, 1)]);
let diff_ab = diff_diagrams(&a, &b);
let diff_ba = diff_diagrams(&b, &a);
assert_eq!(diff_ab.added_nodes, diff_ba.removed_nodes);
assert_eq!(diff_ab.removed_nodes, diff_ba.added_nodes);
}
#[test]
fn render_diff_minimal_height_with_legend() {
let old = make_test_ir(&["A", "B"], &[(0, 1)]);
let new = make_test_ir(&["A"], &[]);
let diff = diff_diagrams(&old, &new);
let config = MermaidConfig::default();
let layout = mermaid_layout_diagram(&new, &config);
let area = Rect {
x: 0,
y: 0,
width: 40,
height: 6,
};
let mut buf = make_test_buffer(40, 6);
render_diff(&diff, &layout, &config, area, &mut buf);
}
}