deckcraft/crates/engine/src/tests.rs

546 lines
26 KiB
Rust

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<f64> = 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::<Vec<_>>()] {
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<String> = 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<Vec<f64>> = 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);
}