Files
deckcraft/crates/engine/src/tests.rs
T
ddbd71e297 Shapes: flipped shapes keep their text readable (#35)
Flips mirror a shape's geometry, never its text. The text was drawn with the shape's full
transform, so every label on a flipped arrow came out mirrored (flipH) or mirrored upside down
(flipV). As in PowerPoint, the text rect keeps its flipped place and the text is mirrored back
about the rect's centre: flipH text stays upright, flipV (or both flips) turns it 180°, rotation
still applies, and the same holds for shapes inside flipped groups.

render::text_transform is that transform (plus vertical-text rotation). Drawing, the PDF text
layer and link boxes, morph text, the editing caret and selection, and click-to-place-the-caret
all use it, so a click lands on the character drawn under it.

Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:18:46 -04:00

468 lines
22 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 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);
}