diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 830bb52bec..f1e3512754 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -922,447 +922,4 @@ fn replay(s: &mut Session, v: &Value) -> CmdResult { } #[cfg(test)] -mod tests { - use serde_json::json; - use wordcraft_doc::StoryRef; - use wordcraft_doc::freeform::InkTool; - use wordcraft_doc::para::{InlineObject, ShapeKind, Wrap}; - - use crate::Session; - - fn session() -> Session { - let mut s = Session::new(wordcraft_doc::Document::new()); - s.run("document.setText", &json!({"text": "First paragraph of text.\nSecond paragraph of text."})).unwrap(); - s - } - - fn ink(s: &Session) -> Vec { - s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).filter(|o| o.ink().is_some()).cloned().collect() - } - - #[test] - fn a_stroke_inserts_floating_ink_through_its_points_in_one_undo_step() { - let mut s = session(); - let text = s.doc.plain_text(StoryRef::Body); - let r = s - .run("draw.stroke", &json!({"page": 0, "points": [[100, 90], [150, 120], [f64::NAN, 3], [200, 95]], "color": "C00000", "width": 2})) - .unwrap(); - let [x, y] = [r["rect"][0].as_f64().unwrap() as f32, r["rect"][1].as_f64().unwrap() as f32]; - assert_eq!((x, y), (99.0, 89.0), "the points' bounds, grown by half the width"); - let objs = ink(&s); - let [InlineObject::Shape { stroke, stroke_width, float, freeform: Some(f), .. }] = objs.as_slice() else { panic!("one stroke: {objs:?}") }; - assert_eq!((*stroke, *stroke_width, float.wrap), (Some(wordcraft_doc::Rgb(0xC0, 0, 0)), 2.0, Wrap::InFrontOfText)); - assert_eq!(f.ink, Some(InkTool::Pen)); - assert_eq!(f.paths[0].pts, vec![[1.0, 1.0], [51.0, 31.0], [101.0, 6.0]], "NaN dropped, shape-local"); - // Laid out where it was drawn. - let at = wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 }; - let hit = s.layout().object(&at, 0).unwrap(); - assert!((hit.rect.x - 99.0).abs() < 0.01 && (hit.rect.y - 89.0).abs() < 0.01, "{:?}", hit.rect); - s.run("edit.undo", &json!({})).unwrap(); - assert!(ink(&s).is_empty()); - assert_eq!(s.doc.plain_text(StoryRef::Body), text); - // A highlighter goes behind the text, translucent. - s.run("draw.highlighter", &json!({"color": "00FF00"})).unwrap(); - s.run("draw.stroke", &json!({"page": 0, "points": [[80, 80]]})).unwrap(); - let objs = ink(&s); - let [InlineObject::Shape { float, freeform: Some(f), stroke, .. }] = objs.as_slice() else { panic!() }; - assert_eq!((float.wrap, f.ink, *stroke), (Wrap::BehindText, Some(InkTool::Highlighter), Some(wordcraft_doc::Rgb(0, 0xFF, 0)))); - assert!(f.alpha < 1.0); - // Hostile input is refused, not drawn. - assert!(s.run("draw.stroke", &json!({"page": 99, "points": [[1, 1]]})).is_err()); - assert!(s.run("draw.stroke", &json!({"page": 0, "points": [["x", 1], [1]]})).is_err()); - assert!(s.run("draw.stroke", &json!({"page": 0, "points": [[1, 1]], "tool": "crayon"})).is_err()); - } - - #[test] - fn the_eraser_removes_the_stroke_under_the_point_and_hide_ink_hides_it() { - let mut s = session(); - s.run("draw.stroke", &json!({"page": 0, "points": [[100, 100], [200, 100]], "width": 2})).unwrap(); - assert!(s.run("draw.eraser", &json!({"page": 0, "x": 150, "y": 130})).is_err(), "nothing there"); - assert_eq!(ink(&s).len(), 1); - // Review › Hide Ink: not drawn, not erasable, still in the document. - assert_eq!(s.run("review.hideInk", &json!({})).unwrap()["value"], true); - assert!(s.view.hide_ink); - assert!(s.run("draw.eraser", &json!({"page": 0, "x": 150, "y": 101})).is_err()); - s.run("review.hideInk", &json!({"value": false})).unwrap(); - s.run("draw.eraser", &json!({"page": 0, "x": 150, "y": 101})).unwrap(); - assert!(ink(&s).is_empty()); - // Without a point it picks up the eraser; Select puts it down. - s.run("draw.eraser", &json!({})).unwrap(); - assert_eq!(s.view.draw.mode, super::DrawMode::Eraser); - s.run("draw.pencil", &json!({"width": 1e9})).unwrap(); - assert_eq!((s.view.draw.mode, s.view.draw.pencil.width), (super::DrawMode::Pen(InkTool::Pencil), super::MAX_WIDTH)); - s.run("draw.select", &json!({})).unwrap(); - assert_eq!(s.view.draw.mode, super::DrawMode::Select); - } - - /// #445: a turned stroke is erased where it is drawn, not where it was before the turn. - #[test] - fn a_rotated_stroke_is_erased_where_it_is_drawn() { - let rotated = || { - let mut s = session(); - s.run("draw.stroke", &json!({"page": 0, "points": [[100, 100], [200, 100]], "width": 2, "color": "000000"})).unwrap(); - s.run("select.objects", &json!({"index": 0})).unwrap(); - s.run("arrange.rotation", &json!({"degrees": 90})).unwrap(); - s - }; - // Its old horizontal path is blank now: nothing to erase there, and it stays. - let mut s = rotated(); - assert!(s.run("draw.erase", &json!({"page": 0, "x": 110, "y": 100})).is_err()); - assert_eq!(ink(&s).len(), 1); - // The visible vertical stroke (centred on x = 150) is erased. - s.run("draw.erase", &json!({"page": 0, "x": 150, "y": 60})).unwrap(); - assert!(ink(&s).is_empty()); - let mut s = rotated(); - s.run("draw.eraser", &json!({"page": 0, "x": 150, "y": 140})).unwrap(); - assert!(ink(&s).is_empty(), "the point-taking eraser too"); - // Hidden ink stays out of reach. - let mut s = rotated(); - s.run("review.hideInk", &json!({"value": true})).unwrap(); - assert!(s.run("draw.erase", &json!({"page": 0, "x": 150, "y": 60})).is_err()); - } - - /// The lasso, like the eraser, finds a turned stroke where it is drawn. - #[test] - fn the_lasso_selects_a_rotated_stroke_where_it_is_drawn() { - let mut s = session(); - s.run("draw.stroke", &json!({"page": 0, "points": [[100, 100], [200, 100]], "width": 2})).unwrap(); - s.run("select.objects", &json!({"index": 0})).unwrap(); - s.run("arrange.rotation", &json!({"degrees": 90})).unwrap(); - // A loop around its old horizontal path misses it; one around the vertical stroke takes it. - let r = s.run("draw.lasso", &json!({"page": 0, "points": [[90, 95], [210, 95], [210, 105], [90, 105]]})).unwrap(); - assert_eq!(r["count"], 0); - let r = s.run("draw.lasso", &json!({"page": 0, "points": [[140, 40], [160, 40], [160, 160], [140, 160]]})).unwrap(); - assert_eq!(r["count"], 1); - } - - /// `draw.eraser` is a view command, but with a point it erases through `draw.erase`, which - /// changes the document: that must be one undo step and mark the document changed. - #[test] - fn erasing_through_the_eraser_command_is_undoable_and_marks_the_document_dirty() { - let mut s = session(); - s.run("draw.stroke", &json!({"page": 0, "points": [[100, 100], [200, 100]], "width": 2})).unwrap(); - s.dirty = false; - let steps = s.undo_depth(); - s.run("draw.eraser", &json!({"page": 0, "x": 150, "y": 101})).unwrap(); - assert!(ink(&s).is_empty()); - assert!(s.dirty, "erasing is an unsaved change"); - assert_eq!(s.undo_depth(), steps + 1, "one undo step"); - s.run("edit.undo", &json!({})).unwrap(); - assert_eq!(ink(&s).len(), 1, "undo brings the stroke back"); - s.run("edit.redo", &json!({})).unwrap(); - assert!(ink(&s).is_empty()); - } - - /// A closed rectangle drawn as page points, `per` points to a side. - fn rect_stroke(x: f32, y: f32, w: f32, h: f32) -> Vec<[f32; 2]> { - let c = [[x, y], [x + w, y], [x + w, y + h], [x, y + h]]; - let mut pts = Vec::new(); - for i in 0..4 { - let (a, b) = (c[i], c[(i + 1) % 4]); - for k in 0..10 { - let t = k as f32 / 10.0; - pts.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]); - } - } - pts.push([x, y]); - pts - } - - #[test] - fn the_lasso_selects_the_ink_inside_and_moves_recolours_and_deletes_it() { - let mut s = session(); - s.run("draw.stroke", &json!({"page": 0, "points": [[100, 100], [140, 110]]})).unwrap(); - s.run("draw.stroke", &json!({"page": 0, "points": [[300, 300], [340, 310]]})).unwrap(); - // Without points it picks up the lasso; a loop around the first stroke selects only it. - s.run("draw.lasso", &json!({})).unwrap(); - assert_eq!(s.view.draw.mode, super::DrawMode::Lasso); - let r = s.run("draw.lasso", &json!({"page": 0, "points": [[80, 80], [200, 80], [200, 150], [80, 150]]})).unwrap(); - assert_eq!(r["count"], 1); - let before = s.layout().object(&super::lasso_selection(&s).unwrap().items[0], 0).unwrap().rect; - // Moved, recoloured: each one undo step, and the selection stays. - let steps = s.undo_depth(); - s.run("draw.lassoMove", &json!({"dx": 20, "dy": -10})).unwrap(); - s.run("draw.lassoColor", &json!({"color": "0070C0"})).unwrap(); - assert_eq!(s.undo_depth(), steps + 2); - let sel = super::lasso_selection(&s).unwrap().items[0].clone(); - let after = s.layout().object(&sel, 0).unwrap().rect; - assert!((after.x - before.x - 20.0).abs() < 0.01 && (after.y - before.y + 10.0).abs() < 0.01, "{before:?} -> {after:?}"); - let objs = ink(&s); - let blue = objs.iter().filter(|o| matches!(o, InlineObject::Shape { stroke: Some(c), .. } if c.hex() == "0070C0")).count(); - assert_eq!(blue, 1); - // Recolouring to the colour it has changes nothing, and the selection stays; an edit - // elsewhere ends it. - s.run("draw.lassoColor", &json!({"color": "0070C0"})).unwrap(); - assert_eq!(s.undo_depth(), steps + 2); - assert_eq!(super::lasso_selection(&s).map(|l| l.items.clone()), Some(vec![sel.clone()])); - s.run("text.insert", &json!({"text": "x"})).unwrap(); - assert!(super::lasso_selection(&s).is_none()); - let r = s.run("draw.lasso", &json!({"page": 0, "points": [[80, 80], [260, 80], [260, 180], [80, 180]]})).unwrap(); - assert_eq!(r["count"], 1); - // Deleted: only the selected stroke goes, and the selection with it. - s.run("draw.lassoDelete", &json!({})).unwrap(); - assert_eq!(ink(&s).len(), 1); - assert!(super::lasso_selection(&s).is_none()); - assert!(s.run("draw.lassoMove", &json!({"dx": 1, "dy": 1})).is_err(), "nothing selected"); - // Hostile loops are refused or select nothing; they never panic. - assert!(s.run("draw.lasso", &json!({"page": 0, "points": [[1, 1], [2, 2]]})).is_err()); - let huge: Vec<[f64; 2]> = (0..20_000).map(|i| [(i % 600) as f64, (i / 40) as f64]).collect(); - let _ = s.run("draw.lasso", &json!({"page": 0, "points": huge})); - } - - #[test] - fn ink_to_shape_turns_shape_like_strokes_into_shapes_and_leaves_scribbles() { - let mut s = session(); - assert_eq!(s.run("draw.inkToShape", &json!({})).unwrap()["value"], true); - let steps = s.undo_depth(); - let r = s.run("draw.stroke", &json!({"page": 0, "points": rect_stroke(100.0, 100.0, 120.0, 60.0), "color": "C00000", "width": 2})).unwrap(); - assert_eq!(r["shape"], "rectangle"); - assert_eq!(s.undo_depth(), steps + 1, "drawn and converted in one undo step"); - let at: wordcraft_doc::Pos = serde_json::from_value(r["pos"].clone()).unwrap(); - let obj = s.doc.para_at(&at).and_then(|p| p.object_at(at.off)).cloned().unwrap(); - let InlineObject::Shape { kind, w, h, stroke, stroke_width, freeform, .. } = obj else { panic!("{obj:?}") }; - assert_eq!((kind, stroke, stroke_width, freeform.is_none()), (ShapeKind::Rectangle, Some(wordcraft_doc::Rgb(0xC0, 0, 0)), 2.0, true)); - assert!((w - 120.0).abs() < 2.0 && (h - 60.0).abs() < 2.0, "{w} x {h}"); - let rect = s.layout().object(&at, 0).unwrap().rect; - assert!((rect.x - 100.0).abs() < 2.0 && (rect.y - 100.0).abs() < 2.0, "where it was drawn: {rect:?}"); - // A scribble stays ink. - let r = s.run("draw.stroke", &json!({"page": 0, "points": [[300, 300], [320, 340], [340, 300], [360, 340], [380, 300]]})).unwrap(); - assert!(r["shape"].is_null()); - assert_eq!(ink(&s).len(), 1); - // Off again; a drawn ellipse stays ink until the lasso selects it and Ink to Shape converts it. - s.run("draw.inkToShape", &json!({"value": false})).unwrap(); - let ring: Vec<[f32; 2]> = - (0..=48).map(|i| i as f32 / 48.0 * std::f32::consts::TAU).map(|a| [200.0 + 50.0 * a.cos(), 500.0 + 30.0 * a.sin()]).collect(); - s.run("draw.stroke", &json!({"page": 0, "points": ring})).unwrap(); - assert_eq!(ink(&s).len(), 2); - s.run("draw.lasso", &json!({"page": 0, "points": [[130, 450], [270, 450], [270, 550], [130, 550]]})).unwrap(); - let r = s.run("draw.inkToShape", &json!({})).unwrap(); - assert_eq!(r["converted"][0]["shape"], "ellipse"); - assert_eq!(ink(&s).len(), 1); - assert!(!s.view.draw.ink_to_shape, "converting the selection doesn't turn the mode on"); - } - - #[test] - fn ink_to_shape_turns_an_arrow_stroke_into_a_line_shape_with_a_head_that_survives_docx() { - let mut s = session(); - s.run("draw.inkToShape", &json!({"value": true})).unwrap(); - // Right to left and downwards: shaft, then both barbs of the head at the tip (100, 300). - let tail = [300.0f32, 200.0]; - let tip = [100.0f32, 300.0]; - let back = (tail[1] - tip[1]).atan2(tail[0] - tip[0]); - let barb = |sign: f32| [tip[0] + 30.0 * (back + sign * 0.5).cos(), tip[1] + 30.0 * (back + sign * 0.5).sin()]; - let mut pts: Vec<[f32; 2]> = - (0..20).map(|i| i as f32 / 20.0).map(|t| [tail[0] + (tip[0] - tail[0]) * t, tail[1] + (tip[1] - tail[1]) * t]).collect(); - pts.extend([tip, barb(1.0), tip, barb(-1.0)]); - let r = s.run("draw.stroke", &json!({"page": 0, "points": pts, "color": "C00000", "width": 2})).unwrap(); - assert_eq!(r["shape"], "arrow"); - let at: wordcraft_doc::Pos = serde_json::from_value(r["pos"].clone()).unwrap(); - let obj = s.doc.para_at(&at).and_then(|p| p.object_at(at.off)).cloned().unwrap(); - let InlineObject::Shape { kind, w, h, stroke, stroke_width, freeform, extra, float, .. } = obj else { panic!("{obj:?}") }; - assert_eq!((kind, stroke, stroke_width, freeform.is_none()), (ShapeKind::StraightConnector, Some(wordcraft_doc::Rgb(0xC0, 0, 0)), 2.0, true)); - assert!(extra.arrow_end && !extra.arrow_start); - // Tail at the top right, tip at the bottom left: the frame is flipped across. - assert!(float.flip_h && !float.flip_v, "{float:?}"); - assert!((w - 200.0).abs() < 1.0 && (h - 100.0).abs() < 1.0, "{w} x {h}"); - // It survives a DOCX save and open. - let bytes = wordcraft_docx::write(&s.doc).unwrap(); - let back = wordcraft_docx::read(&bytes).unwrap(); - let again: Vec<_> = back.body.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).collect(); - let Some(InlineObject::Shape { extra, float, .. }) = - again.iter().find(|o| matches!(o, InlineObject::Shape { kind: ShapeKind::StraightConnector, .. })) - else { - panic!("{again:?}") - }; - assert!(extra.arrow_end && !extra.arrow_start && float.flip_h, "{extra:?} {float:?}"); - } - - /// Every drawing in the body, in document order. - fn drawings(s: &Session) -> Vec { - s.doc - .body - .iter() - .filter_map(|b| b.as_para()) - .flat_map(|p| p.objects.iter()) - .filter(|o| matches!(o, InlineObject::Shape { .. })) - .cloned() - .collect() - } - - /// A straight stroke from (100, 300) to (300, 300). - fn shaft_stroke() -> Vec<[f32; 2]> { - (0..=40).map(|i| [100.0 + i as f32 * 5.0, 300.0]).collect() - } - - #[test] - fn ink_to_shape_turns_a_line_and_a_head_drawn_after_it_into_one_arrow_in_one_undo_step() { - let mut s = session(); - s.run("draw.inkToShape", &json!({"value": true})).unwrap(); - let r = s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke(), "color": "C00000", "width": 2})).unwrap(); - assert_eq!(r["shape"], "line"); - let steps = s.undo_depth(); - // An open V at the right-hand end, drawn as a second stroke. - let head = [[274.0, 285.0], [287.0, 292.5], [300.0, 300.0], [287.0, 307.5], [274.0, 315.0]]; - let r = s.run("draw.stroke", &json!({"page": 0, "points": head})).unwrap(); - assert_eq!(r["shape"], "arrow"); - assert_eq!(s.undo_depth(), steps + 1, "paired in one undo step"); - assert!(ink(&s).is_empty(), "the head stroke is gone"); - let all = drawings(&s); - let [InlineObject::Shape { kind, w, stroke, stroke_width, extra, float, .. }] = all.as_slice() else { panic!("one arrow: {all:?}") }; - assert_eq!((*kind, *stroke, *stroke_width), (ShapeKind::StraightConnector, Some(wordcraft_doc::Rgb(0xC0, 0, 0)), 2.0)); - assert!(extra.arrow_end && !extra.arrow_start && !float.flip_h && !float.flip_v, "{extra:?} {float:?}"); - assert!((w - 200.0).abs() < 1.0, "{w}"); - let at: wordcraft_doc::Pos = serde_json::from_value(r["pos"].clone()).unwrap(); - assert!(matches!(s.doc.para_at(&at).and_then(|p| p.object_at(at.off)), Some(InlineObject::Shape { kind: ShapeKind::StraightConnector, .. }))); - let rect = s.layout().object(&at, 0).unwrap().rect; - assert!((rect.x - 100.0).abs() < 1.0 && (rect.y - 300.0).abs() < 1.0, "where the line was: {rect:?}"); - let got: Vec = r["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap() as f32).collect(); - assert!( - got.len() == 4 && [rect.x, rect.y, rect.w, rect.h].iter().zip(&got).all(|(a, b)| (a - b).abs() < 0.5), - "the arrow's rect, not the deleted head's: {got:?} vs {rect:?}" - ); - // It survives a DOCX save and open. - let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); - let again: Vec<_> = back.body.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).collect(); - assert!( - again - .iter() - .any(|o| matches!(o, InlineObject::Shape { kind: ShapeKind::StraightConnector, extra, .. } if extra.arrow_end && !extra.arrow_start)), - "{again:?}" - ); - // Undo brings back the line, without the head. - s.run("edit.undo", &json!({})).unwrap(); - let all = drawings(&s); - let [InlineObject::Shape { kind: ShapeKind::Freeform, extra, .. }] = all.as_slice() else { panic!("the line: {all:?}") }; - assert!(!extra.arrow_end); - assert!(ink(&s).is_empty()); - } - - #[test] - fn ink_to_shape_pairs_a_closed_triangle_head_at_the_start_and_a_head_drawn_first() { - let mut s = session(); - s.run("draw.inkToShape", &json!({"value": true})).unwrap(); - s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke()})).unwrap(); - // A closed triangle at the left end, where the line began: the arrow points left. - let tri = [[100.0, 300.0], [113.0, 292.5], [126.0, 285.0], [126.0, 300.0], [126.0, 315.0], [113.0, 307.5], [100.0, 300.0]]; - let r = s.run("draw.stroke", &json!({"page": 0, "points": tri})).unwrap(); - assert_eq!(r["shape"], "arrow"); - let all = drawings(&s); - let [InlineObject::Shape { kind: ShapeKind::StraightConnector, extra, float, .. }] = all.as_slice() else { panic!("one arrow: {all:?}") }; - assert!(extra.arrow_end && float.flip_h && !float.flip_v, "tip at the left: {extra:?} {float:?}"); - // Head first (it stays ink on its own), then the shaft that ends at its apex. - let mut s = session(); - s.run("draw.inkToShape", &json!({"value": true})).unwrap(); - let r = s.run("draw.stroke", &json!({"page": 0, "points": [[274.0, 285.0], [300.0, 300.0], [274.0, 315.0]]})).unwrap(); - assert!(r["shape"].is_null()); - let r = s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke()})).unwrap(); - assert_eq!(r["shape"], "arrow"); - assert!(ink(&s).is_empty()); - let all = drawings(&s); - let [InlineObject::Shape { kind: ShapeKind::StraightConnector, extra, float, .. }] = all.as_slice() else { panic!("one arrow: {all:?}") }; - assert!(extra.arrow_end && !float.flip_h, "{extra:?} {float:?}"); - // A V far from the line's ends stays ink, and the line stays a line. - let mut s = session(); - s.run("draw.inkToShape", &json!({"value": true})).unwrap(); - s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke()})).unwrap(); - let r = s.run("draw.stroke", &json!({"page": 0, "points": [[174.0, 385.0], [200.0, 400.0], [174.0, 415.0]]})).unwrap(); - assert!(r["shape"].is_null()); - assert_eq!(ink(&s).len(), 1); - assert!(drawings(&s).iter().all(|o| !matches!(o, InlineObject::Shape { kind: ShapeKind::StraightConnector, .. }))); - } - - #[test] - fn converting_a_lasso_selection_pairs_a_line_and_a_head_stroke_into_an_arrow() { - let mut s = session(); - s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke(), "color": "0070C0"})).unwrap(); - s.run("draw.stroke", &json!({"page": 0, "points": [[274.0, 285.0], [300.0, 300.0], [274.0, 315.0]]})).unwrap(); - s.run("draw.stroke", &json!({"page": 0, "points": rect_stroke(100.0, 400.0, 120.0, 60.0)})).unwrap(); - assert_eq!(ink(&s).len(), 3); - let steps = s.undo_depth(); - s.run("draw.lasso", &json!({"page": 0, "points": [[80, 260], [340, 260], [340, 480], [80, 480]]})).unwrap(); - let r = s.run("draw.inkToShape", &json!({})).unwrap(); - let names: Vec<&str> = r["converted"].as_array().unwrap().iter().filter_map(|c| c["shape"].as_str()).collect(); - assert!(names.contains(&"arrow") && names.contains(&"rectangle"), "{names:?}"); - assert!(ink(&s).is_empty(), "the head stroke went into the arrow"); - let all = drawings(&s); - assert_eq!(all.len(), 2, "{all:?}"); - let arrow = all.iter().find(|o| matches!(o, InlineObject::Shape { kind: ShapeKind::StraightConnector, .. })); - let Some(InlineObject::Shape { extra, stroke, .. }) = arrow else { panic!("{all:?}") }; - assert!(extra.arrow_end && *stroke == Some(wordcraft_doc::Rgb(0, 0x70, 0xC0))); - // The selection still holds what's left: deleting it removes both shapes. - s.run("draw.lassoDelete", &json!({})).unwrap(); - assert!(drawings(&s).is_empty()); - s.run("edit.undo", &json!({})).unwrap(); - assert_eq!(drawings(&s).len(), 2); - s.run("edit.undo", &json!({})).unwrap(); - assert_eq!(ink(&s).len(), 3, "one undo step brings all three strokes back"); - assert_eq!(s.undo_depth(), steps); - } - - #[test] - fn converting_a_crowded_lasso_selection_recognises_each_stroke_a_bounded_number_of_times() { - let mut s = session(); - s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke()})).unwrap(); - s.run("draw.stroke", &json!({"page": 0, "points": [[274.0, 285.0], [300.0, 300.0], [274.0, 315.0]]})).unwrap(); - // A stack of 40 lines close together: every pair is near, none makes an arrow. - let n = 40; - for i in 0..n { - let y = 420.0 + i as f32 * 2.0; - let pts: Vec<[f32; 2]> = (0..=40).map(|k| [100.0 + k as f32 * 5.0, y]).collect(); - s.run("draw.stroke", &json!({"page": 0, "points": pts})).unwrap(); - } - s.run("draw.lasso", &json!({"page": 0, "points": [[80, 260], [340, 260], [340, 560], [80, 560]]})).unwrap(); - super::super::ink_shape::RECOGNISED.with(|c| c.set(0)); - let r = s.run("draw.inkToShape", &json!({})).unwrap(); - let calls = super::super::ink_shape::RECOGNISED.with(|c| c.get()); - let strokes = n + 2; - assert!(calls <= 2 * strokes, "{calls} recognitions for {strokes} strokes"); - let names: Vec<&str> = r["converted"].as_array().unwrap().iter().filter_map(|c| c["shape"].as_str()).collect(); - assert_eq!(names.iter().filter(|n| **n == "arrow").count(), 1, "{names:?}"); - assert_eq!(names.iter().filter(|n| **n == "line").count(), n, "{names:?}"); - assert!(ink(&s).is_empty()); - } - - #[test] - fn ink_replay_plays_strokes_in_the_order_they_were_drawn() { - let mut s = session(); - // Drawn low on the page first, then higher: document order would be the other way round. - s.run("draw.stroke", &json!({"page": 0, "points": [[100, 400], [150, 400]]})).unwrap(); - s.run("draw.stroke", &json!({"page": 0, "points": [[100, 90], [150, 90]]})).unwrap(); - let r = s.run("draw.replay", &json!({})).unwrap(); - assert!(s.view.draw.replay); - let orders: Vec = r["strokes"].as_array().unwrap().iter().map(|k| k["order"].as_u64().unwrap()).collect(); - assert_eq!(orders, vec![1, 2]); - let ys: Vec = super::strokes_in_drawing_order(&mut s).iter().map(|(_, k)| k.rect.y).collect(); - assert!(ys[0] > ys[1], "the lower stroke was drawn first: {ys:?}"); - assert_eq!(s.run("draw.replay", &json!({})).unwrap()["replaying"], false); - // Nothing to replay without ink. - assert!(session().run("draw.replay", &json!({})).is_err()); - } - - #[test] - fn add_pen_validates_and_saved_galleries_drop_junk() { - let mut s = session(); - let r = s.run("draw.addPen", &json!({"kind": "highlighter", "color": "00B050", "width": 1e9})).unwrap(); - assert_eq!(r["pen"], json!({"kind": "highlighter", "color": "00B050", "width": super::MAX_WIDTH})); - assert_eq!(s.view.draw.mode, super::DrawMode::Pen(InkTool::Highlighter), "the new pen is picked up"); - for bad in [json!({"kind": "crayon"}), json!({"color": "blue"}), json!(5), json!(null)] { - assert!(s.run("draw.addPen", &bad).is_err(), "{bad}"); - } - for _ in 0..super::MAX_PENS + 3 { - let _ = s.run("draw.addPen", &json!({})); - } - assert_eq!(s.view.draw.pens.len(), super::MAX_PENS); - s.run("draw.removePen", &json!({"index": 0})).unwrap(); - assert!(s.run("draw.removePen", &json!({"index": 999})).is_err()); - // A saved gallery keeps its pens and drops the junk around them. - let saved = - json!({"pens": [{"kind": "pencil", "color": "7030A0", "width": 3}, "junk", {"kind": "crayon"}, {"width": "x"}], "inkToShape": true}); - let d: super::DrawState = serde_json::from_value(saved).unwrap(); - assert_eq!(d.pens.len(), 2); - assert_eq!((d.pens[0].kind, d.pens[0].color.hex(), d.pens[0].width), (InkTool::Pencil, "7030A0".to_string(), 3.0)); - assert!(d.ink_to_shape); - let back: super::DrawState = serde_json::from_value(serde_json::to_value(&d).unwrap()).unwrap(); - assert_eq!(back.pens, d.pens); - } -} +mod tests; diff --git a/crates/engine/src/cmd/draw/tests.rs b/crates/engine/src/cmd/draw/tests.rs new file mode 100644 index 0000000000..66c02cc848 --- /dev/null +++ b/crates/engine/src/cmd/draw/tests.rs @@ -0,0 +1,441 @@ +use serde_json::json; +use wordcraft_doc::StoryRef; +use wordcraft_doc::freeform::InkTool; +use wordcraft_doc::para::{InlineObject, ShapeKind, Wrap}; + +use crate::Session; + +fn session() -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("document.setText", &json!({"text": "First paragraph of text.\nSecond paragraph of text."})).unwrap(); + s +} + +fn ink(s: &Session) -> Vec { + s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).filter(|o| o.ink().is_some()).cloned().collect() +} + +#[test] +fn a_stroke_inserts_floating_ink_through_its_points_in_one_undo_step() { + let mut s = session(); + let text = s.doc.plain_text(StoryRef::Body); + let r = s + .run("draw.stroke", &json!({"page": 0, "points": [[100, 90], [150, 120], [f64::NAN, 3], [200, 95]], "color": "C00000", "width": 2})) + .unwrap(); + let [x, y] = [r["rect"][0].as_f64().unwrap() as f32, r["rect"][1].as_f64().unwrap() as f32]; + assert_eq!((x, y), (99.0, 89.0), "the points' bounds, grown by half the width"); + let objs = ink(&s); + let [InlineObject::Shape { stroke, stroke_width, float, freeform: Some(f), .. }] = objs.as_slice() else { panic!("one stroke: {objs:?}") }; + assert_eq!((*stroke, *stroke_width, float.wrap), (Some(wordcraft_doc::Rgb(0xC0, 0, 0)), 2.0, Wrap::InFrontOfText)); + assert_eq!(f.ink, Some(InkTool::Pen)); + assert_eq!(f.paths[0].pts, vec![[1.0, 1.0], [51.0, 31.0], [101.0, 6.0]], "NaN dropped, shape-local"); + // Laid out where it was drawn. + let at = wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 }; + let hit = s.layout().object(&at, 0).unwrap(); + assert!((hit.rect.x - 99.0).abs() < 0.01 && (hit.rect.y - 89.0).abs() < 0.01, "{:?}", hit.rect); + s.run("edit.undo", &json!({})).unwrap(); + assert!(ink(&s).is_empty()); + assert_eq!(s.doc.plain_text(StoryRef::Body), text); + // A highlighter goes behind the text, translucent. + s.run("draw.highlighter", &json!({"color": "00FF00"})).unwrap(); + s.run("draw.stroke", &json!({"page": 0, "points": [[80, 80]]})).unwrap(); + let objs = ink(&s); + let [InlineObject::Shape { float, freeform: Some(f), stroke, .. }] = objs.as_slice() else { panic!() }; + assert_eq!((float.wrap, f.ink, *stroke), (Wrap::BehindText, Some(InkTool::Highlighter), Some(wordcraft_doc::Rgb(0, 0xFF, 0)))); + assert!(f.alpha < 1.0); + // Hostile input is refused, not drawn. + assert!(s.run("draw.stroke", &json!({"page": 99, "points": [[1, 1]]})).is_err()); + assert!(s.run("draw.stroke", &json!({"page": 0, "points": [["x", 1], [1]]})).is_err()); + assert!(s.run("draw.stroke", &json!({"page": 0, "points": [[1, 1]], "tool": "crayon"})).is_err()); +} + +#[test] +fn the_eraser_removes_the_stroke_under_the_point_and_hide_ink_hides_it() { + let mut s = session(); + s.run("draw.stroke", &json!({"page": 0, "points": [[100, 100], [200, 100]], "width": 2})).unwrap(); + assert!(s.run("draw.eraser", &json!({"page": 0, "x": 150, "y": 130})).is_err(), "nothing there"); + assert_eq!(ink(&s).len(), 1); + // Review › Hide Ink: not drawn, not erasable, still in the document. + assert_eq!(s.run("review.hideInk", &json!({})).unwrap()["value"], true); + assert!(s.view.hide_ink); + assert!(s.run("draw.eraser", &json!({"page": 0, "x": 150, "y": 101})).is_err()); + s.run("review.hideInk", &json!({"value": false})).unwrap(); + s.run("draw.eraser", &json!({"page": 0, "x": 150, "y": 101})).unwrap(); + assert!(ink(&s).is_empty()); + // Without a point it picks up the eraser; Select puts it down. + s.run("draw.eraser", &json!({})).unwrap(); + assert_eq!(s.view.draw.mode, super::DrawMode::Eraser); + s.run("draw.pencil", &json!({"width": 1e9})).unwrap(); + assert_eq!((s.view.draw.mode, s.view.draw.pencil.width), (super::DrawMode::Pen(InkTool::Pencil), super::MAX_WIDTH)); + s.run("draw.select", &json!({})).unwrap(); + assert_eq!(s.view.draw.mode, super::DrawMode::Select); +} + +/// #445: a turned stroke is erased where it is drawn, not where it was before the turn. +#[test] +fn a_rotated_stroke_is_erased_where_it_is_drawn() { + let rotated = || { + let mut s = session(); + s.run("draw.stroke", &json!({"page": 0, "points": [[100, 100], [200, 100]], "width": 2, "color": "000000"})).unwrap(); + s.run("select.objects", &json!({"index": 0})).unwrap(); + s.run("arrange.rotation", &json!({"degrees": 90})).unwrap(); + s + }; + // Its old horizontal path is blank now: nothing to erase there, and it stays. + let mut s = rotated(); + assert!(s.run("draw.erase", &json!({"page": 0, "x": 110, "y": 100})).is_err()); + assert_eq!(ink(&s).len(), 1); + // The visible vertical stroke (centred on x = 150) is erased. + s.run("draw.erase", &json!({"page": 0, "x": 150, "y": 60})).unwrap(); + assert!(ink(&s).is_empty()); + let mut s = rotated(); + s.run("draw.eraser", &json!({"page": 0, "x": 150, "y": 140})).unwrap(); + assert!(ink(&s).is_empty(), "the point-taking eraser too"); + // Hidden ink stays out of reach. + let mut s = rotated(); + s.run("review.hideInk", &json!({"value": true})).unwrap(); + assert!(s.run("draw.erase", &json!({"page": 0, "x": 150, "y": 60})).is_err()); +} + +/// The lasso, like the eraser, finds a turned stroke where it is drawn. +#[test] +fn the_lasso_selects_a_rotated_stroke_where_it_is_drawn() { + let mut s = session(); + s.run("draw.stroke", &json!({"page": 0, "points": [[100, 100], [200, 100]], "width": 2})).unwrap(); + s.run("select.objects", &json!({"index": 0})).unwrap(); + s.run("arrange.rotation", &json!({"degrees": 90})).unwrap(); + // A loop around its old horizontal path misses it; one around the vertical stroke takes it. + let r = s.run("draw.lasso", &json!({"page": 0, "points": [[90, 95], [210, 95], [210, 105], [90, 105]]})).unwrap(); + assert_eq!(r["count"], 0); + let r = s.run("draw.lasso", &json!({"page": 0, "points": [[140, 40], [160, 40], [160, 160], [140, 160]]})).unwrap(); + assert_eq!(r["count"], 1); +} + +/// `draw.eraser` is a view command, but with a point it erases through `draw.erase`, which +/// changes the document: that must be one undo step and mark the document changed. +#[test] +fn erasing_through_the_eraser_command_is_undoable_and_marks_the_document_dirty() { + let mut s = session(); + s.run("draw.stroke", &json!({"page": 0, "points": [[100, 100], [200, 100]], "width": 2})).unwrap(); + s.dirty = false; + let steps = s.undo_depth(); + s.run("draw.eraser", &json!({"page": 0, "x": 150, "y": 101})).unwrap(); + assert!(ink(&s).is_empty()); + assert!(s.dirty, "erasing is an unsaved change"); + assert_eq!(s.undo_depth(), steps + 1, "one undo step"); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(ink(&s).len(), 1, "undo brings the stroke back"); + s.run("edit.redo", &json!({})).unwrap(); + assert!(ink(&s).is_empty()); +} + +/// A closed rectangle drawn as page points, `per` points to a side. +fn rect_stroke(x: f32, y: f32, w: f32, h: f32) -> Vec<[f32; 2]> { + let c = [[x, y], [x + w, y], [x + w, y + h], [x, y + h]]; + let mut pts = Vec::new(); + for i in 0..4 { + let (a, b) = (c[i], c[(i + 1) % 4]); + for k in 0..10 { + let t = k as f32 / 10.0; + pts.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]); + } + } + pts.push([x, y]); + pts +} + +#[test] +fn the_lasso_selects_the_ink_inside_and_moves_recolours_and_deletes_it() { + let mut s = session(); + s.run("draw.stroke", &json!({"page": 0, "points": [[100, 100], [140, 110]]})).unwrap(); + s.run("draw.stroke", &json!({"page": 0, "points": [[300, 300], [340, 310]]})).unwrap(); + // Without points it picks up the lasso; a loop around the first stroke selects only it. + s.run("draw.lasso", &json!({})).unwrap(); + assert_eq!(s.view.draw.mode, super::DrawMode::Lasso); + let r = s.run("draw.lasso", &json!({"page": 0, "points": [[80, 80], [200, 80], [200, 150], [80, 150]]})).unwrap(); + assert_eq!(r["count"], 1); + let before = s.layout().object(&super::lasso_selection(&s).unwrap().items[0], 0).unwrap().rect; + // Moved, recoloured: each one undo step, and the selection stays. + let steps = s.undo_depth(); + s.run("draw.lassoMove", &json!({"dx": 20, "dy": -10})).unwrap(); + s.run("draw.lassoColor", &json!({"color": "0070C0"})).unwrap(); + assert_eq!(s.undo_depth(), steps + 2); + let sel = super::lasso_selection(&s).unwrap().items[0].clone(); + let after = s.layout().object(&sel, 0).unwrap().rect; + assert!((after.x - before.x - 20.0).abs() < 0.01 && (after.y - before.y + 10.0).abs() < 0.01, "{before:?} -> {after:?}"); + let objs = ink(&s); + let blue = objs.iter().filter(|o| matches!(o, InlineObject::Shape { stroke: Some(c), .. } if c.hex() == "0070C0")).count(); + assert_eq!(blue, 1); + // Recolouring to the colour it has changes nothing, and the selection stays; an edit + // elsewhere ends it. + s.run("draw.lassoColor", &json!({"color": "0070C0"})).unwrap(); + assert_eq!(s.undo_depth(), steps + 2); + assert_eq!(super::lasso_selection(&s).map(|l| l.items.clone()), Some(vec![sel.clone()])); + s.run("text.insert", &json!({"text": "x"})).unwrap(); + assert!(super::lasso_selection(&s).is_none()); + let r = s.run("draw.lasso", &json!({"page": 0, "points": [[80, 80], [260, 80], [260, 180], [80, 180]]})).unwrap(); + assert_eq!(r["count"], 1); + // Deleted: only the selected stroke goes, and the selection with it. + s.run("draw.lassoDelete", &json!({})).unwrap(); + assert_eq!(ink(&s).len(), 1); + assert!(super::lasso_selection(&s).is_none()); + assert!(s.run("draw.lassoMove", &json!({"dx": 1, "dy": 1})).is_err(), "nothing selected"); + // Hostile loops are refused or select nothing; they never panic. + assert!(s.run("draw.lasso", &json!({"page": 0, "points": [[1, 1], [2, 2]]})).is_err()); + let huge: Vec<[f64; 2]> = (0..20_000).map(|i| [(i % 600) as f64, (i / 40) as f64]).collect(); + let _ = s.run("draw.lasso", &json!({"page": 0, "points": huge})); +} + +#[test] +fn ink_to_shape_turns_shape_like_strokes_into_shapes_and_leaves_scribbles() { + let mut s = session(); + assert_eq!(s.run("draw.inkToShape", &json!({})).unwrap()["value"], true); + let steps = s.undo_depth(); + let r = s.run("draw.stroke", &json!({"page": 0, "points": rect_stroke(100.0, 100.0, 120.0, 60.0), "color": "C00000", "width": 2})).unwrap(); + assert_eq!(r["shape"], "rectangle"); + assert_eq!(s.undo_depth(), steps + 1, "drawn and converted in one undo step"); + let at: wordcraft_doc::Pos = serde_json::from_value(r["pos"].clone()).unwrap(); + let obj = s.doc.para_at(&at).and_then(|p| p.object_at(at.off)).cloned().unwrap(); + let InlineObject::Shape { kind, w, h, stroke, stroke_width, freeform, .. } = obj else { panic!("{obj:?}") }; + assert_eq!((kind, stroke, stroke_width, freeform.is_none()), (ShapeKind::Rectangle, Some(wordcraft_doc::Rgb(0xC0, 0, 0)), 2.0, true)); + assert!((w - 120.0).abs() < 2.0 && (h - 60.0).abs() < 2.0, "{w} x {h}"); + let rect = s.layout().object(&at, 0).unwrap().rect; + assert!((rect.x - 100.0).abs() < 2.0 && (rect.y - 100.0).abs() < 2.0, "where it was drawn: {rect:?}"); + // A scribble stays ink. + let r = s.run("draw.stroke", &json!({"page": 0, "points": [[300, 300], [320, 340], [340, 300], [360, 340], [380, 300]]})).unwrap(); + assert!(r["shape"].is_null()); + assert_eq!(ink(&s).len(), 1); + // Off again; a drawn ellipse stays ink until the lasso selects it and Ink to Shape converts it. + s.run("draw.inkToShape", &json!({"value": false})).unwrap(); + let ring: Vec<[f32; 2]> = + (0..=48).map(|i| i as f32 / 48.0 * std::f32::consts::TAU).map(|a| [200.0 + 50.0 * a.cos(), 500.0 + 30.0 * a.sin()]).collect(); + s.run("draw.stroke", &json!({"page": 0, "points": ring})).unwrap(); + assert_eq!(ink(&s).len(), 2); + s.run("draw.lasso", &json!({"page": 0, "points": [[130, 450], [270, 450], [270, 550], [130, 550]]})).unwrap(); + let r = s.run("draw.inkToShape", &json!({})).unwrap(); + assert_eq!(r["converted"][0]["shape"], "ellipse"); + assert_eq!(ink(&s).len(), 1); + assert!(!s.view.draw.ink_to_shape, "converting the selection doesn't turn the mode on"); +} + +#[test] +fn ink_to_shape_turns_an_arrow_stroke_into_a_line_shape_with_a_head_that_survives_docx() { + let mut s = session(); + s.run("draw.inkToShape", &json!({"value": true})).unwrap(); + // Right to left and downwards: shaft, then both barbs of the head at the tip (100, 300). + let tail = [300.0f32, 200.0]; + let tip = [100.0f32, 300.0]; + let back = (tail[1] - tip[1]).atan2(tail[0] - tip[0]); + let barb = |sign: f32| [tip[0] + 30.0 * (back + sign * 0.5).cos(), tip[1] + 30.0 * (back + sign * 0.5).sin()]; + let mut pts: Vec<[f32; 2]> = + (0..20).map(|i| i as f32 / 20.0).map(|t| [tail[0] + (tip[0] - tail[0]) * t, tail[1] + (tip[1] - tail[1]) * t]).collect(); + pts.extend([tip, barb(1.0), tip, barb(-1.0)]); + let r = s.run("draw.stroke", &json!({"page": 0, "points": pts, "color": "C00000", "width": 2})).unwrap(); + assert_eq!(r["shape"], "arrow"); + let at: wordcraft_doc::Pos = serde_json::from_value(r["pos"].clone()).unwrap(); + let obj = s.doc.para_at(&at).and_then(|p| p.object_at(at.off)).cloned().unwrap(); + let InlineObject::Shape { kind, w, h, stroke, stroke_width, freeform, extra, float, .. } = obj else { panic!("{obj:?}") }; + assert_eq!((kind, stroke, stroke_width, freeform.is_none()), (ShapeKind::StraightConnector, Some(wordcraft_doc::Rgb(0xC0, 0, 0)), 2.0, true)); + assert!(extra.arrow_end && !extra.arrow_start); + // Tail at the top right, tip at the bottom left: the frame is flipped across. + assert!(float.flip_h && !float.flip_v, "{float:?}"); + assert!((w - 200.0).abs() < 1.0 && (h - 100.0).abs() < 1.0, "{w} x {h}"); + // It survives a DOCX save and open. + let bytes = wordcraft_docx::write(&s.doc).unwrap(); + let back = wordcraft_docx::read(&bytes).unwrap(); + let again: Vec<_> = back.body.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).collect(); + let Some(InlineObject::Shape { extra, float, .. }) = + again.iter().find(|o| matches!(o, InlineObject::Shape { kind: ShapeKind::StraightConnector, .. })) + else { + panic!("{again:?}") + }; + assert!(extra.arrow_end && !extra.arrow_start && float.flip_h, "{extra:?} {float:?}"); +} + +/// Every drawing in the body, in document order. +fn drawings(s: &Session) -> Vec { + s.doc + .body + .iter() + .filter_map(|b| b.as_para()) + .flat_map(|p| p.objects.iter()) + .filter(|o| matches!(o, InlineObject::Shape { .. })) + .cloned() + .collect() +} + +/// A straight stroke from (100, 300) to (300, 300). +fn shaft_stroke() -> Vec<[f32; 2]> { + (0..=40).map(|i| [100.0 + i as f32 * 5.0, 300.0]).collect() +} + +#[test] +fn ink_to_shape_turns_a_line_and_a_head_drawn_after_it_into_one_arrow_in_one_undo_step() { + let mut s = session(); + s.run("draw.inkToShape", &json!({"value": true})).unwrap(); + let r = s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke(), "color": "C00000", "width": 2})).unwrap(); + assert_eq!(r["shape"], "line"); + let steps = s.undo_depth(); + // An open V at the right-hand end, drawn as a second stroke. + let head = [[274.0, 285.0], [287.0, 292.5], [300.0, 300.0], [287.0, 307.5], [274.0, 315.0]]; + let r = s.run("draw.stroke", &json!({"page": 0, "points": head})).unwrap(); + assert_eq!(r["shape"], "arrow"); + assert_eq!(s.undo_depth(), steps + 1, "paired in one undo step"); + assert!(ink(&s).is_empty(), "the head stroke is gone"); + let all = drawings(&s); + let [InlineObject::Shape { kind, w, stroke, stroke_width, extra, float, .. }] = all.as_slice() else { panic!("one arrow: {all:?}") }; + assert_eq!((*kind, *stroke, *stroke_width), (ShapeKind::StraightConnector, Some(wordcraft_doc::Rgb(0xC0, 0, 0)), 2.0)); + assert!(extra.arrow_end && !extra.arrow_start && !float.flip_h && !float.flip_v, "{extra:?} {float:?}"); + assert!((w - 200.0).abs() < 1.0, "{w}"); + let at: wordcraft_doc::Pos = serde_json::from_value(r["pos"].clone()).unwrap(); + assert!(matches!(s.doc.para_at(&at).and_then(|p| p.object_at(at.off)), Some(InlineObject::Shape { kind: ShapeKind::StraightConnector, .. }))); + let rect = s.layout().object(&at, 0).unwrap().rect; + assert!((rect.x - 100.0).abs() < 1.0 && (rect.y - 300.0).abs() < 1.0, "where the line was: {rect:?}"); + let got: Vec = r["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap() as f32).collect(); + assert!( + got.len() == 4 && [rect.x, rect.y, rect.w, rect.h].iter().zip(&got).all(|(a, b)| (a - b).abs() < 0.5), + "the arrow's rect, not the deleted head's: {got:?} vs {rect:?}" + ); + // It survives a DOCX save and open. + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + let again: Vec<_> = back.body.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).collect(); + assert!( + again + .iter() + .any(|o| matches!(o, InlineObject::Shape { kind: ShapeKind::StraightConnector, extra, .. } if extra.arrow_end && !extra.arrow_start)), + "{again:?}" + ); + // Undo brings back the line, without the head. + s.run("edit.undo", &json!({})).unwrap(); + let all = drawings(&s); + let [InlineObject::Shape { kind: ShapeKind::Freeform, extra, .. }] = all.as_slice() else { panic!("the line: {all:?}") }; + assert!(!extra.arrow_end); + assert!(ink(&s).is_empty()); +} + +#[test] +fn ink_to_shape_pairs_a_closed_triangle_head_at_the_start_and_a_head_drawn_first() { + let mut s = session(); + s.run("draw.inkToShape", &json!({"value": true})).unwrap(); + s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke()})).unwrap(); + // A closed triangle at the left end, where the line began: the arrow points left. + let tri = [[100.0, 300.0], [113.0, 292.5], [126.0, 285.0], [126.0, 300.0], [126.0, 315.0], [113.0, 307.5], [100.0, 300.0]]; + let r = s.run("draw.stroke", &json!({"page": 0, "points": tri})).unwrap(); + assert_eq!(r["shape"], "arrow"); + let all = drawings(&s); + let [InlineObject::Shape { kind: ShapeKind::StraightConnector, extra, float, .. }] = all.as_slice() else { panic!("one arrow: {all:?}") }; + assert!(extra.arrow_end && float.flip_h && !float.flip_v, "tip at the left: {extra:?} {float:?}"); + // Head first (it stays ink on its own), then the shaft that ends at its apex. + let mut s = session(); + s.run("draw.inkToShape", &json!({"value": true})).unwrap(); + let r = s.run("draw.stroke", &json!({"page": 0, "points": [[274.0, 285.0], [300.0, 300.0], [274.0, 315.0]]})).unwrap(); + assert!(r["shape"].is_null()); + let r = s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke()})).unwrap(); + assert_eq!(r["shape"], "arrow"); + assert!(ink(&s).is_empty()); + let all = drawings(&s); + let [InlineObject::Shape { kind: ShapeKind::StraightConnector, extra, float, .. }] = all.as_slice() else { panic!("one arrow: {all:?}") }; + assert!(extra.arrow_end && !float.flip_h, "{extra:?} {float:?}"); + // A V far from the line's ends stays ink, and the line stays a line. + let mut s = session(); + s.run("draw.inkToShape", &json!({"value": true})).unwrap(); + s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke()})).unwrap(); + let r = s.run("draw.stroke", &json!({"page": 0, "points": [[174.0, 385.0], [200.0, 400.0], [174.0, 415.0]]})).unwrap(); + assert!(r["shape"].is_null()); + assert_eq!(ink(&s).len(), 1); + assert!(drawings(&s).iter().all(|o| !matches!(o, InlineObject::Shape { kind: ShapeKind::StraightConnector, .. }))); +} + +#[test] +fn converting_a_lasso_selection_pairs_a_line_and_a_head_stroke_into_an_arrow() { + let mut s = session(); + s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke(), "color": "0070C0"})).unwrap(); + s.run("draw.stroke", &json!({"page": 0, "points": [[274.0, 285.0], [300.0, 300.0], [274.0, 315.0]]})).unwrap(); + s.run("draw.stroke", &json!({"page": 0, "points": rect_stroke(100.0, 400.0, 120.0, 60.0)})).unwrap(); + assert_eq!(ink(&s).len(), 3); + let steps = s.undo_depth(); + s.run("draw.lasso", &json!({"page": 0, "points": [[80, 260], [340, 260], [340, 480], [80, 480]]})).unwrap(); + let r = s.run("draw.inkToShape", &json!({})).unwrap(); + let names: Vec<&str> = r["converted"].as_array().unwrap().iter().filter_map(|c| c["shape"].as_str()).collect(); + assert!(names.contains(&"arrow") && names.contains(&"rectangle"), "{names:?}"); + assert!(ink(&s).is_empty(), "the head stroke went into the arrow"); + let all = drawings(&s); + assert_eq!(all.len(), 2, "{all:?}"); + let arrow = all.iter().find(|o| matches!(o, InlineObject::Shape { kind: ShapeKind::StraightConnector, .. })); + let Some(InlineObject::Shape { extra, stroke, .. }) = arrow else { panic!("{all:?}") }; + assert!(extra.arrow_end && *stroke == Some(wordcraft_doc::Rgb(0, 0x70, 0xC0))); + // The selection still holds what's left: deleting it removes both shapes. + s.run("draw.lassoDelete", &json!({})).unwrap(); + assert!(drawings(&s).is_empty()); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(drawings(&s).len(), 2); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(ink(&s).len(), 3, "one undo step brings all three strokes back"); + assert_eq!(s.undo_depth(), steps); +} + +#[test] +fn converting_a_crowded_lasso_selection_recognises_each_stroke_a_bounded_number_of_times() { + let mut s = session(); + s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke()})).unwrap(); + s.run("draw.stroke", &json!({"page": 0, "points": [[274.0, 285.0], [300.0, 300.0], [274.0, 315.0]]})).unwrap(); + // A stack of 40 lines close together: every pair is near, none makes an arrow. + let n = 40; + for i in 0..n { + let y = 420.0 + i as f32 * 2.0; + let pts: Vec<[f32; 2]> = (0..=40).map(|k| [100.0 + k as f32 * 5.0, y]).collect(); + s.run("draw.stroke", &json!({"page": 0, "points": pts})).unwrap(); + } + s.run("draw.lasso", &json!({"page": 0, "points": [[80, 260], [340, 260], [340, 560], [80, 560]]})).unwrap(); + super::super::ink_shape::RECOGNISED.with(|c| c.set(0)); + let r = s.run("draw.inkToShape", &json!({})).unwrap(); + let calls = super::super::ink_shape::RECOGNISED.with(|c| c.get()); + let strokes = n + 2; + assert!(calls <= 2 * strokes, "{calls} recognitions for {strokes} strokes"); + let names: Vec<&str> = r["converted"].as_array().unwrap().iter().filter_map(|c| c["shape"].as_str()).collect(); + assert_eq!(names.iter().filter(|n| **n == "arrow").count(), 1, "{names:?}"); + assert_eq!(names.iter().filter(|n| **n == "line").count(), n, "{names:?}"); + assert!(ink(&s).is_empty()); +} + +#[test] +fn ink_replay_plays_strokes_in_the_order_they_were_drawn() { + let mut s = session(); + // Drawn low on the page first, then higher: document order would be the other way round. + s.run("draw.stroke", &json!({"page": 0, "points": [[100, 400], [150, 400]]})).unwrap(); + s.run("draw.stroke", &json!({"page": 0, "points": [[100, 90], [150, 90]]})).unwrap(); + let r = s.run("draw.replay", &json!({})).unwrap(); + assert!(s.view.draw.replay); + let orders: Vec = r["strokes"].as_array().unwrap().iter().map(|k| k["order"].as_u64().unwrap()).collect(); + assert_eq!(orders, vec![1, 2]); + let ys: Vec = super::strokes_in_drawing_order(&mut s).iter().map(|(_, k)| k.rect.y).collect(); + assert!(ys[0] > ys[1], "the lower stroke was drawn first: {ys:?}"); + assert_eq!(s.run("draw.replay", &json!({})).unwrap()["replaying"], false); + // Nothing to replay without ink. + assert!(session().run("draw.replay", &json!({})).is_err()); +} + +#[test] +fn add_pen_validates_and_saved_galleries_drop_junk() { + let mut s = session(); + let r = s.run("draw.addPen", &json!({"kind": "highlighter", "color": "00B050", "width": 1e9})).unwrap(); + assert_eq!(r["pen"], json!({"kind": "highlighter", "color": "00B050", "width": super::MAX_WIDTH})); + assert_eq!(s.view.draw.mode, super::DrawMode::Pen(InkTool::Highlighter), "the new pen is picked up"); + for bad in [json!({"kind": "crayon"}), json!({"color": "blue"}), json!(5), json!(null)] { + assert!(s.run("draw.addPen", &bad).is_err(), "{bad}"); + } + for _ in 0..super::MAX_PENS + 3 { + let _ = s.run("draw.addPen", &json!({})); + } + assert_eq!(s.view.draw.pens.len(), super::MAX_PENS); + s.run("draw.removePen", &json!({"index": 0})).unwrap(); + assert!(s.run("draw.removePen", &json!({"index": 999})).is_err()); + // A saved gallery keeps its pens and drops the junk around them. + let saved = json!({"pens": [{"kind": "pencil", "color": "7030A0", "width": 3}, "junk", {"kind": "crayon"}, {"width": "x"}], "inkToShape": true}); + let d: super::DrawState = serde_json::from_value(saved).unwrap(); + assert_eq!(d.pens.len(), 2); + assert_eq!((d.pens[0].kind, d.pens[0].color.hex(), d.pens[0].width), (InkTool::Pencil, "7030A0".to_string(), 3.0)); + assert!(d.ink_to_shape); + let back: super::DrawState = serde_json::from_value(serde_json::to_value(&d).unwrap()).unwrap(); + assert_eq!(back.pens, d.pens); +}