Files
lightcraft/crates/engine/src/tests_slide_template.rs
T
mahmatias 54c6505191 test(slideshow): drop the template cache after the hand-written bad files
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).
2026-10-19 14:02:35 -03:00

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