Files
Marcos Paulo 00e96fe8c6 feat(whiteboard): wave 2 whiteboard
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:11:18 -03:00

147 lines
6.3 KiB
Rust

//! Malformed input, hard caps, and the builder API.
use notescraft_whiteboard::{Element, ElementKind, Error, IdGen, Whiteboard};
fn wrap(elements: &str) -> String {
format!(r#"{{"type":"excalidraw","version":2,"elements":[{elements}]}}"#)
}
#[test]
fn malformed_json_is_rejected() {
for bad in ["", "{", "[]", "null", "{\"elements\":{}}", "{\"elements\":[1,2]}", "{\"elements\":[{\"id\":5}]}"] {
assert!(Whiteboard::from_str(bad).is_err(), "expected an error for `{bad}`");
}
let err = Whiteboard::from_str("").expect_err("empty string fails");
assert!(matches!(err, Error::Json(_)), "unexpected error: {err:?}");
}
/// JSON has no `NaN`, and a number too large for `f64` is a parse error.
#[test]
fn non_finite_numbers_from_json_are_rejected() {
for literal in ["NaN", "Infinity", "1e400"] {
let bad = wrap(&format!(r#"{{"id":"a","type":"rectangle","x":{literal}}}"#));
let err = Whiteboard::from_str(&bad).expect_err("out of range numbers must fail");
assert!(matches!(err, Error::Json(_)), "unexpected error for {literal}: {err:?}");
}
let ok = wrap(r#"{"id":"a","type":"rectangle","x":1e300,"width":-5.0}"#);
assert!(Whiteboard::from_str(&ok).is_ok(), "large but finite numbers are accepted");
}
#[test]
fn wrong_typed_fields_are_reported() {
let bad = wrap(r#"{"id":"a","type":"rectangle","x":"left"}"#);
let err = Whiteboard::from_str(&bad).expect_err("wrong type must fail");
assert!(matches!(err, Error::InvalidField { .. }), "unexpected error: {err:?}");
}
#[test]
fn element_count_is_capped() {
let many = (0..notescraft_whiteboard::MAX_ELEMENTS).map(|i| format!(r#"{{"id":"e{i}","type":"rectangle"}}"#)).collect::<Vec<_>>().join(",");
assert!(Whiteboard::from_str(&wrap(&many)).is_ok(), "exactly the cap is allowed");
let over = format!(r#"{{"id":"e{}","type":"rectangle"}}"#, 100_000);
let err = Whiteboard::from_str(&wrap(&format!("{many},{over}"))).expect_err("over the cap must fail");
assert!(matches!(err, Error::TooManyElements { .. }), "unexpected error: {err:?}");
}
#[test]
fn input_size_is_capped() {
let filler = "x".repeat(notescraft_whiteboard::MAX_INPUT_BYTES);
let big = format!(r#"{{"type":"excalidraw","version":2,"elements":[],"appState":{{"pad":"{filler}"}}}}"#);
assert!(big.len() > notescraft_whiteboard::MAX_INPUT_BYTES);
let err = Whiteboard::from_str(&big).expect_err("over the size cap must fail");
assert!(matches!(err, Error::TooLarge { .. }), "unexpected error: {err:?}");
}
#[test]
fn builders_produce_excalidraw_shapes_with_fresh_ids() {
let mut ids = IdGen::new();
let mut made: Vec<Element> = vec![
Element::rectangle(&mut ids, 1.0, 2.0, 3.0, 4.0),
Element::ellipse(&mut ids, 1.0, 2.0, 3.0, 4.0),
Element::diamond(&mut ids, 1.0, 2.0, 3.0, 4.0),
Element::line(&mut ids, 1.0, 2.0, 3.0, 4.0),
Element::arrow(&mut ids, 1.0, 2.0, 3.0, 4.0),
Element::text(&mut ids, 1.0, 2.0, "hello"),
];
let ids_seen: Vec<&str> = made.iter().map(|e| e.id.as_str()).collect();
for id in &ids_seen {
assert_eq!(id.chars().count(), 16, "ids are 16 chars: {id}");
}
let mut sorted = ids_seen.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), ids_seen.len(), "ids must be unique");
let names: Vec<&str> = made.iter().map(Element::type_name).collect();
assert_eq!(names, ["rectangle", "ellipse", "diamond", "line", "arrow", "text"]);
for element in &made {
assert!(element.roughness > 0);
assert_eq!(element.opacity, 100);
assert!(!element.is_deleted);
}
let ElementKind::Line { points } = &made[3].kind else { panic!("line") };
assert_eq!(points.len(), 2);
let ElementKind::Arrow { points, start_binding, end_binding, end_arrowhead, .. } = &made[4].kind else {
panic!("arrow");
};
assert_eq!(points.len(), 2);
assert!(start_binding.is_none() && end_binding.is_none());
assert_eq!(end_arrowhead.as_deref(), Some("arrow"));
let ElementKind::Text { text, .. } = &made[5].kind else { panic!("text") };
assert_eq!(text, "hello");
made[0].id.push('x');
let mut fresh = IdGen::new();
assert_eq!(fresh.next_id().chars().count(), 16);
assert_ne!(fresh.next_id(), fresh.next_id(), "the generator keeps counting");
}
#[test]
fn delete_keeps_the_element_and_marks_it() {
let mut ids = IdGen::new();
let mut board = Whiteboard::default();
let rect = Element::rectangle(&mut ids, 0.0, 0.0, 10.0, 10.0);
board.add(rect.clone());
board.add(Element::ellipse(&mut ids, 0.0, 0.0, 10.0, 10.0));
assert!(board.delete("nope").is_none());
let deleted = board.delete(&rect.id).expect("returns the deleted element");
assert!(deleted.is_deleted);
assert_eq!(board.elements.len(), 2);
let stored = board.element(&rect.id).expect("still stored");
assert!(stored.is_deleted);
assert_eq!(board.bounding_box().expect("the ellipse is still visible").width, 10.0);
let last_id = board.elements[1].id.clone();
board.delete(&last_id);
assert_eq!(board.bounding_box(), None, "deleted elements do not extend the box");
}
#[test]
fn bounding_box_accounts_for_rotation_and_points() {
let mut ids = IdGen::new();
let mut board = Whiteboard::default();
board.add(Element::rectangle(&mut ids, 0.0, 0.0, 10.0, 10.0));
let bounds = board.bounding_box().expect("box");
assert_eq!((bounds.x, bounds.y, bounds.width, bounds.height), (0.0, 0.0, 10.0, 10.0));
let mut rotated = Element::rectangle(&mut ids, 0.0, 0.0, 20.0, 10.0);
rotated.angle = std::f64::consts::FRAC_PI_2;
let mut board = Whiteboard::default();
board.add(rotated);
let bounds = board.bounding_box().expect("box");
assert!((bounds.width - 10.0).abs() < 1e-9 && (bounds.height - 20.0).abs() < 1e-9, "the box follows the rotation: {bounds:?}");
assert!((bounds.x - 5.0).abs() < 1e-9 && (bounds.y + 5.0).abs() < 1e-9, "the box follows the rotation: {bounds:?}");
let mut board = Whiteboard::default();
board.add(Element::line(&mut ids, 10.0, 10.0, 30.0, 20.0));
let bounds = board.bounding_box().expect("box");
assert_eq!((bounds.x, bounds.y, bounds.width, bounds.height), (10.0, 10.0, 30.0, 20.0));
}
#[test]
fn empty_board_has_no_bounding_box() {
assert_eq!(Whiteboard::default().bounding_box(), None);
}