Files
vectorcraft/crates/engine/src/tests_fileinfo.rs
T
bflatasticandClaude Opus 5.5 6a446b4fa3 M4.55: File Info with author, description, keyword chips, rating and copyright, written to SVG, PDF and PNG
file.info takes and reports the title, author, author title, description,
keywords, rating, copyright status, notice and URL in one undo step, plus
the read-only created (file.new) and modified (every save to a file) dates;
junk values are refused. Document.metadata (DocMetadata) round-trips
natively. PDF export writes author, subject and keywords to the info
dictionary and XMP, PNG export writes tEXt/iTXt chunks, SVG export writes
<desc> and, with the metadata option, every field as Dublin Core. The File
Info dialog (fileInfo, ui.fileInfoDialog) edits it with keyword chips and
star ratings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:55:47 +07:00

164 lines
6.9 KiB
Rust

//! File → File Info (`file.info`): editing as one undo step, the native round trip, and the PDF,
//! PNG and SVG metadata it writes.
use serde_json::{Value, json};
use vectorcraft_doc::{CopyrightStatus, DocMetadata};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80, "title": "Poster"})).unwrap();
s
}
fn info() -> Value {
json!({
"author": "Ada Lovelace",
"authorTitle": "Designer",
"description": "A poster\nfor the fair",
"keywords": "fair, Poster; print, poster",
"rating": 4,
"copyrightStatus": "copyrighted",
"copyrightNotice": "(c) 2026 Ada",
"copyrightUrl": "https://example.com/rights",
})
}
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
}
#[test]
fn file_info_is_one_undo_step_and_reports_everything() {
let mut s = session();
let r = s.execute("file.info", &info()).unwrap();
assert_eq!(r["keywords"], json!(["fair", "Poster", "print"]), "keywords split, trimmed, each once ignoring case");
assert_eq!((r["rating"].as_u64(), r["copyrightStatus"].as_str()), (Some(4), Some("copyrighted")));
assert_eq!(r["description"], "A poster\nfor the fair");
assert!(r["created"].as_str().is_some_and(|c| c.ends_with('Z')), "a new document has its creation date: {}", r["created"]);
assert!(r["modified"].is_null(), "never saved");
assert_eq!(r["title"], "Poster");
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), 1);
assert_eq!(st.doc.metadata.author, "Ada Lovelace");
// The report fed back changes nothing (read-only values are ignored).
let again = s.execute("file.info", &r).unwrap();
assert_eq!(again, r);
assert_eq!(s.doc().unwrap().history.undo.len(), 1);
s.execute("edit.undo", &json!({})).unwrap();
let m = &s.doc().unwrap().doc.metadata;
assert_eq!((m.author.as_str(), m.keywords.len()), ("", 0));
// Clearing a field with null.
s.execute("edit.redo", &json!({})).unwrap();
s.execute("file.info", &json!({"author": null, "keywords": []})).unwrap();
let m = &s.doc().unwrap().doc.metadata;
assert_eq!((m.author.as_str(), m.keywords.len(), m.rating), ("", 0, 4));
}
#[test]
fn junk_is_rejected() {
let mut s = session();
for bad in [
json!({"rating": 9}),
json!({"rating": -1}),
json!({"rating": "five"}),
json!({"keywords": 5}),
json!({"keywords": ["ok", 3]}),
json!({"copyrightStatus": "maybe"}),
json!({"copyrightUrl": "not a url"}),
json!({"author": "two\nlines"}),
json!({"description": "bell\u{7}"}),
json!({"title": " "}),
json!({"author": 3}),
json!({"favouriteColour": "blue"}),
json!([1, 2]),
json!({"keywords": "x".repeat(300)}),
] {
assert!(s.execute("file.info", &bad).is_err(), "{bad}");
}
assert_eq!(s.doc().unwrap().history.undo.len(), 0);
assert_eq!(s.doc().unwrap().doc.metadata, DocMetadata { created: s.doc().unwrap().doc.metadata.created, ..Default::default() });
}
#[test]
fn file_info_round_trips_and_old_files_load() {
let mut s = session();
s.execute("file.info", &info()).unwrap();
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save_file(d)).unwrap();
assert_eq!(back.metadata, d.metadata);
assert_eq!(back.metadata.copyright_status, CopyrightStatus::Copyrighted);
// A document saved before File Info existed has none.
let mut old = vectorcraft_doc::Document::new(10.0, 10.0);
old.metadata = DocMetadata::default();
let bytes = vectorcraft_format::save_file(&old);
assert!(!String::from_utf8_lossy(&bytes).contains("\"metadata\""), "default File Info isn't written");
assert_eq!(vectorcraft_format::load(&bytes).unwrap().metadata, DocMetadata::default());
}
#[test]
fn saving_to_a_file_stamps_the_modified_date() {
let mut s = session();
let created = s.doc().unwrap().doc.metadata.created;
let path = std::env::temp_dir().join(format!("vc-fileinfo-{}.vectorcraft", std::process::id()));
s.execute("document.save", &json!({"path": path.to_string_lossy()})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.metadata.modified.is_some() && st.doc.metadata.created == created);
assert!(!st.is_dirty(), "the stamped document is the saved one");
let back = vectorcraft_format::load(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(back.metadata.modified, st.doc.metadata.modified);
let _ = std::fs::remove_file(&path);
assert!(s.execute("file.info", &json!({})).unwrap()["modified"].is_string());
}
#[test]
fn pdf_info_carries_author_subject_and_keywords() {
let mut s = session();
s.execute("file.info", &info()).unwrap();
let pdf = crate::export_pdf(&s.doc().unwrap().doc, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
let text = String::from_utf8_lossy(&pdf);
// (The description has a line break, so it is written as UTF-16.)
for want in ["/Author(Ada Lovelace)", "/Keywords(fair, Poster, print)", "/Title(Poster)", "/Subject<FEFF0041"] {
assert!(text.contains(want), "PDF Info has {want}");
}
// XMP too.
assert!(text.contains("<dc:creator>") && text.contains("Ada Lovelace"));
}
#[test]
fn png_exports_carry_text_chunks() {
let mut s = session();
s.execute("file.info", &info()).unwrap();
let png = b64(&s.execute("document.export", &json!({"format": "png"})).unwrap());
let text = crate::cmd::fileio::pngtext::text(&png);
let get = |k: &str| text.iter().find(|(key, _)| key == k).map(|(_, v)| v.as_str());
assert_eq!(get("Title"), Some("Poster"));
assert_eq!(get("Author"), Some("Ada Lovelace"));
assert_eq!(get("Keywords"), Some("fair, Poster, print"));
assert_eq!(get("Copyright"), Some("(c) 2026 Ada"));
assert!(get("Creation Time").is_some_and(|t| t.ends_with('Z')));
assert!(image::load_from_memory(&png).is_ok(), "still a valid PNG");
}
#[test]
fn svg_metadata_carries_file_info_as_dublin_core() {
let mut s = session();
s.execute("file.info", &info()).unwrap();
let svg = String::from_utf8(b64(&s.execute("document.export", &json!({"format": "svg", "metadata": true})).unwrap())).unwrap();
for want in [
"<dc:title>Poster</dc:title>",
"<dc:creator>Ada Lovelace</dc:creator>",
"<dc:subject>fair</dc:subject>",
"<dc:subject>print</dc:subject>",
"<dc:rights>(c) 2026 Ada</dc:rights>",
"<dc:rights>https://example.com/rights</dc:rights>",
"<desc>A poster\nfor the fair</desc>",
] {
assert!(svg.contains(want), "SVG has {want}");
}
// Without the option: no <metadata>, but the title and description stay.
let plain = String::from_utf8(b64(&s.execute("document.export", &json!({"format": "svg"})).unwrap())).unwrap();
assert!(!plain.contains("<metadata>") && plain.contains("<desc>"));
}