Files
deckcraft/crates/engine/src/tests.rs
T
Sazid Al Bayazid e70fce996b Model: an exhausted id space refuses growth instead of duplicating ids (#407)
alloc_id bumped next_id with saturating_add: a presentation whose
next_id reached u32::MAX (a hostile file can arrive that way;
fix_next_id imports it) handed out that same id to every new slide,
shape, layout and media - duplicate ids, ambiguous references, silent
corruption on every later insert.

alloc_id now returns Option<u32> via checked_add, and every caller
refuses: the engine's insert/duplicate/media/layout commands answer
with a clear error naming the command, paste and duplicate re-id
helpers propagate it, media addition is fallible, and a fresh slide is
Option. The pptx export's synthesized blank layout is guarded the same
way. Loading keeps such a deck viewable: repair re-ids duplicate
slides while it can, and a duplicate it cannot re-id stays as it is -
growth is refused, and only that duplicate keeps its ambiguous id.

Renumbering the loaded graph was rejected: it needs the
cross-document reference remapping that issue #406 tracks.

Tests: model exhaustion (alloc_id None, fresh media refused, a new
slide refused), engine (slide.new, shape.insert, slide.duplicate and
edit.duplicate all refuse; the document holds no duplicate ids),
format repair (an unre-id-able duplicate stays visible).
2026-10-13 21:13:25 +06:00

863 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
}
/// 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 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<_>>(), 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<_>>(), 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<_>>(), 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::<Vec<_>>().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");
}
/// A deck whose ids are used up (a hostile file can arrive with next_id at the top of the
/// range) refuses to grow instead of handing out duplicate ids (#407).
#[test]
fn an_exhausted_id_space_refuses_growth() {
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).next_id = u32::MAX;
let before = s.doc().unwrap().doc.clone();
assert!(s.execute("slide.new", &json!({})).is_err(), "a new slide is refused");
assert!(s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 100, 50]})).is_err());
assert!(s.execute("slide.duplicate", &json!({})).is_err());
assert!(s.execute("edit.duplicate", &json!({})).is_err());
// What the deck holds is untouched: same slides, same ids, no duplicates.
let after = s.doc().unwrap().doc.clone();
assert_eq!(after.slides.len(), before.slides.len());
let mut ids = vec![];
deckcraft_model::walk(&after.slides[0].shapes, &mut |sh, _| ids.push(sh.id.0));
ids.push(after.slides[0].id.0);
ids.push(after.next_id);
let mut sorted = ids.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), ids.len(), "no id repeats");
}