vectorcraft/crates/format/tests/streaming.rs

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");
}
}