mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Panels to Illustrator parity (19 panels), ~70 menu commands, generic param dialogs, view rotation, property/model-based tests + MCP e2e; fix arrange overflow, compound grouping, double journaling, float round-trip, coordinate sanity, simplify growth, batch rollback of session state
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
213b0927ee
commit
42df20ca7f
143 files changed
+12927
-642
No files matched your search
@@ -15,4 +15,6 @@ serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
drawcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
drawcraft-geom = { workspace = true }
|
||||
@@ -0,0 +1,147 @@
|
||||
//! `.drawcraft` format: round trips, robustness against garbage, versioning, unknown fields.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use drawcraft_doc::{Document, ImageBlob, NodeId};
|
||||
use drawcraft_format::{base64_decode, base64_encode, load, save, sniff};
|
||||
use drawcraft_testkit::fixtures;
|
||||
use drawcraft_testkit::invariants::{check_document, check_native_roundtrip, check_native_roundtrip_exact, doc_json, json_approx_eq};
|
||||
use drawcraft_testkit::strategies::arb_ops;
|
||||
use proptest::prelude::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn rich_doc() -> Document {
|
||||
(*fixtures::rich_session().doc().unwrap().doc).clone()
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 128, failure_persistence: None, ..ProptestConfig::default() })]
|
||||
|
||||
#[test]
|
||||
fn base64_roundtrip(data in prop::collection::vec(any::<u8>(), 0..300)) {
|
||||
let s = base64_encode(&data);
|
||||
prop_assert_eq!(s.len() % 4, 0);
|
||||
prop_assert_eq!(base64_decode(&s), Some(data.clone()));
|
||||
// Whitespace is ignored.
|
||||
let spaced: String = s.chars().flat_map(|c| [c, '\n']).collect();
|
||||
prop_assert_eq!(base64_decode(&spaced), Some(data));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_decode_never_panics(s in ".{0,64}") {
|
||||
let _ = base64_decode(&s);
|
||||
}
|
||||
|
||||
/// Arbitrary bytes are rejected (or, if they happen to parse, yield a valid document) — never a panic.
|
||||
#[test]
|
||||
fn load_garbage_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..512)) {
|
||||
if let Ok(d) = load(&bytes) {
|
||||
check_document(&d).map_err(TestCaseError::fail)?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutating a valid file (byte flips / truncation) never panics the loader.
|
||||
#[test]
|
||||
fn load_mutated_file_never_panics(cut in 0usize..4000, flips in prop::collection::vec((0usize..4000, any::<u8>()), 0..8)) {
|
||||
let mut bytes = save(&Document::new(100.0, 100.0), false);
|
||||
for (i, b) in flips {
|
||||
let n = bytes.len();
|
||||
bytes[i % n] = b;
|
||||
}
|
||||
bytes.truncate(cut.min(bytes.len()));
|
||||
let _ = load(&bytes);
|
||||
}
|
||||
|
||||
/// Documents produced by random editing survive save → load (numbers to 1e-12).
|
||||
#[test]
|
||||
fn random_documents_roundtrip(ops in arb_ops(5..40)) {
|
||||
let mut s = fixtures::session();
|
||||
for op in &ops {
|
||||
let _ = op.apply(&mut s);
|
||||
}
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
check_native_roundtrip(&d).map_err(TestCaseError::fail)?;
|
||||
// Pretty and compact encodings load to the same document.
|
||||
let a = doc_json(&load(&save(&d, true)).unwrap());
|
||||
let b = doc_json(&load(&save(&d, false)).unwrap());
|
||||
prop_assert_eq!(a, b);
|
||||
}
|
||||
|
||||
/// Embedded image bytes survive the base64 round trip exactly.
|
||||
#[test]
|
||||
fn images_roundtrip(data in prop::collection::vec(any::<u8>(), 0..2000), key in "[a-z]{1,8}") {
|
||||
let mut d = Document::new(10.0, 10.0);
|
||||
d.images.insert(key.clone(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(data.clone()) });
|
||||
let back = load(&save(&d, false)).unwrap();
|
||||
prop_assert_eq!(back.images.get(&key).map(|b| b.bytes.as_ref().clone()), Some(data));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_document_roundtrips() {
|
||||
let d = rich_doc();
|
||||
check_native_roundtrip(&d).unwrap();
|
||||
let bytes = save(&d, true);
|
||||
assert!(sniff(&bytes));
|
||||
let v: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(v["format"], "drawcraft");
|
||||
assert_eq!(v["version"], drawcraft_format::VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniff_distinguishes_formats() {
|
||||
assert!(sniff(&save(&Document::new(1.0, 1.0), false)));
|
||||
assert!(!sniff(b"<svg xmlns=\"http://www.w3.org/2000/svg\"/>"));
|
||||
assert!(!sniff(b"%PDF-1.7"));
|
||||
assert!(!sniff(b""));
|
||||
assert!(!sniff(&[0xff, 0xfe, 0x00]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_newer_versions_and_other_formats() {
|
||||
let mut v: Value = serde_json::from_slice(&save(&Document::new(1.0, 1.0), false)).unwrap();
|
||||
v["version"] = json!(drawcraft_format::VERSION + 1);
|
||||
assert!(matches!(load(&serde_json::to_vec(&v).unwrap()), Err(drawcraft_format::FormatError::TooNew(_))));
|
||||
v["version"] = json!(drawcraft_format::VERSION);
|
||||
v["format"] = json!("other");
|
||||
assert!(matches!(load(&serde_json::to_vec(&v).unwrap()), Err(drawcraft_format::FormatError::NotDrawcraft(_))));
|
||||
v["format"] = json!("drawcraft");
|
||||
v["images"] = json!({"x": {"mime": "image/png", "data": "not base64!"}});
|
||||
assert!(matches!(load(&serde_json::to_vec(&v).unwrap()), Err(drawcraft_format::FormatError::BadImage(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_fields_are_preserved() {
|
||||
let mut v: Value = serde_json::from_slice(&save(&Document::new(1.0, 1.0), false)).unwrap();
|
||||
v["document"]["unknown"] = json!({"futureFeature": {"a": [1, 2, 3]}});
|
||||
let d = load(&serde_json::to_vec(&v).unwrap()).unwrap();
|
||||
let again: Value = serde_json::from_slice(&save(&d, false)).unwrap();
|
||||
assert_eq!(again["document"]["unknown"]["futureFeature"]["a"], json!([1, 2, 3]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_repairs_next_id() {
|
||||
let d = rich_doc();
|
||||
let mut v: Value = serde_json::from_slice(&save(&d, false)).unwrap();
|
||||
v["document"]["next_id"] = json!(1);
|
||||
let mut back = load(&serde_json::to_vec(&v).unwrap()).unwrap();
|
||||
check_document(&back).unwrap();
|
||||
let fresh = back.alloc_id();
|
||||
assert!(back.node(fresh).is_none(), "allocated id {fresh} collides");
|
||||
let max = fixtures::all_ids(&back).into_iter().map(|NodeId(i)| i).max().unwrap();
|
||||
assert!(fresh.0 > max);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approx_equal_documents_compare_equal() {
|
||||
let d = rich_doc();
|
||||
assert!(json_approx_eq(&doc_json(&d), &doc_json(&d), 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bug_f64_bit_exact_roundtrip() {
|
||||
check_native_roundtrip_exact(&rich_doc()).unwrap();
|
||||
let mut d = Document::new(126.86291501015239, 214.69463130731182);
|
||||
d.raster_effects_ppi = 0.1 + 0.2;
|
||||
check_native_roundtrip_exact(&d).unwrap();
|
||||
}
|
||||
Reference in new issue
Block a user