Files
Marcos Paulo ead48a824e feat(canvas): wave 1 implementation
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:59:07 -03:00

135 lines
5.6 KiB
Rust

//! Model API: construction, helpers, validation, diagnostics and id generation.
use notescraft_canvas::{Canvas, Diagnostic, End, IdGen, Node, NodeKind, Rect, Side, new_id};
fn node(id: &str, x: f64, y: f64) -> Node {
Node::new(id, NodeKind::Text { text: id.into() }, x, y, 100.0, 50.0)
}
fn canvas_with_nodes() -> Canvas {
let mut c = Canvas::default();
c.add_node(node("a", 0.0, 0.0)).expect("added");
c.add_node(node("b", 200.0, 100.0)).expect("added");
c
}
#[test]
fn add_node_rejects_duplicate_ids() {
let mut c = canvas_with_nodes();
assert!(c.add_node(node("a", 5.0, 5.0)).is_err());
assert_eq!(c.nodes.len(), 2);
}
#[test]
fn remove_node_drops_its_edges() {
let mut c = canvas_with_nodes();
c.add_edge(notescraft_canvas::Edge::new("e1", "a", "b")).expect("added");
c.add_edge(notescraft_canvas::Edge::new("e2", "b", "a")).expect("added");
assert!(c.remove_node("a"));
assert!(!c.remove_node("a"));
assert_eq!(c.nodes.len(), 1);
assert_eq!(c.edges.len(), 0);
}
#[test]
fn bounding_box_covers_every_node() {
let c = canvas_with_nodes();
assert_eq!(c.bounding_box(), Some(Rect { x: 0.0, y: 0.0, width: 300.0, height: 150.0 }));
assert!(Canvas::default().bounding_box().is_none());
// a node that came in with absurd coordinates does not poison the box
let mut weird = Canvas::default();
weird.add_node(node("w", f64::INFINITY, 0.0)).expect("added");
weird.add_node(node("k", 10.0, 10.0)).expect("added");
assert_eq!(weird.bounding_box(), Some(Rect { x: 10.0, y: 10.0, width: 100.0, height: 50.0 }));
}
#[test]
fn file_nodes_lists_referenced_vault_paths() {
let c = Canvas::from_str(
"{\"nodes\":[\
{\"id\":\"t\",\"type\":\"text\",\"text\":\"x\",\"x\":0,\"y\":0,\"width\":1,\"height\":1},\
{\"id\":\"f\",\"type\":\"file\",\"file\":\"a/b.md\",\"subpath\":\"#h\",\"x\":0,\"y\":0,\"width\":1,\"height\":1},\
{\"id\":\"g\",\"type\":\"file\",\"file\":\"img.png\",\"x\":0,\"y\":0,\"width\":1,\"height\":1}]}",
)
.expect("parses");
let refs = c.file_nodes();
assert_eq!(refs.len(), 2);
assert_eq!(refs.first().map(|r| r.file), Some("a/b.md"));
assert_eq!(refs.first().and_then(|r| r.subpath), Some("#h"));
assert_eq!(refs.get(1).map(|r| r.file), Some("img.png"));
assert_eq!(refs.get(1).and_then(|r| r.subpath), None);
}
#[test]
fn validation_flags_duplicates_and_dangling_edges() {
let c = Canvas::from_str(
"{\"nodes\":[\
{\"id\":\"a\",\"type\":\"text\",\"text\":\"\",\"x\":0,\"y\":0,\"width\":1,\"height\":1},\
{\"id\":\"a\",\"type\":\"text\",\"text\":\"\",\"x\":0,\"y\":0,\"width\":1,\"height\":1}],\
\"edges\":[\
{\"id\":\"e1\",\"fromNode\":\"a\",\"toNode\":\"ghost\"},\
{\"id\":\"e1\",\"fromNode\":\"a\",\"toNode\":\"a\",\"label\":\"l\",\"color\":\"zz\"}]}",
)
.expect("parses");
let d = c.validate();
assert!(matches!(d.first(), Some(Diagnostic::DuplicateNodeId { id }) if id == "a"), "{d:?}");
assert!(d.iter().any(|x| matches!(x, Diagnostic::EdgeEndpointMissing { edge, node } if edge == "e1" && node == "ghost")), "{d:?}");
assert!(d.iter().any(|x| matches!(x, Diagnostic::DuplicateEdgeId { id } if id == "e1")), "{d:?}");
assert!(d.iter().any(|x| matches!(x, Diagnostic::BadEdgeColor { edge, .. } if edge == "e1")), "{d:?}");
// edges to missing nodes are kept, not dropped
assert_eq!(c.edges.len(), 2);
for diag in &d {
assert_ne!(diag.message(), String::new(), "{diag:?}");
}
}
#[test]
fn validation_flags_bad_subpath_and_rect() {
let c =
Canvas::from_str("{\"nodes\":[{\"id\":\"a\",\"type\":\"file\",\"file\":\"a.md\",\"subpath\":\"heading\",\"x\":0,\"y\":-5,\"width\":-3,\"height\":2}]}")
.expect("parses");
let d = c.validate();
assert!(d.iter().any(|x| matches!(x, Diagnostic::BadSubpath { node, .. } if node == "a")), "{d:?}");
assert!(d.iter().any(|x| matches!(x, Diagnostic::AbsurdCoordinate { field: "width", .. })), "{d:?}");
// a negative but small y is a perfectly good coordinate
assert!(!d.iter().any(|x| matches!(x, Diagnostic::AbsurdCoordinate { field: "y", .. })), "{d:?}");
assert!(!d.iter().any(|x| matches!(x, Diagnostic::AbsurdCoordinate { field: "height", .. })), "{d:?}");
}
#[test]
fn validation_flags_non_finite_coordinates() {
let mut c = Canvas::default();
c.add_node(node("a", f64::NAN, 0.0)).expect("added");
assert!(matches!(c.validate().first(), Some(Diagnostic::NonFiniteCoordinate { field: "x", .. })));
}
#[test]
fn ids_are_16_lowercase_hex_and_unique() {
let a = new_id(1);
assert_eq!(a.len(), 16);
assert_eq!(new_id(1), a, "deterministic");
assert!(a.chars().all(|ch| ch.is_ascii_hexdigit() && !ch.is_ascii_uppercase()), "{a}");
let mut ids = IdGen::from_seed(7);
let mut seen = std::collections::HashSet::new();
for _ in 0..1000 {
let id = ids.next_id();
assert_eq!(id.len(), 16);
assert!(seen.insert(id), "duplicate id from IdGen");
}
let mut other = IdGen::new();
assert_eq!(other.next_id().len(), 16);
}
#[test]
fn enum_defaults_and_parsing() {
let e = notescraft_canvas::Edge::new("e", "a", "b");
assert_eq!(e.from_end_or_default(), End::None);
assert_eq!(e.to_end_or_default(), End::Arrow);
assert_eq!(Side::parse("top").ok(), Some(Side::Top));
assert_eq!(Side::parse("nope").err().map(|e| e.to_string()), Some("field `side` has the invalid value `nope`".into()));
assert_eq!(End::parse("arrow").ok(), Some(End::Arrow));
assert_eq!(End::None.as_str(), "none");
assert_eq!(Side::Bottom.as_str(), "bottom");
}