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

This commit is contained in:
aiongg
2026-10-26 22:08:44 +00:00
parent 0f4665ebe2
commit b48467943b
2 changed files with 280 additions and 282 deletions
+1 -282
View File
@@ -1587,288 +1587,7 @@ fn slide_number_paragraph() -> deckcraft_model::Paragraph {
mod tests;
#[cfg(test)]
mod browse_theme_tests {
use std::sync::Arc;
use deckcraft_model::text::RunKind;
use deckcraft_model::{Background, Fill, PhType};
use serde_json::json;
use crate::{Session, Target};
#[test]
fn applies_the_theme_of_another_deck_and_undoes() {
let mut other = Session::with_new();
other.execute("design.fonts", &json!({"major": "Lato", "minor": "Source Sans 3"})).unwrap();
other.execute("design.colors", &json!({"colors": {"accent1": "#123456"}})).unwrap();
let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap();
let before = s.doc().unwrap().doc.masters[0].theme.clone();
s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap();
let t = &s.doc().unwrap().doc.masters[0].theme;
assert_eq!(t.fonts.major.latin, "Lato");
assert_eq!(t.fonts.minor.latin, "Source Sans 3");
assert_eq!(t.colors.get(deckcraft_color::SchemeSlot::Accent1).hex().to_lowercase(), "123456");
assert!(s.doc().unwrap().doc.validate().is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.masters[0].theme, before);
assert!(s.execute("design.browseTheme", &json!({"data": "AAAA"})).is_err());
assert!(s.execute("design.browseTheme", &json!({})).is_err());
}
/// A 4:3 brand template: a logo and a background on the master, its layouts renamed (so slides
/// can only find them by kind) and its content layout's title moved.
fn brand_template() -> (String, Vec<u8>, deckcraft_model::Xfrm) {
let mut img = image::RgbaImage::new(2, 2);
img.put_pixel(0, 0, image::Rgba([200, 20, 30, 255]));
let mut logo = Vec::new();
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut logo), image::ImageFormat::Png).unwrap();
let mut t = Session::with_new();
t.execute("design.slideSize", &json!({"preset": "standard", "scale": "none"})).unwrap();
let title = t
.edit(|doc, _| {
let media = doc.add_media("logo.png", "image/png", logo.clone()).unwrap();
let id = deckcraft_model::ShapeId(doc.alloc_id().unwrap());
let m = Arc::make_mut(doc.masters.get_mut(0).unwrap());
m.shapes.push(deckcraft_model::Shape {
id,
name: "Logo".into(),
kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } },
xfrm: Some(deckcraft_model::Xfrm { x: 600.0, y: 20.0, w: 100.0, h: 40.0, ..Default::default() }),
..Default::default()
});
m.background =
Some(Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x0A, 0x2B, 0x4C))) });
for l in &mut m.layouts {
l.name = format!("Brand {}", l.name);
}
let obj = m.layouts.iter_mut().find(|l| l.kind == deckcraft_model::LayoutType::TitleAndContent).unwrap();
let title = obj.shapes.iter_mut().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
let x = deckcraft_model::Xfrm { x: 72.0, y: 400.0, w: 576.0, h: 80.0, ..Default::default() };
title.xfrm = Some(x);
Ok(x)
})
.unwrap();
(crate::cmd::base64_encode(&deckcraft_pptx::export_as(&t.doc().unwrap().doc, "potx").unwrap()), logo, title)
}
#[test]
fn a_template_brings_its_master_and_layouts_and_slides_follow() {
let (data, logo, title) = brand_template();
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "Point one"})).unwrap();
let before = s.doc().unwrap().doc.clone();
let n = undo_len(&s);
let r = s.execute("design.browseTheme", &json!({"name": "Brand.potx", "data": data})).unwrap();
assert_eq!(r["slides"], json!(2), "{r}");
assert_eq!(undo_len(&s), n + 1, "one undo step");
let doc = &s.doc().unwrap().doc;
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
assert_eq!(doc.masters.len(), 1, "the old master is replaced");
let m = &doc.masters[0];
assert_ne!(m.id, before.masters[0].id);
assert!(m.layouts.iter().all(|l| l.name.starts_with("Brand ")));
assert!(matches!(m.background, Some(Background::Fill { fill: Fill::Solid { .. } })));
let pic = m.shapes.iter().find(|sh| sh.name == "Logo").expect("the master's logo comes along");
let deckcraft_model::ShapeKind::Picture { fill } = &pic.kind else { panic!("logo is a picture") };
assert_eq!(doc.media(fill.media).map(|m| m.data.as_ref().clone()), Some(logo));
// The 4:3 master stretches to this deck's 16:9.
let k = doc.slide_size.width / 720.0;
let px = pic.xfrm.unwrap();
assert!((px.x - 600.0 * k).abs() < 0.01 && (px.w - 100.0 * k).abs() < 0.01 && (px.y - 20.0).abs() < 0.01, "{px:?}");
// Each slide moved to the brand layout of its kind, and kept its text.
for (old, new) in before.slides.iter().zip(&doc.slides) {
let (_, was) = before.layout(old.layout).unwrap();
let (owner, now) = doc.layout(new.layout).expect("on a layout of the new master");
assert_eq!(owner.id, m.id);
assert_eq!(now.kind, was.kind);
}
let agenda = doc.slides.iter().find(|sl| sl.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Agenda"))).unwrap();
assert!(agenda.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Point one")));
let (_, lay) = doc.layout(agenda.layout).unwrap();
let lt = lay.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap().xfrm.unwrap();
assert!((lt.x - title.x * k).abs() < 0.01 && (lt.y - title.y).abs() < 0.01, "{lt:?}");
let st = agenda.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
assert!(st.xfrm.is_none(), "the slide title follows the new layout");
s.execute("edit.undo", &json!({})).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.masters, before.masters);
assert_eq!(doc.slides, before.slides);
}
#[test]
fn a_preserved_master_stays_beside_the_template() {
let (data, _, _) = brand_template();
let mut s = Session::with_new();
s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap();
s.execute("master.preserve", &json!({"preserve": true})).unwrap();
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
s.execute("master.preserve", &json!({"preserve": true})).unwrap();
let old = s.doc().unwrap().doc.masters[0].id;
s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap();
s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.doc.masters.len(), 2);
assert_eq!(st.doc.masters[1].id, old);
assert_eq!(st.selection.target, Target::Master { master: 0 }, "Slide Master view shows the new master");
let new = &st.doc.masters[0];
assert!(st.doc.slides.iter().all(|sl| new.layout(sl.layout).is_some()));
}
fn undo_len(s: &crate::Session) -> usize {
s.doc().unwrap().history.undo.len()
}
#[test]
fn effect_scheme_replaces_format_and_keeps_colours_and_fonts() {
let mut s = crate::Session::with_new();
let listed = s.execute("design.themes", &json!({})).unwrap();
let names: Vec<String> = listed["effectSchemes"].as_array().unwrap().iter().filter_map(|v| v.as_str().map(str::to_string)).collect();
assert_eq!(names, ["DeckCraft", "Soft", "Crisp", "Lift", "Glow", "Plain"]);
let n = undo_len(&s);
s.execute("design.effects", &json!({"name": "DeckCraft"})).unwrap();
assert_eq!(undo_len(&s), n, "the scheme already on the theme is not an undo step");
let err = s.execute("design.effects", &json!({"name": "Office"})).unwrap_err().to_string();
assert!(err.contains("no effect scheme"), "{err}");
assert!(s.execute("design.effects", &json!({})).is_err());
assert_eq!(undo_len(&s), n);
s.execute("design.theme", &json!({"name": "Harbor"})).unwrap();
let before = s.doc().unwrap().doc.masters[0].theme.clone();
s.execute("design.effects", &json!({"name": "soft"})).unwrap();
let theme = s.doc().unwrap().doc.masters[0].theme.clone();
assert_eq!(theme.name, "Harbor");
assert_eq!(theme.colors, before.colors);
assert_eq!(theme.fonts, before.fonts);
assert_eq!(theme.format.name, "Soft");
assert!(theme.format.effects[0].outer_shadow.is_some());
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Effects");
let n = undo_len(&s);
s.execute("design.effects", &json!({"name": "Soft"})).unwrap();
assert_eq!(undo_len(&s), n, "the same scheme is not an undo step");
s.execute("design.effects", &json!({"name": "Glow"})).unwrap();
let glow = s.doc().unwrap().doc.masters[0].theme.format.effects[2].glow.clone();
assert!(glow.is_some_and(|g| (g.radius - 12.0).abs() < 1e-6));
s.execute("design.effects", &json!({"name": "Plain"})).unwrap();
let plain = s.doc().unwrap().doc.masters[0].theme.format.clone();
assert!(plain.effects.iter().all(|e| e.outer_shadow.is_none() && e.glow.is_none()));
assert_eq!(plain.lines[2].width, Some(0.75));
}
fn shapes(s: &Session) -> Vec<deckcraft_model::Shape> {
s.doc().unwrap().shapes().to_vec()
}
fn has_title(s: &Session) -> bool {
shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title))
}
fn has_footers(s: &Session) -> bool {
shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind))
}
#[test]
fn preserve_and_placeholders_edit_the_open_master_or_layout() {
for id in ["master.preserve", "master.placeholders"] {
assert!(crate::command_specs().iter().any(|c| c.id == id), "{id}");
}
let mut s = Session::with_new();
let blank = s.doc().unwrap().doc.masters[0].layouts.iter().position(|l| l.name == "Blank").unwrap();
s.execute("view.slideMaster", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: 0 });
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
assert_eq!(s.doc().unwrap().selection.target, Target::Master { master: 0 });
assert!(!s.doc().unwrap().doc.masters[0].preserve);
let n = s.doc().unwrap().history.undo.len();
s.execute("master.preserve", &json!({"preserve": false})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n, "the same value is not an undo step");
s.execute("master.preserve", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.masters[0].preserve);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve");
s.execute("edit.undo", &json!({})).unwrap();
assert!(!s.doc().unwrap().doc.masters[0].preserve);
s.execute("master.placeholders", &json!({"footers": false})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders");
assert!(has_title(&s));
assert!(!has_footers(&s));
s.execute("edit.undo", &json!({})).unwrap();
assert!(has_footers(&s));
s.execute("view.closeMaster", &json!({})).unwrap();
let n = s.doc().unwrap().history.undo.len();
let err = s.execute("master.preserve", &json!({})).unwrap_err();
assert!(err.to_string().contains("open Slide Master view"), "{err}");
let err = s.execute("master.placeholders", &json!({"title": true})).unwrap_err();
assert!(err.to_string().contains("open Slide Master view"), "{err}");
let err = s.execute("master.placeholders", &json!({})).unwrap_err();
assert!(err.to_string().contains("give title or footers"), "{err}");
assert_eq!(s.doc().unwrap().history.undo.len(), n);
s.execute("view.slideMaster", &json!({"master": 0, "layout": blank})).unwrap();
assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: blank });
assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
assert!(!has_title(&s));
assert!(has_footers(&s));
let n = s.doc().unwrap().history.undo.len();
s.execute("master.placeholders", &json!({"title": false, "footers": true})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n, "already in that state");
s.execute("master.placeholders", &json!({"title": true, "footers": false})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1, "both keys are one undo step");
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders");
assert!(has_title(&s));
assert!(!has_footers(&s));
let title = shapes(&s).into_iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
assert_eq!(title.name, "Title 1");
assert_eq!(title.ph.unwrap().idx, 0);
assert!(title.text.unwrap().paragraphs.iter().all(|para| para.runs.is_empty()));
s.execute("edit.undo", &json!({})).unwrap();
assert!(!has_title(&s));
assert!(has_footers(&s));
s.execute("master.placeholders", &json!({"footers": false})).unwrap();
s.execute("master.placeholders", &json!({"footers": true})).unwrap();
let restored = shapes(&s);
assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Date)));
assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Footer)));
let num = restored.iter().find(|sh| sh.ph_type() == Some(PhType::SlideNum)).unwrap();
let run = &num.text.as_ref().unwrap().paragraphs[0].runs[0];
assert_eq!(run.text, "‹#›");
assert!(matches!(&run.kind, RunKind::Field { field } if field == "slidenum"));
assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
s.execute("master.preserve", &json!({"preserve": false})).unwrap();
assert!(!s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve");
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
s.edit(|doc, _| {
let m = std::sync::Arc::make_mut(doc.masters.get_mut(0).unwrap());
m.shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title));
Ok(())
})
.unwrap();
s.execute("master.placeholders", &json!({"title": true})).unwrap();
let title = shapes(&s).into_iter().find(|sh| sh.ph_type() == Some(PhType::Title)).unwrap();
assert_eq!(title.name, "Title");
let box_ = title.xfrm.unwrap();
assert!((box_.x - 60.0).abs() < 0.01 && (box_.y - 29.0).abs() < 0.01);
assert!((box_.w - 840.0).abs() < 0.01 && (box_.h - 104.0).abs() < 0.01);
}
}
mod browse_theme_tests;
#[cfg(test)]
mod header_footer_date_tests {
@@ -0,0 +1,279 @@
use std::sync::Arc;
use deckcraft_model::text::RunKind;
use deckcraft_model::{Background, Fill, PhType};
use serde_json::json;
use crate::{Session, Target};
#[test]
fn applies_the_theme_of_another_deck_and_undoes() {
let mut other = Session::with_new();
other.execute("design.fonts", &json!({"major": "Lato", "minor": "Source Sans 3"})).unwrap();
other.execute("design.colors", &json!({"colors": {"accent1": "#123456"}})).unwrap();
let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap();
let before = s.doc().unwrap().doc.masters[0].theme.clone();
s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap();
let t = &s.doc().unwrap().doc.masters[0].theme;
assert_eq!(t.fonts.major.latin, "Lato");
assert_eq!(t.fonts.minor.latin, "Source Sans 3");
assert_eq!(t.colors.get(deckcraft_color::SchemeSlot::Accent1).hex().to_lowercase(), "123456");
assert!(s.doc().unwrap().doc.validate().is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.masters[0].theme, before);
assert!(s.execute("design.browseTheme", &json!({"data": "AAAA"})).is_err());
assert!(s.execute("design.browseTheme", &json!({})).is_err());
}
/// A 4:3 brand template: a logo and a background on the master, its layouts renamed (so slides
/// can only find them by kind) and its content layout's title moved.
fn brand_template() -> (String, Vec<u8>, deckcraft_model::Xfrm) {
let mut img = image::RgbaImage::new(2, 2);
img.put_pixel(0, 0, image::Rgba([200, 20, 30, 255]));
let mut logo = Vec::new();
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut logo), image::ImageFormat::Png).unwrap();
let mut t = Session::with_new();
t.execute("design.slideSize", &json!({"preset": "standard", "scale": "none"})).unwrap();
let title = t
.edit(|doc, _| {
let media = doc.add_media("logo.png", "image/png", logo.clone()).unwrap();
let id = deckcraft_model::ShapeId(doc.alloc_id().unwrap());
let m = Arc::make_mut(doc.masters.get_mut(0).unwrap());
m.shapes.push(deckcraft_model::Shape {
id,
name: "Logo".into(),
kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } },
xfrm: Some(deckcraft_model::Xfrm { x: 600.0, y: 20.0, w: 100.0, h: 40.0, ..Default::default() }),
..Default::default()
});
m.background = Some(Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x0A, 0x2B, 0x4C))) });
for l in &mut m.layouts {
l.name = format!("Brand {}", l.name);
}
let obj = m.layouts.iter_mut().find(|l| l.kind == deckcraft_model::LayoutType::TitleAndContent).unwrap();
let title = obj.shapes.iter_mut().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
let x = deckcraft_model::Xfrm { x: 72.0, y: 400.0, w: 576.0, h: 80.0, ..Default::default() };
title.xfrm = Some(x);
Ok(x)
})
.unwrap();
(crate::cmd::base64_encode(&deckcraft_pptx::export_as(&t.doc().unwrap().doc, "potx").unwrap()), logo, title)
}
#[test]
fn a_template_brings_its_master_and_layouts_and_slides_follow() {
let (data, logo, title) = brand_template();
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "Point one"})).unwrap();
let before = s.doc().unwrap().doc.clone();
let n = undo_len(&s);
let r = s.execute("design.browseTheme", &json!({"name": "Brand.potx", "data": data})).unwrap();
assert_eq!(r["slides"], json!(2), "{r}");
assert_eq!(undo_len(&s), n + 1, "one undo step");
let doc = &s.doc().unwrap().doc;
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
assert_eq!(doc.masters.len(), 1, "the old master is replaced");
let m = &doc.masters[0];
assert_ne!(m.id, before.masters[0].id);
assert!(m.layouts.iter().all(|l| l.name.starts_with("Brand ")));
assert!(matches!(m.background, Some(Background::Fill { fill: Fill::Solid { .. } })));
let pic = m.shapes.iter().find(|sh| sh.name == "Logo").expect("the master's logo comes along");
let deckcraft_model::ShapeKind::Picture { fill } = &pic.kind else { panic!("logo is a picture") };
assert_eq!(doc.media(fill.media).map(|m| m.data.as_ref().clone()), Some(logo));
// The 4:3 master stretches to this deck's 16:9.
let k = doc.slide_size.width / 720.0;
let px = pic.xfrm.unwrap();
assert!((px.x - 600.0 * k).abs() < 0.01 && (px.w - 100.0 * k).abs() < 0.01 && (px.y - 20.0).abs() < 0.01, "{px:?}");
// Each slide moved to the brand layout of its kind, and kept its text.
for (old, new) in before.slides.iter().zip(&doc.slides) {
let (_, was) = before.layout(old.layout).unwrap();
let (owner, now) = doc.layout(new.layout).expect("on a layout of the new master");
assert_eq!(owner.id, m.id);
assert_eq!(now.kind, was.kind);
}
let agenda = doc.slides.iter().find(|sl| sl.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Agenda"))).unwrap();
assert!(agenda.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Point one")));
let (_, lay) = doc.layout(agenda.layout).unwrap();
let lt = lay.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap().xfrm.unwrap();
assert!((lt.x - title.x * k).abs() < 0.01 && (lt.y - title.y).abs() < 0.01, "{lt:?}");
let st = agenda.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
assert!(st.xfrm.is_none(), "the slide title follows the new layout");
s.execute("edit.undo", &json!({})).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.masters, before.masters);
assert_eq!(doc.slides, before.slides);
}
#[test]
fn a_preserved_master_stays_beside_the_template() {
let (data, _, _) = brand_template();
let mut s = Session::with_new();
s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap();
s.execute("master.preserve", &json!({"preserve": true})).unwrap();
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
s.execute("master.preserve", &json!({"preserve": true})).unwrap();
let old = s.doc().unwrap().doc.masters[0].id;
s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap();
s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.doc.masters.len(), 2);
assert_eq!(st.doc.masters[1].id, old);
assert_eq!(st.selection.target, Target::Master { master: 0 }, "Slide Master view shows the new master");
let new = &st.doc.masters[0];
assert!(st.doc.slides.iter().all(|sl| new.layout(sl.layout).is_some()));
}
fn undo_len(s: &crate::Session) -> usize {
s.doc().unwrap().history.undo.len()
}
#[test]
fn effect_scheme_replaces_format_and_keeps_colours_and_fonts() {
let mut s = crate::Session::with_new();
let listed = s.execute("design.themes", &json!({})).unwrap();
let names: Vec<String> = listed["effectSchemes"].as_array().unwrap().iter().filter_map(|v| v.as_str().map(str::to_string)).collect();
assert_eq!(names, ["DeckCraft", "Soft", "Crisp", "Lift", "Glow", "Plain"]);
let n = undo_len(&s);
s.execute("design.effects", &json!({"name": "DeckCraft"})).unwrap();
assert_eq!(undo_len(&s), n, "the scheme already on the theme is not an undo step");
let err = s.execute("design.effects", &json!({"name": "Office"})).unwrap_err().to_string();
assert!(err.contains("no effect scheme"), "{err}");
assert!(s.execute("design.effects", &json!({})).is_err());
assert_eq!(undo_len(&s), n);
s.execute("design.theme", &json!({"name": "Harbor"})).unwrap();
let before = s.doc().unwrap().doc.masters[0].theme.clone();
s.execute("design.effects", &json!({"name": "soft"})).unwrap();
let theme = s.doc().unwrap().doc.masters[0].theme.clone();
assert_eq!(theme.name, "Harbor");
assert_eq!(theme.colors, before.colors);
assert_eq!(theme.fonts, before.fonts);
assert_eq!(theme.format.name, "Soft");
assert!(theme.format.effects[0].outer_shadow.is_some());
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Effects");
let n = undo_len(&s);
s.execute("design.effects", &json!({"name": "Soft"})).unwrap();
assert_eq!(undo_len(&s), n, "the same scheme is not an undo step");
s.execute("design.effects", &json!({"name": "Glow"})).unwrap();
let glow = s.doc().unwrap().doc.masters[0].theme.format.effects[2].glow.clone();
assert!(glow.is_some_and(|g| (g.radius - 12.0).abs() < 1e-6));
s.execute("design.effects", &json!({"name": "Plain"})).unwrap();
let plain = s.doc().unwrap().doc.masters[0].theme.format.clone();
assert!(plain.effects.iter().all(|e| e.outer_shadow.is_none() && e.glow.is_none()));
assert_eq!(plain.lines[2].width, Some(0.75));
}
fn shapes(s: &Session) -> Vec<deckcraft_model::Shape> {
s.doc().unwrap().shapes().to_vec()
}
fn has_title(s: &Session) -> bool {
shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title))
}
fn has_footers(s: &Session) -> bool {
shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind))
}
#[test]
fn preserve_and_placeholders_edit_the_open_master_or_layout() {
for id in ["master.preserve", "master.placeholders"] {
assert!(crate::command_specs().iter().any(|c| c.id == id), "{id}");
}
let mut s = Session::with_new();
let blank = s.doc().unwrap().doc.masters[0].layouts.iter().position(|l| l.name == "Blank").unwrap();
s.execute("view.slideMaster", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: 0 });
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
assert_eq!(s.doc().unwrap().selection.target, Target::Master { master: 0 });
assert!(!s.doc().unwrap().doc.masters[0].preserve);
let n = s.doc().unwrap().history.undo.len();
s.execute("master.preserve", &json!({"preserve": false})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n, "the same value is not an undo step");
s.execute("master.preserve", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.masters[0].preserve);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve");
s.execute("edit.undo", &json!({})).unwrap();
assert!(!s.doc().unwrap().doc.masters[0].preserve);
s.execute("master.placeholders", &json!({"footers": false})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders");
assert!(has_title(&s));
assert!(!has_footers(&s));
s.execute("edit.undo", &json!({})).unwrap();
assert!(has_footers(&s));
s.execute("view.closeMaster", &json!({})).unwrap();
let n = s.doc().unwrap().history.undo.len();
let err = s.execute("master.preserve", &json!({})).unwrap_err();
assert!(err.to_string().contains("open Slide Master view"), "{err}");
let err = s.execute("master.placeholders", &json!({"title": true})).unwrap_err();
assert!(err.to_string().contains("open Slide Master view"), "{err}");
let err = s.execute("master.placeholders", &json!({})).unwrap_err();
assert!(err.to_string().contains("give title or footers"), "{err}");
assert_eq!(s.doc().unwrap().history.undo.len(), n);
s.execute("view.slideMaster", &json!({"master": 0, "layout": blank})).unwrap();
assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: blank });
assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
assert!(!has_title(&s));
assert!(has_footers(&s));
let n = s.doc().unwrap().history.undo.len();
s.execute("master.placeholders", &json!({"title": false, "footers": true})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n, "already in that state");
s.execute("master.placeholders", &json!({"title": true, "footers": false})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1, "both keys are one undo step");
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders");
assert!(has_title(&s));
assert!(!has_footers(&s));
let title = shapes(&s).into_iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
assert_eq!(title.name, "Title 1");
assert_eq!(title.ph.unwrap().idx, 0);
assert!(title.text.unwrap().paragraphs.iter().all(|para| para.runs.is_empty()));
s.execute("edit.undo", &json!({})).unwrap();
assert!(!has_title(&s));
assert!(has_footers(&s));
s.execute("master.placeholders", &json!({"footers": false})).unwrap();
s.execute("master.placeholders", &json!({"footers": true})).unwrap();
let restored = shapes(&s);
assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Date)));
assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Footer)));
let num = restored.iter().find(|sh| sh.ph_type() == Some(PhType::SlideNum)).unwrap();
let run = &num.text.as_ref().unwrap().paragraphs[0].runs[0];
assert_eq!(run.text, "‹#›");
assert!(matches!(&run.kind, RunKind::Field { field } if field == "slidenum"));
assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
s.execute("master.preserve", &json!({"preserve": false})).unwrap();
assert!(!s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve");
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
s.edit(|doc, _| {
let m = std::sync::Arc::make_mut(doc.masters.get_mut(0).unwrap());
m.shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title));
Ok(())
})
.unwrap();
s.execute("master.placeholders", &json!({"title": true})).unwrap();
let title = shapes(&s).into_iter().find(|sh| sh.ph_type() == Some(PhType::Title)).unwrap();
assert_eq!(title.name, "Title");
let box_ = title.xfrm.unwrap();
assert!((box_.x - 60.0).abs() < 0.01 && (box_.y - 29.0).abs() < 0.01);
assert!((box_.w - 840.0).abs() < 0.01 && (box_.h - 104.0).abs() < 0.01);
}