mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 02:30:15 -06:00
The folder's mtime may not move from the save's listing to the list that follows (same clock tick on a fast machine), so the stale cache hid the bad files and problems came back empty - deterministic on a warm build. The test drops the cache as its own wild.json case already did (and as templates_changed documents).
367 lines
19 KiB
Rust
367 lines
19 KiB
Rust
//! Slideshow templates: built-ins, user template files, validation, apply / previous.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use super::*;
|
|
use crate::Session;
|
|
|
|
fn temp_dir(tag: &str) -> PathBuf {
|
|
let d = std::env::temp_dir().join(format!("lc-sstpl-{tag}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
d
|
|
}
|
|
|
|
/// A demo session whose user templates live in a fresh folder.
|
|
fn session(tag: &str) -> (Session, PathBuf) {
|
|
let dir = temp_dir(tag);
|
|
let mut s = Session::with_demo();
|
|
s.slide_templates_dir = Some(dir.join(DIR_NAME));
|
|
(s, dir)
|
|
}
|
|
|
|
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
|
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
|
}
|
|
|
|
fn err(s: &mut Session, id: &str, p: Value) -> String {
|
|
match s.execute(id, &p) {
|
|
Ok(v) => panic!("{id} {p} should fail, got {v}"),
|
|
Err(e) => e.to_string(),
|
|
}
|
|
}
|
|
|
|
fn ids(s: &mut Session) -> Vec<String> {
|
|
run(s, "slideshow.templates.list", json!({}))["templates"].as_array().unwrap().iter().map(|t| t["id"].as_str().unwrap().to_string()).collect()
|
|
}
|
|
|
|
/// A look different from the defaults in every section.
|
|
fn custom(s: &mut Session) {
|
|
run(s, "slideshow.playback", json!({"slideDuration": 7.5, "fadeDuration": 0.5, "fadeColor": "#102030", "randomOrder": true, "repeat": false}));
|
|
run(
|
|
s,
|
|
"slideshow.layout",
|
|
json!({"margins": 0.1, "strokeBorder": true, "strokeColor": "#ff0000", "castShadow": true, "aspect": "4:3", "backdropColor": "#334455"}),
|
|
);
|
|
run(
|
|
s,
|
|
"slideshow.overlays",
|
|
json!({"ratingStars": {"enabled": true}, "addText": {"text": "{caption} — {exposure}", "anchor": "top"}, "showWatermark": true, "watermark": "© me"}),
|
|
);
|
|
run(s, "slideshow.titles", json!({"intro": {"enabled": true, "content": "text", "text": "Hello", "duration": 3}}));
|
|
}
|
|
|
|
#[test]
|
|
fn builtins_are_valid_distinct_and_apply_cleanly() {
|
|
let b = builtins();
|
|
assert_eq!(b.len(), BUILTINS.len(), "every built-in parses");
|
|
assert!((4..=6).contains(&b.len()));
|
|
let mut names: Vec<_> = b.iter().map(|t| t.name.to_lowercase()).collect();
|
|
names.dedup();
|
|
assert_eq!(names.len(), b.len(), "unique names");
|
|
// our own names: none of Lightroom Classic's built-in slideshow template names
|
|
for lr in ["default", "simple", "caption and rating", "crop to fill", "exif metadata", "widescreen"] {
|
|
assert!(!names.contains(&lr.to_string()), "{lr}");
|
|
}
|
|
for t in &b {
|
|
assert!(!t.description.is_empty(), "{}", t.id);
|
|
// no image or font files: nothing that needs an attribution or can go missing
|
|
assert!(
|
|
t.look.overlays.identity_plate.image.is_empty() && t.look.overlays.watermark.image.is_empty() && t.look.layout.background_image.is_none()
|
|
);
|
|
// the document form reads back to the same look
|
|
let back = parse(&serde_json::to_vec(&t.to_doc()).unwrap()).unwrap();
|
|
assert_eq!(back.look, t.look, "{}", t.id);
|
|
}
|
|
let looks: Vec<_> = b.iter().map(|t| &t.look).collect();
|
|
for (i, a) in looks.iter().enumerate() {
|
|
assert!(looks.iter().skip(i + 1).all(|o| o != a), "each built-in looks different");
|
|
}
|
|
let (mut s, dir) = session("builtins");
|
|
let active = s.active().unwrap();
|
|
for t in b {
|
|
let r = run(&mut s, "slideshow.templates.apply", json!({"id": t.id}));
|
|
assert_eq!(r["warnings"], json!([]), "{}: {r}", t.id);
|
|
assert_eq!(
|
|
SlideLook { playback: PlaybackSettings { prepare_previews: true, ..t.look.playback.clone() }, ..t.look.clone() },
|
|
SlideLook::of(&s)
|
|
);
|
|
let img = run(&mut s, "slideshow.preview", json!({"id": active.0, "width": 160}));
|
|
assert_eq!(img["width"], 160, "{}", t.id);
|
|
}
|
|
// by name, case-insensitive
|
|
run(&mut s, "slideshow.templates.apply", json!({"name": "edge TO edge"}));
|
|
assert!(s.slide_layout.zoom_to_fill);
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[test]
|
|
fn save_list_apply_restores_exactly_and_previous_goes_back() {
|
|
let (mut s, dir) = session("roundtrip");
|
|
s.slideshow.prepare_previews = false;
|
|
custom(&mut s);
|
|
let saved = SlideLook::of(&s);
|
|
let r = run(&mut s, "slideshow.templates.save", json!({"name": "My Look"}));
|
|
assert_eq!(r["id"], "user:My Look");
|
|
let path = PathBuf::from(r["path"].as_str().unwrap());
|
|
assert_eq!(path, dir.join(DIR_NAME).join("My Look.json"));
|
|
let doc: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
|
assert_eq!((doc["format"].as_str(), doc["version"].as_u64(), doc["name"].as_str()), (Some(FORMAT), Some(VERSION), Some("My Look")));
|
|
assert!(doc["playback"].get("preparePreviews").is_none(), "a machine preference, not part of the look");
|
|
assert!(ids(&mut s).contains(&"user:My Look".to_string()));
|
|
|
|
run(&mut s, "slideshow.templates.apply", json!({"id": "builtin:plain-black"}));
|
|
assert_ne!(SlideLook::of(&s), saved);
|
|
assert!(!s.slideshow.prepare_previews, "prepare previews is kept");
|
|
let r = run(&mut s, "slideshow.templates.apply", json!({"id": "user:My Look"}));
|
|
assert_eq!(r["applied"], "user:My Look");
|
|
assert_eq!(SlideLook::of(&s), saved, "applying restores every setting exactly");
|
|
|
|
// previous: back to Plain Black's, then forward again
|
|
let plain = builtins().into_iter().find(|t| t.id == "builtin:plain-black").unwrap();
|
|
run(&mut s, "slideshow.templates.apply", json!({"id": PREVIOUS}));
|
|
assert_eq!(s.slide_layout, plain.look.layout);
|
|
run(&mut s, "slideshow.templates.apply", json!({"id": PREVIOUS}));
|
|
assert_eq!(SlideLook::of(&s), saved);
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[test]
|
|
fn previous_needs_an_apply_first() {
|
|
let (mut s, dir) = session("noprev");
|
|
assert_eq!(run(&mut s, "slideshow.templates.list", json!({}))["previous"], false);
|
|
assert!(err(&mut s, "slideshow.templates.apply", json!({"id": "previous"})).contains("no previous settings"));
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[test]
|
|
fn update_rename_delete_export_import() {
|
|
let (mut s, dir) = session("ops");
|
|
run(&mut s, "slideshow.templates.save", json!({"name": "Mine"}));
|
|
// a taken name, case-insensitively, and a built-in's are refused; replace overwrites
|
|
assert!(err(&mut s, "slideshow.templates.save", json!({"name": "mine"})).contains("already exists"));
|
|
assert!(err(&mut s, "slideshow.templates.save", json!({"name": "Plain Black"})).contains("built-in"));
|
|
run(&mut s, "slideshow.layout", json!({"margins": 0.2}));
|
|
run(&mut s, "slideshow.templates.save", json!({"name": "Mine", "replace": true}));
|
|
assert_eq!(s.slide_template("user:Mine").unwrap().look.layout.margin_top, 0.2);
|
|
// update
|
|
run(&mut s, "slideshow.layout", json!({"margins": 0.05}));
|
|
run(&mut s, "slideshow.templates.update", json!({"id": "user:Mine"}));
|
|
assert_eq!(s.slide_template("user:Mine").unwrap().look.layout.margin_top, 0.05);
|
|
assert!(err(&mut s, "slideshow.templates.update", json!({"id": "builtin:plain-black"})).contains("built-in"));
|
|
// rename (also just the case)
|
|
let r = run(&mut s, "slideshow.templates.rename", json!({"id": "user:Mine", "name": "Ours"}));
|
|
assert_eq!(r["id"], "user:Ours");
|
|
assert!(!dir.join(DIR_NAME).join("Mine.json").exists());
|
|
let r = run(&mut s, "slideshow.templates.rename", json!({"id": "user:Ours", "name": "OURS"}));
|
|
assert_eq!(r["id"], "user:OURS");
|
|
assert_eq!(ids(&mut s).iter().filter(|i| i.starts_with(USER)).count(), 1);
|
|
run(&mut s, "slideshow.templates.save", json!({"name": "Other"}));
|
|
assert!(err(&mut s, "slideshow.templates.rename", json!({"id": "user:Other", "name": "ours"})).contains("already exists"));
|
|
assert!(err(&mut s, "slideshow.templates.rename", json!({"id": "builtin:cinema-wide", "name": "X"})).contains("built-in"));
|
|
// export a user template and a built-in, import them back
|
|
let out = dir.join("out");
|
|
let a = out.join("ours.json").to_string_lossy().to_string();
|
|
run(&mut s, "slideshow.templates.export", json!({"id": "user:OURS", "path": a}));
|
|
let b = out.join("cinema.json").to_string_lossy().to_string();
|
|
run(&mut s, "slideshow.templates.export", json!({"id": "builtin:cinema-wide", "path": b}));
|
|
let r = run(&mut s, "slideshow.templates.import", json!({"path": a}));
|
|
assert_eq!((r["name"].as_str(), r["renamed"].as_bool()), (Some("OURS (2)"), Some(true)), "{r}");
|
|
assert_eq!(s.slide_template("user:OURS (2)").unwrap().look, s.slide_template("user:OURS").unwrap().look);
|
|
let r = run(&mut s, "slideshow.templates.import", json!({"path": b}));
|
|
assert_eq!(r["name"], "Cinema Wide (2)", "a built-in's name is taken too");
|
|
let cinema = s.slide_template("builtin:cinema-wide").unwrap();
|
|
assert_eq!(s.slide_template("user:Cinema Wide (2)").unwrap().look, cinema.look);
|
|
// delete
|
|
run(&mut s, "slideshow.templates.delete", json!({"id": "user:OURS"}));
|
|
assert!(!ids(&mut s).contains(&"user:OURS".to_string()));
|
|
assert!(err(&mut s, "slideshow.templates.delete", json!({"id": "user:OURS"})).contains("no slideshow template"));
|
|
assert!(err(&mut s, "slideshow.templates.delete", json!({"id": "builtin:plain-black"})).contains("built-in"));
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[test]
|
|
fn file_names_are_sanitised_and_ids_never_leave_the_folder() {
|
|
assert_eq!(file_stem("../../etc/passwd").as_deref(), Some("______etc_passwd"));
|
|
assert_eq!(file_stem(r"C:\x\y").as_deref(), Some("C__x_y"));
|
|
assert_eq!(file_stem("a:b*c?d\"e<f>g|h").as_deref(), Some("a_b_c_d_e_f_g_h"));
|
|
assert_eq!(file_stem(" Holiday (2024) ").as_deref(), Some("Holiday (2024)"));
|
|
assert_eq!(file_stem("Été à Kyōto 京都").as_deref(), Some("Été à Kyōto 京都"));
|
|
assert_eq!(file_stem("con").as_deref(), Some("con_"));
|
|
assert_eq!(file_stem("LPT1").as_deref(), Some("LPT1_"));
|
|
assert_eq!(file_stem("..."), None);
|
|
assert_eq!(file_stem("///"), None);
|
|
assert_eq!(file_stem(&"x".repeat(500)).map(|s| s.chars().count()), Some(64));
|
|
let (mut s, dir) = session("escape");
|
|
let folder = dir.join(DIR_NAME);
|
|
let r = run(&mut s, "slideshow.templates.save", json!({"name": "../../escape"}));
|
|
assert_eq!(r["id"], "user:______escape");
|
|
assert!(folder.join("______escape.json").exists());
|
|
assert!(!dir.join("escape.json").exists());
|
|
for name in ["", " ", "...", "/", "a\nb", &"y".repeat(81)] {
|
|
err(&mut s, "slideshow.templates.save", json!({"name": name}));
|
|
}
|
|
// an id can't point outside the folder (or at a file LightCraft wouldn't name)
|
|
std::fs::write(dir.join("victim.json"), b"{}").unwrap();
|
|
for id in ["user:../victim", "user:..", "user:/etc/passwd", "user:a/b", "user:", "user:x.json"] {
|
|
let e = err(&mut s, "slideshow.templates.delete", json!({"id": id}));
|
|
assert!(e.contains("isn't a template id"), "{id}: {e}");
|
|
}
|
|
assert!(dir.join("victim.json").exists());
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[test]
|
|
fn bad_files_are_reported_and_skipped() {
|
|
let (mut s, dir) = session("bad");
|
|
let folder = dir.join(DIR_NAME);
|
|
std::fs::create_dir_all(&folder).unwrap();
|
|
run(&mut s, "slideshow.templates.save", json!({"name": "Good"}));
|
|
let doc = |extra: Value| {
|
|
let mut d = json!({"format": FORMAT, "version": 1, "name": "X"});
|
|
for (k, v) in extra.as_object().unwrap() {
|
|
d[k] = v.clone();
|
|
}
|
|
serde_json::to_vec(&d).unwrap()
|
|
};
|
|
let files: Vec<(&str, Vec<u8>, &str)> = vec![
|
|
("truncated.json", br#"{"format": "lightcraft.slideshowTemplate", "vers"#.to_vec(), "not a slideshow template"),
|
|
("binary.json", vec![0xff, 0xfe, 0x00, 0x01], "not UTF-8"),
|
|
("array.json", b"[1, 2]".to_vec(), "not a JSON object"),
|
|
("preset.json", br#"{"format": "lightcraft.preset", "version": 1}"#.to_vec(), "format `lightcraft.preset`"),
|
|
("newer.json", doc(json!({"version": 2})), "newer LightCraft"),
|
|
("noversion.json", br#"{"format": "lightcraft.slideshowTemplate"}"#.to_vec(), "no `version`"),
|
|
("badversion.json", doc(json!({"version": -1})), "`version`"),
|
|
("unknown.json", doc(json!({"music": {}})), "unknown key `music`"),
|
|
("nan.json", doc(json!({"layout": {"margins": "wide"}})), "layout"),
|
|
("badkey.json", doc(json!({"overlays": {"fireworks": true}})), "overlays"),
|
|
("badtype.json", doc(json!({"titles": [1]})), "`titles` must be an object"),
|
|
("toomanytexts.json", doc(json!({"overlays": {"texts": vec![json!({}); 40]}})), "at most"),
|
|
("badname.json", doc(json!({"name": 5})), "`name`"),
|
|
("huge.json", vec![b' '; MAX_FILE as usize + 1], "too large"),
|
|
("bad name!.json", doc(json!({})), "file name"),
|
|
];
|
|
for (f, bytes, _) in &files {
|
|
std::fs::write(folder.join(f), bytes).unwrap();
|
|
}
|
|
std::fs::create_dir_all(folder.join("folder.json")).unwrap();
|
|
std::fs::write(folder.join("notes.txt"), b"ignored").unwrap();
|
|
// written by hand, not through LightCraft: the folder's time may not have moved since the
|
|
// save's listing (same clock tick), so the cached listing is dropped as a write would drop it
|
|
s.slide_templates_cache = None;
|
|
let r = run(&mut s, "slideshow.templates.list", json!({}));
|
|
let user: Vec<_> = r["templates"].as_array().unwrap().iter().filter(|t| t["builtin"] == false).collect();
|
|
assert_eq!(user.len(), 1, "only the good one: {r}");
|
|
let problems: Vec<String> = r["problems"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect();
|
|
for (f, _, why) in &files {
|
|
assert!(problems.iter().any(|p| p.starts_with(f) && p.contains(why)), "{f}: {why} in {problems:?}");
|
|
}
|
|
assert!(problems.iter().any(|p| p.starts_with("folder.json") && p.contains("not a regular file")), "{problems:?}");
|
|
assert_eq!(problems.len(), files.len() + 1, "{problems:?}");
|
|
// the same refusals on import
|
|
for (f, _, why) in files.iter().filter(|(f, _, _)| *f != "bad name!.json") {
|
|
let e = err(&mut s, "slideshow.templates.import", json!({"path": folder.join(f)}));
|
|
assert!(e.contains(why), "{f}: {e}");
|
|
}
|
|
// out-of-range numbers in a hand-edited file are clamped, never applied as they are
|
|
std::fs::write(folder.join("wild.json"), doc(json!({"layout": {"margins": 9, "shadowAngle": 1e300}, "playback": {"slideDuration": 1e9}})))
|
|
.unwrap();
|
|
// written by hand, not through LightCraft: the folder's time may not have moved since the
|
|
// listing above (same clock tick), so the cached listing is dropped as a write would drop it
|
|
s.slide_templates_cache = None;
|
|
let t = s.slide_template("user:wild").unwrap();
|
|
assert_eq!(t.look.layout.margin_left, lightcraft_slideshow::MARGIN_MAX);
|
|
assert_eq!(t.look.playback.slide_duration, crate::slideshow::SLIDE_MAX);
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[test]
|
|
fn thousands_of_templates_are_capped_not_fatal() {
|
|
let (mut s, dir) = session("many");
|
|
let folder = dir.join(DIR_NAME);
|
|
std::fs::create_dir_all(&folder).unwrap();
|
|
let body = |i: usize| format!(r#"{{"format": "{FORMAT}", "version": 1, "name": "T{i}"}}"#);
|
|
for i in 0..MAX_TEMPLATES + 50 {
|
|
std::fs::write(folder.join(format!("T{i:05}.json")), body(i)).unwrap();
|
|
}
|
|
let r = run(&mut s, "slideshow.templates.list", json!({}));
|
|
let user = r["templates"].as_array().unwrap().iter().filter(|t| t["builtin"] == false).count();
|
|
assert_eq!(user, MAX_TEMPLATES);
|
|
assert!(r["problems"][0].as_str().unwrap().contains("only the first"), "{r}");
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_files_apply_with_a_warning() {
|
|
let (mut s, dir) = session("missing");
|
|
let folder = dir.join(DIR_NAME);
|
|
std::fs::create_dir_all(&folder).unwrap();
|
|
let d = json!({
|
|
"format": FORMAT, "version": 1, "name": "Logo",
|
|
"layout": {"backgroundImage": 987654},
|
|
"overlays": {"identityPlate": {"enabled": true, "image": "/no/such/logo.png"}, "showWatermark": true, "watermark": {"text": "", "image": "/no/such/mark.png"}}
|
|
});
|
|
std::fs::write(folder.join("Logo.json"), serde_json::to_vec(&d).unwrap()).unwrap();
|
|
let r = run(&mut s, "slideshow.templates.apply", json!({"id": "user:Logo"}));
|
|
let w = r["warnings"].to_string();
|
|
assert!(w.contains("background image") && w.contains("logo.png") && w.contains("mark.png"), "{w}");
|
|
assert_eq!(s.slide_overlays.identity_plate.image, "/no/such/logo.png", "applied anyway");
|
|
// the slide still renders (the backdrop colour, no plate)
|
|
let id = s.active().unwrap();
|
|
run(&mut s, "slideshow.preview", json!({"id": id.0, "width": 64}));
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[test]
|
|
fn preview_of_a_template_leaves_the_settings_alone() {
|
|
let (mut s, dir) = session("preview");
|
|
custom(&mut s);
|
|
let before = SlideLook::of(&s);
|
|
let id = s.active().unwrap();
|
|
let mine = run(&mut s, "slideshow.preview", json!({"id": id.0, "width": 200, "height": 120}));
|
|
let r = run(&mut s, "slideshow.preview", json!({"id": id.0, "width": 200, "height": 120, "template": "builtin:edge-to-edge"}));
|
|
assert_eq!(r["template"], "builtin:edge-to-edge");
|
|
assert_eq!(r["photo"], json!([0.0, 0.0, 200.0, 120.0]), "zoom to fill, no margins: {r}");
|
|
assert_ne!(r["photo"], mine["photo"]);
|
|
assert_eq!(r["overlays"], json!([]), "the template's overlays (none), not the library's");
|
|
let t = run(&mut s, "slideshow.preview", json!({"title": "ending", "template": "builtin:cinema-wide", "width": 160}));
|
|
assert_eq!((t["enabled"].as_bool(), t["height"].as_u64()), (Some(true), Some(90)), "the template's ending, at its 16:9 aspect: {t}");
|
|
assert_eq!(SlideLook::of(&s), before, "nothing changed");
|
|
assert!(s.slide_templates_previous.is_none());
|
|
assert!(err(&mut s, "slideshow.preview", json!({"template": "builtin:nope"})).contains("no slideshow template"));
|
|
assert!(err(&mut s, "slideshow.preview", json!({"template": 3})).contains("template id"));
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[test]
|
|
fn without_a_settings_folder_only_builtins() {
|
|
let mut s = Session::with_demo();
|
|
assert!(s.slide_templates_dir.is_none());
|
|
let r = run(&mut s, "slideshow.templates.list", json!({}));
|
|
assert_eq!(r["templates"].as_array().unwrap().len(), BUILTINS.len());
|
|
assert!(err(&mut s, "slideshow.templates.save", json!({"name": "X"})).contains("settings folder"));
|
|
run(&mut s, "slideshow.templates.apply", json!({"id": "builtin:gallery-wall"}));
|
|
assert!(s.slide_overlays.rating_stars.enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn a_saved_template_reads_back_through_its_section_validation() {
|
|
let (mut s, dir) = session("sections");
|
|
custom(&mut s);
|
|
let look = SlideLook::of(&s);
|
|
let parsed = parse(&serde_json::to_vec(&doc("N", "d", &look)).unwrap()).unwrap();
|
|
assert_eq!(parsed.name.as_deref(), Some("N"));
|
|
assert_eq!(parsed.description, "d");
|
|
assert_eq!(parsed.look.layout, look.layout);
|
|
assert_eq!(parsed.look.overlays, look.overlays);
|
|
assert_eq!(parsed.look.titles, look.titles);
|
|
assert_eq!(PlaybackSettings { prepare_previews: look.playback.prepare_previews, ..parsed.look.playback }, look.playback);
|
|
// a BOM and a missing name are fine
|
|
let mut bytes = "\u{feff}".as_bytes().to_vec();
|
|
bytes.extend(br#"{"format": "lightcraft.slideshowTemplate", "version": 1}"#);
|
|
assert_eq!(parse(&bytes).unwrap().name, None);
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|