369 lines
17 KiB
Rust
369 lines
17 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:?}"),
|
|
}
|
|
}
|