mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
A linked image (`ImageObject.link`, now a LinkInfo: absolute path, path relative to the document, size, modification time, hash, page; old path strings still load) keeps a low-resolution preview (`ImageBlob.proxy`, at most 256 px a side) that a native save writes in place of the pixels of images only linked images show. Opening a native document reads the linked files again, looking for each at its path, then relative to the document and by name in its folder: missing files show their preview, modified ones are read again only with Update Links: Automatically. `document.open` reports `missingLinks`, `modifiedLinks` and `updatedLinks`. links.check reports each link's status, links.update reads modified files again and links.relink points images at another file or at same-named files in a folder (each keeps its bounds; one undo step). In the app, a document opened without its linked files asks about each (Replace…, Ignore, Apply to All), then offers to update modified links when the preference asks. The renderer caches previews apart from the file's pixels; the Eyedropper samples previews in the object's pixel space. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
148 lines
5.9 KiB
Rust
148 lines
5.9 KiB
Rust
//! `.vectorcraft` format: round trips, robustness against garbage, versioning, unknown fields.
|
||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||
|
||
use proptest::prelude::*;
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_doc::{Document, ImageBlob, NodeId};
|
||
use vectorcraft_format::{base64_decode, base64_encode, load, save, sniff};
|
||
use vectorcraft_testkit::fixtures;
|
||
use vectorcraft_testkit::invariants::{check_document, check_native_roundtrip, check_native_roundtrip_exact, doc_json, json_approx_eq};
|
||
use vectorcraft_testkit::strategies::arb_ops;
|
||
|
||
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::new("image/png", 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"], "vectorcraft");
|
||
assert_eq!(v["version"], vectorcraft_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!(vectorcraft_format::VERSION + 1);
|
||
assert!(matches!(load(&serde_json::to_vec(&v).unwrap()), Err(vectorcraft_format::FormatError::TooNew(_))));
|
||
v["version"] = json!(vectorcraft_format::VERSION);
|
||
v["format"] = json!("other");
|
||
assert!(matches!(load(&serde_json::to_vec(&v).unwrap()), Err(vectorcraft_format::FormatError::NotVectorcraft(_))));
|
||
v["format"] = json!("vectorcraft");
|
||
v["images"] = json!({"x": {"mime": "image/png", "data": "not base64!"}});
|
||
assert!(matches!(load(&serde_json::to_vec(&v).unwrap()), Err(vectorcraft_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();
|
||
}
|