Files
vectorcraft/crates/format/tests/streaming.rs
arhammusheerandClaude Opus 5.5 023c8e3edd #1244: streaming open and save, per-section limits, recovery that keeps images apart, background open
Save serializes JSON straight into the compressor and writes images from their Arc into the
file; open reads each image into its own buffer, and NodeKind reads its tag first instead of
buffering every node. Already-compressed images are stored rather than deflated again in the
same gzip stream. Limits apply per section, so any file VectorCraft writes opens again. Data
Recovery writes each image once, content-addressed, instead of every interval. Native files open
on a background thread. Files stay readable by main both ways; JSON-only saves are identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
2026-10-12 11:02:33 +07:00

200 lines
8.8 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Saving and opening as streams: the bytes a save writes are those an in-memory save wrote
//! before (the same JSON and data after it, unpacked; the same gzip bytes when no image is
//! compressed already), files written the old way still open, entries are read whatever their
//! order, and the limits apply per part of the file.
// 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 std::collections::BTreeMap;
use std::io::{Read, Write};
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_format::{BLOB_MAGIC, FormatError, INLINE_MAX, MAX_BLOB, SaveOptions, load, load_file, pdf_content, preview, save_to, save_with};
use vectorcraft_geom::Affine;
/// A document showing images `(key, mime, len)`.
fn doc_with(images: &[(&str, &str, usize)]) -> Document {
let mut d = Document::new(100.0, 100.0);
let l = d.layers[0].id;
for (n, &(key, mime, len)) in images.iter().enumerate() {
let bytes: Vec<u8> = (0..len).map(|i| ((i * 7 + n * 13) % 251) as u8).collect();
d.images.insert(key.into(), ImageBlob::new(mime, bytes));
let id = d.alloc_id();
let im = ImageObject { key: key.into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default() };
d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
}
d
}
/// gzip as the app compressed files before saves were streamed (the whole file at once).
fn gzip(bytes: &[u8]) -> Vec<u8> {
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
gz.write_all(bytes).unwrap();
gz.finish().unwrap()
}
fn gunzip(bytes: &[u8]) -> Vec<u8> {
let mut out = vec![];
flate2::read::GzDecoder::new(bytes).read_to_end(&mut out).unwrap();
out
}
fn mixed() -> Document {
doc_with(&[
("photo", "image/jpeg", INLINE_MAX * 3),
("scan", "image/tiff", INLINE_MAX * 2),
("icon", "image/png", 100),
("art", "image/png", INLINE_MAX + 1),
])
}
#[test]
fn compressed_files_unpack_to_the_plain_file() {
let pdf = vec![b'%'; INLINE_MAX * 2];
let profiles = BTreeMap::from([("Mine".to_string(), vec![7u8; INLINE_MAX + 3])]);
for d in [Document::new(10.0, 10.0), mixed()] {
let o = SaveOptions { pdf: Some(pdf.clone()), profiles: profiles.clone(), preview: Some(vec![1, 2, 3]), ..SaveOptions::default() };
let plain = save_with(&d, &o).unwrap();
let packed = save_with(&d, &SaveOptions { compress: true, ..o }).unwrap();
assert_eq!(gunzip(&packed), plain, "the same file, compressed");
let back = load_file(&packed).unwrap();
for (k, b) in &d.images {
assert_eq!(back.doc.images[k].bytes, b.bytes, "{k}");
}
assert_eq!(back.profiles, profiles);
assert_eq!(pdf_content(&packed).unwrap(), pdf);
assert_eq!(preview(&packed).unwrap(), vec![1, 2, 3]);
}
}
#[test]
fn without_compressed_images_the_gzip_bytes_are_the_old_ones() {
let mut d = doc_with(&[("scan", "image/tiff", INLINE_MAX * 2), ("icon", "image/png", 100)]);
d.title = "No photos".into();
for o in [SaveOptions::default(), SaveOptions { pdf: Some(vec![b'%'; INLINE_MAX]), ..SaveOptions::default() }] {
let plain = save_with(&d, &o).unwrap();
assert_eq!(save_with(&d, &SaveOptions { compress: true, ..o }).unwrap(), gzip(&plain));
}
}
#[test]
fn files_compressed_the_old_way_still_open() {
let d = mixed();
let old = gzip(&save_with(&d, &SaveOptions::default()).unwrap());
let back = load(&old).unwrap();
assert_eq!(back.images.len(), d.images.len());
for (k, b) in &d.images {
assert_eq!(back.images[k].bytes, b.bytes, "{k}");
}
assert_eq!(back.node_count(), d.node_count());
}
#[test]
fn save_to_writes_what_save_with_returns() {
let d = mixed();
for compress in [false, true] {
let o = SaveOptions { compress, ..SaveOptions::default() };
let mut streamed = vec![];
save_to(&d, &o, &mut streamed).unwrap();
assert_eq!(streamed, save_with(&d, &o).unwrap());
let back = load(&streamed).unwrap();
assert_eq!(back.images["photo"].bytes, d.images["photo"].bytes);
}
// A failing writer fails the save.
struct Full;
impl Write for Full {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("disk full"))
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
assert!(save_to(&d, &SaveOptions::default(), Full).is_err());
assert!(save_to(&d, &SaveOptions { compress: true, ..SaveOptions::default() }, Full).is_err());
}
/// `plain` with its JSON changed by `edit` (the data after it kept).
fn with_json(plain: &[u8], edit: impl FnOnce(&mut serde_json::Value)) -> Vec<u8> {
let at = plain.windows(BLOB_MAGIC.len()).position(|w| w == BLOB_MAGIC).unwrap();
let mut v: serde_json::Value = serde_json::from_slice(&plain[..at]).unwrap();
edit(&mut v);
let mut out = serde_json::to_vec(&v).unwrap();
out.extend_from_slice(&plain[at..]);
out
}
#[test]
fn entries_in_any_order_or_shared_read_back() {
let d = doc_with(&[("a", "image/png", INLINE_MAX), ("b", "image/png", INLINE_MAX + 5)]);
let plain = save_with(&d, &SaveOptions::default()).unwrap();
// Swap the entries' places (b's bytes listed first), and point a third image at a's bytes.
let file = with_json(&plain, |v| {
let (a, b) = (v["images"]["a"]["blob"].clone(), v["images"]["b"]["blob"].clone());
v["images"]["a"]["blob"] = b.clone();
v["images"]["b"]["blob"] = a.clone();
v["images"]["c"] = serde_json::json!({"mime": "image/png", "blob": b});
});
for bytes in [file.clone(), gzip(&file)] {
let back = load(&bytes).unwrap();
assert_eq!(back.images["a"].bytes, d.images["b"].bytes);
assert_eq!(back.images["b"].bytes, d.images["a"].bytes);
assert_eq!(back.images["c"].bytes, d.images["b"].bytes);
}
}
#[test]
fn limits_apply_per_entry_and_to_all_entries() {
let d = doc_with(&[("a", "image/png", INLINE_MAX), ("b", "image/png", INLINE_MAX)]);
let plain = save_with(&d, &SaveOptions::default()).unwrap();
// One entry past the limit: refused before anything is allocated.
let huge = with_json(&plain, |v| v["images"]["a"]["blob"] = serde_json::json!([0, MAX_BLOB + 1]));
assert!(matches!(load_file(&gzip(&huge)), Err(FormatError::TooBig)));
// Each within it, together past the limit for all.
let many = with_json(&plain, |v| {
for i in 0..5 {
v["images"][format!("x{i}")] = serde_json::json!({"mime": "image/png", "blob": [0, MAX_BLOB]});
}
});
assert!(matches!(load_file(&gzip(&many)), Err(FormatError::TooBig)));
// A length the data doesn't have: the image is damaged, nothing more.
let short = with_json(&plain, |v| v["images"]["a"]["blob"] = serde_json::json!([0, 1u64 << 20]));
assert!(matches!(load_file(&gzip(&short)), Err(FormatError::BadImage(_))));
// Uncompressed files have no limits to apply (the file is all there is).
assert!(matches!(load_file(&huge), Err(FormatError::BadImage(_))));
}
#[test]
fn a_damaged_compressed_file_is_refused() {
let bytes = save_with(&mixed(), &SaveOptions { compress: true, ..SaveOptions::default() }).unwrap();
// The data after the JSON cut short, then its checksum wrong.
assert!(load_file(&bytes[..bytes.len() - 4000]).is_err());
let mut bad = bytes.clone();
let n = bad.len();
bad[n - 6] ^= 0xff;
assert!(load_file(&bad).is_err(), "the checksum is checked to the end");
}
#[test]
fn documents_read_whatever_their_key_order_and_keep_keys_they_dont_know() {
let d = mixed();
let plain = save_with(&d, &SaveOptions::default()).unwrap();
let file = with_json(&plain, |v| {
// serde_json's map keeps keys sorted: `type` is no longer first in the node kinds (a
// layer's `children`, `clip` and `color` come before it), and the document's fields come
// in another order.
v["document"]["aaa_from_a_newer_version"] = serde_json::json!({"x": [1, 2]});
v["document"]["zzz_too"] = serde_json::json!("kept");
});
let text = String::from_utf8_lossy(&file);
assert!(!text.contains(r#"{"type":"layer""#) && text.contains(r#""type":"layer""#), "`type` isn't first");
for bytes in [file.clone(), gzip(&file)] {
let back = load(&bytes).unwrap();
assert_eq!(back.layers, load(&plain).unwrap().layers);
assert_eq!(back.extra["aaa_from_a_newer_version"]["x"], serde_json::json!([1, 2]));
assert_eq!(back.extra["zzz_too"], "kept");
// Saved again, they are kept.
assert_eq!(load(&save_with(&back, &SaveOptions::default()).unwrap()).unwrap().extra["zzz_too"], "kept");
}
}