Files
figcraft/crates/format/src/library_tests.rs
T
BFlatandClaude Opus 5.5 360b6df206 M4: local libraries: publish, enable, place and apply, updates with overrides kept (#32)
- Publish (`library.publish {description?, include?, exclude?}`, Libraries > Publish library…):
  the file's components, component sets and styles (by default all but names starting with `.` or
  `_`, or what the last publish offered) and every component and style they use go into a snapshot
  with a manifest (library id, version, per-asset version stamps). An asset keeps the version it
  last changed in; publishing without changes is refused.
- .figcraft: `library/manifest.json`, `library/document.json` (the snapshot) and
  `library/images/<sha256>` for images only the library still uses. Older files and readers are
  unaffected (extra members); a damaged library is skipped and reported when the author's file
  opens. Files using a library read only these members (size-limited), never unpublished edits.
- Version history: the library entries are written and read alongside the versions (a library
  survives saving with versions and reopening); restoring a version keeps what the file published.
- Enable: `library.add {path}` / `library.remove {id}` / `library.list {assets?}`; the document
  keeps its enabled libraries (id, name, path, last seen version) in its data. The session caches
  each library file and reads it again when it changes on disk; in the browser these commands
  return a clear error (no file system).
- Use: `library.place {library, component, x?, y?}` / `library.import` copy the component (with
  the components and styles it uses) into a hidden library store (a page-like root outside the
  page list) as read-only main components and place an instance; instances behave like any other
  (overrides, properties, variants, swap, reset, detach). `library.applyStyle` copies a style in as
  a linked remote style. Editing the copies or library styles is refused; Go to main component
  names the library. Importing a file's pages brings its library copies along (shared with copies
  the document already has).
- Updates: `library.updates` and `library.acceptUpdates {ids?}` re-import changed copies in place
  (layer ids kept, so instance overrides survive, also when layers are added); unused copies are
  dropped when a library is removed or updated.
- Assets panel: each enabled library's components (thumbnails, search, click or drag to place,
  shared with the local tile code) and styles (click to apply, paint swatches), collapsible per
  library; "Library updates available" banner with Update; a libraries view (book button,
  Libraries menu) to publish this file (choose assets, description) and add or remove library
  files. Local styles in the panels list only this file's styles.
- Tests: manifest round trip and versions, add/place/update keeping overrides (incl. a structural
  update), variants/swap/undo, library styles and importing pages, missing/corrupt/truncated/
  oversized/never-published files; format tests for damaged and oversized members.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:24:46 +07:00

144 lines
6.8 KiB
Rust

