Files
vectorcraft/crates/engine/src/tests_links.rs
T
bflatasticandClaude Opus 5.5 2925e7f9b5 M4.63: Links panel with status, Relink, Go To, Update, Edit Original, Embed/Unembed, Link Info and Placement Options
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>
2026-10-05 12:40:05 +07:00

331 lines
15 KiB
Rust
Raw 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.
//! Linked images: what a link records, the preview saved in place of the pixels, finding files
//! that moved, missing and modified files (`document.open`, `links.check`, `links.update`,
//! `links.relink`).
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use vectorcraft_doc::{Document, ImageObject, LinkInfo, NodeKind};
use vectorcraft_geom::Rect;
use super::*;
/// A fresh folder for one test (removed when dropped).
pub(crate) struct Folder(pub(crate) PathBuf);
impl Folder {
pub(crate) fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("vectorcraft-links-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
pub(crate) fn file(&self, name: &str) -> String {
self.0.join(name).to_string_lossy().into_owned()
}
}
impl Drop for Folder {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A `w`×`h` PNG of one colour.
pub(crate) fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
let mut out = vec![];
let [r, g, b] = rgb;
image::RgbaImage::from_pixel(w, h, image::Rgba([r, g, b, 255])).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
pub(crate) const RED: [u8; 3] = [230, 20, 20];
pub(crate) const BLUE: [u8; 3] = [20, 20, 230];
pub(crate) fn write(path: &str, bytes: &[u8]) {
if let Some(dir) = Path::new(path).parent() {
std::fs::create_dir_all(dir).unwrap();
}
std::fs::write(path, bytes).unwrap();
}
pub(crate) fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
/// Place the file at `path` (linked) → its id.
pub(crate) fn place(s: &mut Session, path: &str) -> NodeId {
let r = s.execute("file.place", &json!({"path": path, "at": [200, 150]})).unwrap();
assert_eq!(r["linked"], true, "{r}");
NodeId(r["ids"][0].as_u64().unwrap())
}
pub(crate) fn image(s: &Session, id: NodeId) -> ImageObject {
match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Image(im) => im.clone(),
k => panic!("not an image: {k:?}"),
}
}
pub(crate) fn bounds(s: &Session, id: NodeId) -> Rect {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
}
/// The colour at the centre of the active document's artboard.
pub(crate) fn centre_colour(doc: &Document) -> [u8; 3] {
let img = vectorcraft_render::Renderer::new().render_region(doc, Rect::new(0.0, 0.0, 400.0, 300.0), 1.0, true);
let [r, g, b, _] = img.pixel(200, 150);
[r, g, b]
}
pub(crate) fn near(a: [u8; 3], b: [u8; 3]) -> bool {
a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 6)
}
/// The first image object's JSON in a saved document.
fn saved_image(v: &mut Value) -> &mut Value {
&mut v["document"]["layers"][0]["kind"]["children"][0]["kind"]
}
pub(crate) fn save(s: &mut Session, path: &str) {
s.execute("document.save", &json!({"path": path})).unwrap();
}
pub(crate) fn open(s: &mut Session, path: &str) -> Value {
s.execute("document.open", &json!({"path": path})).unwrap()
}
#[test]
fn a_placed_link_records_the_file_and_the_document_saves_only_a_preview() {
let dir = Folder::new("record");
let pic = dir.file("art/photo.png");
let bytes = png(600, 300, RED);
write(&pic, &bytes);
let mut s = session();
let id = place(&mut s, &pic);
let im = image(&s, id);
let link = im.link.clone().unwrap();
assert_eq!((link.path.as_str(), link.size), (pic.as_str(), Some(bytes.len() as u64)));
assert_eq!(link.hash.as_deref(), Some(vectorcraft_doc::links::hash_bytes(&bytes).as_str()));
assert!(link.modified.is_some_and(|m| m > 1_600_000_000_000), "{link:?}");
let blob = &s.doc().unwrap().doc.images[&im.key];
assert!(blob.proxy.is_some() && !blob.is_proxy(), "full pixels, with a preview to save");
let doc_path = dir.file("poster.vectorcraft");
save(&mut s, &doc_path);
let mut saved: Value = serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap();
let entry = &saved["images"][&im.key];
assert_eq!(entry["proxy"], true);
assert!(entry["data"].as_str().unwrap().len() * 3 / 4 < bytes.len(), "the preview is smaller than the file");
let saved_link = saved_image(&mut saved)["link"].clone();
assert_eq!((saved_link["path"].as_str(), saved_link["relative"].as_str()), (Some(pic.as_str()), Some("art/photo.png")));
// Embedding keeps the pixels, and no preview is saved for them.
s.execute("file.place", &json!({"path": pic, "link": false})).unwrap();
save(&mut s, &doc_path);
let saved: Value = serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap();
assert_eq!(saved["images"][&im.key]["proxy"], Value::Null, "an embedded image shows it too");
}
#[test]
fn the_relative_path_finds_the_file_after_the_folder_moves() {
let a = Folder::new("move-a");
let b = Folder::new("move-b");
let pic = a.file("art/photo.png");
write(&pic, &png(600, 300, RED));
let mut s = session();
let id = place(&mut s, &pic);
save(&mut s, &a.file("poster.vectorcraft"));
// Move the document with its links: the old folder is gone.
std::fs::remove_dir_all(&b.0).unwrap();
std::fs::rename(&a.0, &b.0).unwrap();
let r = open(&mut s, &b.file("poster.vectorcraft"));
assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}");
let moved = b.file("art/photo.png");
let im = image(&s, id);
assert_eq!(Path::new(&im.link.unwrap().path), Path::new(&moved), "the link follows the file");
assert!(!s.doc().unwrap().doc.images[&im.key].is_proxy(), "the file's pixels were read");
let c = s.execute("links.check", &json!({})).unwrap();
assert_eq!((c["links"][0]["status"].as_str(), c["missing"].as_u64()), (Some("ok"), Some(0)), "{c}");
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
}
#[test]
fn a_missing_link_renders_its_preview_and_relinks() {
let dir = Folder::new("missing");
let pic = dir.file("photo.png");
write(&pic, &png(600, 300, RED));
let mut s = session();
let id = place(&mut s, &pic);
let placed = bounds(&s, id);
let doc_path = dir.file("poster.vectorcraft");
save(&mut s, &doc_path);
std::fs::remove_file(&pic).unwrap();
let r = open(&mut s, &doc_path);
assert_eq!(r["missingLinks"], json!([{"name": "photo.png", "path": pic, "ids": [id.0]}]));
let doc = &s.doc().unwrap().doc;
assert!(doc.images[&image(&s, id).key].is_proxy(), "the preview stands in");
assert!(near(centre_colour(doc), RED), "missing still renders");
let c = s.execute("links.check", &json!({})).unwrap();
assert_eq!((c["links"][0]["status"].as_str(), c["links"][0]["preview"].as_bool(), c["missing"].as_u64()), (Some("missing"), Some(true), Some(1)));
assert_eq!(s.execute("links.update", &json!({})).unwrap(), json!({"updated": [], "missing": [id.0]}));
// Replace: another file, smaller; the image keeps its bounds.
let other = dir.file("other/blue.png");
write(&other, &png(300, 100, BLUE));
let r = s.execute("links.relink", &json!({"ids": [id.0], "path": other})).unwrap();
assert_eq!(r, json!({"relinked": [id.0], "notFound": []}));
let im = image(&s, id);
assert_eq!((im.width, im.height, im.link.unwrap().path), (300, 100, other.clone()));
let b = bounds(&s, id);
assert!((b.x0 - placed.x0).abs() < 1e-6 && (b.y1 - placed.y1).abs() < 1e-6, "{b:?} vs {placed:?}");
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "one undo step");
// Relink to Folder: the file of the link's name there.
write(&dir.file("found/photo.png"), &png(600, 300, BLUE));
let r = s.execute("links.relink", &json!({"folder": dir.file("found")})).unwrap();
assert_eq!(r, json!({"relinked": [id.0], "notFound": []}), "nothing selected: every missing link");
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
assert!(s.execute("links.relink", &json!({"ids": [id.0]})).is_err(), "path or folder");
}
#[test]
fn a_modified_link_follows_the_update_links_preference() {
let dir = Folder::new("modified");
let pic = dir.file("photo.png");
write(&pic, &png(600, 300, RED));
let mut s = session();
let id = place(&mut s, &pic);
let placed = bounds(&s, id);
let doc_path = dir.file("poster.vectorcraft");
save(&mut s, &doc_path);
write(&pic, &png(400, 400, BLUE));
let c = s.execute("links.check", &json!({})).unwrap();
assert_eq!((c["links"][0]["status"].as_str(), c["modified"].as_u64()), (Some("modified"), Some(1)), "{c}");
// Ask When Modified (the default): reported, left as it was.
let r = open(&mut s, &doc_path);
assert_eq!((r["modifiedLinks"][0]["ids"].clone(), r["updatedLinks"].clone()), (json!([id.0]), json!([])), "{r}");
assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "the preview until updated");
let r = s.execute("links.update", &json!({})).unwrap();
assert_eq!(r, json!({"updated": [id.0], "missing": []}));
let im = image(&s, id);
assert_eq!((im.width, im.height), (400, 400));
let b = bounds(&s, id);
assert!((b.width() - placed.width()).abs() < 1e-6 && (b.height() - placed.height()).abs() < 1e-6, "keeps its bounds: {b:?}");
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Update Links");
let c = s.execute("links.check", &json!({})).unwrap();
assert_eq!(c["links"][0]["status"], "ok");
assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], json!([]), "nothing left to update");
// Automatically: read again as the document opens.
s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap();
let r = open(&mut s, &doc_path);
assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"][0]["ids"].clone()), (json!([]), json!([id.0])), "{r}");
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
assert!(s.doc().unwrap().history.undo.is_empty(), "opening is not an undo step");
}
#[test]
fn links_from_older_files_load_and_keep_their_pixels() {
let dir = Folder::new("legacy");
let pic = dir.file("photo.png");
let bytes = png(600, 300, RED);
write(&pic, &bytes);
let mut s = session();
s.execute("file.place", &json!({"path": pic, "at": [200, 150], "link": false})).unwrap();
// As older versions saved a link: the path alone, the pixels embedded.
let mut v: Value = serde_json::from_slice(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
saved_image(&mut v)["link"] = json!(pic);
v["format"] = json!("drawcraft");
let legacy = serde_json::to_vec(&v).unwrap();
let doc_path = dir.file("old.drawcraft");
write(&doc_path, &legacy);
let r = open(&mut s, &doc_path);
assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}");
let st = s.doc().unwrap();
let id = st.doc.layers[0].children().unwrap()[0].id;
let im = image(&s, id);
let link = im.link.unwrap();
assert_eq!((link.path.as_str(), link.size), (pic.as_str(), Some(bytes.len() as u64)), "the details are filled in");
assert!(s.doc().unwrap().doc.images[&im.key].proxy.is_some(), "a preview to save from now on");
// Gone, the embedded pixels still show.
std::fs::remove_file(&pic).unwrap();
let r = open(&mut s, &doc_path);
assert_eq!(r["missingLinks"][0]["name"], "photo.png");
assert!(!s.doc().unwrap().doc.images[&im.key].is_proxy());
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
}
#[test]
fn without_a_file_system_the_saved_preview_shows() {
// What the web does: the document arrives as bytes and no linked file can be read.
let dir = Folder::new("web");
let pic = dir.file("photo.png");
write(&pic, &png(600, 300, RED));
let mut s = session();
let id = place(&mut s, &pic);
let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false);
std::fs::remove_file(&pic).unwrap();
let doc = vectorcraft_format::load(&saved).unwrap();
assert!(doc.images.values().all(|b| b.is_proxy()));
assert!(near(centre_colour(&doc), RED), "the preview renders");
let r = s.execute("document.open", &json!({"name": "poster.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(&saved)})).unwrap();
assert_eq!(r["missingLinks"][0]["ids"], json!([id.0]));
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
// Exports carry the preview.
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
assert!(svg["text"].as_str().unwrap().contains("data:image/png;base64,"));
}
#[test]
fn relative_paths_are_written_against_the_saved_file() {
let mut d = Document::new(10.0, 10.0);
let (lid, nid) = (d.layers[0].id, d.alloc_id());
let root = if cfg!(windows) { "C:\\work" } else { "/work" };
let sep = std::path::MAIN_SEPARATOR;
let im = ImageObject {
key: "k".into(),
width: 1,
height: 1,
xf: Default::default(),
link: Some(LinkInfo::new(format!("{root}{sep}art{sep}a.png"))),
placement: Default::default(),
};
d.insert(Some(lid), 0, vectorcraft_doc::Node::new(nid, NodeKind::Image(im))).unwrap();
let rel = |dest: String| {
let d = cmd::links::with_relative_paths(&d, &dest).unwrap_or_else(|| d.clone());
match &d.node(nid).unwrap().kind {
NodeKind::Image(im) => im.link.as_ref().unwrap().relative.clone(),
_ => None,
}
};
assert_eq!(rel(format!("{root}{sep}doc.vectorcraft")).as_deref(), Some("art/a.png"));
assert_eq!(rel(format!("{root}{sep}docs{sep}deep{sep}doc.vectorcraft")).as_deref(), Some("../../art/a.png"));
assert_eq!(rel(format!("{root}{sep}art{sep}doc.vectorcraft")).as_deref(), Some("a.png"));
if cfg!(windows) {
assert_eq!(rel("D:\\other\\doc.vectorcraft".into()), None, "another drive");
assert_eq!(rel("c:\\WORK\\doc.vectorcraft".into()).as_deref(), Some("art/a.png"), "without case");
}
}
#[test]
fn a_placed_document_brings_its_links_with_the_files_pixels() {
let dir = Folder::new("place-doc");
let pic = dir.file("photo.png");
write(&pic, &png(600, 300, RED));
let mut s = session();
place(&mut s, &pic);
let doc_path = dir.file("part.vectorcraft");
save(&mut s, &doc_path);
let mut s = session();
s.execute("file.place", &json!({"path": doc_path})).unwrap();
let doc = &s.doc().unwrap().doc;
let mut placed = vec![];
doc.visit_images(|_, im| placed.push(im.clone()));
let [im] = &placed[..] else { panic!("one image: {placed:?}") };
assert_eq!(im.link.as_ref().map(|l| l.path.as_str()), Some(pic.as_str()), "still linked");
assert!(!doc.images[&im.key].is_proxy(), "the file's pixels, not the preview");
}