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

120 lines
5.1 KiB
Rust

//! Malformed input, size caps and diagnostics: errors and diagnostics only, never a panic.
use notescraft_canvas::{Canvas, Color, Diagnostic, Error, MAX_BYTES, MAX_NODES};
fn err(input: &str) -> Error {
match Canvas::from_str(input) {
Ok(_) => panic!("expected an error for: {input}"),
Err(e) => e,
}
}
#[test]
fn truncated_json_is_an_error() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/truncated.canvas");
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("fixture: {e}"));
assert!(matches!(err(&text), Error::Json(_)));
assert!(matches!(err("{\"nodes\": ["), Error::Json(_)));
assert!(matches!(err(""), Error::Json(_)));
}
#[test]
fn root_must_be_an_object() {
assert!(matches!(err("[]"), Error::NotAnObject { .. }));
assert!(matches!(err("\"canvas\""), Error::NotAnObject { .. }));
assert!(matches!(err("42"), Error::NotAnObject { .. }));
}
#[test]
fn wrong_types_are_errors() {
assert!(matches!(err("{\"nodes\": {}}"), Error::NotAnArray { .. }));
assert!(matches!(err("{\"edges\": 3}"), Error::NotAnArray { .. }));
assert!(matches!(err("{\"nodes\": [3]}"), Error::NotAnObject { .. }));
assert!(matches!(err("{\"nodes\": [{\"id\": 3, \"type\": \"text\"}]}"), Error::BadType { .. }));
assert!(matches!(err("{\"nodes\": [{\"type\": \"text\"}]}"), Error::MissingField { .. }));
assert!(matches!(err("{\"nodes\": [{\"id\": \"a\"}]}"), Error::MissingField { .. }));
assert!(matches!(err("{\"nodes\": [{\"id\": \"a\", \"type\": \"text\", \"x\": \"0\", \"y\": 0, \"width\": 1, \"height\": 1}]}"), Error::BadType { .. }));
assert!(matches!(
err("{\"nodes\": [{\"id\": \"a\", \"type\": \"text\", \"x\": 0, \"y\": 0, \"width\": 1, \"height\": 1, \"text\": 5}]}"),
Error::BadType { .. }
));
assert!(matches!(
err("{\"nodes\": [{\"id\": \"a\", \"type\": \"text\", \"x\": 0, \"y\": 0, \"width\": 1, \"height\": 1, \"color\": 2}]}"),
Error::BadType { .. }
));
// unknown keys keep any value shape at all
assert!(
Canvas::from_str("{\"nodes\": [{\"id\": \"a\", \"type\": \"text\", \"x\": 0, \"y\": 0, \"width\": 1, \"height\": 1, \"unknown\": [\"a\", 2]}]}")
.is_ok()
);
assert!(matches!(err("{\"edges\": [{\"id\": \"e\", \"fromNode\": \"a\"}]}"), Error::MissingField { .. }));
assert!(matches!(err("{\"edges\": [{\"id\": \"e\", \"fromNode\": \"a\", \"toNode\": \"b\", \"fromSide\": \"up\"}]}"), Error::BadValue { .. }));
assert!(matches!(err("{\"edges\": [{\"id\": \"e\", \"fromNode\": \"a\", \"toNode\": \"b\", \"toEnd\": \"triangle\"}]}"), Error::BadValue { .. }));
assert!(matches!(
err("{\"nodes\": [{\"id\": \"a\", \"type\": \"group\", \"x\": 0, \"y\": 0, \"width\": 1, \"height\": 1, \"backgroundStyle\": \"zoom\"}]}"),
Error::BadValue { .. }
));
}
#[test]
fn bad_color_is_kept_and_flagged_not_rejected() {
let c = Canvas::from_str(
"{\"nodes\": [{\"id\": \"a\", \"type\": \"text\", \"text\": \"t\", \"x\": 0, \"y\": 0, \"width\": 1, \"height\": 1, \"color\": \"7\"}]}",
)
.expect("tolerant read");
assert_eq!(c.nodes.first().map(|n| n.color.clone()), Some(Some(Color::Hex("7".into()))));
assert!(matches!(c.validate().first(), Some(Diagnostic::BadColor { .. })));
}
#[test]
fn huge_numbers_are_diagnosed_or_rejected() {
// serde_json rejects numbers that overflow f64.
assert!(matches!(err("{\"nodes\": [{\"id\": \"a\", \"type\": \"text\", \"x\": 1e400, \"y\": 0, \"width\": 1, \"height\": 1}]}"), Error::Json(_)));
// A finite but absurd coordinate is flagged, never rewritten.
let c = Canvas::from_str("{\"nodes\": [{\"id\": \"a\", \"type\": \"text\", \"text\": \"t\", \"x\": 1e308, \"y\": -1e308, \"width\": 1, \"height\": 1}]}")
.expect("parses");
let diags = c.validate();
assert_eq!(diags.len(), 2, "{diags:?}");
assert!(diags.iter().all(|d| matches!(d, Diagnostic::AbsurdCoordinate { .. })));
let again = Canvas::from_str(&c.to_string_pretty()).expect("re-parses");
assert_eq!(c, again, "absurd coordinates are not rewritten");
}
#[test]
fn deep_nesting_is_rejected_not_overflowed() {
let deep = format!("{}{}", "[".repeat(500), "]".repeat(500));
assert!(matches!(err(&deep), Error::Json(_)));
}
#[test]
fn node_cap_is_enforced() {
let ok = gen_nodes(MAX_NODES);
let c = Canvas::from_str(&ok).expect("cap size parses");
assert_eq!(c.nodes.len(), MAX_NODES);
let too_many = gen_nodes(MAX_NODES + 1);
assert!(matches!(err(&too_many), Error::TooManyNodes { .. }));
}
fn gen_nodes(count: usize) -> String {
let mut s = String::from("{\"nodes\":[");
for i in 0..count {
if i > 0 {
s.push(',');
}
s.push_str("{\"id\":\"n");
s.push_str(&i.to_string());
s.push_str("\",\"type\":\"text\",\"text\":\"\",\"x\":0,\"y\":0,\"width\":1,\"height\":1}");
}
s.push_str("]}");
s
}
#[test]
fn size_cap_is_enforced() {
let padding = "a".repeat(MAX_BYTES + 16);
let big = format!("{{\"nodes\":[],\"edges\":[],\"x\":\"{padding}\"}}");
assert!(big.len() > MAX_BYTES);
assert!(matches!(err(&big), Error::TooLarge { .. }));
}