use serde_json::json; use crate::*; fn session() -> Session { Session::with_new() } #[test] fn new_presentation_and_slides() { let mut s = session(); assert_eq!(s.doc().unwrap().doc.slides.len(), 1); s.execute("slide.new", &json!({})).unwrap(); s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); assert_eq!(s.doc().unwrap().doc.slides.len(), 3); assert_eq!(s.doc().unwrap().selection.slide, 2); s.execute("slide.duplicate", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.slides.len(), 4); s.execute("slide.move", &json!({"from": 3, "to": 0})).unwrap(); s.execute("slide.delete", &json!({"index": 0})).unwrap(); assert_eq!(s.doc().unwrap().doc.slides.len(), 3); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.slides.len(), 4); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.slides.len(), 3); } #[test] fn insert_shape_and_format() { let mut s = session(); let r = s.execute("shape.insert", &json!({"preset": "roundRect", "rect": [100, 100, 200, 100], "text": "Hi"})).unwrap(); let id = r["id"].as_u64().unwrap(); s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap(); s.execute("shape.line", &json!({"width": 3, "color": "accent2", "dash": "dash"})).unwrap(); s.execute("shape.rotate", &json!({"deg": 30})).unwrap(); s.execute("shape.move", &json!({"dx": 10, "dy": -5})).unwrap(); s.execute("format.bold", &json!({})).unwrap(); let v = s.execute("shape.inspect", &json!({"id": id})).unwrap(); assert_eq!(v["box"]["x"], 110.0); assert_eq!(v["box"]["rot"], 30.0); let runs = &v["shape"]["text"]["paragraphs"][0]["runs"][0]["props"]; assert_eq!(runs["bold"], true); // Undo restores step by step. for _ in 0..5 { s.execute("edit.undo", &json!({})).unwrap(); } let v = s.execute("shape.inspect", &json!({"id": id})).unwrap(); assert!(v["shape"]["fill"].is_null()); } #[test] fn text_editing_flow() { let mut s = session(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; s.execute("text.edit", &json!({"id": title})).unwrap(); s.execute("text.insert", &json!({"text": "Hello world"})).unwrap(); s.execute("text.move", &json!({"to": "wordLeft", "extend": true})).unwrap(); s.execute("format.italic", &json!({})).unwrap(); s.execute("text.move", &json!({"to": "end"})).unwrap(); s.execute("text.delete", &json!({"dir": "backward"})).unwrap(); s.execute("text.insert", &json!({"text": "D\nSecond"})).unwrap(); let t = s.execute("text.get", &json!({"id": title})).unwrap(); assert_eq!(t["text"], "Hello worlD\nSecond"); s.execute("text.exit", &json!({})).unwrap(); assert!(s.doc().unwrap().selection.text.is_none()); assert_eq!(s.doc().unwrap().selection.shapes.len(), 1); } #[test] fn editing_text_refits_a_saved_shrink() { use deckcraft_model::text::AutoFit; let mut s = session(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id; s.execute("text.edit", &json!({"id": title.0})).unwrap(); s.execute("text.insert", &json!({"text": "Hi"})).unwrap(); // As if opened from a file PowerPoint saved shrunk. s.edit(|doc, sel| { let shapes = crate::shapes_mut(doc, sel).unwrap(); let sh = deckcraft_model::find_shape_mut(shapes, title).unwrap(); sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: 0.5, line_reduction: 0.2 }); Ok(()) }) .unwrap(); s.execute("text.insert", &json!({"text": "!"})).unwrap(); let sh = s.doc().unwrap().shape(title).cloned().unwrap(); assert_eq!(sh.text.unwrap().body.autofit, Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 })); } #[test] fn smart_quotes_and_autocorrect() { let mut s = session(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; s.execute("text.edit", &json!({"id": title})).unwrap(); for c in ["\"", "h", "i", "\"", " ", "t", "e", "h", " "] { s.execute("text.insert", &json!({"text": c})).unwrap(); } let t = s.execute("text.get", &json!({"id": title})).unwrap(); assert_eq!(t["text"], "“hi” the "); } #[test] fn clipboard_copy_paste_shapes_and_slides() { let mut s = session(); s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10, 10, 50, 50]})).unwrap(); s.execute("edit.copy", &json!({})).unwrap(); s.execute("edit.paste", &json!({})).unwrap(); s.execute("edit.paste", &json!({})).unwrap(); let n = s.doc().unwrap().current_slide().unwrap().shapes.len(); assert_eq!(n, 2 + 3); s.execute("edit.copy", &json!({"scope": "slides"})).unwrap(); s.execute("edit.paste", &json!({"scope": "slides"})).unwrap(); assert_eq!(s.doc().unwrap().doc.slides.len(), 2); assert!(s.doc().unwrap().doc.validate().is_empty()); } #[test] fn group_ungroup_roundtrip_positions() { let mut s = session(); let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 50, 50]})).unwrap()["id"].as_u64().unwrap(); let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [300, 200, 50, 50]})).unwrap()["id"].as_u64().unwrap(); s.execute("edit.select", &json!({"ids": [a, b]})).unwrap(); let g = s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap(); s.execute("shape.move", &json!({"id": g, "dx": 10})).unwrap(); s.execute("arrange.ungroup", &json!({"ids": [g]})).unwrap(); let v = s.execute("shape.inspect", &json!({"id": b})).unwrap(); assert!((v["box"]["x"].as_f64().unwrap() - 310.0).abs() < 1e-6, "{v}"); } #[test] fn align_and_distribute() { let mut s = session(); let mut ids = vec![]; for (x, y) in [(10.0, 10.0), (200.0, 50.0), (500.0, 90.0)] { ids.push(s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, y, 40, 40]})).unwrap()["id"].as_u64().unwrap()); } s.execute("edit.select", &json!({"ids": ids})).unwrap(); s.execute("arrange.align", &json!({"edge": "top"})).unwrap(); s.execute("arrange.distribute", &json!({"dir": "horizontal"})).unwrap(); let ys: Vec = ids.iter().map(|i| s.execute("shape.inspect", &json!({"id": i})).unwrap()["box"]["y"].as_f64().unwrap()).collect(); assert!(ys.iter().all(|y| (*y - 10.0).abs() < 1e-6)); let x1 = s.execute("shape.inspect", &json!({"id": ids[1]})).unwrap()["box"]["x"].as_f64().unwrap(); assert!((x1 - 255.0).abs() < 1e-6, "{x1}"); } #[test] fn pointer_draw_move_resize() { let mut s = session(); s.set_tool(ToolKind::Shape { preset: "rect".into() }); let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; s.pointer(ev(PointerKind::Down, 100.0, 100.0)).unwrap(); s.pointer(ev(PointerKind::Drag, 200.0, 180.0)).unwrap(); let r = s.pointer(ev(PointerKind::Up, 200.0, 180.0)).unwrap(); let id = r["id"].as_u64().unwrap(); assert_eq!(s.tool.kind, ToolKind::Select); // Move by dragging the middle. s.prefs.snap_to_grid = false; s.prefs.smart_guides = false; s.pointer(ev(PointerKind::Down, 150.0, 140.0)).unwrap(); s.pointer(ev(PointerKind::Drag, 170.0, 150.0)).unwrap(); s.pointer(ev(PointerKind::Up, 170.0, 150.0)).unwrap(); let v = s.execute("shape.inspect", &json!({"id": id})).unwrap(); assert_eq!(v["box"]["x"], 120.0); assert_eq!(v["box"]["y"], 110.0); // One undo step per gesture. s.execute("edit.undo", &json!({})).unwrap(); let v = s.execute("shape.inspect", &json!({"id": id})).unwrap(); assert_eq!(v["box"]["x"], 100.0); // Resize from the SE handle. s.execute("edit.select", &json!({"ids": [id]})).unwrap(); s.pointer(ev(PointerKind::Down, 200.0, 180.0)).unwrap(); s.pointer(ev(PointerKind::Drag, 250.0, 200.0)).unwrap(); s.pointer(ev(PointerKind::Up, 250.0, 200.0)).unwrap(); let v = s.execute("shape.inspect", &json!({"id": id})).unwrap(); assert_eq!(v["box"]["w"], 150.0); } #[test] fn click_into_placeholder_starts_editing() { let mut s = session(); let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; s.pointer(ev(PointerKind::Down, 480.0, 200.0)).unwrap(); s.pointer(ev(PointerKind::Up, 480.0, 200.0)).unwrap(); assert!(s.doc().unwrap().selection.text.is_some()); s.execute("text.insert", &json!({"text": "Typed"})).unwrap(); assert_eq!(s.doc().unwrap().current_slide().unwrap().title(), "Typed"); } #[test] fn sample_deck_builds_and_renders() { let mut s = Session::new(); sample::open_sample(&mut s).unwrap(); let st = s.doc().unwrap(); assert!(st.doc.slides.len() >= 8); assert!(st.doc.validate().is_empty()); assert!(!st.is_dirty()); for i in 0..st.doc.slides.len() { let img = deckcraft_render::render_slide(&st.doc, i, &deckcraft_render::RenderOpts { scale: 0.2, ..Default::default() }); assert_eq!(img.width, 192); } let v = s.execute("document.inspect", &json!({})).unwrap(); assert!(v["slides"].as_array().unwrap().len() >= 8); } #[test] fn writable_format_for_path_rejects_formats_deckcraft_does_not_write() { use crate::cmd::file::writable_format_for_path as f; assert_eq!(f("a.deckcraft"), Some("deckcraft")); assert_eq!(f("dir/A.PPTX"), Some("pptx")); assert_eq!(f("a.pdf"), Some("pdf")); assert_eq!(f("a.jpg"), Some("jpeg")); assert_eq!(f("a.txt"), Some("outline")); for path in ["a.odp", "a.gif", "a.svg", "a.outline", "a", "dir.v2/a"] { assert_eq!(f(path), None, "{path}"); } } #[test] fn save_and_reopen_native() { let mut s = Session::new(); sample::open_sample(&mut s).unwrap(); let bytes = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); let n = s.doc().unwrap().doc.slides.len(); s.execute("file.openBytes", &json!({"name": "x.deckcraft", "data": bytes})).unwrap(); assert_eq!(s.doc().unwrap().doc.slides.len(), n); assert_eq!(s.documents().len(), 2); } #[test] fn every_command_survives_empty_and_hostile_params() { let mut s = Session::new(); sample::open_sample(&mut s).unwrap(); s.execute("edit.select", &json!({"ids": [s.doc().unwrap().current_slide().unwrap().shapes[0].id.0]})).unwrap(); let ids: Vec<&str> = command_specs() .iter() .map(|c| c.id) .filter(|id| !matches!(*id, "file.close" | "file.open" | "file.save" | "file.saveAs" | "file.export" | "file.saveTemplate")) .collect(); for p in [ json!({}), json!(null), json!([1, 2]), json!({"index": 99999, "ids": [999999], "id": -1, "text": "x", "rect": "bad", "color": 5, "size": 1e308}), ] { for id in &ids { let _ = s.execute(id, &p); } } assert!(s.active().is_some()); for d in s.documents() { assert!(d.doc.validate().is_empty(), "{:?}", d.doc.validate()); } } #[test] fn base64_roundtrip() { for data in [vec![], vec![0u8], vec![1, 2], vec![1, 2, 3], (0..=255u8).collect::>()] { assert_eq!(cmd::base64_decode(&cmd::base64_encode(&data)).unwrap(), data); } assert!(cmd::base64_decode("!!").is_none()); } #[test] fn layout_change_keeps_text() { let mut s = session(); s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap(); s.execute("slide.layout", &json!({"layout": "twoContent"})).unwrap(); let v = s.execute("slide.inspect", &json!({})).unwrap(); let texts: Vec = v["shapes"].as_array().unwrap().iter().filter_map(|x| x["text"].as_str().map(String::from)).collect(); assert!(texts.contains(&"T".to_string()) && texts.contains(&"B".to_string()), "{v}"); assert_eq!(v["layout"], "Two Content"); } #[test] fn themes_and_slide_size() { let mut s = session(); s.execute("design.theme", &json!({"name": "Ember"})).unwrap(); assert_eq!(s.doc().unwrap().doc.masters[0].theme.name, "Ember"); s.execute("design.slideSize", &json!({"preset": "standard"})).unwrap(); assert_eq!(s.doc().unwrap().doc.slide_size.width, 720.0); s.execute("design.slideSize", &json!({"preset": "widescreen", "orientation": "portrait"})).unwrap(); assert_eq!((s.doc().unwrap().doc.slide_size.width, s.doc().unwrap().doc.slide_size.height), (540.0, 960.0)); s.execute("design.slideSize", &json!({"w": 540.0, "h": 960.0, "orientation": "landscape"})).unwrap(); assert_eq!((s.doc().unwrap().doc.slide_size.width, s.doc().unwrap().doc.slide_size.height), (960.0, 540.0)); assert!(s.execute("design.slideSize", &json!({"preset": "standard", "orientation": "sideways"})).is_err()); s.execute("design.headerFooter", &json!({"slideNumber": true, "footer": true, "footerText": "Hello"})).unwrap(); let v = s.execute("slide.inspect", &json!({})).unwrap(); assert!(v["shapes"].as_array().unwrap().iter().any(|x| x["placeholder"] == "sldNum")); } fn line_ends(s: &Session, id: u64) -> (deckcraft_geom::Point, deckcraft_geom::Point) { let st = s.doc().unwrap(); let sh = st.shape(deckcraft_model::ShapeId(id as u32)).unwrap(); connect::endpoints(&cmd::xfrm_of(&st.doc, &st.selection, sh)) } #[test] fn connectors_follow_glued_shapes() { let mut s = session(); let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 50]})).unwrap()["id"].as_u64().unwrap(); let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 300, 100, 50]})).unwrap()["id"].as_u64().unwrap(); let r = s.execute("shape.connect", &json!({"from": a, "to": b, "preset": "bentConnector3"})).unwrap(); let c = r["id"].as_u64().unwrap(); let (p0, p1) = line_ends(&s, c); // Closest pair: right side of A → left side of B... or bottom/top; either way on the boxes. let sites_a: Vec> = serde_json::from_value(s.execute("shape.sites", &json!({"id": a})).unwrap()).unwrap(); assert!(sites_a.iter().any(|q| (q[0] - p0.x).abs() < 1e-6 && (q[1] - p0.y).abs() < 1e-6)); // Move B: the connector's end follows, its start stays. s.execute("edit.select", &json!({"ids": [b]})).unwrap(); s.execute("shape.move", &json!({"dx": 50, "dy": 20})).unwrap(); let (q0, q1) = line_ends(&s, c); assert!((q0 - p0).hypot() < 1e-6); assert!(((q1 - p1) - deckcraft_geom::Vec2::new(50.0, 20.0)).hypot() < 1e-6, "{p1:?} → {q1:?}"); // Undo puts both back in one step. s.execute("edit.undo", &json!({})).unwrap(); assert!((line_ends(&s, c).1 - p1).hypot() < 1e-6); // Deleting a glued shape unglues that end and leaves the line where it was. s.execute("edit.select", &json!({"ids": [a]})).unwrap(); s.execute("edit.delete", &json!({})).unwrap(); let st = s.doc().unwrap(); let sh = st.shape(deckcraft_model::ShapeId(c as u32)).unwrap(); assert!(matches!(sh.kind, deckcraft_model::ShapeKind::Connector { start: None, end: Some(_) })); } #[test] fn drawing_a_line_between_shapes_glues_it() { let mut s = session(); let a = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [100, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap(); let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap(); s.execute("edit.deselect", &json!({})).unwrap(); s.tool.kind = tools::ToolKind::Shape { preset: "straightConnector1".into() }; let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 4.0 }; // Right site of the ellipse (200,150) → left site of the rect (400,150), a little off. s.pointer(ev(tools::PointerKind::Down, 202.0, 151.0)).unwrap(); s.pointer(ev(tools::PointerKind::Drag, 300.0, 150.0)).unwrap(); assert!(!s.tool.sites.is_empty() || s.tool.glue.is_none()); s.pointer(ev(tools::PointerKind::Drag, 398.0, 149.0)).unwrap(); assert!(s.tool.glue.is_some()); s.pointer(ev(tools::PointerKind::Up, 398.0, 149.0)).unwrap(); let st = s.doc().unwrap(); let line = st.shapes().iter().find(|x| x.is_line()).unwrap(); match line.kind { deckcraft_model::ShapeKind::Connector { start: Some((x, _)), end: Some((y, _)) } => { assert_eq!((x.0 as u64, y.0 as u64), (a, b)); } ref k => panic!("not glued: {k:?}"), } let id = line.id.0 as u64; let (p0, p1) = line_ends(&s, id); assert!((p0.x - 200.0).abs() < 1e-6 && (p1.x - 400.0).abs() < 1e-6, "{p0:?} {p1:?}"); } #[test] fn freeform_tools_and_command() { let mut s = session(); let before = s.doc().unwrap().shapes().len(); // Composite command: one undo step. let r = s.execute("shape.freeform", &json!({"points": [[100, 100], [200, 120], [150, 220]], "closed": true})).unwrap(); assert!(r["id"].as_u64().is_some()); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().shapes().len(), before); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().shapes().len(), before + 1); let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 3.0 }; // Polygon: click, click, click, click on the start → closed, filled. s.set_tool(tools::ToolKind::Shape { preset: "freeform".into() }); for (x, y) in [(300.0, 300.0), (400.0, 300.0), (400.0, 400.0)] { s.pointer(ev(tools::PointerKind::Down, x, y)).unwrap(); s.pointer(ev(tools::PointerKind::Up, x, y)).unwrap(); } assert!(s.tool.drawing_freeform()); s.pointer(ev(tools::PointerKind::Down, 301.0, 301.0)).unwrap(); assert!(!s.tool.drawing_freeform()); let st = s.doc().unwrap(); let sh = st.shapes().last().unwrap(); match &sh.geom { deckcraft_model::Geom::Custom { paths } => assert!(paths[0].d.ends_with('Z') && paths[0].d.matches(" L ").count() == 2, "{}", paths[0].d), g => panic!("{g:?}"), } let x = sh.xfrm.unwrap(); assert_eq!((x.x, x.y, x.w, x.h), (300.0, 300.0, 100.0, 100.0)); // Scribble: one drag, open path. s.set_tool(tools::ToolKind::Shape { preset: "scribble".into() }); s.pointer(ev(tools::PointerKind::Down, 10.0, 10.0)).unwrap(); for i in 1..20 { s.pointer(ev(tools::PointerKind::Drag, 10.0 + i as f64 * 5.0, 10.0 + (i as f64).sin() * 10.0)).unwrap(); } let r = s.pointer(ev(tools::PointerKind::Up, 110.0, 10.0)).unwrap(); assert!(r["id"].as_u64().is_some()); assert_eq!(s.tool.kind, tools::ToolKind::Select); // Curve: clicks then Enter → smooth open path. s.set_tool(tools::ToolKind::Shape { preset: "curve".into() }); for (x, y) in [(500.0, 100.0), (550.0, 50.0), (600.0, 100.0), (650.0, 50.0)] { s.pointer(ev(tools::PointerKind::Down, x, y)).unwrap(); s.pointer(ev(tools::PointerKind::Up, x, y)).unwrap(); } let r = s.key("Enter", Default::default()).unwrap(); let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); let st = s.doc().unwrap(); match &st.shape(id).unwrap().geom { deckcraft_model::Geom::Custom { paths } => assert_eq!(paths[0].d.matches(" C ").count(), 3), g => panic!("{g:?}"), } } #[test] fn double_click_in_table_uses_grown_row_heights() { // Rows stored 4 pt tall are drawn tall enough for their text, so 6 pt down is still row 0. let mut s = session(); let data = json!([["a", "b"], ["c", "d"], ["e", "f"]]); let id = s.execute("insert.table", &json!({"rows": 3, "cols": 2, "rect": [100.0, 100.0, 400.0, 120.0], "data": data})).unwrap()["id"].clone(); for row in 0..3 { s.execute("table.rowHeight", &json!({"id": id, "row": row, "height": 4.0})).unwrap(); } let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; s.pointer(ev(PointerKind::DoubleClick, 120.0, 106.0)).unwrap(); assert_eq!(s.doc().unwrap().selection.text.as_ref().and_then(|t| t.cell), Some((0, 0))); } #[test] fn double_click_edits_text_inside_group() { // A text member of a group is still editable: double-click reaches it through the group // (regression: it used to be a no-op, so grouped text boxes could not be edited). let mut s = session(); let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100.0, 100.0, 120.0, 50.0], "text": "Hello"})).unwrap()["id"].as_u64().unwrap(); let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [300.0, 200.0, 50.0, 50.0]})).unwrap()["id"].as_u64().unwrap(); s.execute("edit.select", &json!({"ids": [a, b]})).unwrap(); s.execute("arrange.group", &json!({})).unwrap(); let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; // Double-click the grouped text box: it becomes editable and targets that member. s.pointer(ev(PointerKind::DoubleClick, 160.0, 125.0)).unwrap(); assert_eq!(s.doc().unwrap().selection.text.as_ref().map(|t| t.shape.0 as u64), Some(a)); } #[test] fn grown_table_frame_covers_its_drawn_rows() { // Rows stored 4 pt tall are drawn taller: the box, its handles and hit tests follow the drawn rows. let mut s = session(); let data = json!([["a", "b"], ["c", "d"], ["e", "f"]]); let id = s.execute("insert.table", &json!({"rows": 3, "cols": 2, "rect": [100.0, 100.0, 400.0, 120.0], "data": data})).unwrap()["id"].clone(); for row in 0..3 { s.execute("table.rowHeight", &json!({"id": id, "row": row, "height": 4.0})).unwrap(); } let st = s.doc().unwrap(); let sh = st.shapes().iter().find(|sh| json!(sh.id) == id).unwrap().clone(); let deckcraft_model::ShapeKind::Table(t) = &sh.kind else { panic!("not a table") }; let drawn: f64 = tools::table_rows(st, t).iter().sum(); assert!(drawn > 12.0, "rows should grow: {drawn}"); let x = cmd::xfrm_of(&st.doc, &st.selection, &sh); assert!((x.h - drawn).abs() < 1e-6, "frame {} vs drawn {drawn}", x.h); let below_stored = deckcraft_geom::Point::new(120.0, 100.0 + drawn - 2.0); assert!(matches!(s.hit_test(below_stored, 1.0), Some(tools::Hit::Shape { .. }))); } #[test] fn right_to_left_paragraphs() { let mut s = session(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; s.execute("text.edit", &json!({"id": title})).unwrap(); s.execute("text.insert", &json!({"text": "سنة 2024 (Retrait) فقط"})).unwrap(); s.execute("format.rtl", &json!({})).unwrap(); let para = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone(); assert_eq!(para(&s).rtl, Some(true)); // Left (the default) becomes right: the text stays at its start edge. assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::Right)); s.execute("format.align", &json!({"align": "justifyLow"})).unwrap(); assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::JustLow)); // Toggling back keeps a justified alignment. s.execute("format.rtl", &json!({})).unwrap(); assert_eq!(para(&s).rtl, Some(false)); assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::JustLow)); s.execute("format.rtl", &json!({"on": true})).unwrap(); s.execute("format.alignLeft", &json!({})).unwrap(); s.execute("format.rtl", &json!({"on": false})).unwrap(); assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::Right)); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(para(&s).rtl, Some(true)); } #[test] fn header_footer_fixed_date_is_the_slides_own_text() { use deckcraft_model::text::RunKind; let mut s = session(); let date = |s: &Session| { let slide = s.doc().unwrap().current_slide().unwrap(); slide.shapes.iter().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)).and_then(|x| x.text.clone()).unwrap() }; let kind = |s: &Session| date(s).paragraphs[0].runs[0].kind.clone(); // Automatic: a date field, saved with today's date. s.execute("design.headerFooter", &json!({"date": true})).unwrap(); assert_eq!(kind(&s), RunKind::Field { field: "datetime1".into() }); assert_eq!(date(&s).text().matches('/').count(), 2, "M/D/YYYY"); // Fixed: plain text in the slide's own placeholder (other slides' fields aren't affected). s.execute("design.headerFooter", &json!({"date": true, "dateText": "Launch day"})).unwrap(); assert_eq!((kind(&s), date(&s).text()), (RunKind::Text, "Launch day".to_string())); // Back to automatic. s.execute("design.headerFooter", &json!({"date": true, "dateText": ""})).unwrap(); assert!(matches!(kind(&s), RunKind::Field { .. })); // A new date field is saved with the date it shows. s.execute("text.edit", &json!({"id": s.doc().unwrap().current_slide().unwrap().shapes[0].id.0})).unwrap(); s.execute("text.insert", &json!({"text": "Updated "})).unwrap(); s.execute("insert.dateTime", &json!({"format": "datetime10"})).unwrap(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap(); let run = title.paragraphs[0].runs.iter().find(|r| matches!(r.kind, RunKind::Field { .. })).cloned().unwrap(); assert!(run.text.contains(':'), "H:mm {run:?}"); } /// (text, linked?) of each run in the first paragraph of the current slide's title. fn title_runs(s: &Session) -> Vec<(String, bool)> { let sh = &s.doc().unwrap().current_slide().unwrap().shapes[0]; let p = &sh.text.as_ref().unwrap().paragraphs[0]; p.runs.iter().map(|r| (r.text.clone(), r.props.link.is_some())).collect() } #[test] fn hyperlink_with_a_bare_caret_inserts_the_address() { // #31: with the caret at the end of the text, Insert Hyperlink used to format an empty range // and change nothing. let mut s = session(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; s.execute("text.edit", &json!({"id": title})).unwrap(); s.execute("text.insert", &json!({"text": "See "})).unwrap(); s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap(); s.execute("text.insert", &json!({"text": " now"})).unwrap(); assert_eq!( title_runs(&s), vec![("See ".into(), false), ("https://example.org".into(), true), (" now".into(), false)], "the address is inserted as a link, and typing after it doesn't extend it" ); } #[test] fn hyperlink_display_text_and_targets() { let mut s = session(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; s.execute("text.edit", &json!({"id": title})).unwrap(); s.execute("insert.hyperlink", &json!({"url": "mailto:a@b.org"})).unwrap(); assert_eq!(title_runs(&s), vec![("a@b.org".into(), true)], "email links show the address without mailto:"); s.execute("text.insert", &json!({"text": " "})).unwrap(); s.execute("insert.hyperlink", &json!({"slide": 0, "text": "start"})).unwrap(); assert_eq!(title_runs(&s), vec![("a@b.org".into(), true), (" ".into(), false), ("start".into(), true)]); } #[test] fn hyperlink_on_a_word_or_shape_links_it() { let mut s = session(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; s.execute("text.edit", &json!({"id": title})).unwrap(); s.execute("text.insert", &json!({"text": "Hello world"})).unwrap(); s.execute("text.move", &json!({"to": "left"})).unwrap(); s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap(); assert_eq!(title_runs(&s), vec![("Hello ".into(), false), ("world".into(), true)], "a caret inside a word links the word"); s.execute("text.exit", &json!({})).unwrap(); s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap(); assert!(s.doc().unwrap().current_slide().unwrap().shapes[0].click.is_some(), "a selected shape gets the link as its click action"); } /// A .pptx (base64) whose first slide's title is set in `font`. fn deck_in_font(font: &str) -> String { let mut p = deckcraft_model::Presentation::default(); let slide = std::sync::Arc::make_mut(&mut p.slides[0]); let mut body = deckcraft_model::text::TextBody::from_text("Hello"); body.paragraphs[0].runs[0].props.font = Some(font.into()); slide.shapes[0].text = Some(body); crate::cmd::base64_encode(&crate::cmd::file::save_bytes(&p, "pptx").unwrap()) } #[test] fn opening_a_file_lists_the_fonts_it_uses_that_are_missing() { let mut s = Session::with_new(); let r = s.execute("file.openBytes", &json!({"name": "a.pptx", "data": deck_in_font("Qqq Nonexistent Sans")})).unwrap(); assert_eq!(r["missingFonts"], json!(["Qqq Nonexistent Sans"])); assert_eq!(s.doc().unwrap().missing_fonts, vec!["Qqq Nonexistent Sans".to_string()]); // A font that is there (bundled or installed) isn't listed; new documents list nothing. let have = deckcraft_fonts::FontDb::global().face(deckcraft_fonts::FALLBACK_FAMILY, "Regular").family.clone(); let r = s.execute("file.openBytes", &json!({"name": "b.pptx", "data": deck_in_font(&have)})).unwrap(); assert_eq!(r["missingFonts"], json!([])); s.execute("file.new", &json!({})).unwrap(); assert!(s.doc().unwrap().missing_fonts.is_empty()); } #[test] fn an_embedded_face_is_used_when_the_machine_lacks_it() { let mut p = deckcraft_model::Presentation::default(); let slide = std::sync::Arc::make_mut(&mut p.slides[0]); let mut body = deckcraft_model::text::TextBody::from_text("Hello"); body.paragraphs[0].runs[0].props.font = Some("DeckCraft Colr Squares".into()); slide.shapes[0].text = Some(body); let id = p.add_media("font1.fntdata", "application/x-fontdata", include_bytes!("../../render/tests/fixtures/colr-squares.ttf").to_vec()); p.embedded_fonts.push(deckcraft_model::EmbeddedFont { typeface: "DeckCraft Colr Squares".into(), regular: Some(id), ..Default::default() }); let data = crate::cmd::base64_encode(&crate::cmd::file::save_bytes(&p, "pptx").unwrap()); let mut s = Session::with_new(); let r = s.execute("file.openBytes", &json!({"name": "fonts.pptx", "data": data})).unwrap(); assert_eq!(r["missingFonts"], json!([])); let font = s.doc().unwrap().doc.embedded_fonts.first().unwrap(); assert_eq!(font.typeface, "DeckCraft Colr Squares"); assert!(font.regular.is_some()); } #[test] fn clicks_in_flipped_shapes_hit_the_text_where_it_is_drawn() { // Flips don't mirror text, so a click lands on the character drawn under it. let mut s = session(); let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 400, 100], "text": "Hello flipped world"})).unwrap(); let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); let pos = |s: &Session, x: f64, y: f64| { let st = s.doc().unwrap(); tools::text_pos(st, st.shape(id).unwrap(), deckcraft_geom::Point::new(x, y)) }; let xs = [240.0, 300.0, 360.0]; let plain = xs.map(|x| pos(&s, x, 150.0)); assert!(plain[0].1 < plain[1].1 && plain[1].1 < plain[2].1, "{plain:?}"); s.execute("shape.flip", &json!({"axis": "horizontal"})).unwrap(); assert_eq!(xs.map(|x| pos(&s, x, 150.0)), plain); // flipV turns the text 180° about the box centre (300, 150). s.execute("shape.flip", &json!({"axis": "horizontal"})).unwrap(); s.execute("shape.flip", &json!({"axis": "vertical"})).unwrap(); assert_eq!(xs.map(|x| pos(&s, 600.0 - x, 150.0)), plain); } #[test] fn inserted_hyperlinks_look_like_links() { let mut s = session(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; s.execute("text.edit", &json!({"id": title})).unwrap(); s.execute("text.insert", &json!({"text": "Read the docs"})).unwrap(); s.execute("text.move", &json!({"to": "wordLeft", "extend": true})).unwrap(); assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false); s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap(); // Underlined (and hlink-coloured: same resolution as files) without touching the run's own props. assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], true); let body = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap(); let run = body.paragraphs[0].runs.iter().find(|r| r.props.link.is_some()).unwrap(); assert!(run.props.fill.is_none() && run.props.underline.is_none(), "{run:?}"); // Underline off is the run's own u="none", as in PowerPoint. s.execute("format.underline", &json!({})).unwrap(); assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false); } /// #270: shapes off the slide (in the editor's pasteboard) are hit, selected, moved and marqueed /// like any other; one can be dragged back onto the slide or out of it. #[test] fn shapes_off_the_slide_can_be_selected_and_moved() { let mut s = session(); s.prefs.snap_to_grid = false; s.prefs.smart_guides = false; let off = s.execute("shape.insert", &json!({"preset": "rect", "rect": [1000.0, 100.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap(); let half = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [-60.0, 300.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap(); // Inserting off the slide keeps the box where it was asked for. assert_eq!(s.execute("shape.inspect", &json!({"id": off})).unwrap()["box"]["x"], 1000.0); s.execute("edit.select", &json!({"ids": []})).unwrap(); let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; // Hover and click wholly off the slide. assert!(matches!(s.hit_test(deckcraft_geom::Point::new(1060.0, 140.0), 3.0), Some(tools::Hit::Shape { id, .. }) if id.0 as u64 == off)); s.pointer(ev(PointerKind::Down, 1060.0, 140.0)).unwrap(); s.pointer(ev(PointerKind::Up, 1060.0, 140.0)).unwrap(); assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::>(), vec![off]); // Drag it further off, then back onto the slide. s.pointer(ev(PointerKind::Down, 1060.0, 140.0)).unwrap(); s.pointer(ev(PointerKind::Drag, 1110.0, 160.0)).unwrap(); s.pointer(ev(PointerKind::Up, 1110.0, 160.0)).unwrap(); let v = s.execute("shape.inspect", &json!({"id": off})).unwrap(); assert_eq!((v["box"]["x"].as_f64(), v["box"]["y"].as_f64()), (Some(1050.0), Some(120.0))); s.pointer(ev(PointerKind::Down, 1110.0, 160.0)).unwrap(); s.pointer(ev(PointerKind::Drag, 500.0, 160.0)).unwrap(); s.pointer(ev(PointerKind::Up, 500.0, 160.0)).unwrap(); assert_eq!(s.execute("shape.inspect", &json!({"id": off})).unwrap()["box"]["x"], 440.0); // The off-slide half of a straddling shape is hit too. s.pointer(ev(PointerKind::Down, -30.0, 340.0)).unwrap(); s.pointer(ev(PointerKind::Up, -30.0, 340.0)).unwrap(); assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::>(), vec![half]); // A marquee started on the pasteboard selects what it encloses there. s.pointer(ev(PointerKind::Down, -100.0, 250.0)).unwrap(); s.pointer(ev(PointerKind::Drag, 80.0, 420.0)).unwrap(); s.pointer(ev(PointerKind::Up, 80.0, 420.0)).unwrap(); assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::>(), vec![half]); } fn title_text(s: &Session) -> String { let sh = &s.doc().unwrap().current_slide().unwrap().shapes[0]; sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::>().join("\n")).unwrap_or_default() } /// #101: Korean typed through an input method arrives as preedit updates (ㅎ → 하 → 한 → 한ㄱ → /// 한그 → 한글) and a commit. The preedit is shown in place and replaced on each update; the /// committed text is one undo step, and nothing on the way is an undo step of its own. #[test] fn ime_composition_replaces_its_preedit_and_commits_as_one_undo_step() { let mut s = session(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; s.execute("text.edit", &json!({"id": title})).unwrap(); s.execute("text.insert", &json!({"text": "A "})).unwrap(); let undo0 = s.doc().unwrap().history.undo.len(); for pre in ["ㅎ", "하", "한", "한ㄱ", "한그", "한글"] { s.compose_text(pre, None).unwrap(); assert_eq!(title_text(&s), format!("A {pre}"), "the preedit is shown in place"); let c = s.doc().unwrap().composition.clone().unwrap(); assert_eq!((c.start, c.len), ((0, 2), pre.chars().count())); assert_eq!(s.doc().unwrap().selection.text.as_ref().unwrap().caret, (0, 2 + pre.chars().count()), "caret after the preedit"); assert_eq!(s.doc().unwrap().history.undo.len(), undo0, "no undo step while composing"); } s.commit_text("한글").unwrap(); assert_eq!(title_text(&s), "A 한글"); assert!(s.doc().unwrap().composition.is_none()); assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1, "one undo step for the composition"); assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Type Text"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(title_text(&s), "A "); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(title_text(&s), "A 한글"); // Windows' Korean IME commits a syllable when the next begins: an empty preedit, the commit, // then a new preedit. s.compose_text("ㄴ", None).unwrap(); s.compose_text("", None).unwrap(); s.commit_text("ㄴ").unwrap(); s.compose_text("ㅁ", None).unwrap(); assert_eq!(title_text(&s), "A 한글ㄴㅁ"); s.commit_text("마").unwrap(); assert_eq!(title_text(&s), "A 한글ㄴ마"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(title_text(&s), "A 한글ㄴ"); } /// #101: an empty preedit cancels the composition (Escape, or Backspace over its last jamo): /// the text and the selection it replaced are as they were, with no undo step. #[test] fn ime_cancel_restores_the_text_and_selection() { let mut s = session(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; s.execute("text.edit", &json!({"id": title})).unwrap(); s.execute("text.insert", &json!({"text": "Hello world"})).unwrap(); s.execute("text.select", &json!({"anchor": [0, 6], "caret": [0, 11]})).unwrap(); let undo0 = s.doc().unwrap().history.undo.len(); let rev = s.doc().unwrap().revision; s.compose_text("せかい", Some((0, 3))).unwrap(); assert_eq!(title_text(&s), "Hello せかい", "the preedit replaces the selection"); assert_eq!(s.doc().unwrap().composition.as_ref().unwrap().active, Some((0, 3))); s.compose_text("世界", Some((0, 9))).unwrap(); assert_eq!(s.doc().unwrap().composition.as_ref().unwrap().active, Some((0, 2)), "clamped to the preedit"); s.compose_text("", None).unwrap(); assert_eq!(title_text(&s), "Hello world"); let t = s.doc().unwrap().selection.text.clone().unwrap(); assert_eq!((t.anchor, t.caret), ((0, 6), (0, 11)), "the selection is back"); assert_eq!(s.doc().unwrap().history.undo.len(), undo0); assert!(s.doc().unwrap().revision > rev, "redrawn"); // A commit of nothing without a composition is nothing. s.commit_text("").unwrap(); assert_eq!(s.doc().unwrap().history.undo.len(), undo0); // A commit without a preedit (some input methods send only the result) is one step. s.commit_text("世界").unwrap(); assert_eq!(title_text(&s), "Hello 世界"); assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1); } /// #101: anything else settles the composition first: its text stays and is its own undo step. #[test] fn ime_composition_is_kept_by_a_key_a_click_or_another_command() { let mut s = session(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; s.execute("text.edit", &json!({"id": title})).unwrap(); let undo0 = s.doc().unwrap().history.undo.len(); s.compose_text("안", None).unwrap(); s.key("Right", Mods::default()).unwrap(); assert!(s.doc().unwrap().composition.is_none()); assert_eq!(title_text(&s), "안"); assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1); s.compose_text("녕", None).unwrap(); s.pointer(PointerEvent { kind: PointerKind::Move, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap(); assert!(s.doc().unwrap().composition.is_some(), "hovering keeps composing"); s.pointer(PointerEvent { kind: PointerKind::Down, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap(); assert!(s.doc().unwrap().composition.is_none(), "a click settles it"); assert_eq!(title_text(&s), "안녕"); s.execute("text.edit", &json!({"id": title})).unwrap(); s.compose_text("하", None).unwrap(); s.execute("text.get", &json!({"id": title})).unwrap(); assert!(s.doc().unwrap().composition.is_some(), "a query doesn't"); s.execute("text.insert", &json!({"text": "!"})).unwrap(); assert_eq!(title_text(&s), "안녕하!"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(title_text(&s), "안녕하"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(title_text(&s), "안녕"); // Text changed under the composition (here behind its back): a new one starts, no panic. s.compose_text("ㄱ", None).unwrap(); s.doc_mut().unwrap().composition.as_mut().unwrap().len = 99; s.compose_text("가", None).unwrap(); assert!(title_text(&s).ends_with('가')); // Control characters (an Enter sent as text) never split the paragraph. s.commit_text("가\n\r\u{b}").unwrap(); assert_eq!(s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs.len(), 1); } /// #101: the first preedit on a selected shape starts editing it and replaces its text, as typing /// does; with nothing selected there is nowhere to compose. #[test] fn ime_on_a_selected_shape_replaces_its_text() { let mut s = session(); let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old"})).unwrap(); let id = r["id"].as_u64().unwrap(); s.select(|_, sel| { sel.text = None; sel.shapes = vec![ShapeId(id as u32)]; }) .unwrap(); assert_eq!(s.compose_text("", None).unwrap()["handled"], false, "an empty preedit doesn't start editing"); s.compose_text("新", None).unwrap(); s.commit_text("新しい").unwrap(); let v = s.execute("text.get", &json!({"id": id})).unwrap(); assert_eq!(v["text"], "新しい"); s.select(|_, sel| { sel.text = None; sel.shapes.clear(); }) .unwrap(); assert_eq!(s.compose_text("x", None).unwrap()["handled"], false); assert_eq!(s.commit_text("x").unwrap()["handled"], false); } /// #101: the composition is underlined in place, under the composed characters, with a thicker /// line under the part being converted. #[test] fn ime_composition_underlines_follow_the_text() { let mut s = session(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; s.execute("text.edit", &json!({"id": title})).unwrap(); assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty()); s.compose_text("にほんご", Some((2, 4))).unwrap(); let u = cmd::text::composition_underlines(s.doc().unwrap()); let thin: Vec<_> = u.iter().filter(|x| !x.2).collect(); let thick: Vec<_> = u.iter().filter(|x| x.2).collect(); assert_eq!((thin.len(), thick.len()), (1, 1), "{u:?}"); let sh = s.doc().unwrap().current_slide().unwrap().shapes[0].clone(); let x = cmd::xfrm_of(&s.doc().unwrap().doc, &s.doc().unwrap().selection, &sh); for (a, b, _) in &u { assert!(b.x > a.x && (a.y - b.y).abs() < 1e-9, "a horizontal line: {a:?} {b:?}"); assert!(a.x >= x.x - 1.0 && b.x <= x.x + x.w + 1.0 && a.y > x.y && a.y < x.y + x.h, "inside the title box"); } assert!(thick[0].0.x > thin[0].0.x && (thick[0].1.x - thin[0].1.x).abs() < 1e-6, "the converted part is the end"); s.commit_text("日本語").unwrap(); assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty()); } /// File › Info (#76): the active presentation's name, location, counts and properties. #[test] fn file_info_reports_the_presentation() { let mut s = session(); s.execute("slide.new", &json!({})).unwrap(); s.execute("slide.new", &json!({})).unwrap(); s.execute("slide.go", &json!({"index": 1})).unwrap(); s.execute("slide.hide", &json!({})).unwrap(); s.execute("file.properties", &json!({"title": "Quarterly", "author": "Ada"})).unwrap(); let v = s.execute("file.info", &json!({})).unwrap(); assert_eq!(v["slides"], 3); assert_eq!(v["hiddenSlides"], 1); assert_eq!(v["path"], serde_json::Value::Null); assert_eq!(v["dirty"], true); assert_eq!(v["properties"]["title"], "Quarterly"); assert_eq!(v["properties"]["author"], "Ada"); assert!(v["title"].as_str().is_some_and(|t| !t.is_empty())); // A query: no undo step, and nothing changes. let before = s.doc().unwrap().history.undo.len(); s.execute("file.info", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().history.undo.len(), before); // Saved to disk, it reports where and how big. let dir = std::env::temp_dir().join(format!("deckcraft-info-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("Info test.pptx").to_string_lossy().to_string(); s.execute("file.saveAs", &json!({"path": path})).unwrap(); let v = s.execute("file.info", &json!({})).unwrap(); assert_eq!(v["path"], path.as_str()); assert_eq!(v["format"], "pptx"); assert_eq!(v["dirty"], false); assert!(v["bytes"].as_u64().is_some_and(|b| b > 0)); let _ = std::fs::remove_dir_all(&dir); // Nothing open: an error, not a panic. let mut empty = Session::new(); assert!(empty.execute("file.info", &json!({})).is_err()); } /// The Print page's Slides box (#76): PowerPoint-style ranges, 1-based. #[test] fn slide_ranges_parse_like_powerpoints_print_box() { use cmd::file::parse_slide_range as r; assert_eq!(r("1-3, 5", 6).unwrap(), vec![0, 1, 2, 4]); assert_eq!(r("3-1;2", 6).unwrap(), vec![0, 1, 2], "reversed ranges and repeats"); assert_eq!(r(" 6 ", 6).unwrap(), vec![5]); assert!(r("7", 6).is_err(), "past the last slide"); assert!(r("0", 6).is_err()); assert!(r("a-b", 6).is_err()); assert!(r(" , ", 6).is_err()); assert!(r("1-99999999999999999999999", 6).is_err(), "hostile numbers are refused"); // file.export takes the same text for a PDF. let mut s = session(); s.execute("slide.new", &json!({})).unwrap(); let doc = s.doc().unwrap().doc.clone(); let one = cmd::file::pdf_bytes(&doc, &json!({"slides": "2"})).unwrap(); let both = cmd::file::pdf_bytes(&doc, &json!({"slides": "1-2"})).unwrap(); assert!(one.len() < both.len()); assert!(cmd::file::pdf_bytes(&doc, &json!({"slides": "5"})).is_err()); let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } #[test] fn cell_fill_walks_only_the_cells_a_huge_selection_covers() { // A selection may reach far past the table; filling it touches the real cells and nothing more. let mut s = session(); let id = s.execute("insert.table", &json!({"rows": 2, "cols": 2, "rect": [100.0, 100.0, 200.0, 40.0]})).unwrap()["id"].clone(); s.execute("table.selectCells", &json!({"id": id, "from": [1, 1], "to": [1_000_000_000u64, 1_000_000_000u64]})).unwrap(); s.execute("table.cellFill", &json!({"none": true})).unwrap(); let st = s.doc().unwrap(); let sh = st.shapes().iter().find(|sh| json!(sh.id) == id).unwrap(); let deckcraft_model::ShapeKind::Table(t) = &sh.kind else { panic!("not a table") }; let fills: Vec = [(0, 0), (0, 1), (1, 0), (1, 1)].iter().map(|&(r, c)| t.cell(r, c).is_some_and(|x| x.fill.is_some())).collect(); assert_eq!(fills, [false, false, false, true]); } /// Each slide as (title, [(level, bullet text)]). fn outline_of(p: &deckcraft_model::Presentation) -> Vec<(String, Vec<(u8, String)>)> { p.slides .iter() .map(|s| { let body = s.shapes.get(1).and_then(|b| b.text.as_ref()).map(|t| t.paragraphs.iter().map(|p| (p.level, p.text())).collect()).unwrap_or_default(); (s.title(), body) }) .collect() } const MD_OUTLINE: &str = "# Intro\n- Point one\n- Point two\n - Sub point\n\n# Second\n- More\n"; fn md_expected() -> Vec<(String, Vec<(u8, String)>)> { vec![ ("Intro".into(), vec![(0, "Point one".into()), (0, "Point two".into()), (1, "Sub point".into())]), ("Second".into(), vec![(0, "More".into())]), ] } #[test] fn markdown_outlines_take_headings_as_slides_and_lists_as_bullets() { let p = crate::cmd::file::open_presentation("outline.md", MD_OUTLINE.as_bytes()).unwrap(); assert_eq!(outline_of(&p), md_expected()); // numbered lists, `*` / `+` bullets, text under a heading, and closing #s let p = crate::cmd::file::open_presentation("o.md", b"## Plan ##\n1. First\n2) Second\n* star\n+ plus\nA plain line\n").unwrap(); assert_eq!( outline_of(&p), vec![("Plan".into(), vec![(0, "First".into()), (0, "Second".into()), (0, "star".into()), (0, "plus".into()), (0, "A plain line".into())])] ); } #[test] fn slides_from_a_markdown_outline_file() { let mut s = session(); s.execute("slide.fromOutline", &json!({"text": MD_OUTLINE, "markdown": true})).unwrap(); let doc = &s.doc().unwrap().doc; assert_eq!(outline_of(doc)[1..], md_expected()[..]); } #[test] fn plain_outlines_are_unchanged() { // .txt keeps the indentation format, and so does a .md without headings let indented = "Intro\n\tPoint one\n\t\tSub point\nSecond\n\tMore\n"; let want = vec![("Intro".to_string(), vec![(0, "Point one".to_string()), (1, "Sub point".to_string())]), ("Second".into(), vec![(0, "More".into())])]; for name in ["outline.txt", "outline.md"] { let p = crate::cmd::file::open_presentation(name, indented.as_bytes()).unwrap(); assert_eq!(outline_of(&p), want, "{name}"); } let p = crate::cmd::file::open_presentation("outline.txt", MD_OUTLINE.as_bytes()).unwrap(); assert_eq!(p.slides.first().map(|s| s.title()).as_deref(), Some("# Intro")); } fn text_box_xfrm(s: &Session, id: u64) -> deckcraft_geom::Xfrm { let st = s.doc().unwrap(); cmd::xfrm_of(&st.doc, &st.selection, st.shape(deckcraft_model::ShapeId(id as u32)).unwrap()) } fn right_click(s: &mut Session, x: f64, y: f64) -> serde_json::Value { s.pointer(PointerEvent { kind: PointerKind::ContextClick, x, y, mods: Mods::default(), tol: 3.0 }).unwrap() } /// A blank slide with a rectangle A (100,100 200×100) and a text box B (400,100 200 wide), /// nothing selected. fn two_shapes() -> (Session, deckcraft_model::ShapeId, deckcraft_model::ShapeId) { let mut s = session(); s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100.0, 100.0, 200.0, 100.0], "text": "Shape A"})).unwrap()["id"] .as_u64() .unwrap(); let b = s.execute("insert.textBox", &json!({"rect": [400.0, 100.0, 200.0, 40.0], "text": "Box B"})).unwrap()["id"].as_u64().unwrap(); s.select(|_, sel| { sel.shapes.clear(); sel.text = None; }) .unwrap(); (s, deckcraft_model::ShapeId(a as u32), deckcraft_model::ShapeId(b as u32)) } #[test] fn right_click_selects_the_shape_under_the_pointer() { let (mut s, a, b) = two_shapes(); let undo_before = s.doc().unwrap().history.undo.len(); // An unselected shape: selected alone, no text editing, no drag. let r = right_click(&mut s, 150.0, 150.0); assert_eq!(r["selected"], json!([a])); let st = s.doc().unwrap(); assert_eq!(st.selection.shapes, vec![a]); assert!(st.selection.text.is_none()); assert!(!s.tool.dragging()); s.pointer(PointerEvent { kind: PointerKind::Drag, x: 190.0, y: 190.0, mods: Mods::default(), tol: 3.0 }).unwrap(); assert_eq!(text_box_xfrm(&s, a.0 as u64).x, 100.0); // A text box, which a left click would start editing: only selected. right_click(&mut s, 450.0, 115.0); let st = s.doc().unwrap(); assert_eq!(st.selection.shapes, vec![b]); assert!(st.selection.text.is_none()); // A multiple selection stays when the right click lands on one of its shapes. s.execute("edit.select", &json!({"ids": [a, b]})).unwrap(); right_click(&mut s, 450.0, 115.0); assert_eq!(s.doc().unwrap().selection.shapes, vec![a, b]); // Empty slide: nothing selected (the slide menu). let r = right_click(&mut s, 700.0, 450.0); assert!(r["hit"].is_null(), "{r}"); assert!(s.doc().unwrap().selection.shapes.is_empty()); assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); } #[test] fn right_click_in_edited_text_keeps_the_text_selection() { let (mut s, a, b) = two_shapes(); s.execute("text.edit", &json!({"id": a, "at": [0, 0]})).unwrap(); s.execute("text.move", &json!({"to": "wordRight", "extend": true})).unwrap(); let before = s.doc().unwrap().selection.text.clone().unwrap(); assert!(before.is_range()); // Anywhere in the box, even away from the selected word. let r = right_click(&mut s, 280.0, 185.0); assert_eq!(r["editing"], true); assert_eq!(s.doc().unwrap().selection.text.as_ref(), Some(&before)); // The context menu's Copy and Cut then act on the selected text. assert_eq!(s.execute("edit.copy", &json!({})).unwrap()["text"].as_str().unwrap().trim(), "Shape"); s.execute("edit.cut", &json!({})).unwrap(); assert_eq!(s.execute("text.get", &json!({"id": a})).unwrap()["text"].as_str().unwrap().trim(), "A"); // A right click on another shape leaves editing and selects that shape. right_click(&mut s, 450.0, 115.0); let st = s.doc().unwrap(); assert!(st.selection.text.is_none()); assert_eq!(st.selection.shapes, vec![b]); } mod copy_remap { use std::sync::Arc; use deckcraft_model::style::PictureFill; use deckcraft_model::{Animation, Shape, ShapeId, ShapeKind, Xfrm}; use serde_json::json; use crate::*; /// Slide 0 of `s` gets a picture of `bytes`, a box, and a connector from the picture to the box. /// Returns (picture, box, connector). fn populate(s: &mut Session, bytes: &[u8]) -> (ShapeId, ShapeId, ShapeId) { let st = s.doc_mut().unwrap(); let doc = Arc::make_mut(&mut st.doc); let media = doc.add_media("p.png", "image/png", bytes.to_vec()); let (p, b, c) = (ShapeId(doc.alloc_id()), ShapeId(doc.alloc_id()), ShapeId(doc.alloc_id())); let x = Some(Xfrm::new(10.0, 10.0, 50.0, 50.0)); let slide = Arc::make_mut(&mut doc.slides[0]); slide.shapes.push(Shape { id: p, kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } }, xfrm: x, ..Default::default() }); slide.shapes.push(Shape { id: b, xfrm: x, ..Default::default() }); slide.shapes.push(Shape { id: c, kind: ShapeKind::Connector { start: Some((p, 1)), end: Some((b, 3)) }, xfrm: x, ..Default::default() }); (p, b, c) } fn select(s: &mut Session, ids: &[ShapeId]) { let sel = &mut s.doc_mut().unwrap().selection; sel.slides.clear(); sel.shapes = ids.to_vec(); } fn shape(s: &Session, slide: usize, id: ShapeId) -> Shape { let d = s.doc().unwrap(); d.doc.slides[slide].shapes.iter().find(|x| x.id == id).cloned().unwrap() } fn picture_bytes(s: &Session, sh: &Shape) -> Vec { let ShapeKind::Picture { fill } = &sh.kind else { panic!("not a picture") }; s.doc().unwrap().doc.media(fill.media).unwrap().data.as_ref().clone() } #[test] fn paste_into_a_deck_with_a_colliding_media_id_shows_the_copied_picture() { let mut a = Session::with_new(); let mut b = Session::with_new(); let (pa, ..) = populate(&mut a, b"deck a picture"); let (pb, ..) = populate(&mut b, b"deck b picture"); assert_eq!(picture_bytes(&a, &shape(&a, 0, pa)), b"deck a picture"); select(&mut a, &[pa]); a.execute("edit.copy", &json!({})).unwrap(); b.clipboard = a.clipboard.clone(); b.doc_mut().unwrap().selection.shapes.clear(); let r = b.execute("edit.paste", &json!({})).unwrap(); let new = ShapeId(r["ids"][0].as_u64().unwrap() as u32); assert_eq!(picture_bytes(&b, &shape(&b, 0, new)), b"deck a picture"); // Deck b's own picture is untouched. assert_eq!(picture_bytes(&b, &shape(&b, 0, pb)), b"deck b picture"); } #[test] fn copied_connectors_follow_copied_ends_and_detach_from_the_rest() { let mut s = Session::with_new(); let (p, b, c) = populate(&mut s, b"x"); // Both ends copied: the copy connects the copies. let r = s.execute("edit.duplicate", &json!({"ids": [p.0, b.0, c.0]})).unwrap(); let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| ShapeId(v.as_u64().unwrap() as u32)).collect(); let ShapeKind::Connector { start, end } = shape(&s, 0, ids[2]).kind else { panic!() }; assert_eq!((start, end), (Some((ids[0], 1)), Some((ids[1], 3)))); // Only the connector copied: it doesn't stay glued to the originals. select(&mut s, &[c]); s.execute("edit.copy", &json!({})).unwrap(); s.doc_mut().unwrap().selection.shapes.clear(); let r = s.execute("edit.paste", &json!({})).unwrap(); let only = ShapeId(r["ids"][0].as_u64().unwrap() as u32); let ShapeKind::Connector { start, end } = shape(&s, 0, only).kind else { panic!() }; assert_eq!((start, end), (None, None)); // The original keeps its ends. let ShapeKind::Connector { start, .. } = shape(&s, 0, c).kind else { panic!() }; assert_eq!(start, Some((p, 1))); } fn triggered(s: &mut Session, target: ShapeId, trigger: ShapeId) { let st = s.doc_mut().unwrap(); let doc = Arc::make_mut(&mut st.doc); Arc::make_mut(&mut doc.slides[0]).animations.push(Animation { shape: target, trigger: Some(trigger), effect: "fade".into(), ..Default::default() }); } fn resolves(s: &Session, slide: usize) -> (ShapeId, ShapeId) { let d = s.doc().unwrap(); let sl = &d.doc.slides[slide]; let a = &sl.animations[0]; let t = a.trigger.unwrap(); assert!(sl.shapes.iter().any(|x| x.id == a.shape) && sl.shapes.iter().any(|x| x.id == t), "{a:?}"); (a.shape, t) } #[test] fn duplicated_and_pasted_slides_retarget_animations_and_triggers() { let mut s = Session::with_new(); let (p, b, _) = populate(&mut s, b"x"); triggered(&mut s, p, b); s.execute("slide.duplicate", &json!({"index": 0})).unwrap(); let (t, tr) = resolves(&s, 1); assert!(t != p && tr != b); // Pasted slide. s.execute("edit.copy", &json!({"scope": "slides"})).unwrap(); s.execute("edit.paste", &json!({"scope": "slides"})).unwrap(); let n = s.doc().unwrap().doc.slides.len(); let (t2, tr2) = resolves(&s, n - 1); assert!(t2 != t && tr2 != tr); // Save and reload keeps them resolving. let bytes = crate::cmd::file::save_bytes(&s.doc().unwrap().doc, "deckcraft").unwrap(); let back = deckcraft_format::load(&bytes).unwrap(); for sl in &back.slides { for a in &sl.animations { assert!(sl.shapes.iter().any(|x| x.id == a.shape)); assert!(a.trigger.is_none_or(|t| sl.shapes.iter().any(|x| x.id == t))); } } } } #[test] fn font_add_refuses_non_fonts_and_rescan_reports_the_catalog() { let mut s = session(); let not_font = crate::cmd::base64_encode(b"definitely not a font file"); assert!(s.execute("font.add", &json!({"data": not_font})).is_err()); assert!(s.execute("font.add", &json!({})).is_err()); let r = s.execute("font.rescan", &json!({})).unwrap(); assert!(r["families"].as_u64().unwrap() >= 1, "{r}"); } /// The Print page's paper and orientation reach the PDF options; Letter portrait by default. #[test] fn pdf_options_take_paper_and_orientation() { let o = cmd::file::pdf_options(&json!({}), 1).unwrap(); assert_eq!(o.paper, (612.0, 792.0)); let o = cmd::file::pdf_options(&json!({"paper": "A4", "orientation": "landscape"}), 1).unwrap(); assert_eq!(o.paper, (841.89, 595.28)); let o = cmd::file::pdf_options(&json!({"paper": "legal"}), 1).unwrap(); assert_eq!(o.paper, (612.0, 1008.0)); assert!(cmd::file::pdf_options(&json!({"paper": "tabloid"}), 1).is_err()); assert!(cmd::file::pdf_options(&json!({"orientation": "sideways"}), 1).is_err()); }