230 lines
8.3 KiB
Rust
230 lines
8.3 KiB
Rust
//! Structural invariants for documents and sessions, and round-trip checks.
|
|
|
|
use std::collections::HashSet;
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_doc::{Document, NodeKind};
|
|
use vectorcraft_engine::Session;
|
|
|
|
/// Serialize a document to JSON (the canonical form for equality checks).
|
|
pub fn doc_json(doc: &Document) -> Value {
|
|
serde_json::to_value(doc).expect("document serializes")
|
|
}
|
|
|
|
/// Tree invariants: ids unique, `node_count` equals the number of walked nodes, `next_id` above
|
|
/// every id, top level made of layers, layers only at the top or inside layers, clip groups have
|
|
/// a first child, compound children are paths.
|
|
pub fn check_document(doc: &Document) -> Result<(), String> {
|
|
let mut seen = HashSet::new();
|
|
let mut walked = 0usize;
|
|
let mut max = 0u64;
|
|
let mut err = None;
|
|
doc.walk(|n| {
|
|
walked += 1;
|
|
max = max.max(n.id.0);
|
|
if !seen.insert(n.id) && err.is_none() {
|
|
err = Some(format!("duplicate id {}", n.id));
|
|
}
|
|
match &n.kind {
|
|
NodeKind::Group { children, .. } if children.iter().any(|c| c.is_layer()) && err.is_none() => {
|
|
err = Some(format!("group {} contains a layer", n.id));
|
|
}
|
|
NodeKind::Compound { children, .. } if children.iter().any(|c| !matches!(c.kind, NodeKind::Path { .. })) && err.is_none() => {
|
|
err = Some(format!("compound {} has a non-path child", n.id));
|
|
}
|
|
_ => {}
|
|
}
|
|
if !(n.opacity.is_finite() && (0.0..=1.0).contains(&n.opacity)) && err.is_none() {
|
|
err = Some(format!("node {} opacity {}", n.id, n.opacity));
|
|
}
|
|
});
|
|
if let Some(e) = err {
|
|
return Err(e);
|
|
}
|
|
if walked != doc.node_count() {
|
|
return Err(format!("node_count {} != walked {walked}", doc.node_count()));
|
|
}
|
|
if doc.peek_next_id() <= max {
|
|
return Err(format!("next_id {} <= max id {max}", doc.peek_next_id()));
|
|
}
|
|
if let Some(l) = doc.layers.iter().find(|l| !l.is_layer()) {
|
|
return Err(format!("top-level node {} is not a layer", l.id));
|
|
}
|
|
for id in seen {
|
|
if doc.node(id).is_none() {
|
|
return Err(format!("walked id {id} not found by lookup"));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Session invariants for the active document: document invariants, every selected id exists,
|
|
/// the key object (if any) exists, no interaction left open, history snapshots valid.
|
|
pub fn check_session(s: &Session) -> Result<(), String> {
|
|
let Some(st) = s.active() else { return Ok(()) };
|
|
check_document(&st.doc)?;
|
|
for id in &st.selection.objects {
|
|
if st.doc.node(*id).is_none() {
|
|
return Err(format!("selected id {id} does not exist"));
|
|
}
|
|
}
|
|
let mut uniq = HashSet::new();
|
|
if let Some(d) = st.selection.objects.iter().find(|i| !uniq.insert(**i)) {
|
|
return Err(format!("selection lists {d} twice"));
|
|
}
|
|
for id in st.selection.anchors.keys() {
|
|
if st.doc.node(*id).is_none() {
|
|
return Err(format!("anchor selection on missing id {id}"));
|
|
}
|
|
}
|
|
if let Some(k) = st.selection.key
|
|
&& st.doc.node(k).is_none()
|
|
{
|
|
return Err(format!("key object {k} does not exist"));
|
|
}
|
|
if st.interaction.is_some() {
|
|
return Err("interaction left open".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// `.vectorcraft` save → load → the JSON of the document and of what loaded, without `images`: a
|
|
/// save keeps only the images in use, which must come back byte for byte.
|
|
fn native_roundtrip(doc: &Document) -> Result<(Value, Value), String> {
|
|
let bytes = vectorcraft_format::save(doc, false);
|
|
let back = vectorcraft_format::load(&bytes).map_err(|e| format!("load: {e}"))?;
|
|
if let Some(k) = back.images.iter().find(|(k, b)| doc.images.get(*k).map(|d| &d.bytes) != Some(&b.bytes)).map(|(k, _)| k) {
|
|
return Err(format!("native round trip changed image `{k}`"));
|
|
}
|
|
let mut lost = None;
|
|
back.walk(|n| {
|
|
if let NodeKind::Image(im) = &n.kind
|
|
&& doc.images.contains_key(&im.key)
|
|
&& !back.images.contains_key(&im.key)
|
|
{
|
|
lost = Some(im.key.clone());
|
|
}
|
|
});
|
|
if let Some(k) = lost {
|
|
return Err(format!("native round trip lost image `{k}`, which is in use"));
|
|
}
|
|
let strip = |mut v: Value| {
|
|
if let Some(o) = v.as_object_mut() {
|
|
o.remove("images");
|
|
}
|
|
v
|
|
};
|
|
Ok((strip(doc_json(doc)), strip(doc_json(&back))))
|
|
}
|
|
|
|
/// `.vectorcraft` save → load must reproduce the document (numbers compared to 1e-12 relative —
|
|
/// see [`check_native_roundtrip_exact`] for the bit-exact version).
|
|
pub fn check_native_roundtrip(doc: &Document) -> Result<(), String> {
|
|
let (a, b) = native_roundtrip(doc)?;
|
|
if !json_approx_eq(&a, &b, 1e-12) {
|
|
return Err(format!("native round trip differs:\n{}", first_diff(&a, &b, "$")));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Bit-exact `.vectorcraft` round trip (every f64 must survive).
|
|
pub fn check_native_roundtrip_exact(doc: &Document) -> Result<(), String> {
|
|
let (a, b) = native_roundtrip(doc)?;
|
|
if a != b {
|
|
return Err(format!("native round trip differs:\n{}", first_diff(&a, &b, "$")));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Structural JSON equality with numbers compared to `rel` relative tolerance.
|
|
pub fn json_approx_eq(a: &Value, b: &Value, rel: f64) -> bool {
|
|
match (a, b) {
|
|
(Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) {
|
|
(Some(x), Some(y)) => x == y || (x - y).abs() <= rel * x.abs().max(y.abs()),
|
|
_ => x == y,
|
|
},
|
|
(Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| json_approx_eq(p, q, rel)),
|
|
(Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| json_approx_eq(v, w, rel))),
|
|
_ => a == b,
|
|
}
|
|
}
|
|
|
|
/// SVG export must not panic and must re-import.
|
|
pub fn check_svg_roundtrip(doc: &Document) -> Result<Document, String> {
|
|
let svg = vectorcraft_svg::export(doc, &vectorcraft_svg::ExportOptions::default());
|
|
vectorcraft_svg::import(&svg).map_err(|e| format!("svg re-import: {e}\n{svg}"))
|
|
}
|
|
|
|
/// `document.inspect` must succeed and serialize.
|
|
pub fn check_inspect(s: &mut Session) -> Result<(), String> {
|
|
if s.active().is_none() {
|
|
return Ok(());
|
|
}
|
|
let v = s.execute("document.inspect", &json!({})).map_err(|e| format!("inspect: {e}"))?;
|
|
serde_json::to_string(&v).map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Everything above.
|
|
pub fn check_all(s: &mut Session) -> Result<(), String> {
|
|
check_session(s)?;
|
|
check_inspect(s)?;
|
|
if let Some(st) = s.active() {
|
|
let doc = st.doc.clone();
|
|
check_native_roundtrip(&doc)?;
|
|
check_svg_roundtrip(&doc)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// A short description of the first difference between two JSON values.
|
|
pub fn first_diff(a: &Value, b: &Value, path: &str) -> String {
|
|
match (a, b) {
|
|
(Value::Object(x), Value::Object(y)) => {
|
|
for (k, v) in x {
|
|
match y.get(k) {
|
|
None => return format!("{path}.{k}: missing on right"),
|
|
Some(w) if w != v => return first_diff(v, w, &format!("{path}.{k}")),
|
|
_ => {}
|
|
}
|
|
}
|
|
for k in y.keys() {
|
|
if !x.contains_key(k) {
|
|
return format!("{path}.{k}: missing on left");
|
|
}
|
|
}
|
|
format!("{path}: equal")
|
|
}
|
|
(Value::Array(x), Value::Array(y)) => {
|
|
if x.len() != y.len() {
|
|
return format!("{path}: array length {} vs {}", x.len(), y.len());
|
|
}
|
|
for (i, (v, w)) in x.iter().zip(y).enumerate() {
|
|
if v != w {
|
|
return first_diff(v, w, &format!("{path}[{i}]"));
|
|
}
|
|
}
|
|
format!("{path}: equal")
|
|
}
|
|
_ => format!("{path}: {a} vs {b}"),
|
|
}
|
|
}
|
|
|
|
/// Undo everything, then redo everything, returning (initial, final) JSON snapshots.
|
|
pub fn undo_all(s: &mut Session) -> usize {
|
|
let mut n = 0;
|
|
while s.execute("edit.undo", &json!({})).is_ok() {
|
|
n += 1;
|
|
assert!(n < 10_000, "undo does not terminate");
|
|
}
|
|
n
|
|
}
|
|
pub fn redo_all(s: &mut Session) -> usize {
|
|
let mut n = 0;
|
|
while s.execute("edit.redo", &json!({})).is_ok() {
|
|
n += 1;
|
|
assert!(n < 10_000, "redo does not terminate");
|
|
}
|
|
n
|
|
}
|