use devup_editor_core::{Block, BlockId, SequentialIdGenerator, TextSpan};
use devup_editor_html::{
CopiedBlocks, blocks_to_html, clean_html, copied_blocks_to_html, decode_props, encode_props,
html_to_copied_blocks, slice_content,
};
use serde_json::{Map, Value};
use std::collections::HashMap;
fn parse(html: &str) -> CopiedBlocks {
let mut id_gen = SequentialIdGenerator::new("t");
html_to_copied_blocks(html, &mut id_gen)
}
fn first_table_row_cell(
cb: &CopiedBlocks,
table_idx: usize,
row_idx: usize,
cell_idx: usize,
) -> &Block {
let table = &cb.roots[table_idx];
let row_id = &table.children[row_idx];
let row = cb.by_id.get(row_id).expect("row");
let cell_id = &row.children[cell_idx];
cb.by_id.get(cell_id).expect("cell")
}
#[test]
fn table_cell_background_color_from_inline_style() {
let cb = parse(
"<table><tbody><tr><td style=\"background-color: #fef3c7\">x</td></tr></tbody></table>",
);
let table = &cb.roots[0];
assert_eq!(table.ty, "table");
let cell = first_table_row_cell(&cb, 0, 0, 0);
assert_eq!(
cell.props.get("backgroundColor").and_then(Value::as_str),
Some("#fef3c7")
);
}
#[test]
fn table_cell_full_border_padding_vertical_align() {
let cb = parse(
"<table><tbody><tr><td style=\"border-color: #f59e0b; border-width: 2px; border-style: dashed; padding: 12px; vertical-align: bottom\">x</td></tr></tbody></table>",
);
let cell = first_table_row_cell(&cb, 0, 0, 0);
assert_eq!(
cell.props.get("borderColor").and_then(Value::as_str),
Some("#f59e0b")
);
assert_eq!(
cell.props.get("borderWidth").and_then(Value::as_str),
Some("2px")
);
assert_eq!(
cell.props.get("borderStyle").and_then(Value::as_str),
Some("dashed")
);
assert_eq!(
cell.props.get("padding").and_then(Value::as_str),
Some("12px")
);
assert_eq!(
cell.props.get("verticalAlign").and_then(Value::as_str),
Some("bottom")
);
}
#[test]
fn table_cell_colspan_rowspan() {
let cb = parse("<table><tbody><tr><td colspan=\"2\" rowspan=\"3\">x</td></tr></tbody></table>");
let cell = first_table_row_cell(&cb, 0, 0, 0);
assert_eq!(cell.props.get("colspan").and_then(Value::as_u64), Some(2));
assert_eq!(cell.props.get("rowspan").and_then(Value::as_u64), Some(3));
}
#[test]
fn table_level_style_and_row_height() {
let cb = parse(
"<table style=\"background-color: #f3e8ff; border-color: #8b5cf6; border-width: 1px; border-style: solid\"><tbody><tr style=\"height: 80px\"><td>x</td></tr></tbody></table>",
);
let table = &cb.roots[0];
assert_eq!(
table.props.get("backgroundColor").and_then(Value::as_str),
Some("#f3e8ff")
);
assert_eq!(
table.props.get("borderColor").and_then(Value::as_str),
Some("#8b5cf6")
);
assert_eq!(
table.props.get("borderWidth").and_then(Value::as_str),
Some("1px")
);
assert_eq!(
table.props.get("borderStyle").and_then(Value::as_str),
Some("solid")
);
let row = cb.by_id.get(&table.children[0]).unwrap();
assert_eq!(row.props.get("height").and_then(Value::as_i64), Some(80));
}
#[test]
fn colgroup_col_widths_with_default_fallback() {
let cb = parse(
"<table><colgroup><col style=\"width: 200px\"><col style=\"width: 60px\"><col></colgroup><tbody><tr><td>a</td><td>b</td><td>c</td></tr></tbody></table>",
);
let table = &cb.roots[0];
let cols = table
.props
.get("columns")
.and_then(Value::as_array)
.unwrap();
assert_eq!(cols.len(), 3);
assert_eq!(
cols[0].get("width").and_then(Value::as_f64),
Some(200.0),
"first col: {cols:?}"
);
assert_eq!(cols[1].get("width").and_then(Value::as_f64), Some(60.0));
assert_eq!(cols[2].get("width").and_then(Value::as_f64), Some(120.0));
}
#[test]
fn td_style_does_not_leak_into_text_highlight() {
let cb = parse(
"<table><tbody><tr><td style=\"background-color: #fef3c7\">plain</td></tr></tbody></table>",
);
let cell = first_table_row_cell(&cb, 0, 0, 0);
let mark_count: usize = cell.content.iter().map(|s| s.marks.len()).sum();
assert_eq!(mark_count, 0, "cell content marks: {:?}", cell.content);
}
fn build_single_cell_table() -> (CopiedBlocks, String) {
let mut cell_props = Map::new();
cell_props.insert("backgroundColor".into(), Value::String("#fef3c7".into()));
cell_props.insert("borderColor".into(), Value::String("#f59e0b".into()));
cell_props.insert("borderWidth".into(), Value::String("2px".into()));
cell_props.insert("borderStyle".into(), Value::String("dashed".into()));
cell_props.insert("padding".into(), Value::String("12px".into()));
cell_props.insert("verticalAlign".into(), Value::String("bottom".into()));
let cell_id = BlockId::new("c1");
let mut cell = Block::with_props(cell_id.clone(), "table_cell", cell_props);
cell.content = vec![TextSpan::plain("hi")];
cell.parent = Some(BlockId::new("r1"));
let row_id = BlockId::new("r1");
let mut row = Block::new(row_id.clone(), "table_row");
row.children = vec![cell_id.clone()];
row.parent = Some(BlockId::new("t1"));
let table_id = BlockId::new("t1");
let mut columns = Vec::new();
let mut col = Map::new();
col.insert("width".into(), Value::from(120u64));
columns.push(Value::Object(col));
let mut table_props = Map::new();
table_props.insert("columns".into(), Value::Array(columns));
let mut table = Block::with_props(table_id.clone(), "table", table_props);
table.children = vec![row_id.clone()];
let mut by_id = HashMap::new();
by_id.insert(table_id, table.clone());
by_id.insert(row_id, row);
by_id.insert(cell_id, cell);
let html = blocks_to_html(&[table.clone()], &by_id);
(
CopiedBlocks {
roots: vec![table],
by_id,
},
html,
)
}
#[test]
fn roundtrip_cell_background_border_padding() {
let (_built, html) = build_single_cell_table();
let parsed = parse(&html);
let cell = first_table_row_cell(&parsed, 0, 0, 0);
assert_eq!(
cell.props.get("backgroundColor").and_then(Value::as_str),
Some("#fef3c7")
);
assert_eq!(
cell.props.get("borderColor").and_then(Value::as_str),
Some("#f59e0b")
);
assert_eq!(
cell.props.get("borderWidth").and_then(Value::as_str),
Some("2px")
);
assert_eq!(
cell.props.get("borderStyle").and_then(Value::as_str),
Some("dashed")
);
assert_eq!(
cell.props.get("padding").and_then(Value::as_str),
Some("12px")
);
assert_eq!(
cell.props.get("verticalAlign").and_then(Value::as_str),
Some("bottom")
);
}
#[test]
fn roundtrip_row_height_as_number_and_column_widths() {
let mut cell_a = Block::new(BlockId::new("cA"), "table_cell");
cell_a.content = vec![TextSpan::plain("a")];
cell_a.parent = Some(BlockId::new("r1"));
let mut cell_b = Block::new(BlockId::new("cB"), "table_cell");
cell_b.content = vec![TextSpan::plain("b")];
cell_b.parent = Some(BlockId::new("r1"));
let mut row_props = Map::new();
row_props.insert("height".into(), Value::from(80u64));
let mut row = Block::with_props(BlockId::new("r1"), "table_row", row_props);
row.children = vec![BlockId::new("cA"), BlockId::new("cB")];
row.parent = Some(BlockId::new("t1"));
let mut cols = Vec::new();
for w in [200u64, 60u64] {
let mut col = Map::new();
col.insert("width".into(), Value::from(w));
cols.push(Value::Object(col));
}
let mut tprops = Map::new();
tprops.insert("columns".into(), Value::Array(cols));
let mut table = Block::with_props(BlockId::new("t1"), "table", tprops);
table.children = vec![BlockId::new("r1")];
let mut by_id = HashMap::new();
by_id.insert(BlockId::new("t1"), table.clone());
by_id.insert(BlockId::new("r1"), row);
by_id.insert(BlockId::new("cA"), cell_a);
by_id.insert(BlockId::new("cB"), cell_b);
let html = blocks_to_html(&[table], &by_id);
let parsed = parse(&html);
let table = &parsed.roots[0];
let cols = table
.props
.get("columns")
.and_then(Value::as_array)
.unwrap();
assert_eq!(cols.len(), 2);
assert_eq!(cols[0].get("width").and_then(Value::as_f64), Some(200.0));
assert_eq!(cols[1].get("width").and_then(Value::as_f64), Some(60.0));
let row = parsed.by_id.get(&table.children[0]).unwrap();
assert_eq!(row.props.get("height").and_then(Value::as_i64), Some(80));
}
#[test]
fn roundtrip_colspan_rowspan() {
let mut cell_props = Map::new();
cell_props.insert("colspan".into(), Value::from(2u64));
cell_props.insert("rowspan".into(), Value::from(3u64));
let mut cell = Block::with_props(BlockId::new("c1"), "table_cell", cell_props);
cell.content = vec![TextSpan::plain("m")];
cell.parent = Some(BlockId::new("r1"));
let mut row = Block::new(BlockId::new("r1"), "table_row");
row.children = vec![BlockId::new("c1")];
row.parent = Some(BlockId::new("t1"));
let mut cols = Vec::new();
for _ in 0..2 {
let mut col = Map::new();
col.insert("width".into(), Value::from(120u64));
cols.push(Value::Object(col));
}
let mut tprops = Map::new();
tprops.insert("columns".into(), Value::Array(cols));
let mut table = Block::with_props(BlockId::new("t1"), "table", tprops);
table.children = vec![BlockId::new("r1")];
let mut by_id = HashMap::new();
by_id.insert(BlockId::new("t1"), table.clone());
by_id.insert(BlockId::new("r1"), row);
by_id.insert(BlockId::new("c1"), cell);
let html = blocks_to_html(&[table], &by_id);
let parsed = parse(&html);
let cell = first_table_row_cell(&parsed, 0, 0, 0);
assert_eq!(cell.props.get("colspan").and_then(Value::as_u64), Some(2));
assert_eq!(cell.props.get("rowspan").and_then(Value::as_u64), Some(3));
}
#[test]
fn roundtrip_preserves_arbitrary_marker_props() {
let mut props = Map::new();
props.insert("fontFamily".into(), Value::String("serif".into()));
props.insert("customFoo".into(), Value::from(42u64));
let encoded = encode_props(Some(&props));
assert!(!encoded.is_empty());
let decoded = decode_props(&encoded).expect("marker decode");
assert_eq!(
decoded.get("fontFamily").and_then(Value::as_str),
Some("serif")
);
assert_eq!(decoded.get("customFoo").and_then(Value::as_u64), Some(42));
}
#[test]
fn notion_v3_toggle_li_with_multiple_block_children() {
let cb = parse("<ul><li><p>Title</p><p>Child paragraph</p></li></ul>");
let types: Vec<String> = cb.roots.iter().map(|b| b.ty.clone()).collect();
assert!(types.contains(&"toggle".to_string()), "roots: {types:?}");
let paragraph = cb
.roots
.iter()
.find(|b| b.ty == "paragraph")
.expect("child paragraph");
assert_eq!(
paragraph.props.get("indent").and_then(Value::as_i64),
Some(1)
);
}
#[test]
fn nested_list_carries_indent_prop() {
let cb = parse("<ul><li>a<ul><li>b</li></ul></li></ul>");
let indents: Vec<i64> = cb
.roots
.iter()
.map(|b| b.props.get("indent").and_then(Value::as_i64).unwrap_or(0))
.collect();
assert_eq!(indents, vec![0, 1], "indents: {indents:?}");
}
#[test]
fn notion_todo_list_with_checkbox_classes() {
let cb = parse(
r#"<ul class="to-do-list"><li><div class="checkbox checkbox-on"></div><span class="to-do-children-checked">Pick up milk</span></li></ul>"#,
);
let todo = cb
.roots
.iter()
.find(|b| b.ty == "todo")
.expect("todo block");
assert_eq!(
todo.props.get("checked").and_then(Value::as_bool),
Some(true)
);
let text = todo.plain_text();
assert!(text.contains("Pick up milk"), "text: {text:?}");
}
#[test]
fn unknown_mark_survives_as_span_data_mark() {
use devup_editor_core::{Mark, TextSpan};
let mut cell = Block::new(BlockId::new("p"), "paragraph");
cell.content = vec![TextSpan::with_marks("ghost", vec![Mark::new("mystery")])];
let mut by_id = HashMap::new();
by_id.insert(BlockId::new("p"), cell.clone());
let html = blocks_to_html(&[cell], &by_id);
assert!(
html.contains("data-mark=\"mystery\""),
"unknown mark fallback missing: {html}"
);
}
#[test]
fn newline_in_span_becomes_br_on_export() {
use devup_editor_core::TextSpan;
let mut para = Block::new(BlockId::new("p"), "paragraph");
para.content = vec![TextSpan::plain("line 1\nline 2")];
let mut by_id = HashMap::new();
by_id.insert(BlockId::new("p"), para.clone());
let html = blocks_to_html(&[para], &by_id);
assert!(html.contains("line 1<br>line 2"), "html: {html}");
let parsed = parse(&html);
let p = parsed
.roots
.iter()
.find(|b| b.ty == "paragraph")
.expect("paragraph");
assert!(
p.content.iter().any(|s| s.text.contains('\n')),
"br did not round-trip to \\n: {:?}",
p.content
);
}
#[test]
fn clean_html_strips_word_artifacts() {
let cleaned = clean_html("<!--StartFragment--><p>real<o:p>junk</o:p></p><!--EndFragment-->");
assert!(!cleaned.contains("StartFragment"));
assert!(!cleaned.contains("EndFragment"));
assert!(!cleaned.contains("<o:p"));
assert!(cleaned.contains("<p>real</p>"));
}
#[test]
fn slice_content_offsets_match_utf16_semantics() {
use devup_editor_core::TextSpan;
let spans = vec![
TextSpan::plain("abc"),
TextSpan::plain(" "),
TextSpan::plain("def"),
];
let out = slice_content(&spans, 1, 6);
assert_eq!(out.len(), 1);
assert_eq!(out[0].text, "bc de");
}
#[test]
fn copied_blocks_to_html_matches_blocks_to_html() {
let mut para = Block::new(BlockId::new("p"), "paragraph");
para.content = vec![devup_editor_core::TextSpan::plain("hi")];
let mut by_id = HashMap::new();
by_id.insert(BlockId::new("p"), para.clone());
let a = blocks_to_html(&[para.clone()], &by_id);
let b = copied_blocks_to_html(&CopiedBlocks {
roots: vec![para],
by_id,
});
assert_eq!(a, b);
}