mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
The Links panel lists every image (thumbnail, name, page, missing/modified/embedded badge) with Show filters, sorting and thumbnail sizes; its buttons and flyout relink (to a file or a folder), go to, update, edit the original, embed and unembed, show in folder and open Placement Options. Link Info shows format, colour space, location, ppi and effective ppi, dimensions, scale, rotation, size and dates. Edit > Edit Original and the Properties panel's image section reach the same commands. Engine: links.list, links.goTo, links.embed, links.unembed, links.info and links.placementOptions; links.relink and links.update now place the new file as the image's Placement Options say (transforms, bounds, file dimensions, fit, fill, the alignment point, clip to the old bounds). Model: ImageObject.placement. UI: links.editOriginal, links.reveal, ui.placementOptionsDialog; Services.open_file and pick_folder on the desktop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
186 lines
7.8 KiB
Rust
186 lines
7.8 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, ImageObject, Node, NodeId, NodeKind};
|
||
use vectorcraft_format::{SaveOptions, base64_decode, base64_encode, load, preview, save, save_with, sniff};
|
||
use vectorcraft_geom::Affine;
|
||
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 (saves keep the images in use).
|
||
#[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 layer = d.layers[0].id;
|
||
let id = d.alloc_id();
|
||
let im = ImageObject { key: key.clone(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() };
|
||
d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap();
|
||
for pretty in [false, true] {
|
||
let back = load(&save(&d, pretty)).unwrap();
|
||
prop_assert_eq!(back.images.get(&key).map(|b| b.bytes.as_ref().clone()), Some(data.clone()));
|
||
}
|
||
}
|
||
|
||
/// Compressed and older-version saves of random documents load to the same document.
|
||
#[test]
|
||
fn compressed_and_older_saves_roundtrip(ops in arb_ops(5..30)) {
|
||
let mut s = fixtures::session();
|
||
for op in &ops {
|
||
let _ = op.apply(&mut s);
|
||
}
|
||
let d = s.doc().unwrap().doc.clone();
|
||
let want = doc_json(&load(&save(&d, false)).unwrap());
|
||
for o in [SaveOptions { compress: true, ..SaveOptions::default() }, SaveOptions { version: 2, ..SaveOptions::default() }, SaveOptions { version: 1, pretty: true, ..SaveOptions::default() }] {
|
||
let back = load(&save_with(&d, &o).unwrap()).unwrap();
|
||
prop_assert!(json_approx_eq(&doc_json(&back), &want, 1e-12), "{:?} differs", o);
|
||
}
|
||
}
|
||
|
||
/// Mutating a compressed file (byte flips / truncation) never panics the loader or the sniffer.
|
||
#[test]
|
||
fn load_mutated_compressed_file_never_panics(cut in 0usize..2000, flips in prop::collection::vec((0usize..2000, any::<u8>()), 0..8)) {
|
||
let mut bytes = save_with(&Document::new(100.0, 100.0), &SaveOptions { compress: true, ..SaveOptions::default() }).unwrap();
|
||
for (i, b) in flips {
|
||
let n = bytes.len();
|
||
bytes[i % n] = b;
|
||
}
|
||
bytes.truncate(cut.min(bytes.len()));
|
||
let _ = sniff(&bytes);
|
||
let _ = preview(&bytes);
|
||
if let Ok(d) = load(&bytes) {
|
||
check_document(&d).map_err(TestCaseError::fail)?;
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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();
|
||
}
|