From b48467943babe8e960bd0a27c392d1be7b4beb50 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:33 +0000 Subject: [PATCH] refactor(engine): move design browse_theme_tests to cmd/design/browse_theme_tests.rs --- crates/engine/src/cmd/design.rs | 283 +----------------- .../src/cmd/design/browse_theme_tests.rs | 279 +++++++++++++++++ 2 files changed, 280 insertions(+), 282 deletions(-) create mode 100644 crates/engine/src/cmd/design/browse_theme_tests.rs diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index ece1f201..520b7b6a 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -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, 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 = 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 { - 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 { diff --git a/crates/engine/src/cmd/design/browse_theme_tests.rs b/crates/engine/src/cmd/design/browse_theme_tests.rs new file mode 100644 index 00000000..89136269 --- /dev/null +++ b/crates/engine/src/cmd/design/browse_theme_tests.rs @@ -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, 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 = 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 { + 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); +}