refactor(engine): move design tests to cmd/design/tests.rs

This commit is contained in:
aiongg
2026-10-26 22:08:44 +00:00
parent 8528674c57
commit 0f4665ebe2
2 changed files with 479 additions and 480 deletions
+1 -480
View File
@@ -1584,486 +1584,7 @@ fn slide_number_paragraph() -> deckcraft_model::Paragraph {
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use deckcraft_color::SchemeSlot;
use deckcraft_model::style::PictureMode;
use deckcraft_model::theme::{FontScheme, ScriptFont, builtin_color_schemes, builtin_font_schemes};
use deckcraft_model::{Background, Fill};
use serde_json::{Value, json};
use crate::{Session, UiRequest};
fn scheme(s: &Session) -> deckcraft_color::ColorScheme {
s.doc().unwrap().doc.masters[0].theme.colors.clone()
}
fn font_scheme(s: &Session) -> FontScheme {
s.doc().unwrap().doc.masters[0].theme.fonts.clone()
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
fn png() -> String {
let mut img = image::RgbaImage::new(2, 2);
img.put_pixel(0, 0, image::Rgba([10, 20, 30, 255]));
let mut bytes = Vec::new();
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
crate::cmd::base64_encode(&bytes)
}
fn picture(s: &Session) -> deckcraft_model::style::PictureFill {
match &s.doc().unwrap().doc.slides[0].background {
Some(Background::Fill { fill: Fill::Picture(pf) }) => pf.clone(),
other => panic!("expected a picture background, got {other:?}"),
}
}
#[test]
fn layout_commands_reject_invalid_index() {
let mut s = Session::with_new();
s.execute("master.insertLayout", &json!({})).unwrap();
let count = |s: &Session| s.doc().unwrap().doc.masters.iter().map(|m| m.layouts.len()).sum::<usize>();
let names = |s: &Session| s.doc().unwrap().doc.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.name.clone())).collect::<Vec<_>>();
let n = count(&s);
let before = names(&s);
for bad in [json!(-1), json!(1.5)] {
assert!(s.execute("master.deleteLayout", &json!({"layout": bad})).is_err());
assert!(s.execute("master.deleteLayout", &json!({"master": bad})).is_err());
assert!(s.execute("master.renameLayout", &json!({"layout": bad, "name": "x"})).is_err());
assert_eq!(count(&s), n);
assert_eq!(names(&s), before);
}
s.execute("master.deleteLayout", &json!({})).unwrap();
assert_eq!(count(&s), n - 1);
}
#[test]
fn civil_dates() {
assert_eq!(super::civil(0), (1970, 1, 1));
assert_eq!(super::civil(20_734), (2026, 10, 8));
// Past `NaiveDate`'s range the 400-year cycles carry the year, exactly, up to the extremes.
assert_eq!(super::civil(95_026_602), (262_144, 1, 1));
assert_eq!(super::civil(-96_465_964), (-262_145, 3, 1));
assert_eq!(super::civil(i64::MAX), (25_252_734_927_768_524, 7, 27));
assert_eq!(super::civil(i64::MIN), (-25_252_734_927_764_585, 6, 7));
}
#[test]
fn colors_dialog_apply_and_reject() {
let mut s = Session::with_new();
let original = scheme(&s);
let n = undo_len(&s);
s.execute("design.colors", &json!({})).unwrap();
s.execute("design.colors", &Value::Null).unwrap();
assert_eq!(scheme(&s), original);
assert_eq!(undo_len(&s), n, "opening the dialog does not edit");
assert_eq!(s.ui_requests.len(), 2);
match &s.ui_requests[0] {
UiRequest::Dialog { id, params } => {
assert_eq!(id, "themeColors");
assert_eq!(params["name"], "DeckCraft");
let colors = params["colors"].as_object().unwrap();
assert_eq!(colors.len(), 12);
assert_eq!(colors["accent1"].as_str().unwrap(), format!("#{}", original.get(SchemeSlot::Accent1).hex()));
assert!(colors.contains_key("folHlink"));
}
other => panic!("expected a dialog, got {other:?}"),
}
s.execute("design.colors", &json!({"name": "Harbor"})).unwrap();
let harbor = builtin_color_schemes().into_iter().find(|c| c.name == "Harbor").unwrap();
assert_eq!(scheme(&s).name, "Harbor");
assert_eq!(scheme(&s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1));
assert_eq!(undo_len(&s), n + 1);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Colors");
s.execute("design.colors", &json!({"name": "Harbor"})).unwrap();
assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step");
let err = s.execute("design.colors", &json!({"name": "Nope"})).unwrap_err().to_string();
assert!(err.contains("no colour scheme `Nope`"), "{err}");
assert!(s.execute("design.colors", &json!({"name": " "})).is_err());
assert!(s.execute("design.colors", &json!({"name": 1})).is_err());
assert!(s.execute("design.colors", &json!({"colors": {}})).is_err());
assert!(s.execute("design.colors", &json!({"colors": "red"})).is_err());
assert!(s.execute("design.colors", &json!({"colors": {"bg1": "#112233"}})).is_err());
assert!(s.execute("design.colors", &json!({"colors": {"tx1": "#112233"}})).is_err());
assert!(s.execute("design.colors", &json!({"colors": {"phClr": "#112233"}})).is_err());
assert!(s.execute("design.colors", &json!({"colors": {"nope": "#112233"}})).is_err());
assert!(s.execute("design.colors", &json!({"colors": {"accent1": "zz"}})).is_err());
assert!(s.execute("design.colors", &json!({"name": "Harbor", "colors": {}})).is_err());
assert_eq!(scheme(&s).name, "Harbor");
assert_eq!(scheme(&s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1));
assert_eq!(undo_len(&s), n + 1);
s.execute("design.colors", &json!({"colors": {"accent1": "112233"}})).unwrap();
assert_eq!(scheme(&s).name, "Custom");
assert_eq!(scheme(&s).get(SchemeSlot::Accent1).hex(), "112233");
assert_eq!(scheme(&s).get(SchemeSlot::Accent2), harbor.get(SchemeSlot::Accent2));
assert_eq!(undo_len(&s), n + 2);
let meadow = builtin_color_schemes().into_iter().find(|c| c.name == "Meadow").unwrap();
s.execute("design.colors", &json!({"name": "Meadow", "colors": {"lt1": "#010203"}})).unwrap();
assert_eq!(scheme(&s).name, "Meadow");
assert_eq!(scheme(&s).get(SchemeSlot::Lt1).hex(), "010203");
assert_eq!(scheme(&s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1));
assert_eq!(undo_len(&s), n + 3);
s.execute("design.colors", &json!({"name": "Talk", "colors": {"dk1": "#ABCDEF"}})).unwrap();
assert_eq!(scheme(&s).name, "Talk");
assert_eq!(scheme(&s).get(SchemeSlot::Dk1).hex(), "ABCDEF");
assert_eq!(scheme(&s).get(SchemeSlot::Lt1).hex(), "010203");
assert_eq!(scheme(&s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(scheme(&s).name, "Meadow");
assert_eq!(scheme(&s).get(SchemeSlot::Dk1), meadow.get(SchemeSlot::Dk1));
}
#[test]
fn fonts_dialog_apply_and_reject() {
let mut s = Session::with_new();
let original = font_scheme(&s);
let n = undo_len(&s);
s.execute("design.fonts", &json!({})).unwrap();
s.execute("design.fonts", &Value::Null).unwrap();
s.execute("design.fonts", &json!([1, 2])).unwrap();
assert_eq!(font_scheme(&s), original);
assert_eq!(undo_len(&s), n, "opening the dialog does not edit");
assert_eq!(s.ui_requests.len(), 3);
match &s.ui_requests[0] {
UiRequest::Dialog { id, params } => {
assert_eq!(id, "themeFonts");
assert_eq!(params["name"], original.name);
assert_eq!(params["major"], original.major.latin);
assert_eq!(params["minor"], original.minor.latin);
}
other => panic!("expected a dialog, got {other:?}"),
}
s.execute("design.fonts", &json!({"name": "classic"})).unwrap();
let classic = builtin_font_schemes().into_iter().find(|f| f.name == "Classic").unwrap();
assert_eq!(font_scheme(&s), classic, "a builtin name keeps that scheme, including its own name");
assert_eq!(undo_len(&s), n + 1);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts");
s.execute("design.fonts", &json!({"name": "Classic"})).unwrap();
assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step");
let err = s.execute("design.fonts", &json!({"name": "Nope"})).unwrap_err().to_string();
assert!(err.contains("no font scheme `Nope`"), "{err}");
assert!(s.execute("design.fonts", &json!({"name": " "})).is_err());
assert!(s.execute("design.fonts", &json!({"name": 1})).is_err());
assert!(s.execute("design.fonts", &json!({"major": ""})).is_err());
assert!(s.execute("design.fonts", &json!({"major": " "})).is_err());
assert!(s.execute("design.fonts", &json!({"minor": 1})).is_err());
assert!(s.execute("design.fonts", &json!({"name": "Classic", "major": ""})).is_err());
assert_eq!(font_scheme(&s), classic);
assert_eq!(undo_len(&s), n + 1);
s.edit(|doc, _| {
let fonts = &mut std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts;
fonts.major.ea = "EA Major".into();
fonts.minor.cs = "CS Minor".into();
fonts.major.scripts.push(ScriptFont { script: "Arab".into(), typeface: "Noto Naskh Arabic".into() });
Ok(())
})
.unwrap();
let marked = font_scheme(&s);
let n = undo_len(&s);
s.execute("design.fonts", &json!({"major": " Lato "})).unwrap();
let custom = font_scheme(&s);
assert_eq!(custom.name, "Custom");
assert_eq!(custom.major.latin, "Lato");
assert_eq!(custom.minor.latin, "Lato", "an omitted minor copies major");
assert_eq!(custom.major.ea, "EA Major");
assert_eq!(custom.minor.cs, "CS Minor");
assert_eq!(custom.major.scripts, marked.major.scripts);
assert_eq!(undo_len(&s), n + 1);
s.execute("design.fonts", &json!({"minor": "Roboto"})).unwrap();
assert_eq!(font_scheme(&s).name, "Custom");
assert_eq!(font_scheme(&s).major.latin, "Lato");
assert_eq!(font_scheme(&s).minor.latin, "Roboto");
assert_eq!(font_scheme(&s).major.ea, "EA Major");
assert_eq!(undo_len(&s), n + 2);
s.execute("design.fonts", &json!({"name": "Classic", "major": "Inter"})).unwrap();
assert_eq!(font_scheme(&s).name, "Classic");
assert_eq!(font_scheme(&s).major.latin, "Inter");
assert_eq!(font_scheme(&s).minor.latin, "Open Sans");
assert_eq!(font_scheme(&s).major.ea, "");
assert!(font_scheme(&s).major.scripts.is_empty(), "a builtin is applied before the latin overlay");
assert_eq!(undo_len(&s), n + 3);
s.edit(|doc, _| {
std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts.major.ea = "Kept".into();
Ok(())
})
.unwrap();
s.execute("design.fonts", &json!({"name": "Talk", "minor": "Nunito Sans"})).unwrap();
assert_eq!(font_scheme(&s).name, "Talk");
assert_eq!(font_scheme(&s).major.latin, "Inter");
assert_eq!(font_scheme(&s).minor.latin, "Nunito Sans");
assert_eq!(font_scheme(&s).major.ea, "Kept");
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(font_scheme(&s).name, "Classic");
assert_eq!(font_scheme(&s).major.ea, "Kept");
assert_eq!(font_scheme(&s).minor.latin, "Open Sans");
}
#[test]
fn background_picture_options_tile_transparency_and_copy() {
let mut s = Session::with_new();
let n = undo_len(&s);
let err = s.execute("design.background", &json!({"pictureProps": {"tile": true}})).unwrap_err().to_string();
assert!(err.contains("picture background"), "{err}");
assert!(s.execute("design.background", &json!({"transparency": 0.2})).is_err());
assert_eq!(undo_len(&s), n);
s.execute("design.background", &json!({"color": "accent1", "transparency": 0.25})).unwrap();
match &s.doc().unwrap().doc.slides[0].background {
Some(Background::Fill { fill: Fill::Solid { color } }) => assert!((color.alpha() - 0.75).abs() < 1e-4, "{}", color.alpha()),
other => panic!("expected a solid background, got {other:?}"),
}
let n = undo_len(&s);
s.execute("design.background", &json!({"transparency": 0.25})).unwrap();
assert_eq!(undo_len(&s), n, "the same transparency is not an undo step");
s.execute("design.background", &json!({"picture": png(), "name": "dot.png", "transparency": 0.4, "pictureProps": {"tile": true}})).unwrap();
let pf = picture(&s);
assert!((pf.alpha.unwrap_or(1.0) - 0.6).abs() < 1e-6);
assert!(matches!(pf.mode, PictureMode::Tile { .. }));
let n = undo_len(&s);
s.execute(
"design.background",
&json!({"pictureProps": {"offsetX": 12.0, "offsetY": -3.0, "scaleX": 0.5, "scaleY": 50.0, "align": "ctr", "mirror": "xy", "rotateWithShape": false}}),
)
.unwrap();
match picture(&s).mode {
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
assert_eq!((tx, ty, sx, sy), (12.0, -3.0, 0.5, 10.0));
assert_eq!(flip, "xy");
assert_eq!(align, "ctr");
}
other => panic!("expected a tile, got {other:?}"),
}
assert!(picture(&s).upright);
s.execute("design.background", &json!({"pictureProps": {"align": "bogus", "offsetX": "nope"}})).unwrap();
match picture(&s).mode {
PictureMode::Tile { tx, align, .. } => {
assert_eq!(tx, 12.0);
assert_eq!(align, "ctr");
}
other => panic!("expected the tile to stay, got {other:?}"),
}
assert_eq!(undo_len(&s), n + 1, "ignored options are not an undo step");
s.execute("design.background", &json!({"pictureProps": {"tile": false, "stretch": [0.1, 0.2, 0.0, 0.0]}})).unwrap();
match picture(&s).mode {
PictureMode::Stretch { fill_rect } => assert_eq!(fill_rect, [0.1, 0.2, 0.0, 0.0]),
other => panic!("expected a stretch, got {other:?}"),
}
s.execute("slide.new", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.slides[1].background.is_none());
s.execute("slide.go", &json!({"index": 0})).unwrap();
let n = undo_len(&s);
s.execute("design.background", &json!({"copy": true})).unwrap();
assert_eq!(s.doc().unwrap().doc.slides[1].background, s.doc().unwrap().doc.slides[0].background);
assert_eq!(undo_len(&s), n + 1);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Format Background");
s.execute("design.background", &json!({"copy": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "copying an already shared background is not an undo step");
}
#[test]
fn background_all_applies_to_master_and_new_slides_inherit() {
let mut s = Session::with_new();
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
let s0 = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
assert_eq!(s0["background"], "#121826");
let n = undo_len(&s);
s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1);
// Idempotency: re-applying the same background to all is a no-op (no extra undo step)
s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "re-applying already applied background is not an undo step");
// Slide 0 has new background, master has new background, layouts cleared
let doc = &s.doc().unwrap().doc;
assert!(matches!(doc.slides[0].background, Some(Background::Fill { .. })));
assert_eq!(doc.masters[0].background, doc.slides[0].background);
for l in &doc.masters[0].layouts {
assert!(l.background.is_none());
}
// Newly added slides inherit the master background (fixes #891)
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "new"})).unwrap();
let s0_after = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
let s1_after = s.execute("slide.inspect", &json!({"index": 1})).unwrap();
assert_eq!(s0_after["background"], "#0D0D0D");
assert_eq!(s1_after["background"], "#0D0D0D");
// Undo restores previous slide and master backgrounds
s.execute("edit.undo", &json!({})).unwrap(); // undos slide.new
assert_eq!(s.doc().unwrap().doc.slides.len(), 1);
s.execute("edit.undo", &json!({})).unwrap(); // undos design.background
let s0_reverted = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
assert_eq!(s0_reverted["background"], "#121826");
}
#[test]
fn background_copy_from_slide_following_master_preserves_master_and_layout_backgrounds() {
let mut s = Session::with_new();
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap();
let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x22, 0x22, 0x22))) };
s.edit(|doc, _| {
let m = Arc::make_mut(&mut doc.masters[0]);
m.layouts[0].background = Some(custom_layout_bg.clone());
Ok(())
})
.unwrap();
// Slide 0 follows master (background: None)
assert!(s.doc().unwrap().doc.slides[0].background.is_none());
// Give Slide 1 an explicit override
s.execute("design.background", &json!({"color": "#FF0000", "index": 1})).unwrap();
assert!(s.doc().unwrap().doc.slides[1].background.is_some());
let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone();
assert!(orig_master_bg.is_some());
// Select slide 0 (which follows master) and apply copy to all
s.execute("slide.go", &json!({"index": 0})).unwrap();
let n = undo_len(&s);
s.execute("design.background", &json!({"copy": true})).unwrap();
assert_eq!(undo_len(&s), n + 1);
// Slide 1 has been reset to None (follows master), slide 0 remains None
let doc = &s.doc().unwrap().doc;
assert!(doc.slides[0].background.is_none());
assert!(doc.slides[1].background.is_none());
// Master and layout backgrounds survived!
assert_eq!(doc.masters[0].background, orig_master_bg);
assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg));
// Idempotency: re-applying copy is a no-op
s.execute("design.background", &json!({"copy": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "re-copying when already matching is a no-op");
}
#[test]
fn background_reset_all_preserves_master_and_layout_backgrounds() {
let mut s = Session::with_new();
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap();
let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x33, 0x33, 0x33))) };
s.edit(|doc, _| {
let m = Arc::make_mut(&mut doc.masters[0]);
m.layouts[0].background = Some(custom_layout_bg.clone());
Ok(())
})
.unwrap();
let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone();
assert!(orig_master_bg.is_some());
// Set explicit backgrounds on both slides individually (master remains untouched)
s.execute("design.background", &json!({"color": "#0D0D0D", "index": 0})).unwrap();
s.execute("design.background", &json!({"color": "#0D0D0D", "index": 1})).unwrap();
assert!(s.doc().unwrap().doc.slides[0].background.is_some());
assert!(s.doc().unwrap().doc.slides[1].background.is_some());
// Now perform reset with all
let n = undo_len(&s);
s.execute("design.background", &json!({"reset": true, "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1);
// Slides reset to None to follow master
let doc = &s.doc().unwrap().doc;
assert!(doc.slides[0].background.is_none());
assert!(doc.slides[1].background.is_none());
// Master and layout backgrounds survived!
assert_eq!(doc.masters[0].background, orig_master_bg);
assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg));
// Idempotency: re-running reset all is a no-op
s.execute("design.background", &json!({"reset": true, "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "re-resetting when already reset is a no-op");
}
#[test]
fn background_all_only_touches_used_masters() {
let mut s = Session::with_new();
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
// Add a second master to presentation
let second_master_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x55, 0x55, 0x55))) };
s.edit(|doc, _| {
let mut m2 = (*doc.masters[0]).clone();
m2.id = deckcraft_model::MasterId(999);
m2.background = Some(second_master_bg.clone());
doc.masters.push(Arc::new(m2));
Ok(())
})
.unwrap();
assert_eq!(s.doc().unwrap().doc.masters.len(), 2);
// Slides only use master 0 (default layout)
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
let doc = &s.doc().unwrap().doc;
// Master 0 updated
assert_eq!(doc.masters[0].background, doc.slides[0].background);
// Master 1 preserved!
assert_eq!(doc.masters[1].background, Some(second_master_bg));
}
#[test]
fn background_all_clears_layout_overrides_even_if_slides_and_master_match() {
let mut s = Session::with_new();
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
// Set all slides and master to #AA0000
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
assert_eq!(s.doc().unwrap().doc.masters[0].background, s.doc().unwrap().doc.slides[0].background);
// Add a layout override on layout 0
let custom_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x12, 0x34, 0x56))) };
s.edit(|doc, _| {
let m = Arc::make_mut(&mut doc.masters[0]);
m.layouts[0].background = Some(custom_bg);
Ok(())
})
.unwrap();
// Re-run Apply to All with #AA0000. Must not be skipped as no-op; must clear layout override.
let n = undo_len(&s);
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "should execute and clear layout override, not treat as no-op");
let doc = &s.doc().unwrap().doc;
assert!(doc.masters[0].layouts[0].background.is_none());
// Now that layout override is cleared, repeating is an actual no-op
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op");
}
}
mod tests;
#[cfg(test)]
mod browse_theme_tests {
+478
View File
@@ -0,0 +1,478 @@
use std::sync::Arc;
use deckcraft_color::SchemeSlot;
use deckcraft_model::style::PictureMode;
use deckcraft_model::theme::{FontScheme, ScriptFont, builtin_color_schemes, builtin_font_schemes};
use deckcraft_model::{Background, Fill};
use serde_json::{Value, json};
use crate::{Session, UiRequest};
fn scheme(s: &Session) -> deckcraft_color::ColorScheme {
s.doc().unwrap().doc.masters[0].theme.colors.clone()
}
fn font_scheme(s: &Session) -> FontScheme {
s.doc().unwrap().doc.masters[0].theme.fonts.clone()
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
fn png() -> String {
let mut img = image::RgbaImage::new(2, 2);
img.put_pixel(0, 0, image::Rgba([10, 20, 30, 255]));
let mut bytes = Vec::new();
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
crate::cmd::base64_encode(&bytes)
}
fn picture(s: &Session) -> deckcraft_model::style::PictureFill {
match &s.doc().unwrap().doc.slides[0].background {
Some(Background::Fill { fill: Fill::Picture(pf) }) => pf.clone(),
other => panic!("expected a picture background, got {other:?}"),
}
}
#[test]
fn layout_commands_reject_invalid_index() {
let mut s = Session::with_new();
s.execute("master.insertLayout", &json!({})).unwrap();
let count = |s: &Session| s.doc().unwrap().doc.masters.iter().map(|m| m.layouts.len()).sum::<usize>();
let names = |s: &Session| s.doc().unwrap().doc.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.name.clone())).collect::<Vec<_>>();
let n = count(&s);
let before = names(&s);
for bad in [json!(-1), json!(1.5)] {
assert!(s.execute("master.deleteLayout", &json!({"layout": bad})).is_err());
assert!(s.execute("master.deleteLayout", &json!({"master": bad})).is_err());
assert!(s.execute("master.renameLayout", &json!({"layout": bad, "name": "x"})).is_err());
assert_eq!(count(&s), n);
assert_eq!(names(&s), before);
}
s.execute("master.deleteLayout", &json!({})).unwrap();
assert_eq!(count(&s), n - 1);
}
#[test]
fn civil_dates() {
assert_eq!(super::civil(0), (1970, 1, 1));
assert_eq!(super::civil(20_734), (2026, 10, 8));
// Past `NaiveDate`'s range the 400-year cycles carry the year, exactly, up to the extremes.
assert_eq!(super::civil(95_026_602), (262_144, 1, 1));
assert_eq!(super::civil(-96_465_964), (-262_145, 3, 1));
assert_eq!(super::civil(i64::MAX), (25_252_734_927_768_524, 7, 27));
assert_eq!(super::civil(i64::MIN), (-25_252_734_927_764_585, 6, 7));
}
#[test]
fn colors_dialog_apply_and_reject() {
let mut s = Session::with_new();
let original = scheme(&s);
let n = undo_len(&s);
s.execute("design.colors", &json!({})).unwrap();
s.execute("design.colors", &Value::Null).unwrap();
assert_eq!(scheme(&s), original);
assert_eq!(undo_len(&s), n, "opening the dialog does not edit");
assert_eq!(s.ui_requests.len(), 2);
match &s.ui_requests[0] {
UiRequest::Dialog { id, params } => {
assert_eq!(id, "themeColors");
assert_eq!(params["name"], "DeckCraft");
let colors = params["colors"].as_object().unwrap();
assert_eq!(colors.len(), 12);
assert_eq!(colors["accent1"].as_str().unwrap(), format!("#{}", original.get(SchemeSlot::Accent1).hex()));
assert!(colors.contains_key("folHlink"));
}
other => panic!("expected a dialog, got {other:?}"),
}
s.execute("design.colors", &json!({"name": "Harbor"})).unwrap();
let harbor = builtin_color_schemes().into_iter().find(|c| c.name == "Harbor").unwrap();
assert_eq!(scheme(&s).name, "Harbor");
assert_eq!(scheme(&s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1));
assert_eq!(undo_len(&s), n + 1);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Colors");
s.execute("design.colors", &json!({"name": "Harbor"})).unwrap();
assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step");
let err = s.execute("design.colors", &json!({"name": "Nope"})).unwrap_err().to_string();
assert!(err.contains("no colour scheme `Nope`"), "{err}");
assert!(s.execute("design.colors", &json!({"name": " "})).is_err());
assert!(s.execute("design.colors", &json!({"name": 1})).is_err());
assert!(s.execute("design.colors", &json!({"colors": {}})).is_err());
assert!(s.execute("design.colors", &json!({"colors": "red"})).is_err());
assert!(s.execute("design.colors", &json!({"colors": {"bg1": "#112233"}})).is_err());
assert!(s.execute("design.colors", &json!({"colors": {"tx1": "#112233"}})).is_err());
assert!(s.execute("design.colors", &json!({"colors": {"phClr": "#112233"}})).is_err());
assert!(s.execute("design.colors", &json!({"colors": {"nope": "#112233"}})).is_err());
assert!(s.execute("design.colors", &json!({"colors": {"accent1": "zz"}})).is_err());
assert!(s.execute("design.colors", &json!({"name": "Harbor", "colors": {}})).is_err());
assert_eq!(scheme(&s).name, "Harbor");
assert_eq!(scheme(&s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1));
assert_eq!(undo_len(&s), n + 1);
s.execute("design.colors", &json!({"colors": {"accent1": "112233"}})).unwrap();
assert_eq!(scheme(&s).name, "Custom");
assert_eq!(scheme(&s).get(SchemeSlot::Accent1).hex(), "112233");
assert_eq!(scheme(&s).get(SchemeSlot::Accent2), harbor.get(SchemeSlot::Accent2));
assert_eq!(undo_len(&s), n + 2);
let meadow = builtin_color_schemes().into_iter().find(|c| c.name == "Meadow").unwrap();
s.execute("design.colors", &json!({"name": "Meadow", "colors": {"lt1": "#010203"}})).unwrap();
assert_eq!(scheme(&s).name, "Meadow");
assert_eq!(scheme(&s).get(SchemeSlot::Lt1).hex(), "010203");
assert_eq!(scheme(&s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1));
assert_eq!(undo_len(&s), n + 3);
s.execute("design.colors", &json!({"name": "Talk", "colors": {"dk1": "#ABCDEF"}})).unwrap();
assert_eq!(scheme(&s).name, "Talk");
assert_eq!(scheme(&s).get(SchemeSlot::Dk1).hex(), "ABCDEF");
assert_eq!(scheme(&s).get(SchemeSlot::Lt1).hex(), "010203");
assert_eq!(scheme(&s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(scheme(&s).name, "Meadow");
assert_eq!(scheme(&s).get(SchemeSlot::Dk1), meadow.get(SchemeSlot::Dk1));
}
#[test]
fn fonts_dialog_apply_and_reject() {
let mut s = Session::with_new();
let original = font_scheme(&s);
let n = undo_len(&s);
s.execute("design.fonts", &json!({})).unwrap();
s.execute("design.fonts", &Value::Null).unwrap();
s.execute("design.fonts", &json!([1, 2])).unwrap();
assert_eq!(font_scheme(&s), original);
assert_eq!(undo_len(&s), n, "opening the dialog does not edit");
assert_eq!(s.ui_requests.len(), 3);
match &s.ui_requests[0] {
UiRequest::Dialog { id, params } => {
assert_eq!(id, "themeFonts");
assert_eq!(params["name"], original.name);
assert_eq!(params["major"], original.major.latin);
assert_eq!(params["minor"], original.minor.latin);
}
other => panic!("expected a dialog, got {other:?}"),
}
s.execute("design.fonts", &json!({"name": "classic"})).unwrap();
let classic = builtin_font_schemes().into_iter().find(|f| f.name == "Classic").unwrap();
assert_eq!(font_scheme(&s), classic, "a builtin name keeps that scheme, including its own name");
assert_eq!(undo_len(&s), n + 1);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts");
s.execute("design.fonts", &json!({"name": "Classic"})).unwrap();
assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step");
let err = s.execute("design.fonts", &json!({"name": "Nope"})).unwrap_err().to_string();
assert!(err.contains("no font scheme `Nope`"), "{err}");
assert!(s.execute("design.fonts", &json!({"name": " "})).is_err());
assert!(s.execute("design.fonts", &json!({"name": 1})).is_err());
assert!(s.execute("design.fonts", &json!({"major": ""})).is_err());
assert!(s.execute("design.fonts", &json!({"major": " "})).is_err());
assert!(s.execute("design.fonts", &json!({"minor": 1})).is_err());
assert!(s.execute("design.fonts", &json!({"name": "Classic", "major": ""})).is_err());
assert_eq!(font_scheme(&s), classic);
assert_eq!(undo_len(&s), n + 1);
s.edit(|doc, _| {
let fonts = &mut std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts;
fonts.major.ea = "EA Major".into();
fonts.minor.cs = "CS Minor".into();
fonts.major.scripts.push(ScriptFont { script: "Arab".into(), typeface: "Noto Naskh Arabic".into() });
Ok(())
})
.unwrap();
let marked = font_scheme(&s);
let n = undo_len(&s);
s.execute("design.fonts", &json!({"major": " Lato "})).unwrap();
let custom = font_scheme(&s);
assert_eq!(custom.name, "Custom");
assert_eq!(custom.major.latin, "Lato");
assert_eq!(custom.minor.latin, "Lato", "an omitted minor copies major");
assert_eq!(custom.major.ea, "EA Major");
assert_eq!(custom.minor.cs, "CS Minor");
assert_eq!(custom.major.scripts, marked.major.scripts);
assert_eq!(undo_len(&s), n + 1);
s.execute("design.fonts", &json!({"minor": "Roboto"})).unwrap();
assert_eq!(font_scheme(&s).name, "Custom");
assert_eq!(font_scheme(&s).major.latin, "Lato");
assert_eq!(font_scheme(&s).minor.latin, "Roboto");
assert_eq!(font_scheme(&s).major.ea, "EA Major");
assert_eq!(undo_len(&s), n + 2);
s.execute("design.fonts", &json!({"name": "Classic", "major": "Inter"})).unwrap();
assert_eq!(font_scheme(&s).name, "Classic");
assert_eq!(font_scheme(&s).major.latin, "Inter");
assert_eq!(font_scheme(&s).minor.latin, "Open Sans");
assert_eq!(font_scheme(&s).major.ea, "");
assert!(font_scheme(&s).major.scripts.is_empty(), "a builtin is applied before the latin overlay");
assert_eq!(undo_len(&s), n + 3);
s.edit(|doc, _| {
std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts.major.ea = "Kept".into();
Ok(())
})
.unwrap();
s.execute("design.fonts", &json!({"name": "Talk", "minor": "Nunito Sans"})).unwrap();
assert_eq!(font_scheme(&s).name, "Talk");
assert_eq!(font_scheme(&s).major.latin, "Inter");
assert_eq!(font_scheme(&s).minor.latin, "Nunito Sans");
assert_eq!(font_scheme(&s).major.ea, "Kept");
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(font_scheme(&s).name, "Classic");
assert_eq!(font_scheme(&s).major.ea, "Kept");
assert_eq!(font_scheme(&s).minor.latin, "Open Sans");
}
#[test]
fn background_picture_options_tile_transparency_and_copy() {
let mut s = Session::with_new();
let n = undo_len(&s);
let err = s.execute("design.background", &json!({"pictureProps": {"tile": true}})).unwrap_err().to_string();
assert!(err.contains("picture background"), "{err}");
assert!(s.execute("design.background", &json!({"transparency": 0.2})).is_err());
assert_eq!(undo_len(&s), n);
s.execute("design.background", &json!({"color": "accent1", "transparency": 0.25})).unwrap();
match &s.doc().unwrap().doc.slides[0].background {
Some(Background::Fill { fill: Fill::Solid { color } }) => assert!((color.alpha() - 0.75).abs() < 1e-4, "{}", color.alpha()),
other => panic!("expected a solid background, got {other:?}"),
}
let n = undo_len(&s);
s.execute("design.background", &json!({"transparency": 0.25})).unwrap();
assert_eq!(undo_len(&s), n, "the same transparency is not an undo step");
s.execute("design.background", &json!({"picture": png(), "name": "dot.png", "transparency": 0.4, "pictureProps": {"tile": true}})).unwrap();
let pf = picture(&s);
assert!((pf.alpha.unwrap_or(1.0) - 0.6).abs() < 1e-6);
assert!(matches!(pf.mode, PictureMode::Tile { .. }));
let n = undo_len(&s);
s.execute(
"design.background",
&json!({"pictureProps": {"offsetX": 12.0, "offsetY": -3.0, "scaleX": 0.5, "scaleY": 50.0, "align": "ctr", "mirror": "xy", "rotateWithShape": false}}),
)
.unwrap();
match picture(&s).mode {
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
assert_eq!((tx, ty, sx, sy), (12.0, -3.0, 0.5, 10.0));
assert_eq!(flip, "xy");
assert_eq!(align, "ctr");
}
other => panic!("expected a tile, got {other:?}"),
}
assert!(picture(&s).upright);
s.execute("design.background", &json!({"pictureProps": {"align": "bogus", "offsetX": "nope"}})).unwrap();
match picture(&s).mode {
PictureMode::Tile { tx, align, .. } => {
assert_eq!(tx, 12.0);
assert_eq!(align, "ctr");
}
other => panic!("expected the tile to stay, got {other:?}"),
}
assert_eq!(undo_len(&s), n + 1, "ignored options are not an undo step");
s.execute("design.background", &json!({"pictureProps": {"tile": false, "stretch": [0.1, 0.2, 0.0, 0.0]}})).unwrap();
match picture(&s).mode {
PictureMode::Stretch { fill_rect } => assert_eq!(fill_rect, [0.1, 0.2, 0.0, 0.0]),
other => panic!("expected a stretch, got {other:?}"),
}
s.execute("slide.new", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.slides[1].background.is_none());
s.execute("slide.go", &json!({"index": 0})).unwrap();
let n = undo_len(&s);
s.execute("design.background", &json!({"copy": true})).unwrap();
assert_eq!(s.doc().unwrap().doc.slides[1].background, s.doc().unwrap().doc.slides[0].background);
assert_eq!(undo_len(&s), n + 1);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Format Background");
s.execute("design.background", &json!({"copy": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "copying an already shared background is not an undo step");
}
#[test]
fn background_all_applies_to_master_and_new_slides_inherit() {
let mut s = Session::with_new();
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
let s0 = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
assert_eq!(s0["background"], "#121826");
let n = undo_len(&s);
s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1);
// Idempotency: re-applying the same background to all is a no-op (no extra undo step)
s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "re-applying already applied background is not an undo step");
// Slide 0 has new background, master has new background, layouts cleared
let doc = &s.doc().unwrap().doc;
assert!(matches!(doc.slides[0].background, Some(Background::Fill { .. })));
assert_eq!(doc.masters[0].background, doc.slides[0].background);
for l in &doc.masters[0].layouts {
assert!(l.background.is_none());
}
// Newly added slides inherit the master background (fixes #891)
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "new"})).unwrap();
let s0_after = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
let s1_after = s.execute("slide.inspect", &json!({"index": 1})).unwrap();
assert_eq!(s0_after["background"], "#0D0D0D");
assert_eq!(s1_after["background"], "#0D0D0D");
// Undo restores previous slide and master backgrounds
s.execute("edit.undo", &json!({})).unwrap(); // undos slide.new
assert_eq!(s.doc().unwrap().doc.slides.len(), 1);
s.execute("edit.undo", &json!({})).unwrap(); // undos design.background
let s0_reverted = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
assert_eq!(s0_reverted["background"], "#121826");
}
#[test]
fn background_copy_from_slide_following_master_preserves_master_and_layout_backgrounds() {
let mut s = Session::with_new();
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap();
let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x22, 0x22, 0x22))) };
s.edit(|doc, _| {
let m = Arc::make_mut(&mut doc.masters[0]);
m.layouts[0].background = Some(custom_layout_bg.clone());
Ok(())
})
.unwrap();
// Slide 0 follows master (background: None)
assert!(s.doc().unwrap().doc.slides[0].background.is_none());
// Give Slide 1 an explicit override
s.execute("design.background", &json!({"color": "#FF0000", "index": 1})).unwrap();
assert!(s.doc().unwrap().doc.slides[1].background.is_some());
let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone();
assert!(orig_master_bg.is_some());
// Select slide 0 (which follows master) and apply copy to all
s.execute("slide.go", &json!({"index": 0})).unwrap();
let n = undo_len(&s);
s.execute("design.background", &json!({"copy": true})).unwrap();
assert_eq!(undo_len(&s), n + 1);
// Slide 1 has been reset to None (follows master), slide 0 remains None
let doc = &s.doc().unwrap().doc;
assert!(doc.slides[0].background.is_none());
assert!(doc.slides[1].background.is_none());
// Master and layout backgrounds survived!
assert_eq!(doc.masters[0].background, orig_master_bg);
assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg));
// Idempotency: re-applying copy is a no-op
s.execute("design.background", &json!({"copy": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "re-copying when already matching is a no-op");
}
#[test]
fn background_reset_all_preserves_master_and_layout_backgrounds() {
let mut s = Session::with_new();
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap();
let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x33, 0x33, 0x33))) };
s.edit(|doc, _| {
let m = Arc::make_mut(&mut doc.masters[0]);
m.layouts[0].background = Some(custom_layout_bg.clone());
Ok(())
})
.unwrap();
let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone();
assert!(orig_master_bg.is_some());
// Set explicit backgrounds on both slides individually (master remains untouched)
s.execute("design.background", &json!({"color": "#0D0D0D", "index": 0})).unwrap();
s.execute("design.background", &json!({"color": "#0D0D0D", "index": 1})).unwrap();
assert!(s.doc().unwrap().doc.slides[0].background.is_some());
assert!(s.doc().unwrap().doc.slides[1].background.is_some());
// Now perform reset with all
let n = undo_len(&s);
s.execute("design.background", &json!({"reset": true, "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1);
// Slides reset to None to follow master
let doc = &s.doc().unwrap().doc;
assert!(doc.slides[0].background.is_none());
assert!(doc.slides[1].background.is_none());
// Master and layout backgrounds survived!
assert_eq!(doc.masters[0].background, orig_master_bg);
assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg));
// Idempotency: re-running reset all is a no-op
s.execute("design.background", &json!({"reset": true, "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "re-resetting when already reset is a no-op");
}
#[test]
fn background_all_only_touches_used_masters() {
let mut s = Session::with_new();
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
// Add a second master to presentation
let second_master_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x55, 0x55, 0x55))) };
s.edit(|doc, _| {
let mut m2 = (*doc.masters[0]).clone();
m2.id = deckcraft_model::MasterId(999);
m2.background = Some(second_master_bg.clone());
doc.masters.push(Arc::new(m2));
Ok(())
})
.unwrap();
assert_eq!(s.doc().unwrap().doc.masters.len(), 2);
// Slides only use master 0 (default layout)
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
let doc = &s.doc().unwrap().doc;
// Master 0 updated
assert_eq!(doc.masters[0].background, doc.slides[0].background);
// Master 1 preserved!
assert_eq!(doc.masters[1].background, Some(second_master_bg));
}
#[test]
fn background_all_clears_layout_overrides_even_if_slides_and_master_match() {
let mut s = Session::with_new();
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
// Set all slides and master to #AA0000
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
assert_eq!(s.doc().unwrap().doc.masters[0].background, s.doc().unwrap().doc.slides[0].background);
// Add a layout override on layout 0
let custom_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x12, 0x34, 0x56))) };
s.edit(|doc, _| {
let m = Arc::make_mut(&mut doc.masters[0]);
m.layouts[0].background = Some(custom_bg);
Ok(())
})
.unwrap();
// Re-run Apply to All with #AA0000. Must not be skipped as no-op; must clear layout override.
let n = undo_len(&s);
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "should execute and clear layout override, not treat as no-op");
let doc = &s.doc().unwrap().doc;
assert!(doc.masters[0].layouts[0].background.is_none());
// Now that layout override is cleared, repeating is an actual no-op
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op");
}