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 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 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.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 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"); } #[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); }