00e96fe8c6
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
147 lines
6.3 KiB
Rust
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);
|
|
}
|