//! Published libraries inside `.figcraft` archives.
use std::io::{Read, Write};
use super::*;
use crate::{add_image, load_bytes, save_bytes};
use figcraft_doc::color::{ImageScaleMode, Paint};
use figcraft_doc::{Document, LibraryAsset, NodeKind};
fn png() -> Vec<u8> {
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([0, 128, 255, 255]));
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// A published file: component "Tile" with an image fill; the file itself no longer uses the
/// image (it only lives on in the library).
fn published() -> (Document, figcraft_doc::NodeId, String) {
let mut d = Document::new("Kit");
let p = d.first_page().unwrap();
let tile = d.create(p, NodeKind::Component { description: String::new(), props: Default::default() }, "Tile", 0.0, 0.0, 10.0, 10.0).unwrap();
let (hash, _, _) = add_image(&mut d, png()).unwrap();
d.get_mut(tile).unwrap().fills = vec![Paint::image(hash.clone(), ImageScaleMode::Fill)];
let snap = d.clone();
d.images.clear();
d.get_mut(tile).unwrap().fills.clear();
let manifest = LibraryManifest {
id: "kit-1".into(),
name: "Kit".into(),
version: 3,
components: vec![
LibraryAsset { id: tile, name: "Tile".into(), set: false, version: 2, published: true },
LibraryAsset { id: figcraft_doc::NodeId(999), name: "Gone".into(), set: false, version: 1, published: true },
],
..Default::default()
};
d.published = Some(Arc::new(PublishedLibrary { manifest, doc: Arc::new(snap) }));
(d, tile, hash)
}
#[test]
fn library_roundtrip_with_its_own_images() {
let (d, tile, hash) = published();
let bytes = save_bytes(&d, None).unwrap();
// The author's file: its library comes back, with the image only the library uses.
let (back, issues) = load_bytes(&bytes).unwrap();
assert!(issues.is_empty(), "{issues:?}");
assert!(back.images.is_empty(), "the file itself doesn't use the image");
let lib = back.published.unwrap();
assert_eq!(lib.manifest.version, 3);
assert!(lib.doc.images.get(&hash).is_some_and(|i| (i.width, i.height) == (2, 2)));
assert!(lib.manifest.component(figcraft_doc::NodeId(999)).is_none(), "assets the snapshot lacks are dropped");
assert!(lib.manifest.component(tile).is_some());
// Files using it read only the library entries.
let read = read_library(std::io::Cursor::new(&bytes)).unwrap().unwrap();
assert_eq!(read.manifest, lib.manifest);
assert!(read.doc.images.contains_key(&hash));
// Saving the loaded file again keeps everything.
let again = save_bytes(&back_with(&bytes), None).unwrap();
assert_eq!(read_library(std::io::Cursor::new(&again)).unwrap().unwrap().manifest, lib.manifest);
}
fn back_with(bytes: &[u8]) -> Document {
load_bytes(bytes).unwrap().0
}
/// `zip_bytes` with member `name` replaced by `bytes` (or removed when `None`).
fn replace_member(zip_bytes: &[u8], name: &str, bytes: Option<&[u8]>) -> Vec<u8> {
let mut src = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).unwrap();
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..src.len() {
let mut f = src.by_index(i).unwrap();
let n = f.name().to_string();
let mut data = Vec::new();
f.read_to_end(&mut data).unwrap();
let data = if n == name { bytes.map(<[u8]>::to_vec) } else { Some(data) };
if let Some(data) = data {
out.start_file(n.as_str(), zip::write::SimpleFileOptions::default()).unwrap();
out.write_all(&data).unwrap();
}
}
out.finish().unwrap().into_inner()
}
#[test]
fn damaged_libraries_are_errors_never_crashes() {
let (d, _, hash) = published();
let good = save_bytes(&d, None).unwrap();
let cases: [(&str, Option<&[u8]>); 6] = [
("library/manifest.json", Some(b"{nope")),
("library/manifest.json", Some(br#"{"id": "../../evil", "version": 1}"#)),
("library/manifest.json", Some(br#"{"id": "ok", "components": [{"id": "x"}]}"#)),
("library/document.json", Some(b"[1, 2]")),
("library/document.json", Some(br#"{"version": 999}"#)),
("library/document.json", None),
];
for (member, bytes) in cases {
let damaged = replace_member(&good, member, bytes);
assert!(read_library(std::io::Cursor::new(&damaged)).is_err(), "{member} {bytes:?}");
// The author's own file still opens: the library is skipped and reported.
let (doc, issues) = load_bytes(&damaged).unwrap();
assert!(doc.published.is_none() && issues.iter().any(|i| i.contains("library")), "{issues:?}");
}
// A missing image shows as a placeholder; the library still reads.
let no_image = replace_member(&good, &format!("library/images/{hash}"), None);
let lib = read_library(std::io::Cursor::new(&no_image)).unwrap().unwrap();
assert!(lib.doc.images.is_empty());
// Not an archive at all.
assert!(read_library(std::io::Cursor::new(b"PK\x03\x04 nope")).is_err());
assert!(read_library(std::io::Cursor::new(b"")).is_err());
}
#[test]
fn oversized_members_are_refused() {
let (d, _, _) = published();
let good = save_bytes(&d, None).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&good)).unwrap();
assert!(read_member(&mut z, MANIFEST, 4).is_err_and(|e| e.to_string().contains("too large")));
assert!(read_member(&mut z, MANIFEST, MAX_MANIFEST).is_ok());
assert!(read_member(&mut z, "library/none.json", MAX_MANIFEST).is_err());
assert!(is_valid_library_id("a1_B-2") && !is_valid_library_id("") && !is_valid_library_id("a/b") && !is_valid_library_id(&"x".repeat(65)));
}
#[test]
fn library_survives_a_save_with_versions() {
let (d, tile, hash) = published();
let mut old = d.clone();
old.published = None;
let versions = [crate::Version::new("v1", "Before", "", 1, Arc::new(old))];
let bytes = crate::save_bytes_with_versions(&d, None, &versions).unwrap();
let loaded = crate::load_bytes_with_versions(&bytes).unwrap();
assert!(loaded.issues.is_empty(), "{:?}", loaded.issues);
assert_eq!(loaded.versions.len(), 1);
let lib = loaded.doc.published.clone().unwrap();
assert_eq!((lib.manifest.id.as_str(), lib.manifest.version), ("kit-1", 3));
assert!(lib.doc.images.contains_key(&hash) && lib.manifest.component(tile).is_some());
assert!(read_library(std::io::Cursor::new(&bytes)).unwrap().unwrap().doc.images.contains_key(&hash));
// Saved again with its versions, it is still a library.
let again = crate::save_bytes_with_versions(&loaded.doc, None, &loaded.versions).unwrap();
assert_eq!(read_library(std::io::Cursor::new(&again)).unwrap().unwrap().manifest, lib.manifest);
assert_eq!(crate::load_bytes_with_versions(&again).unwrap().versions.len(), 1);
}