From 65a3ddc8f0d804ba2455dbf3675914c9ff255851 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:36 +0000 Subject: [PATCH] refactor(format): move lib.rs tests to tests.rs --- crates/format/src/lib.rs | 430 +------------------------------------ crates/format/src/tests.rs | 427 ++++++++++++++++++++++++++++++++++++ 2 files changed, 428 insertions(+), 429 deletions(-) create mode 100644 crates/format/src/tests.rs diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index cea34a6a..7610398e 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -673,435 +673,7 @@ pub fn slides_to_outline(p: &Presentation) -> String { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn chart_sidecars_are_optional_for_older_native_files() { - use deckcraft_model::chart::{Chart, ChartType}; - let chart = Chart::sample(ChartType::Pie); - let mut json = serde_json::to_value(&chart).expect("chart JSON"); - assert!(json.get("kept").is_none(), "empty sidecars should not enlarge ordinary files"); - assert_eq!(serde_json::from_value::(json.clone()).expect("old chart without kept"), chart); - json["kept"] = serde_json::json!({}); - assert_eq!(serde_json::from_value::(json).expect("empty sidecar"), chart); - } - - #[test] - fn native_save_keeps_chart_relationships_and_payloads() { - use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType}; - use deckcraft_model::{Shape, ShapeId, ShapeKind}; - let kept = ChartKept { - rels: vec![ChartRel { - id: "rWorkbook".into(), - rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package".into(), - target: "../embeddings/data.xlsx".into(), - part: "ppt/embeddings/data.xlsx".into(), - external: false, - }], - parts: vec![ChartPart { - name: "ppt/embeddings/data.xlsx".into(), - content_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into(), - bytes: vec![0, 255, 128, 42], - native_entry: None, - }], - }; - let mut p = defaults::new_presentation(None); - let chart = Chart { kept: kept.clone(), ..Chart::sample(ChartType::Pie) }; - let s = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Chart(Box::new(chart)), ..Default::default() }; - // Serialization must reach charts inside groups as well as top-level charts. - Arc::make_mut(&mut p.slides[0]).shapes = vec![Shape { - id: ShapeId(p.alloc_id().expect("group id")), - kind: ShapeKind::Group { children: vec![s], child: Default::default() }, - ..Default::default() - }]; - let mut q = load(&save(&p).expect("save")).expect("load"); - for _ in 0..2 { - let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") }; - let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") }; - assert_eq!(chart.kept, kept); - q = load(&save(&q).expect("save again")).expect("load again"); - } - } - - /// The same package without the entries `drop` names. - fn without(bytes: &[u8], drop: &str) -> Vec { - let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); - let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); - for i in 0..z.len() { - let f = z.by_index(i).unwrap(); - if f.name() != drop { - out.raw_copy_file(f).unwrap(); - } - } - out.finish().unwrap().into_inner() - } - - #[test] - fn missing_or_corrupt_media_is_reported() { - let mut p = Presentation::default(); - let id = p.add_media("a.png", "image/png", b"PICTURE-BYTES-0123456789".to_vec()).expect("media"); - let good = save(&p).unwrap(); - assert!(load_checked(&good).unwrap().1.is_empty()); - // Missing member. - let (q, damaged) = load_checked(&without(&good, &format!("media/{}", id.0))).unwrap(); - assert_eq!(damaged.len(), 1, "{damaged:?}"); - assert!(q.media(id).is_some_and(|m| m.data.is_empty())); - // Bad CRC: PNGs are stored, so their bytes sit in the file as they are. - let mut bad = good.clone(); - let at = bad.windows(13).position(|w| w == b"PICTURE-BYTES").unwrap(); - bad[at] ^= 0xff; - let (_, damaged) = load_checked(&bad).unwrap(); - assert_eq!(damaged.len(), 1, "{damaged:?}"); - // Linked media saved without its bytes isn't damage. - let mut l = Presentation::default(); - let lid = l.add_media("v.mp4", "video/mp4", vec![]).expect("media"); - if let Some(m) = l.media.iter_mut().find(|m| m.id == lid) { - m.link = Some("file:///movies/v.mp4".into()); - } - assert!(load_checked(&save(&l).unwrap()).unwrap().1.is_empty()); - } - - #[test] - fn media_budget_is_cumulative() { - let mut p = Presentation::default(); - p.add_media("a.bin", "application/octet-stream", vec![1; 600]); - p.add_media("b.bin", "application/octet-stream", vec![2; 600]); - let bytes = save(&p).unwrap(); - // Exactly at the limit opens; one byte under is refused, though each item fits. - assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok()); - assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err()); - assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err()); - } - - #[test] - fn repeated_media_ids_are_read_once() { - use std::io::Write; - let mut p = Presentation::default(); - p.add_media("a.bin", "application/octet-stream", vec![7; 1000]); - let good = save(&p).unwrap(); - // The same file with its media item listed 51 times. - let mut z = zip::ZipArchive::new(Cursor::new(good.as_slice())).unwrap(); - let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); - for i in 0..z.len() { - let mut f = z.by_index(i).unwrap(); - if f.name() == "presentation.json" { - let mut json = vec![]; - f.read_to_end(&mut json).unwrap(); - let mut v: serde_json::Value = serde_json::from_slice(&json).unwrap(); - let media = &mut v["presentation"]["media"]; - let one = media[0].clone(); - *media = serde_json::Value::Array(vec![one; 51]); - out.start_file("presentation.json", zip::write::SimpleFileOptions::default()).unwrap(); - out.write_all(&serde_json::to_vec(&v).unwrap()).unwrap(); - } else { - out.raw_copy_file(f).unwrap(); - } - } - let bytes = out.finish().unwrap().into_inner(); - // 51 references to one 1000-byte member fit a 1000-byte budget: it's inflated once. - let q = load_within(&bytes, Budget { total: 1000, items: 51 }).unwrap().0; - assert_eq!(q.media.len(), 1); - assert_eq!(q.media[0].data.len(), 1000); - // And the deck saves again. - assert!(save(&q).is_ok()); - } - - #[test] - fn roundtrip_with_media() { - let mut p = Presentation::default(); - let id = p.add_media("a.png", "image/png", vec![1, 2, 3, 4]).expect("media"); - let bytes = save(&p).unwrap(); - assert!(sniff(&bytes)); - let q = load(&bytes).unwrap(); - assert_eq!(q.slides.len(), p.slides.len()); - assert_eq!(q.media(id).map(|m| m.data.as_slice()), Some(&[1u8, 2, 3, 4][..])); - assert_eq!(serde_json::to_value(&q.slides).unwrap(), serde_json::to_value(&p.slides).unwrap()); - } - - #[test] - fn garbage_is_an_error_not_a_panic() { - assert!(load(b"").is_err()); - assert!(load(b"PK\x03\x04garbage").is_err()); - assert!(load(&[0u8; 1000]).is_err()); - let mut b = save(&Presentation::default()).unwrap(); - let n = b.len(); - b.truncate(n / 2); - assert!(load(&b).is_err()); - } - - /// A video's crop is brought into the same range as a picture's (#972). - #[test] - fn a_video_crop_is_repaired_like_a_picture_crop() { - use deckcraft_model::{MediaClip, Shape}; - let mut p = Presentation::default(); - let clip = MediaClip { video: true, crop: [f64::NAN, 0.25, -1e9, 0.0], ..Default::default() }; - Arc::make_mut(&mut p.slides[0]).shapes.push(Shape { kind: ShapeKind::Media(clip), ..Default::default() }); - let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}")); - let ShapeKind::Media(m) = &back.slides[0].shapes.last().unwrap().kind else { panic!("media") }; - assert_eq!(m.crop, [0.0, 0.25, -10.0, 0.0]); - } - - /// Non-finite numbers are written as `null` by serde_json; the file still opens, with each - /// such number repaired (it failed as "invalid type: null, expected f64"). - #[test] - fn a_file_saved_with_non_finite_numbers_reopens() { - use deckcraft_model::{Shape, ShapeKind, Xfrm}; - let mut p = Presentation::default(); - let nan = f64::NAN; - let inf = f64::INFINITY; - let mut pic = Shape { xfrm: Some(Xfrm { x: nan, y: -inf, w: 1e300, h: -4.0, rot: 725.0, ..Default::default() }), ..Default::default() }; - pic.kind = ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop: [nan, inf, 0.25, -1e9], ..Default::default() } }; - pic.line = Some(deckcraft_model::Line { width: Some(1e9), ..Default::default() }); - let mut text = Shape { xfrm: Some(Xfrm { rot: inf, w: 100.0, h: 50.0, ..Default::default() }), ..Default::default() }; - let mut body = deckcraft_model::text::TextBody::default(); - let mut para = deckcraft_model::text::Paragraph::default(); - para.runs.push(deckcraft_model::text::Run::with("x", deckcraft_model::text::RunProps { size: Some(-3.0), ..Default::default() })); - para.end_props.size = Some(1e9); - body.paragraphs.push(para); - text.text = Some(body); - let group = Shape { - xfrm: Some(Xfrm { w: 10.0, h: 10.0, ..Default::default() }), - kind: ShapeKind::Group { children: vec![pic, text], child: Xfrm { x: nan, w: inf, h: 10.0, ..Default::default() } }, - ..Default::default() - }; - Arc::make_mut(&mut p.slides[0]).shapes.push(group); - let bytes = save(&p).unwrap(); - let back = load(&bytes).unwrap_or_else(|e| panic!("{e}")); - let g = back.slides[0].shapes.last().unwrap(); - let ShapeKind::Group { children, child } = &g.kind else { panic!("group") }; - assert_eq!((child.x, child.w), (0.0, 0.0)); - let x = children[0].xfrm.unwrap(); - assert_eq!((x.x, x.y, x.w, x.h, x.rot), (0.0, 0.0, MAX_COORD, 0.0, 5.0)); - let ShapeKind::Picture { fill } = &children[0].kind else { panic!("picture") }; - assert_eq!(fill.crop, [0.0, 0.0, 0.25, -10.0]); - assert_eq!(children[0].line.as_ref().unwrap().width, Some(1584.0)); - assert_eq!(children[1].xfrm.unwrap().rot, 0.0); - let para = &children[1].text.as_ref().unwrap().paragraphs[0]; - assert_eq!(para.runs[0].props.size, None); - assert_eq!(para.end_props.size, Some(4000.0)); - // Saved again, it is clean. - assert_eq!(load(&save(&back).unwrap()).unwrap(), back); - } - - /// Files written before [`finite::Finite`] (plain serde_json: NaN as `null`) open too, - /// including a `null` inside a tagged enum, where serde reports only the enclosing object. - #[test] - fn older_files_with_null_numbers_open() { - use deckcraft_model::{Shape, ShapeKind, Xfrm}; - let nan = f64::NAN; - for case in 0..3 { - let mut p = Presentation::default(); - let sh = match case { - 0 => Shape { xfrm: Some(Xfrm { x: nan, rot: nan, w: 5.0, h: 5.0, ..Default::default() }), ..Default::default() }, - 1 => Shape { - kind: ShapeKind::Group { children: vec![], child: Xfrm { x: nan, w: 1.0, h: 1.0, ..Default::default() } }, - ..Default::default() - }, - _ => Shape { - kind: ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop: [0.1, nan, 0.0, 0.0], ..Default::default() } }, - ..Default::default() - }, - }; - Arc::make_mut(&mut p.slides[0]).shapes.push(sh); - let m = Manifest { format: "deckcraft".into(), version: VERSION, generator: "old".into(), presentation: p }; - let json = serde_json::to_vec(&m).unwrap(); - assert!(json.windows(4).any(|w| w == b"null")); - let back = parse_manifest(&json).unwrap_or_else(|e| panic!("case {case}: {e}")); - assert_eq!(back.presentation.slides[0].shapes.len(), m.presentation.slides[0].shapes.len(), "case {case}"); - } - } - - /// A body whose file had no `fontScale` (``) keeps that state through the - /// native format: it is drawn at 100 % and only fitted when its text changes (#724). - #[test] - fn a_bare_normautofit_body_round_trips() { - use deckcraft_model::text::{AutoFit, TextBody}; - let mut p = Presentation::default(); - let mut body = TextBody::from_text("Overflowing"); - body.body.autofit = Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 }); - let sh = deckcraft_model::Shape { text: Some(body), ..Default::default() }; - Arc::make_mut(&mut p.slides[0]).shapes.push(sh); - let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}")); - let body = back.slides[0].shapes.last().unwrap().text.clone().unwrap(); - assert_eq!(body.body.autofit, Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 })); - // A scale the file saved round-trips as itself. - let mut body = TextBody::from_text("Overflowing"); - body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.62), line_reduction: 0.2 }); - let sh = deckcraft_model::Shape { text: Some(body), ..Default::default() }; - Arc::make_mut(&mut p.slides[0]).shapes.push(sh); - let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}")); - let body = back.slides[0].shapes.last().unwrap().text.clone().unwrap(); - assert_eq!(body.body.autofit, Some(AutoFit::Shrink { font_scale: Some(0.62), line_reduction: 0.2 })); - } - - /// Only `null`s where numbers belong are patched: other damage still fails. - #[test] - fn other_damage_is_still_reported() { - let ok = serde_json::to_vec(&serde_json::json!({"format": "deckcraft", "version": 1, "generator": "t", "presentation": {}})).unwrap(); - assert!(parse_manifest(&ok).is_ok()); - let bad = br#"{"format": null, "version": 1, "generator": "t", "presentation": {}}"#; - assert!(parse_manifest(bad).is_err()); - let truncated = &ok[..ok.len() - 3]; - assert!(parse_manifest(truncated).is_err()); - // A null number on a later line (pretty-printed by another tool) is found too. - let pretty = b"{\n \"format\": \"deckcraft\",\n \"version\": 1,\n \"generator\": \"t\",\n \"presentation\": {\"slides\": [{\"shapes\": [{\"xfrm\": {\"x\": null, \"y\": 1, \"w\": 2, \"h\": 3}}]}]}\n}"; - let m = parse_manifest(pretty).unwrap_or_else(|e| panic!("{e}")); - assert_eq!(m.presentation.slides[0].shapes[0].xfrm.unwrap().x, 0.0); - } - - #[test] - fn degenerate_slide_sizes_are_repaired() { - use deckcraft_model::Size; - let cases = [ - ((0.0, 0.0), (960.0, 540.0)), - ((-5.0, 540.0), (960.0, 540.0)), - ((f64::NAN, 540.0), (960.0, 540.0)), - ((f64::INFINITY, 1.0), (960.0, 540.0)), - ((1.0, 1.0), (72.0, 72.0)), - ((0.5, 4000.0), (72.0, 4000.0)), - ((1e9, 20.0), (4032.0, 72.0)), - ((720.0, 540.0), (720.0, 540.0)), - ]; - for ((w, h), want) in cases { - let mut p = Presentation { slide_size: Size::new(w, h), notes_size: Size::new(0.0, f64::NAN), ..Default::default() }; - repair(&mut p); - assert_eq!((p.slide_size.width, p.slide_size.height), want, "{w}x{h}"); - assert_eq!(p.notes_size, Presentation::default().notes_size); - } - } - - /// A file whose slide size is null, a string or missing a side (written by hand or by - /// another tool; NaN is also saved as null) opens with the default size rather than failing - /// as damaged. - #[test] - fn files_with_unreadable_slide_sizes_still_open() { - let saved = save(&Presentation::default()).unwrap(); - let mut z = zip::ZipArchive::new(Cursor::new(&saved[..])).unwrap(); - let json: serde_json::Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap(); - for size in [ - serde_json::json!({"width": null, "height": 540}), - serde_json::json!({"width": "big", "height": 540}), - serde_json::json!({"height": 0}), - serde_json::json!(null), - serde_json::json!(7), - ] { - let mut m = json.clone(); - m["presentation"]["slideSize"] = size.clone(); - let mut buf = Cursor::new(Vec::new()); - { - let mut w = zip::ZipWriter::new(&mut buf); - let o = zip::write::SimpleFileOptions::default(); - w.start_file("mimetype", o).unwrap(); - w.write_all(MIMETYPE.as_bytes()).unwrap(); - w.start_file("presentation.json", o).unwrap(); - w.write_all(&serde_json::to_vec(&m).unwrap()).unwrap(); - w.finish().unwrap(); - } - let p = load(buf.get_ref()).unwrap_or_else(|e| panic!("{size}: {e}")); - assert_eq!((p.slide_size.width, p.slide_size.height), (960.0, 540.0), "{size}"); - } - } - - #[test] - fn repair_fixes_missing_layout_and_size() { - let mut p = Presentation::default(); - Arc::make_mut(&mut p.slides[0]).layout = deckcraft_model::LayoutId(99999); - p.slide_size.width = f64::NAN; - repair(&mut p); - assert!(p.validate().is_empty()); - assert_eq!(p.slide_size.width, 960.0); - } - - /// A duplicate slide in a deck whose ids are used up cannot be re-id'd: it stays as it is - /// (the engine refuses edits to such a deck) instead of a duplicated fresh id (#407). - #[test] - fn repair_keeps_a_duplicate_it_cannot_reid() { - let mut p = Presentation { next_id: u32::MAX, ..Default::default() }; - p.slides.push(p.slides[0].clone()); - repair(&mut p); - assert_eq!(p.slides.len(), 2, "the duplicate stays visible"); - assert_eq!(p.slides[0].id, p.slides[1].id, "no fresh id was invented"); - } - - #[test] - fn outline_roundtrip() { - let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); - let n = outline_to_slides(&mut p, "Intro\n\tPoint one\n\t\tSub point\nSecond\n\tMore"); - assert_eq!(n, 2); - let o = slides_to_outline(&p); - assert!(o.contains("Intro\n\tPoint one\n\t\tSub point\nSecond"), "{o}"); - } - - #[test] - fn outline_keeps_leading_dash_text() { - let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); - let n = outline_to_slides(&mut p, "-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\n- Bullets\n\t- Point\n\t• Other"); - assert_eq!(n, 2); - let o = slides_to_outline(&p); - assert!(o.contains("-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\nBullets\n\tPoint\n\tOther"), "{o}"); - } - - #[test] - fn outline_export_keeps_multi_paragraph_title_on_one_line() { - let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); - assert_eq!(outline_to_slides(&mut p, "Intro\n\tPoint one\nSecond\n\tMore"), 2); - let title = Arc::make_mut(&mut p.slides[0]).shapes.iter_mut().find(|s| s.ph_type().is_some_and(|k| k.is_title())).expect("title placeholder"); - title.text = Some(TextBody::from_text("Line one\nLine two")); - let o = slides_to_outline(&p); - assert_eq!(o, "Line one Line two\n\tPoint one\nSecond\n\tMore\n"); - let mut q = defaults::blank_presentation(defaults::WIDE, Default::default(), false); - assert_eq!(outline_to_slides(&mut q, &o), 2); - } - - #[test] - fn markdown_headings_and_list_items() { - assert_eq!(md_heading("# Intro"), Some("Intro")); - assert_eq!(md_heading(" ### Deep ### "), Some("Deep")); - assert_eq!(md_heading("## C# and F#"), Some("C# and F#")); - assert_eq!(md_heading("#"), Some("")); - assert_eq!(md_heading("## Ünïcödé"), Some("Ünïcödé")); - for not in ["#5 priority", "####### seven", " # code", "text", "", "é#"] { - assert_eq!(md_heading(not), None, "{not:?}"); - } - assert_eq!(md_list_item("- a"), Some((0, "a"))); - assert_eq!(md_list_item(" * b"), Some((2, "b"))); - assert_eq!(md_list_item("\t+ c"), Some((1, "c"))); - assert_eq!(md_list_item("12) d"), Some((0, "d"))); - for not in ["-x", "1.x", "1", "1234567890. long", "• bullet", "é. x", ""] { - assert_eq!(md_list_item(not), None, "{not:?}"); - } - } - - /// #814: slides_to_outline includes table cell text, which it used to skip. - #[test] - fn outline_includes_table_text() { - use deckcraft_model::table::{Cell, Row, Table}; - use deckcraft_model::{Shape, ShapeKind, Xfrm}; - let mut p = defaults::new_presentation(None); - let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap(); - let mut s = defaults::new_slide(&mut p, layout).expect("slide"); - s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title())); - let mut body = Shape { xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 100.0)), ..Default::default() }; - body.text = Some(TextBody::from_text("Text box body")); - s.shapes.push(body); - - let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() }; - for texts in [["Cell A1", "Cell B1"], ["Cell A2", "Cell B2"]] { - let cells: Vec = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect(); - table.rows.push(Row { height: 30.0, cells }); - } - let mut tbl = Shape { xfrm: Some(Xfrm::new(0.0, 100.0, 400.0, 60.0)), ..Default::default() }; - tbl.kind = ShapeKind::Table(table); - s.shapes.push(tbl); - p.slides = vec![std::sync::Arc::new(s)]; - - let out = slides_to_outline(&p); - assert!(out.contains("Text box body"), "{out}"); - assert!(out.contains("Cell A1\tCell B1"), "{out}"); - assert!(out.contains("Cell A2\tCell B2"), "{out}"); - } -} +mod tests; #[test] fn outline_table_rows_are_indented_and_do_not_become_slides() { diff --git a/crates/format/src/tests.rs b/crates/format/src/tests.rs new file mode 100644 index 00000000..dcd15636 --- /dev/null +++ b/crates/format/src/tests.rs @@ -0,0 +1,427 @@ +use super::*; + +#[test] +fn chart_sidecars_are_optional_for_older_native_files() { + use deckcraft_model::chart::{Chart, ChartType}; + let chart = Chart::sample(ChartType::Pie); + let mut json = serde_json::to_value(&chart).expect("chart JSON"); + assert!(json.get("kept").is_none(), "empty sidecars should not enlarge ordinary files"); + assert_eq!(serde_json::from_value::(json.clone()).expect("old chart without kept"), chart); + json["kept"] = serde_json::json!({}); + assert_eq!(serde_json::from_value::(json).expect("empty sidecar"), chart); +} + +#[test] +fn native_save_keeps_chart_relationships_and_payloads() { + use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType}; + use deckcraft_model::{Shape, ShapeId, ShapeKind}; + let kept = ChartKept { + rels: vec![ChartRel { + id: "rWorkbook".into(), + rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package".into(), + target: "../embeddings/data.xlsx".into(), + part: "ppt/embeddings/data.xlsx".into(), + external: false, + }], + parts: vec![ChartPart { + name: "ppt/embeddings/data.xlsx".into(), + content_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into(), + bytes: vec![0, 255, 128, 42], + native_entry: None, + }], + }; + let mut p = defaults::new_presentation(None); + let chart = Chart { kept: kept.clone(), ..Chart::sample(ChartType::Pie) }; + let s = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Chart(Box::new(chart)), ..Default::default() }; + // Serialization must reach charts inside groups as well as top-level charts. + Arc::make_mut(&mut p.slides[0]).shapes = vec![Shape { + id: ShapeId(p.alloc_id().expect("group id")), + kind: ShapeKind::Group { children: vec![s], child: Default::default() }, + ..Default::default() + }]; + let mut q = load(&save(&p).expect("save")).expect("load"); + for _ in 0..2 { + let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") }; + let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") }; + assert_eq!(chart.kept, kept); + q = load(&save(&q).expect("save again")).expect("load again"); + } +} + +/// The same package without the entries `drop` names. +fn without(bytes: &[u8], drop: &str) -> Vec { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..z.len() { + let f = z.by_index(i).unwrap(); + if f.name() != drop { + out.raw_copy_file(f).unwrap(); + } + } + out.finish().unwrap().into_inner() +} + +#[test] +fn missing_or_corrupt_media_is_reported() { + let mut p = Presentation::default(); + let id = p.add_media("a.png", "image/png", b"PICTURE-BYTES-0123456789".to_vec()).expect("media"); + let good = save(&p).unwrap(); + assert!(load_checked(&good).unwrap().1.is_empty()); + // Missing member. + let (q, damaged) = load_checked(&without(&good, &format!("media/{}", id.0))).unwrap(); + assert_eq!(damaged.len(), 1, "{damaged:?}"); + assert!(q.media(id).is_some_and(|m| m.data.is_empty())); + // Bad CRC: PNGs are stored, so their bytes sit in the file as they are. + let mut bad = good.clone(); + let at = bad.windows(13).position(|w| w == b"PICTURE-BYTES").unwrap(); + bad[at] ^= 0xff; + let (_, damaged) = load_checked(&bad).unwrap(); + assert_eq!(damaged.len(), 1, "{damaged:?}"); + // Linked media saved without its bytes isn't damage. + let mut l = Presentation::default(); + let lid = l.add_media("v.mp4", "video/mp4", vec![]).expect("media"); + if let Some(m) = l.media.iter_mut().find(|m| m.id == lid) { + m.link = Some("file:///movies/v.mp4".into()); + } + assert!(load_checked(&save(&l).unwrap()).unwrap().1.is_empty()); +} + +#[test] +fn media_budget_is_cumulative() { + let mut p = Presentation::default(); + p.add_media("a.bin", "application/octet-stream", vec![1; 600]); + p.add_media("b.bin", "application/octet-stream", vec![2; 600]); + let bytes = save(&p).unwrap(); + // Exactly at the limit opens; one byte under is refused, though each item fits. + assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok()); + assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err()); + assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err()); +} + +#[test] +fn repeated_media_ids_are_read_once() { + use std::io::Write; + let mut p = Presentation::default(); + p.add_media("a.bin", "application/octet-stream", vec![7; 1000]); + let good = save(&p).unwrap(); + // The same file with its media item listed 51 times. + let mut z = zip::ZipArchive::new(Cursor::new(good.as_slice())).unwrap(); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + if f.name() == "presentation.json" { + let mut json = vec![]; + f.read_to_end(&mut json).unwrap(); + let mut v: serde_json::Value = serde_json::from_slice(&json).unwrap(); + let media = &mut v["presentation"]["media"]; + let one = media[0].clone(); + *media = serde_json::Value::Array(vec![one; 51]); + out.start_file("presentation.json", zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(&serde_json::to_vec(&v).unwrap()).unwrap(); + } else { + out.raw_copy_file(f).unwrap(); + } + } + let bytes = out.finish().unwrap().into_inner(); + // 51 references to one 1000-byte member fit a 1000-byte budget: it's inflated once. + let q = load_within(&bytes, Budget { total: 1000, items: 51 }).unwrap().0; + assert_eq!(q.media.len(), 1); + assert_eq!(q.media[0].data.len(), 1000); + // And the deck saves again. + assert!(save(&q).is_ok()); +} + +#[test] +fn roundtrip_with_media() { + let mut p = Presentation::default(); + let id = p.add_media("a.png", "image/png", vec![1, 2, 3, 4]).expect("media"); + let bytes = save(&p).unwrap(); + assert!(sniff(&bytes)); + let q = load(&bytes).unwrap(); + assert_eq!(q.slides.len(), p.slides.len()); + assert_eq!(q.media(id).map(|m| m.data.as_slice()), Some(&[1u8, 2, 3, 4][..])); + assert_eq!(serde_json::to_value(&q.slides).unwrap(), serde_json::to_value(&p.slides).unwrap()); +} + +#[test] +fn garbage_is_an_error_not_a_panic() { + assert!(load(b"").is_err()); + assert!(load(b"PK\x03\x04garbage").is_err()); + assert!(load(&[0u8; 1000]).is_err()); + let mut b = save(&Presentation::default()).unwrap(); + let n = b.len(); + b.truncate(n / 2); + assert!(load(&b).is_err()); +} + +/// A video's crop is brought into the same range as a picture's (#972). +#[test] +fn a_video_crop_is_repaired_like_a_picture_crop() { + use deckcraft_model::{MediaClip, Shape}; + let mut p = Presentation::default(); + let clip = MediaClip { video: true, crop: [f64::NAN, 0.25, -1e9, 0.0], ..Default::default() }; + Arc::make_mut(&mut p.slides[0]).shapes.push(Shape { kind: ShapeKind::Media(clip), ..Default::default() }); + let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}")); + let ShapeKind::Media(m) = &back.slides[0].shapes.last().unwrap().kind else { panic!("media") }; + assert_eq!(m.crop, [0.0, 0.25, -10.0, 0.0]); +} + +/// Non-finite numbers are written as `null` by serde_json; the file still opens, with each +/// such number repaired (it failed as "invalid type: null, expected f64"). +#[test] +fn a_file_saved_with_non_finite_numbers_reopens() { + use deckcraft_model::{Shape, ShapeKind, Xfrm}; + let mut p = Presentation::default(); + let nan = f64::NAN; + let inf = f64::INFINITY; + let mut pic = Shape { xfrm: Some(Xfrm { x: nan, y: -inf, w: 1e300, h: -4.0, rot: 725.0, ..Default::default() }), ..Default::default() }; + pic.kind = ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop: [nan, inf, 0.25, -1e9], ..Default::default() } }; + pic.line = Some(deckcraft_model::Line { width: Some(1e9), ..Default::default() }); + let mut text = Shape { xfrm: Some(Xfrm { rot: inf, w: 100.0, h: 50.0, ..Default::default() }), ..Default::default() }; + let mut body = deckcraft_model::text::TextBody::default(); + let mut para = deckcraft_model::text::Paragraph::default(); + para.runs.push(deckcraft_model::text::Run::with("x", deckcraft_model::text::RunProps { size: Some(-3.0), ..Default::default() })); + para.end_props.size = Some(1e9); + body.paragraphs.push(para); + text.text = Some(body); + let group = Shape { + xfrm: Some(Xfrm { w: 10.0, h: 10.0, ..Default::default() }), + kind: ShapeKind::Group { children: vec![pic, text], child: Xfrm { x: nan, w: inf, h: 10.0, ..Default::default() } }, + ..Default::default() + }; + Arc::make_mut(&mut p.slides[0]).shapes.push(group); + let bytes = save(&p).unwrap(); + let back = load(&bytes).unwrap_or_else(|e| panic!("{e}")); + let g = back.slides[0].shapes.last().unwrap(); + let ShapeKind::Group { children, child } = &g.kind else { panic!("group") }; + assert_eq!((child.x, child.w), (0.0, 0.0)); + let x = children[0].xfrm.unwrap(); + assert_eq!((x.x, x.y, x.w, x.h, x.rot), (0.0, 0.0, MAX_COORD, 0.0, 5.0)); + let ShapeKind::Picture { fill } = &children[0].kind else { panic!("picture") }; + assert_eq!(fill.crop, [0.0, 0.0, 0.25, -10.0]); + assert_eq!(children[0].line.as_ref().unwrap().width, Some(1584.0)); + assert_eq!(children[1].xfrm.unwrap().rot, 0.0); + let para = &children[1].text.as_ref().unwrap().paragraphs[0]; + assert_eq!(para.runs[0].props.size, None); + assert_eq!(para.end_props.size, Some(4000.0)); + // Saved again, it is clean. + assert_eq!(load(&save(&back).unwrap()).unwrap(), back); +} + +/// Files written before [`finite::Finite`] (plain serde_json: NaN as `null`) open too, +/// including a `null` inside a tagged enum, where serde reports only the enclosing object. +#[test] +fn older_files_with_null_numbers_open() { + use deckcraft_model::{Shape, ShapeKind, Xfrm}; + let nan = f64::NAN; + for case in 0..3 { + let mut p = Presentation::default(); + let sh = match case { + 0 => Shape { xfrm: Some(Xfrm { x: nan, rot: nan, w: 5.0, h: 5.0, ..Default::default() }), ..Default::default() }, + 1 => Shape { + kind: ShapeKind::Group { children: vec![], child: Xfrm { x: nan, w: 1.0, h: 1.0, ..Default::default() } }, + ..Default::default() + }, + _ => Shape { + kind: ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop: [0.1, nan, 0.0, 0.0], ..Default::default() } }, + ..Default::default() + }, + }; + Arc::make_mut(&mut p.slides[0]).shapes.push(sh); + let m = Manifest { format: "deckcraft".into(), version: VERSION, generator: "old".into(), presentation: p }; + let json = serde_json::to_vec(&m).unwrap(); + assert!(json.windows(4).any(|w| w == b"null")); + let back = parse_manifest(&json).unwrap_or_else(|e| panic!("case {case}: {e}")); + assert_eq!(back.presentation.slides[0].shapes.len(), m.presentation.slides[0].shapes.len(), "case {case}"); + } +} + +/// A body whose file had no `fontScale` (``) keeps that state through the +/// native format: it is drawn at 100 % and only fitted when its text changes (#724). +#[test] +fn a_bare_normautofit_body_round_trips() { + use deckcraft_model::text::{AutoFit, TextBody}; + let mut p = Presentation::default(); + let mut body = TextBody::from_text("Overflowing"); + body.body.autofit = Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 }); + let sh = deckcraft_model::Shape { text: Some(body), ..Default::default() }; + Arc::make_mut(&mut p.slides[0]).shapes.push(sh); + let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}")); + let body = back.slides[0].shapes.last().unwrap().text.clone().unwrap(); + assert_eq!(body.body.autofit, Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 })); + // A scale the file saved round-trips as itself. + let mut body = TextBody::from_text("Overflowing"); + body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.62), line_reduction: 0.2 }); + let sh = deckcraft_model::Shape { text: Some(body), ..Default::default() }; + Arc::make_mut(&mut p.slides[0]).shapes.push(sh); + let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}")); + let body = back.slides[0].shapes.last().unwrap().text.clone().unwrap(); + assert_eq!(body.body.autofit, Some(AutoFit::Shrink { font_scale: Some(0.62), line_reduction: 0.2 })); +} + +/// Only `null`s where numbers belong are patched: other damage still fails. +#[test] +fn other_damage_is_still_reported() { + let ok = serde_json::to_vec(&serde_json::json!({"format": "deckcraft", "version": 1, "generator": "t", "presentation": {}})).unwrap(); + assert!(parse_manifest(&ok).is_ok()); + let bad = br#"{"format": null, "version": 1, "generator": "t", "presentation": {}}"#; + assert!(parse_manifest(bad).is_err()); + let truncated = &ok[..ok.len() - 3]; + assert!(parse_manifest(truncated).is_err()); + // A null number on a later line (pretty-printed by another tool) is found too. + let pretty = b"{\n \"format\": \"deckcraft\",\n \"version\": 1,\n \"generator\": \"t\",\n \"presentation\": {\"slides\": [{\"shapes\": [{\"xfrm\": {\"x\": null, \"y\": 1, \"w\": 2, \"h\": 3}}]}]}\n}"; + let m = parse_manifest(pretty).unwrap_or_else(|e| panic!("{e}")); + assert_eq!(m.presentation.slides[0].shapes[0].xfrm.unwrap().x, 0.0); +} + +#[test] +fn degenerate_slide_sizes_are_repaired() { + use deckcraft_model::Size; + let cases = [ + ((0.0, 0.0), (960.0, 540.0)), + ((-5.0, 540.0), (960.0, 540.0)), + ((f64::NAN, 540.0), (960.0, 540.0)), + ((f64::INFINITY, 1.0), (960.0, 540.0)), + ((1.0, 1.0), (72.0, 72.0)), + ((0.5, 4000.0), (72.0, 4000.0)), + ((1e9, 20.0), (4032.0, 72.0)), + ((720.0, 540.0), (720.0, 540.0)), + ]; + for ((w, h), want) in cases { + let mut p = Presentation { slide_size: Size::new(w, h), notes_size: Size::new(0.0, f64::NAN), ..Default::default() }; + repair(&mut p); + assert_eq!((p.slide_size.width, p.slide_size.height), want, "{w}x{h}"); + assert_eq!(p.notes_size, Presentation::default().notes_size); + } +} + +/// A file whose slide size is null, a string or missing a side (written by hand or by +/// another tool; NaN is also saved as null) opens with the default size rather than failing +/// as damaged. +#[test] +fn files_with_unreadable_slide_sizes_still_open() { + let saved = save(&Presentation::default()).unwrap(); + let mut z = zip::ZipArchive::new(Cursor::new(&saved[..])).unwrap(); + let json: serde_json::Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap(); + for size in [ + serde_json::json!({"width": null, "height": 540}), + serde_json::json!({"width": "big", "height": 540}), + serde_json::json!({"height": 0}), + serde_json::json!(null), + serde_json::json!(7), + ] { + let mut m = json.clone(); + m["presentation"]["slideSize"] = size.clone(); + let mut buf = Cursor::new(Vec::new()); + { + let mut w = zip::ZipWriter::new(&mut buf); + let o = zip::write::SimpleFileOptions::default(); + w.start_file("mimetype", o).unwrap(); + w.write_all(MIMETYPE.as_bytes()).unwrap(); + w.start_file("presentation.json", o).unwrap(); + w.write_all(&serde_json::to_vec(&m).unwrap()).unwrap(); + w.finish().unwrap(); + } + let p = load(buf.get_ref()).unwrap_or_else(|e| panic!("{size}: {e}")); + assert_eq!((p.slide_size.width, p.slide_size.height), (960.0, 540.0), "{size}"); + } +} + +#[test] +fn repair_fixes_missing_layout_and_size() { + let mut p = Presentation::default(); + Arc::make_mut(&mut p.slides[0]).layout = deckcraft_model::LayoutId(99999); + p.slide_size.width = f64::NAN; + repair(&mut p); + assert!(p.validate().is_empty()); + assert_eq!(p.slide_size.width, 960.0); +} + +/// A duplicate slide in a deck whose ids are used up cannot be re-id'd: it stays as it is +/// (the engine refuses edits to such a deck) instead of a duplicated fresh id (#407). +#[test] +fn repair_keeps_a_duplicate_it_cannot_reid() { + let mut p = Presentation { next_id: u32::MAX, ..Default::default() }; + p.slides.push(p.slides[0].clone()); + repair(&mut p); + assert_eq!(p.slides.len(), 2, "the duplicate stays visible"); + assert_eq!(p.slides[0].id, p.slides[1].id, "no fresh id was invented"); +} + +#[test] +fn outline_roundtrip() { + let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); + let n = outline_to_slides(&mut p, "Intro\n\tPoint one\n\t\tSub point\nSecond\n\tMore"); + assert_eq!(n, 2); + let o = slides_to_outline(&p); + assert!(o.contains("Intro\n\tPoint one\n\t\tSub point\nSecond"), "{o}"); +} + +#[test] +fn outline_keeps_leading_dash_text() { + let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); + let n = outline_to_slides(&mut p, "-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\n- Bullets\n\t- Point\n\t• Other"); + assert_eq!(n, 2); + let o = slides_to_outline(&p); + assert!(o.contains("-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\nBullets\n\tPoint\n\tOther"), "{o}"); +} + +#[test] +fn outline_export_keeps_multi_paragraph_title_on_one_line() { + let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); + assert_eq!(outline_to_slides(&mut p, "Intro\n\tPoint one\nSecond\n\tMore"), 2); + let title = Arc::make_mut(&mut p.slides[0]).shapes.iter_mut().find(|s| s.ph_type().is_some_and(|k| k.is_title())).expect("title placeholder"); + title.text = Some(TextBody::from_text("Line one\nLine two")); + let o = slides_to_outline(&p); + assert_eq!(o, "Line one Line two\n\tPoint one\nSecond\n\tMore\n"); + let mut q = defaults::blank_presentation(defaults::WIDE, Default::default(), false); + assert_eq!(outline_to_slides(&mut q, &o), 2); +} + +#[test] +fn markdown_headings_and_list_items() { + assert_eq!(md_heading("# Intro"), Some("Intro")); + assert_eq!(md_heading(" ### Deep ### "), Some("Deep")); + assert_eq!(md_heading("## C# and F#"), Some("C# and F#")); + assert_eq!(md_heading("#"), Some("")); + assert_eq!(md_heading("## Ünïcödé"), Some("Ünïcödé")); + for not in ["#5 priority", "####### seven", " # code", "text", "", "é#"] { + assert_eq!(md_heading(not), None, "{not:?}"); + } + assert_eq!(md_list_item("- a"), Some((0, "a"))); + assert_eq!(md_list_item(" * b"), Some((2, "b"))); + assert_eq!(md_list_item("\t+ c"), Some((1, "c"))); + assert_eq!(md_list_item("12) d"), Some((0, "d"))); + for not in ["-x", "1.x", "1", "1234567890. long", "• bullet", "é. x", ""] { + assert_eq!(md_list_item(not), None, "{not:?}"); + } +} + +/// #814: slides_to_outline includes table cell text, which it used to skip. +#[test] +fn outline_includes_table_text() { + use deckcraft_model::table::{Cell, Row, Table}; + use deckcraft_model::{Shape, ShapeKind, Xfrm}; + let mut p = defaults::new_presentation(None); + let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap(); + let mut s = defaults::new_slide(&mut p, layout).expect("slide"); + s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title())); + let mut body = Shape { xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 100.0)), ..Default::default() }; + body.text = Some(TextBody::from_text("Text box body")); + s.shapes.push(body); + + let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() }; + for texts in [["Cell A1", "Cell B1"], ["Cell A2", "Cell B2"]] { + let cells: Vec = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect(); + table.rows.push(Row { height: 30.0, cells }); + } + let mut tbl = Shape { xfrm: Some(Xfrm::new(0.0, 100.0, 400.0, 60.0)), ..Default::default() }; + tbl.kind = ShapeKind::Table(table); + s.shapes.push(tbl); + p.slides = vec![std::sync::Arc::new(s)]; + + let out = slides_to_outline(&p); + assert!(out.contains("Text box body"), "{out}"); + assert!(out.contains("Cell A1\tCell B1"), "{out}"); + assert!(out.contains("Cell A2\tCell B2"), "{out}"); +}